diff --git a/docs/integrations/deepseek-harness-control-plane-adapter.md b/docs/integrations/deepseek-harness-control-plane-adapter.md
index 1e4698f773..9638a4cab7 100644
--- a/docs/integrations/deepseek-harness-control-plane-adapter.md
+++ b/docs/integrations/deepseek-harness-control-plane-adapter.md
@@ -5,14 +5,18 @@ Status: implemented in this repository as the optional
The package embeds LoopX into a visible DeepSeek Harness (DSH) Session without
moving execution authority out of DSH or durable control-plane authority out of
-LoopX. It ships three cooperating surfaces:
+LoopX. It ships four cooperating surfaces:
- `/loopx-init`, which installs or repairs the LoopX CLI and DSH workflow
skills;
- a passive same-session Driver, which may queue one LoopX continuation only
after the exact Session invokes the installed `loopx` skill;
- a loopback-only GoalBar Host and web Client, which show one exact bound
- Goal/Agent lane and expose guarded Start and Pause actions.
+ Goal/Agent lane and expose guarded Start and Pause actions;
+- a web-Client personal workspace panel: one global **LoopX** sidebar entry
+ whose central panel embeds the already-running `loopx dashboard` workspace
+ on loopback, presenting LoopX's own UI without reading or duplicating
+ control-plane state.
The older [`deepseek-harness` connector](deepseek-harness-connector.md) remains
a separate headless Turn adapter. The native plugin uses the
@@ -25,7 +29,7 @@ contract, not as competing implementations to collapse into one transport:
| Path | Primary use | Session and wakeup owner | Frontend |
| --- | --- | --- | --- |
-| `dsh-loopx-plugin` | Person-driven work in a DSH Session | DSH Session; optional same-session Driver after an exact `loopx` skill invocation | DSH Web/Desktop GoalBar and native skills |
+| `dsh-loopx-plugin` | Person-driven work in a DSH Session | DSH Session; optional same-session Driver after an exact `loopx` skill invocation | DSH Web/Desktop GoalBar, personal workspace panel and native skills |
| LoopX Turn on DSH | Headless, bounded, quota-governed work selected without a person present | LoopX Turn and the outer scheduler | LoopX App/CLI executor readback, artifacts and Todo settlement |
Do not duplicate the LoopX control plane inside DSH. Both routes must converge
diff --git a/packages/dsh-loopx-plugin/README.md b/packages/dsh-loopx-plugin/README.md
index 455266330f..c4726c72b3 100644
--- a/packages/dsh-loopx-plugin/README.md
+++ b/packages/dsh-loopx-plugin/README.md
@@ -1,7 +1,7 @@
# LoopX for DeepSeek Harness
-`dsh-loopx-plugin` is one independently versioned DSH package with three
-separate Loader rows:
+`dsh-loopx-plugin` is one independently versioned DSH package with four
+cooperating surfaces (three Loader rows and one Client panel):
- the init row automatically installs or upgrades the LoopX CLI, installs the
packaged workflow skills into `$DSH_AGENTS_HOME/skills` (default
@@ -15,6 +15,11 @@ separate Loader rows:
`/api/loopx.goalbar` route required by DSH 0.1.5. Its web Client adds a compact
GoalBar between DSH's native GoalBar and Queue dock rows, visible only for one
exact live `(goalId, loopxAgentId)` binding.
+- the web Client also adds one global sidebar entry, **LoopX**, whose central
+ panel embeds the already-running personal workspace from
+ `loopx dashboard` on loopback. The panel is presentation only: it starts no
+ service, reads no LoopX state itself, and renders a recovery notice plus
+ retry when that loopback service is absent.
Installing the plugin and starting DSH load and prepare these capabilities;
neither creates a binding nor activates the Driver. The GoalBar
@@ -432,6 +437,35 @@ a change token, not authorization or a compare-and-swap guard. Installing the
package grants no model tool authority, does not create or repair bindings, and
does not change LoopX core state by itself.
+## Personal workspace panel
+
+The web Client registers one global sidebar entry, **LoopX**, and the matching
+central panel. The panel embeds the personal workspace served by
+`loopx dashboard` at its default loopback address
+(`http://127.0.0.1:8767/chat/`) in an iframe. Start the workspace first:
+
+```bash
+loopx dashboard # default loopback address; opens the browser unless --no-open
+```
+
+The panel then shows the same workspace in DSH. It is navigation, not a second
+control plane: the iframe loads LoopX's own UI and LoopX remains the authority
+for Goals, Todos, quotas, and bindings. The iframe shares no state with the DSH
+Session, and the plugin adds no LoopX model tool, credential, or registry read
+for it.
+
+When the loopback service is absent, the panel keeps the frame mounted, states
+the recovery command, and offers **Retry**; starting `loopx dashboard` and
+retrying recovers without reloading DSH. Removing or disabling the plugin
+removes the sidebar entry and panel without changing LoopX state.
+
+Current limitation: the address is the dashboard's default loopback address.
+A workspace started with an explicit other `--port` is not discovered by the
+panel; keep the default port, or open that URL in DSH's own browser panel.
+Hosts that do not declare the global sidebar or keyed main slots (DSH releases
+before the 0.2.1 line) keep the GoalBar and skills but show no panel: the
+injection simply waits for a host that declares those slots.
+
## Automatic initialization and `/loopx-init` repair
When DSH loads the plugin, the init row runs the same typed initialization
diff --git a/packages/dsh-loopx-plugin/package.json b/packages/dsh-loopx-plugin/package.json
index e6964770df..19935bf146 100644
--- a/packages/dsh-loopx-plugin/package.json
+++ b/packages/dsh-loopx-plugin/package.json
@@ -107,8 +107,10 @@
"@deepseek-ai/dsh-client-connection": "0.2.1-rc.2",
"@deepseek-ai/dsh-client-locale": "0.2.1-rc.2",
"@deepseek-ai/dsh-client-ui-conversation": "0.2.1-rc.2",
+ "@deepseek-ai/dsh-client-ui-layout": "0.2.1-rc.2",
"@deepseek-ai/dsh-client-ui-primitives": "0.2.1-rc.2",
"@deepseek-ai/dsh-client-ui-renderer": "0.2.1-rc.2",
+ "@deepseek-ai/dsh-client-ui-sidebar": "0.2.1-rc.2",
"@deepseek-ai/dsh-client-ui-slots": "0.2.1-rc.2",
"@deepseek-ai/dsh-commands": "0.2.1-rc.2",
"@deepseek-ai/dsh-llm": "0.2.1-rc.2",
diff --git a/packages/dsh-loopx-plugin/pnpm-lock.yaml b/packages/dsh-loopx-plugin/pnpm-lock.yaml
index 2f86ce013d..95c0458a6f 100644
--- a/packages/dsh-loopx-plugin/pnpm-lock.yaml
+++ b/packages/dsh-loopx-plugin/pnpm-lock.yaml
@@ -35,12 +35,18 @@ importers:
'@deepseek-ai/dsh-client-ui-conversation':
specifier: 0.2.1-rc.2
version: 0.2.1-rc.2(@deepseek-ai/cordis@4.0.5)
+ '@deepseek-ai/dsh-client-ui-layout':
+ specifier: 0.2.1-rc.2
+ version: 0.2.1-rc.2(@deepseek-ai/cordis@4.0.5)
'@deepseek-ai/dsh-client-ui-primitives':
specifier: 0.2.1-rc.2
version: 0.2.1-rc.2(@deepseek-ai/cordis@4.0.5)
'@deepseek-ai/dsh-client-ui-renderer':
specifier: 0.2.1-rc.2
version: 0.2.1-rc.2(@deepseek-ai/cordis@4.0.5)
+ '@deepseek-ai/dsh-client-ui-sidebar':
+ specifier: 0.2.1-rc.2
+ version: 0.2.1-rc.2(@deepseek-ai/cordis@4.0.5)
'@deepseek-ai/dsh-client-ui-slots':
specifier: 0.2.1-rc.2
version: 0.2.1-rc.2(@deepseek-ai/cordis@4.0.5)
diff --git a/packages/dsh-loopx-plugin/smoke/dsh-client-artifact-smoke.mjs b/packages/dsh-loopx-plugin/smoke/dsh-client-artifact-smoke.mjs
index baea03402b..e58c3a0549 100644
--- a/packages/dsh-loopx-plugin/smoke/dsh-client-artifact-smoke.mjs
+++ b/packages/dsh-loopx-plugin/smoke/dsh-client-artifact-smoke.mjs
@@ -48,6 +48,7 @@ const packedStaticEntries = new Set([
'package/lib/observer.js',
'package/lib/types/cli.d.ts',
'package/lib/types/client/LoopXGoalBar.d.ts',
+ 'package/lib/types/client/dashboard.d.ts',
'package/lib/types/client/index.d.ts',
'package/lib/types/client/locale.d.ts',
'package/lib/types/client/rpc.d.ts',
@@ -300,11 +301,32 @@ function createClientApplyHarness() {
},
slots: {
inject(name, install) {
- assert.equal(name, 'conversation.input.dock')
+ assert(
+ ['sidebar.panellist', 'main', 'conversation.input.dock'].includes(name),
+ `unexpected slot injection ${name}`,
+ )
const dispose = install()
if (typeof dispose === 'function') effects.push(dispose)
},
register(config, component) {
+ if (config.name === 'sidebar.panellist') {
+ assert.equal(config.id, 'loopx-dashboard')
+ assert.equal(config.order, 7)
+ assert.equal(typeof config.label, 'function')
+ assert.equal(config.label(), 'LoopX')
+ assert.equal(typeof component, 'function')
+ assert.equal(slots.has('sidebar:loopx-dashboard'), false)
+ slots.add('sidebar:loopx-dashboard')
+ return () => slots.delete('sidebar:loopx-dashboard')
+ }
+ if (config.name === 'main') {
+ assert.equal(config.key, 'loopx-dashboard')
+ assert.equal(config.locale, 'loopx.dashboard')
+ assert.equal(typeof component, 'function')
+ assert.equal(slots.has('main:loopx-dashboard'), false)
+ slots.add('main:loopx-dashboard')
+ return () => slots.delete('main:loopx-dashboard')
+ }
assert.equal(config.name, 'conversation.input.dock')
assert.equal(config.id, 'loopx-goal')
assert.equal(config.order, 15)
@@ -339,6 +361,7 @@ async function assertClientArtifact(root) {
assert(!/sourceMappingURL|\.(?:ts|tsx)\.map/iu.test(source), 'client artifact contains a source map')
assert(!/(?:^|["'])node:/mu.test(source), 'client artifact contains a Node builtin')
assert(!/(?:typert|remote|EventSource)/u.test(source), 'client artifact contains a forbidden transport')
+ assert(source.includes('http://127.0.0.1:8767/chat/'), 'client artifact lacks the loopback workspace address')
assert(!/\/(?:Users|home|private|workspace)\//u.test(source), 'client artifact contains an absolute path')
assert(!/[A-Za-z]:\\(?:Users|home|workspace)\\/u.test(source), 'client artifact contains a Windows absolute path')
@@ -373,9 +396,14 @@ async function assertClientArtifact(root) {
assert.deepEqual(first.labels, [
'dsh-loopx-plugin CSS ownership',
'dsh-loopx-plugin GoalBar locale',
+ 'dsh-loopx-plugin dashboard locale',
+ ])
+ assert.equal(first.locales.size, 2)
+ assert.deepEqual([...first.slots], [
+ 'sidebar:loopx-dashboard',
+ 'main:loopx-dashboard',
+ 'loopx-goal',
])
- assert.equal(first.locales.size, 1)
- assert.deepEqual([...first.slots], ['loopx-goal'])
assert.equal(styles.length, 1)
assert.equal(styles[0].dataset.plugin, packageId)
assert.equal(styles[0].dataset.pluginCss, `${packageId}/goalbar.module.css`)
@@ -399,7 +427,11 @@ async function assertClientArtifact(root) {
const reapplied = createClientApplyHarness()
assert.equal(cachedExports.apply(reapplied.ctx), undefined)
assert.equal(styles.length, 1, 'cached-export reapply did not restore CSS')
- assert.deepEqual([...reapplied.slots], ['loopx-goal'])
+ assert.deepEqual([...reapplied.slots], [
+ 'sidebar:loopx-dashboard',
+ 'main:loopx-dashboard',
+ 'loopx-goal',
+ ])
reapplied.dispose()
assert.equal(styles.length, 0, 'cached-export unload retained CSS')
removeOwnedStylesForHmr()
diff --git a/packages/dsh-loopx-plugin/smoke/dsh-goalbar-runtime-smoke.mjs b/packages/dsh-loopx-plugin/smoke/dsh-goalbar-runtime-smoke.mjs
index a05a78d1ad..914895a8d5 100644
--- a/packages/dsh-loopx-plugin/smoke/dsh-goalbar-runtime-smoke.mjs
+++ b/packages/dsh-loopx-plugin/smoke/dsh-goalbar-runtime-smoke.mjs
@@ -51,6 +51,7 @@ const packedStaticEntries = new Set([
'package/lib/observer.js',
'package/lib/types/cli.d.ts',
'package/lib/types/client/LoopXGoalBar.d.ts',
+ 'package/lib/types/client/dashboard.d.ts',
'package/lib/types/client/index.d.ts',
'package/lib/types/client/locale.d.ts',
'package/lib/types/client/rpc.d.ts',
@@ -748,14 +749,19 @@ async function materializeServedClient(source) {
locale: { register: () => () => undefined },
slots: {
inject(name, install) {
- assert.equal(name, 'conversation.input.dock')
+ assert(
+ ['sidebar.panellist', 'main', 'conversation.input.dock'].includes(name),
+ `unexpected slot injection ${name}`,
+ )
const dispose = install()
if (typeof dispose === 'function') effects.push(dispose)
},
register(config, component) {
assert.equal(typeof component, 'function')
- slots.set(config.id, config)
- return () => slots.delete(config.id)
+ const key = config.id ?? `main:${config.key}`
+ assert.equal(slots.has(key), false, `duplicate slot ${key}`)
+ slots.set(key, config)
+ return () => slots.delete(key)
},
},
on: () => () => undefined,
@@ -763,9 +769,14 @@ async function materializeServedClient(source) {
client.apply(ctx)
const config = slots.get('loopx-goal')
assert.equal(config.order, 15)
- assert.deepEqual([...slots].sort((a, b) => a[1].order - b[1].order).map(([id]) => id), [
- 'goal', 'loopx-goal', 'queue',
- ])
+ const sidebar = slots.get('loopx-dashboard')
+ assert.equal(sidebar.order, 7)
+ assert.equal(slots.get('main:loopx-dashboard').key, 'loopx-dashboard')
+ const dockOrder = [...slots.entries()]
+ .filter(([id]) => ['goal', 'loopx-goal', 'queue'].includes(id))
+ .sort((a, b) => a[1].order - b[1].order)
+ .map(([id]) => id)
+ assert.deepEqual(dockOrder, ['goal', 'loopx-goal', 'queue'])
assert.equal(config.inject('session-one').rpcSessionId, 'session-one')
assert.equal(config.inject('session-two').rpcSessionId, 'session-two')
return {
diff --git a/packages/dsh-loopx-plugin/smoke/dsh-profile-smoke.mjs b/packages/dsh-loopx-plugin/smoke/dsh-profile-smoke.mjs
index 9bf545eea6..47273153f7 100755
--- a/packages/dsh-loopx-plugin/smoke/dsh-profile-smoke.mjs
+++ b/packages/dsh-loopx-plugin/smoke/dsh-profile-smoke.mjs
@@ -31,6 +31,7 @@ const packedStaticEntries = new Set([
'package/lib/observer.js',
'package/lib/types/cli.d.ts',
'package/lib/types/client/LoopXGoalBar.d.ts',
+ 'package/lib/types/client/dashboard.d.ts',
'package/lib/types/client/index.d.ts',
'package/lib/types/client/locale.d.ts',
'package/lib/types/client/rpc.d.ts',
diff --git a/packages/dsh-loopx-plugin/src/client/dashboard.tsx b/packages/dsh-loopx-plugin/src/client/dashboard.tsx
new file mode 100644
index 0000000000..eb4a1e2e76
--- /dev/null
+++ b/packages/dsh-loopx-plugin/src/client/dashboard.tsx
@@ -0,0 +1,156 @@
+import { useEffect, useState } from 'react'
+import type { JSX } from 'react'
+import type { MainPanelId } from '@deepseek-ai/dsh-client-ui-layout/client'
+import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
+
+import type { DashboardTranslate } from './locale.ts'
+
+/**
+ * Sidebar panel id, reused verbatim as the `main` slot key so one exact
+ * sidebar entry and its central panel always travel together.
+ */
+export const LOOPX_DASHBOARD_PANEL_ID = 'loopx-dashboard' as MainPanelId
+
+/**
+ * Loopback address printed by `loopx dashboard`. The panel only presents the
+ * already-running local workspace; it never starts or owns that service.
+ */
+export const LOOPX_DASHBOARD_URL = 'http://127.0.0.1:8767/chat/'
+
+/** Row title of the LoopX personal workspace; a brand name in every locale. */
+export const LOOPX_DASHBOARD_LABEL = 'LoopX'
+
+/** How long one availability probe may wait before the panel reports the service as off. */
+const UNAVAILABLE_PROBE_MS = 2500
+
+type DashboardAvailability = 'probing' | 'ready' | 'unavailable'
+
+/** Props of the central LoopX workspace panel: keyed dispatch plus its locale seat. */
+export type LoopXDashboardPanelProps =
+ & PropsRuntime<'main', MainPanelId>
+ & { readonly t: DashboardTranslate }
+
+/** Sidebar icon for the LoopX personal workspace panel. */
+export function LoopXDashboardIcon({
+ size,
+}: PropsRuntime<'sidebar.panellist'>): JSX.Element {
+ return (
+
+ )
+}
+
+/**
+ * Central panel presenting the LoopX personal workspace dashboard served by
+ * `loopx dashboard`. The iframe keeps LoopX as the owning UI; this panel is a
+ * navigation surface, not a second control plane. When the loopback service is
+ * absent the panel states the recovery command instead of rendering a blank
+ * frame, and retrying re-probes without reloading DSH.
+ */
+export function LoopXDashboardPanel({
+ t,
+}: LoopXDashboardPanelProps): JSX.Element {
+ const [attempt, setAttempt] = useState(0)
+ const [availability, setAvailability] = useState('probing')
+ useEffect(() => {
+ const controller = new AbortController()
+ const timer = setTimeout(() => {
+ setAvailability('unavailable')
+ controller.abort()
+ }, UNAVAILABLE_PROBE_MS)
+ setAvailability('probing')
+ fetch(LOOPX_DASHBOARD_URL, {
+ mode: 'no-cors',
+ cache: 'no-store',
+ signal: controller.signal,
+ }).then(
+ () => {
+ clearTimeout(timer)
+ setAvailability('ready')
+ },
+ () => {
+ clearTimeout(timer)
+ setAvailability('unavailable')
+ },
+ )
+ return () => {
+ clearTimeout(timer)
+ controller.abort()
+ }
+ }, [attempt])
+
+ return (
+
+
+ {availability === 'unavailable' && (
+
+
+ {t('panel.unavailable')}
+
+
+
+ )}
+
+ )
+}
diff --git a/packages/dsh-loopx-plugin/src/client/index.tsx b/packages/dsh-loopx-plugin/src/client/index.tsx
index 8abe46b8d6..cd06b1cb42 100644
--- a/packages/dsh-loopx-plugin/src/client/index.tsx
+++ b/packages/dsh-loopx-plugin/src/client/index.tsx
@@ -4,10 +4,22 @@ import type {} from '@deepseek-ai/dsh-client-locale/client'
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
// The renderer owns the client slots service in DSH 0.1.5.
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
+// Type-only: declares the sidebar panel list and the keyed `main` slot this
+// plugin contributes into. Both are erased from the client bundle.
+import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
+import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
+import {
+ LOOPX_DASHBOARD_LABEL,
+ LOOPX_DASHBOARD_PANEL_ID,
+ LoopXDashboardIcon,
+ LoopXDashboardPanel,
+} from './dashboard.tsx'
import { LoopXGoalBar } from './LoopXGoalBar.tsx'
import { ensurePluginStyle } from './goalbar.module.css'
import {
+ DASHBOARD_LOCALES,
+ DASHBOARD_LOCALE_NAMESPACE,
GOALBAR_LOCALES,
GOALBAR_LOCALE_NAMESPACE,
} from './locale.ts'
@@ -42,6 +54,27 @@ export function apply(ctx: GoalBarClientContext): void {
'dsh-loopx-plugin GoalBar locale',
)
+ ctx.effect(
+ () => ctx.locale.register(DASHBOARD_LOCALE_NAMESPACE, DASHBOARD_LOCALES),
+ 'dsh-loopx-plugin dashboard locale',
+ )
+
+ // Present the already-running LoopX personal workspace as one sidebar panel.
+ // Registration only navigates: it never starts, owns or duplicates the
+ // dashboard service, which remains `loopx dashboard` on loopback.
+ ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({
+ name: 'sidebar.panellist',
+ id: LOOPX_DASHBOARD_PANEL_ID,
+ order: 7,
+ label: () => LOOPX_DASHBOARD_LABEL,
+ }, LoopXDashboardIcon))
+
+ ctx.slots.inject('main', () => ctx.slots.register({
+ name: 'main',
+ key: LOOPX_DASHBOARD_PANEL_ID,
+ locale: DASHBOARD_LOCALE_NAMESPACE,
+ }, LoopXDashboardPanel))
+
const rpc = createGoalBarRpc(
ctx.connection.rpc,
Reflect.has(ctx.connection, 'generation'),
diff --git a/packages/dsh-loopx-plugin/src/client/locale.ts b/packages/dsh-loopx-plugin/src/client/locale.ts
index b3464e347a..74b2c549ec 100644
--- a/packages/dsh-loopx-plugin/src/client/locale.ts
+++ b/packages/dsh-loopx-plugin/src/client/locale.ts
@@ -66,14 +66,36 @@ const zh: Record = {
export const GOALBAR_LOCALES = Object.freeze({ en, zh })
+/** Dictionary namespace of the LoopX personal-workspace sidebar panel. */
+export const DASHBOARD_LOCALE_NAMESPACE = 'loopx.dashboard' as const
+
+const dashboardEn = {
+ 'iframe.title': 'LoopX personal workspace',
+ 'panel.unavailable': 'The LoopX dashboard is not reachable yet. Run `loopx dashboard` first.',
+ 'panel.retry': 'Retry',
+} as const
+
+export type DashboardLocaleKey = keyof typeof dashboardEn
+
+const dashboardZh: Record = {
+ 'iframe.title': 'LoopX 个人工作区',
+ 'panel.unavailable': '尚未连接到 LoopX 本地看板。请先在终端运行 `loopx dashboard`。',
+ 'panel.retry': '重试',
+}
+
+export const DASHBOARD_LOCALES = Object.freeze({ en: dashboardEn, zh: dashboardZh })
+
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
'loopx.goalbar': GoalBarLocaleKey
+ 'loopx.dashboard': DashboardLocaleKey
}
}
export type GoalBarTranslate = Translate
+export type DashboardTranslate = Translate
+
const ERROR_KEYS: Readonly> = {
session_unavailable: 'error.session_unavailable',
cli_unavailable: 'error.cli_unavailable',
diff --git a/packages/dsh-loopx-plugin/tests/dashboard-client.spec.tsx b/packages/dsh-loopx-plugin/tests/dashboard-client.spec.tsx
new file mode 100644
index 0000000000..5e8510bb8f
--- /dev/null
+++ b/packages/dsh-loopx-plugin/tests/dashboard-client.spec.tsx
@@ -0,0 +1,104 @@
+// @vitest-environment jsdom
+
+import { act } from 'react-dom/test-utils'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
+
+import {
+ LOOPX_DASHBOARD_URL,
+ LoopXDashboardPanel,
+ type LoopXDashboardPanelProps,
+} from '../src/client/dashboard.tsx'
+import {
+ DASHBOARD_LOCALES,
+ type DashboardLocaleKey,
+ type DashboardTranslate,
+} from '../src/client/locale.ts'
+
+const roots: Array<{ root: Root; container: HTMLDivElement; mounted: boolean }> = []
+
+beforeAll(() => {
+ ;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean })
+ .IS_REACT_ACT_ENVIRONMENT = true
+})
+
+afterEach(() => {
+ for (const mounted of roots) {
+ if (mounted.mounted) act(() => mounted.root.unmount())
+ mounted.container.remove()
+ }
+ roots.length = 0
+ vi.unstubAllGlobals()
+ vi.restoreAllMocks()
+})
+
+function translate(key: DashboardLocaleKey): string {
+ return DASHBOARD_LOCALES.en[key]
+}
+
+const englishT = translate as DashboardTranslate
+
+/** Mount the panel with its exact keyed-dispatch props and locale seat. */
+function mountPanel(): HTMLDivElement {
+ const container = document.createElement('div')
+ document.body.append(container)
+ const root = createRoot(container)
+ const props = { t: englishT } as LoopXDashboardPanelProps
+ act(() => root.render())
+ roots.push({ root, container, mounted: true })
+ return container
+}
+
+/** Let the availability probe settle inside the React act boundary. */
+async function settle(): Promise {
+ await act(async () => {
+ await Promise.resolve()
+ })
+}
+
+describe('LoopX personal workspace panel', () => {
+ it('embeds the loopback dashboard and omits the fallback when it responds', async () => {
+ vi.stubGlobal('fetch', vi.fn(async () => new Response(null, { status: 200 })))
+ const container = mountPanel()
+ await settle()
+
+ const frame = container.querySelector('iframe')
+ expect(frame?.getAttribute('src')).toBe(LOOPX_DASHBOARD_URL)
+ expect(frame?.getAttribute('title')).toBe(DASHBOARD_LOCALES.en['iframe.title'])
+ expect(container.textContent).not.toContain(DASHBOARD_LOCALES.en['panel.unavailable'])
+ })
+
+ it('states the recovery command when the loopback service is unreachable', async () => {
+ vi.stubGlobal('fetch', vi.fn(async () => {
+ throw new TypeError('connection refused')
+ }))
+ const container = mountPanel()
+ await settle()
+
+ expect(container.textContent).toContain(DASHBOARD_LOCALES.en['panel.unavailable'])
+ expect(container.textContent).toContain(DASHBOARD_LOCALES.en['panel.retry'])
+ // The iframe stays mounted so an operator can start the service behind it.
+ expect(container.querySelector('iframe')?.getAttribute('src')).toBe(LOOPX_DASHBOARD_URL)
+ })
+
+ it('recovers through the retry control once the service becomes reachable', async () => {
+ const fetchMock = vi.fn()
+ .mockRejectedValueOnce(new TypeError('connection refused'))
+ .mockResolvedValueOnce(new Response(null, { status: 200 }))
+ vi.stubGlobal('fetch', fetchMock)
+ const container = mountPanel()
+ await settle()
+ expect(container.textContent).toContain(DASHBOARD_LOCALES.en['panel.unavailable'])
+
+ const retry = container.querySelector('button')
+ expect(retry).not.toBeNull()
+ await act(async () => {
+ retry?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
+ await Promise.resolve()
+ })
+ await settle()
+
+ expect(fetchMock).toHaveBeenCalledTimes(2)
+ expect(container.textContent).not.toContain(DASHBOARD_LOCALES.en['panel.unavailable'])
+ })
+})
diff --git a/packages/dsh-loopx-plugin/tests/goalbar-client.spec.tsx b/packages/dsh-loopx-plugin/tests/goalbar-client.spec.tsx
index ab98dd1149..1ff0eaaaac 100644
--- a/packages/dsh-loopx-plugin/tests/goalbar-client.spec.tsx
+++ b/packages/dsh-loopx-plugin/tests/goalbar-client.spec.tsx
@@ -998,13 +998,19 @@ describe('GoalBar Connection and registration boundaries', () => {
component: unknown
} | undefined
const registerLocale = vi.fn(() => () => undefined)
+ const injectedKeys: string[] = []
+ const registrations: Array<{ options: Record; component: unknown }> = []
const ctx = {
connection: { rpc: { call: vi.fn() } },
locale: { register: registerLocale },
slots: {
- inject: vi.fn((_key: string, install: () => () => void) => install()),
+ inject: vi.fn((key: string, install: () => () => void) => {
+ injectedKeys.push(key)
+ return install()
+ }),
register: vi.fn((options: typeof registration extends { options: infer O } ? O : never, component: unknown) => {
registration = { options, component }
+ registrations.push({ options: options as unknown as Record, component })
return () => undefined
}),
},
@@ -1026,6 +1032,25 @@ describe('GoalBar Connection and registration boundaries', () => {
expect(registration?.options.id).toBe('loopx-goal')
expect(registration?.options.order).toBe(15)
expect(registration?.component).toBe(LoopXGoalBar)
+ // The panel contributes one global sidebar row plus its keyed central
+ // panel; both must be present so one row always selects one panel.
+ expect(injectedKeys).toEqual([
+ 'sidebar.panellist',
+ 'main',
+ 'conversation.input.dock',
+ ])
+ expect(registerLocale).toHaveBeenCalledWith('loopx.dashboard', expect.any(Object))
+ expect(registrations[0]?.options).toMatchObject({
+ name: 'sidebar.panellist',
+ id: 'loopx-dashboard',
+ order: 7,
+ })
+ expect(registrations[0]?.options.label).toBeTypeOf('function')
+ expect(registrations[1]?.options).toMatchObject({
+ name: 'main',
+ key: 'loopx-dashboard',
+ locale: 'loopx.dashboard',
+ })
const injected = registration?.options.inject('session-injected')
expect(injected?.rpcSessionId).toBe('session-injected')
expect(injected).not.toHaveProperty('sessionId')