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 ( +
+