From cd4a1bf1832374135d9ac11f4d94568d2a92c84b Mon Sep 17 00:00:00 2001 From: xiaoan17 Date: Sun, 8 Nov 2026 14:55:19 +0800 Subject: [PATCH] feat(dsh): present the LoopX personal workspace as a DSH sidebar panel Register one global sidebar entry and its matching central panel in the web Client. The panel embeds the personal workspace served by `loopx dashboard` on its default loopback address, states the recovery command and offers Retry when that service is absent, and never starts, reads or duplicates LoopX state. Add the dashboard locale namespace, unit coverage for the embedded panel and its fallback/retry states, and extend the client artifact smoke to assert both new slot registrations and the loopback address. Signed-off-by: xiaoan17 --- .../deepseek-harness-control-plane-adapter.md | 10 +- packages/dsh-loopx-plugin/README.md | 38 ++++- packages/dsh-loopx-plugin/package.json | 2 + packages/dsh-loopx-plugin/pnpm-lock.yaml | 6 + .../smoke/dsh-client-artifact-smoke.mjs | 40 ++++- .../smoke/dsh-goalbar-runtime-smoke.mjs | 23 ++- .../smoke/dsh-profile-smoke.mjs | 1 + .../dsh-loopx-plugin/src/client/dashboard.tsx | 156 ++++++++++++++++++ .../dsh-loopx-plugin/src/client/index.tsx | 33 ++++ .../dsh-loopx-plugin/src/client/locale.ts | 22 +++ .../tests/dashboard-client.spec.tsx | 104 ++++++++++++ .../tests/goalbar-client.spec.tsx | 27 ++- 12 files changed, 446 insertions(+), 16 deletions(-) create mode 100644 packages/dsh-loopx-plugin/src/client/dashboard.tsx create mode 100644 packages/dsh-loopx-plugin/tests/dashboard-client.spec.tsx 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 ( +
+