From d42102e1d91263a63580db55ad48d5125d5f4891 Mon Sep 17 00:00:00 2001 From: agentfleet Date: Thu, 30 Jul 2026 06:35:03 -0700 Subject: [PATCH 1/2] =?UTF-8?q?feat(desktop):=20unify=20assistant=20dock?= =?UTF-8?q?=20=E2=80=94=20kimi=20web=20+=20companion=20tabs,=20retire=20su?= =?UTF-8?q?rface=20mounts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- desktop/electron/e2e/annotation.spec.ts | 51 ++++--- desktop/src/i18n/index.ts | 10 +- desktop/src/state/annotation.test.ts | 50 +++++++ desktop/src/state/annotation.ts | 34 +++-- desktop/src/state/annotationTargets.test.ts | 13 +- desktop/src/state/annotationTargets.ts | 40 +++++- desktop/src/state/assistant.test.ts | 82 ++++++++++++ desktop/src/state/assistant.ts | 46 ++++++- desktop/src/state/companionBinding.test.ts | 46 +++++++ desktop/src/state/companionBinding.ts | 27 ++++ desktop/src/state/companionContext.test.ts | 83 ++++++++++++ desktop/src/state/companionContext.ts | 66 ++++++++++ .../src/styles/partials/02-job-surfaces.css | 12 +- .../src/styles/partials/04-library-nav.css | 11 -- .../styles/partials/08-plan-terminal-dock.css | 32 ++++- desktop/src/surfaces/AuthorSurface.tsx | 124 +++++++++--------- desktop/src/surfaces/ReadSurface.tsx | 115 ++++++---------- desktop/src/ui/AgentCompanion.tsx | 19 ++- desktop/src/ui/AnnotationOverlay.tsx | 36 +++-- desktop/src/ui/AssistantDock.tsx | 93 ++++++++++--- 20 files changed, 751 insertions(+), 239 deletions(-) create mode 100644 desktop/src/state/annotation.test.ts create mode 100644 desktop/src/state/assistant.test.ts create mode 100644 desktop/src/state/companionBinding.test.ts create mode 100644 desktop/src/state/companionBinding.ts create mode 100644 desktop/src/state/companionContext.test.ts create mode 100644 desktop/src/state/companionContext.ts diff --git a/desktop/electron/e2e/annotation.spec.ts b/desktop/electron/e2e/annotation.spec.ts index b2879eede..716dd9e10 100644 --- a/desktop/electron/e2e/annotation.spec.ts +++ b/desktop/electron/e2e/annotation.spec.ts @@ -10,16 +10,18 @@ import type { AddressInfo } from 'node:net'; /// chip — and that sending posts the image to the (mock) hub. The second test /// covers the D2.1 GLOBAL trigger: the status-bar crosshair chip arms the /// overlay with no companion origin, and the target row still offers the -/// bound Author companion ("Send to "). +/// bound dock companion ("Send to "). /// /// The flow drives the REAL path: a mock hub serves the connect probe + the /// agents list (+ events backfill + a parked SSE stream); the UI-context -/// sharing toggle is seeded on; the companion's "Ask agent" button arms the -/// overlay; the drag is trusted Chromium input (page.mouse); main captures -/// the real window; the target row's "Send to " hands the crop back as -/// a chip. The kimi-web target is not exercised — it needs the real kimi -/// binary. The two tests run serially in one app instance; test 2 builds on -/// test 1's connected session + bound companion. +/// sharing toggle is seeded on; the unified assistant dock is opened and its +/// Companion tab's "Ask agent" button arms the overlay (the dock steps aside +/// while armed, D2.2); the drag is trusted Chromium input (page.mouse); main +/// captures the real window; the target row's "Send to " hands the +/// crop back as a chip and reveals the dock on the Companion tab. The +/// kimi-web target is not exercised — it needs the real kimi binary. The two +/// tests run serially in one app instance; test 2 builds on test 1's +/// connected session + bound companion. /// /// Hermeticity (an e2e instance must never touch the user's state): /// - a THROWAWAY --user-data-dir: the default dir holds the real profile, @@ -157,19 +159,26 @@ test('D2: drag → target row → companion chip → postAgentInput carries the await connect.getByRole('button', { name: /^connect$/i }).click(); await expect(connect).toHaveCount(0, { timeout: 20_000 }); - // Author surface: a fresh markdown doc (into the tmp workspace), then open - // the assistant pane — the companion auto-selects the mock agent. + // Author surface: a fresh markdown doc (into the tmp workspace). The unified + // assistant dock opens from the status-bar chip; its Companion tab hosts the + // one AgentCompanion (the per-surface mounts are retired) — it auto-selects + // the mock agent. await page.locator('[data-job="author"]').click(); await page.getByRole('button', { name: 'New', exact: true }).click(); - await page.getByRole('button', { name: '✦ Assistant' }).click(); + await page.locator('.statusbar .statusbar-term').first().click(); + const dock = page.locator('.assistant-dock'); + await expect(dock).toBeVisible({ timeout: 20_000 }); + await dock.getByRole('tab', { name: 'Companion' }).click(); const composer = page.locator('.companion .composer textarea'); await expect(composer).toBeVisible({ timeout: 20_000 }); - // "Ask agent" shows only because the toggle is on; clicking arms the overlay. + // "Ask agent" shows only because the toggle is on; clicking arms the overlay + // — and the dock steps aside while armed (D2.2), without flipping `open`. const ask = page.locator('.companion button[aria-label*="Ask agent"]'); await expect(ask).toBeVisible(); await ask.click(); await expect(page.locator('.annot-overlay')).toBeVisible(); + await expect(dock).toBeHidden(); // Drag a rect with trusted mouse input; main captures the real window. await page.mouse.move(220, 220); @@ -185,7 +194,10 @@ test('D2: drag → target row → companion chip → postAgentInput carries the await bar.locator('.annot-note').fill('what is this?'); await bar.getByRole('button', { name: /Send to kimi-1/ }).click(); - // The crop is a delete-able chip in the compose box; the note is the draft. + // The handoff reveals the dock on the Companion tab; the crop is a + // delete-able chip in the compose box; the note is the draft. + await expect(dock).toBeVisible(); + await expect(dock.getByRole('tab', { name: 'Companion' })).toHaveClass(/active/); const chip = page.locator('.companion .att-chip'); await expect(chip).toBeVisible(); await expect(chip.locator('.att-thumb')).toBeVisible(); @@ -207,15 +219,18 @@ test('D2.1: status-bar chip arms GLOBALLY — the bound companion is still offer test.setTimeout(90_000); // Builds on the first test's state (serial file, one app instance): the hub - // is connected and the Author companion is mounted + bound to ag_e2e1, so - // it sits in the annotation store's companion registry. kimi web isn't + // is connected and the dock companion is mounted + bound to ag_e2e1, so it + // sits in the annotation store's companion registry. kimi web isn't // running in e2e, so the companion row is the only target offered. + const dock = page.locator('.assistant-dock'); const chip = page.locator('.statusbar-annotate'); await expect(chip).toBeVisible(); await chip.click(); await expect(page.locator('.annot-overlay')).toBeVisible(); - // The chip reads active while the overlay is armed (the dock-chip idiom). + // The chip reads active while the overlay is armed (the dock-chip idiom) — + // and the dock steps aside again. await expect(chip).toHaveClass(/active/); + await expect(dock).toBeHidden(); // Same trusted drag; main captures the real window. await page.mouse.move(240, 240); @@ -223,7 +238,7 @@ test('D2.1: status-bar chip arms GLOBALLY — the bound companion is still offer await page.mouse.move(560, 460, { steps: 6 }); await page.mouse.up(); - // No companion armed the overlay, yet the bound Author companion's session + // No companion armed the overlay, yet the bound dock companion's session // is offered — the D2.1 registry resolution, not an origin. const bar = page.locator('.annot-target'); await expect(bar).toBeVisible({ timeout: 20_000 }); @@ -231,7 +246,9 @@ test('D2.1: status-bar chip arms GLOBALLY — the bound companion is still offer await bar.locator('.annot-note').fill('from the chip'); await bar.getByRole('button', { name: /Send to kimi-1/ }).click(); - // The crop lands as a chip in that companion's compose box, note as draft. + // The reveal lands the dock on the Companion tab; the crop is a chip in its + // compose box, note as draft. + await expect(dock).toBeVisible(); const att = page.locator('.companion .att-chip'); await expect(att).toBeVisible(); await expect(att.locator('.att-thumb')).toBeVisible(); diff --git a/desktop/src/i18n/index.ts b/desktop/src/i18n/index.ts index a07612a04..cb322f416 100644 --- a/desktop/src/i18n/index.ts +++ b/desktop/src/i18n/index.ts @@ -477,6 +477,8 @@ const en: Dict = { 'settings.catAssistant': 'Assistant', 'assistant.title': 'Assistant', 'assistant.toggleHint': 'Toggle the assistant dock (kimi) — hides, keeps running', + 'assistant.tabKimi': 'kimi web', + 'assistant.tabCompanion': 'Companion', 'assistant.settings': 'Assistant settings', 'assistant.dockRight': 'Dock right', 'assistant.dockBottom': 'Dock bottom', @@ -1371,7 +1373,6 @@ const en: Dict = { 'read.adding': 'Adding…', 'read.idNotFound': 'Couldn’t resolve that identifier.', 'read.tabMeta': 'Meta', - 'read.tabAssistant': '✦ Assistant', 'read.scrape': 'Enrich', 'read.scraping': 'Enriching…', 'read.rescrape': 'Re-enrich', @@ -1621,8 +1622,6 @@ const en: Dict = { 'author.confirmClose': 'Close this document? Unsaved in-app text is removed.', 'author.untitled': 'Untitled', 'author.noDocs': 'No documents open.', - 'author.assistant': '✦ Assistant', - 'author.assistantHint': 'Open the agent-assist panel alongside the editor', 'author.newDiagram': 'Diagram (draw.io)', 'author.newCanvas': 'Board (canvas)', 'author.newTable': 'Table', @@ -2529,6 +2528,8 @@ const zh: Dict = { 'settings.catAssistant': '助手', 'assistant.title': '助手', 'assistant.toggleHint': '切换助手停靠面板(kimi)— 隐藏后继续运行', + 'assistant.tabKimi': 'kimi web', + 'assistant.tabCompanion': '智能体', 'assistant.settings': '助手设置', 'assistant.dockRight': '停靠右侧', 'assistant.dockBottom': '停靠底部', @@ -3404,7 +3405,6 @@ const zh: Dict = { 'read.adding': '添加中…', 'read.idNotFound': '无法解析该标识符。', 'read.tabMeta': '元数据', - 'read.tabAssistant': '✦ 助手', 'read.scrape': '抓取', 'read.scraping': '抓取中…', 'read.rescrape': '重新抓取', @@ -3652,8 +3652,6 @@ const zh: Dict = { 'author.confirmClose': '关闭此文档?未保存到磁盘的应用内文本将被移除。', 'author.untitled': '无标题', 'author.noDocs': '没有打开的文档。', - 'author.assistant': '✦ 助手', - 'author.assistantHint': '在编辑器旁打开智能体协助面板', 'author.newDiagram': '图表(draw.io)', 'author.newCanvas': '白板(canvas)', 'author.newTable': '表格', diff --git a/desktop/src/state/annotation.test.ts b/desktop/src/state/annotation.test.ts new file mode 100644 index 000000000..fd9789a86 --- /dev/null +++ b/desktop/src/state/annotation.test.ts @@ -0,0 +1,50 @@ +/// Tests for the annotation flow's dock interplay (D2.2): the annotating-hide +/// flag the assistant dock reads (`dockHiddenForPhase` — arm → hidden, +/// cancel/discard/target-pick → restored, all WITHOUT flipping the dock's +/// `open` state), the handoff routing (`handoffKey`), and the handoff reveal +/// (`handoffRevealsDock` → the store calls `reveal('companion')`, whose +/// open + companion-tab semantics are covered in assistant.test.ts, as is +/// `reveal('kimi')` for the kimi-attach case). The store itself imports the +/// shell bridge, which node ESM cannot resolve — so the decisions live in +/// annotationTargets.ts and are tested here directly. +/// Run locally: `node --test src/state/annotation.test.ts` from `desktop/`. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + dockHiddenForPhase, + GLOBAL_ORIGIN, + handoffKey, + handoffRevealsDock, +} from './annotationTargets.ts'; +import { useAssistant } from './assistant.ts'; +import { DOCK_COMPANION_KEY } from './companionBinding.ts'; + +test('annotating-hide: armed (selecting OR target) hides; idle restores', () => { + assert.equal(dockHiddenForPhase('selecting'), true); // arm → hidden + assert.equal(dockHiddenForPhase('target'), true); // the target row keeps the dock aside + assert.equal(dockHiddenForPhase('idle'), false); // cancel / discard / pick → restored +}); + +test('handoffKey: explicit key (global arm) wins; origin key routes a companion arm', () => { + assert.equal(handoffKey(GLOBAL_ORIGIN, DOCK_COMPANION_KEY), DOCK_COMPANION_KEY); + assert.equal(handoffKey({ storageKey: DOCK_COMPANION_KEY, agentId: 'ag_1' }), DOCK_COMPANION_KEY); + assert.equal(handoffKey(GLOBAL_ORIGIN), null); // no route → the store no-ops + assert.equal(handoffKey(null), null); +}); + +test('handoffRevealsDock: only the dock companion reveals', () => { + assert.equal(handoffRevealsDock(DOCK_COMPANION_KEY), true); + assert.equal(handoffRevealsDock('some.other.companion'), false); +}); + +test('the reveal a dock-companion handoff triggers: open + started + companion tab', () => { + useAssistant.setState({ open: false, started: false, detached: false, view: 'kimi' }); + assert.equal(handoffRevealsDock(DOCK_COMPANION_KEY), true); + useAssistant.getState().reveal('companion'); + const s = useAssistant.getState(); + assert.deepEqual({ open: s.open, started: s.started, view: s.view }, { + open: true, + started: true, + view: 'companion', + }); +}); diff --git a/desktop/src/state/annotation.ts b/desktop/src/state/annotation.ts index dfb0c69e7..396b7c407 100644 --- a/desktop/src/state/annotation.ts +++ b/desktop/src/state/annotation.ts @@ -1,13 +1,24 @@ import { create } from 'zustand'; import { invoke } from '../bridge'; import { isShell } from '../platform'; +import { useAssistant } from './assistant'; import { + handoffKey, + handoffRevealsDock, removeCompanion, upsertCompanion, type AnnotationOrigin, + type AnnotationPhase, type CompanionTarget, } from './annotationTargets'; +// The origin / companion-target types + the target-row resolution + the pure +// decisions (dock-hide flag, handoff routing/reveal) live in +// annotationTargets.ts (bridge-free, so node --test covers the contract); +// re-exported here so existing importers keep one entry point. +export { GLOBAL_ORIGIN, dockHiddenForPhase, resolveTargets } from './annotationTargets'; +export type { AnnotationOrigin, CompanionTarget } from './annotationTargets'; + /// Annotation overlay state (D2 — docs/plans/desktop-ui-context-and-pointing.md /// §3.4): the user→agent pointing gesture. The trigger ARMS the overlay (only /// while the UI-context-sharing toggle is on — the same toggle as D1, no new @@ -23,6 +34,13 @@ import { /// Esc / cancel attaches nothing and records nothing (plan §3.8); a refused /// selection (rect fully inside a capture:refuse region) keeps the overlay /// armed with a hint so the user re-selects. +/// +/// Dock interplay (the unified assistant dock, D2.2): while the overlay is +/// armed (`phase !== 'idle'`) the dock hides WITHOUT flipping its `open` +/// state (ui/AssistantDock.tsx reads the phase into a CSS hide class) so it +/// stays out of the captured pixels; cancel/discard/target-pick restore it. +/// A handoff to the dock companion REVEALS the dock on the companion tab so +/// the user sees the crop chip arrive (`handOffToCompanion` below). export interface AnnotationCapture { /// Main-side 0o600 temp path (the kimi injection + discard). @@ -36,12 +54,6 @@ export interface AnnotationCapture { target: 'shell' | 'guest'; } -// The origin / companion-target types + the target-row resolution live in -// annotationTargets.ts (import-free, so node --test covers the contract); -// re-exported here so existing importers keep one entry point. -export { GLOBAL_ORIGIN, resolveTargets } from './annotationTargets'; -export type { AnnotationOrigin, CompanionTarget } from './annotationTargets'; - /// The companion-path handoff: the crop becomes a delete-able chip in the /// arming companion's compose box and the note lands in its draft — the user /// reviews and hits send (the send is always the user's). `id` bumps per @@ -56,7 +68,7 @@ export interface AnnotationHandoff { } interface AnnotationState { - phase: 'idle' | 'selecting' | 'target'; + phase: AnnotationPhase; origin: AnnotationOrigin | null; capture: AnnotationCapture | null; /// The surface id whose refuse region swallowed the last selection (a hint @@ -112,8 +124,8 @@ export const useAnnotation = create((set, get) => ({ }, handOffToCompanion: (note, storageKey) => { const { origin, capture } = get(); - const key = storageKey ?? origin?.storageKey; - if (key === undefined || capture === null) return; + const key = handoffKey(origin, storageKey); + if (key === null || capture === null) return; handoffSeq += 1; set({ phase: 'idle', @@ -131,6 +143,10 @@ export const useAnnotation = create((set, get) => ({ // ever for the kimi injection — drop it. if (isShell()) void invoke('annotation_discard', { file: capture.file }).catch(() => undefined); set({ origin: null, capture: null }); + // D2.2: a handoff to the dock companion (the only registered one now — + // the per-surface mounts are retired) reveals the dock on the companion + // tab so the user watches the crop chip land in its compose box. + if (handoffRevealsDock(key)) useAssistant.getState().reveal('companion'); }, clearHandoff: () => set({ handoff: null }), registerCompanion: (c) => set((s) => ({ companions: upsertCompanion(s.companions, c) })), diff --git a/desktop/src/state/annotationTargets.test.ts b/desktop/src/state/annotationTargets.test.ts index 391df189d..e55a92078 100644 --- a/desktop/src/state/annotationTargets.test.ts +++ b/desktop/src/state/annotationTargets.test.ts @@ -17,8 +17,11 @@ import { type CompanionTarget, } from './annotationTargets.ts'; -const COMPANION_A: CompanionTarget = { storageKey: 'termipod.read.agent', agentId: 'ag_1', agentLabel: 'kimi-1' }; -const COMPANION_B: CompanionTarget = { storageKey: 'termipod.author.agent', agentId: 'ag_2', agentLabel: 'kimi-2' }; +// The dock companion's key is the only registered one in the app now (the +// per-surface mounts are retired); a second fixture keeps the multi-entry +// registry semantics covered. +const COMPANION_A: CompanionTarget = { storageKey: 'termipod.dock.agent', agentId: 'ag_1', agentLabel: 'kimi-1' }; +const COMPANION_B: CompanionTarget = { storageKey: 'termipod.dock.agent.b', agentId: 'ag_2', agentLabel: 'kimi-2' }; // ── Global arm (D2.1 — status-bar chip / palette) ─────────────────────────── @@ -49,7 +52,7 @@ test('global arm offers the first registered BOUND companion', () => { }); test('global arm skips unbound registrations', () => { - const unbound: CompanionTarget = { storageKey: 'termipod.read.agent', agentId: '', agentLabel: '' }; + const unbound: CompanionTarget = { storageKey: 'termipod.dock.agent', agentId: '', agentLabel: '' }; const t = resolveTargets({ kimiOpen: false, origin: GLOBAL_ORIGIN, companions: [unbound, COMPANION_B] }); assert.deepEqual(t.companion, COMPANION_B); }); @@ -64,7 +67,7 @@ test('a null origin resolves exactly like a global arm', () => { test('companion arm, bound: only the arming mount is offered', () => { const t = resolveTargets({ kimiOpen: true, - origin: { storageKey: 'termipod.author.agent', agentId: 'ag_2', agentLabel: 'kimi-2' }, + origin: { storageKey: 'termipod.dock.agent.b', agentId: 'ag_2', agentLabel: 'kimi-2' }, companions: [COMPANION_A, COMPANION_B], }); assert.equal(t.kimi, true); @@ -74,7 +77,7 @@ test('companion arm, bound: only the arming mount is offered', () => { test('companion arm, UNBOUND: no companion row even when another mount is bound (D2 isolation)', () => { const t = resolveTargets({ kimiOpen: false, - origin: { storageKey: 'termipod.read.reader.agent', agentId: '', agentLabel: '' }, + origin: { storageKey: 'termipod.dock.agent', agentId: '', agentLabel: '' }, companions: [COMPANION_A], }); assert.equal(t.kimi, false); diff --git a/desktop/src/state/annotationTargets.ts b/desktop/src/state/annotationTargets.ts index e78311207..dbca0b522 100644 --- a/desktop/src/state/annotationTargets.ts +++ b/desktop/src/state/annotationTargets.ts @@ -1,9 +1,24 @@ /// Target resolution for the annotation overlay (D2.1 — -/// docs/plans/desktop-ui-context-and-pointing.md §3.4 step 2): pure, import- -/// free logic shared by the zustand store (annotation.ts) and the target row -/// (AnnotationOverlay.tsx), kept import-free so `node --test` can run the -/// contract directly (the store itself imports the shell bridge, which node -/// ESM cannot resolve). +/// docs/plans/desktop-ui-context-and-pointing.md §3.4 step 2): pure logic +/// shared by the zustand store (annotation.ts) and the target row +/// (AnnotationOverlay.tsx), kept free of the shell bridge so `node --test` +/// can run the contract directly (the store itself imports the bridge, which +/// node ESM cannot resolve). + +import { DOCK_COMPANION_KEY } from './companionBinding.ts'; + +/// The overlay's lifecycle. The store (annotation.ts) holds the state; the +/// reducers + decisions below keep it honest. +export type AnnotationPhase = 'idle' | 'selecting' | 'target'; + +/// The annotating-hide flag the assistant dock reads (D2.2): while the overlay +/// is active (selecting OR target) the dock steps aside via a CSS hide class — +/// WITHOUT its `open` state flipping — so it stays out of the captured pixels +/// and the drag gets an unobstructed surface. Idle (cancel / discard / target +/// pick) restores the dock exactly as it was. +export function dockHiddenForPhase(phase: AnnotationPhase): boolean { + return phase !== 'idle'; +} /// Who armed the overlay. A COMPANION arm carries that mount's `storageKey` /// so the handoff routes back to it, plus its bound agent — `agentId` '' @@ -88,3 +103,18 @@ export function upsertCompanion(list: readonly CompanionTarget[], c: CompanionTa export function removeCompanion(list: readonly CompanionTarget[], storageKey: string): CompanionTarget[] { return list.filter((x) => x.storageKey !== storageKey); } + +/// Where a "Send to " handoff routes: the explicitly passed key (a +/// global arm's resolved companion) else the arming origin's mount. null = no +/// route — the store no-ops. +export function handoffKey(origin: AnnotationOrigin | null, storageKey?: string): string | null { + return storageKey ?? origin?.storageKey ?? null; +} + +/// Whether a handoff to `key` should reveal the assistant dock on its +/// Companion tab (D2.2): true only for the dock companion — the one companion +/// mount now that the per-surface mounts are retired. The reveal makes the +/// user watch the crop chip land in the compose box. +export function handoffRevealsDock(key: string): boolean { + return key === DOCK_COMPANION_KEY; +} diff --git a/desktop/src/state/assistant.test.ts b/desktop/src/state/assistant.test.ts new file mode 100644 index 000000000..83e60651c --- /dev/null +++ b/desktop/src/state/assistant.test.ts @@ -0,0 +1,82 @@ +/// Tests for the unified assistant dock's tab state (state/assistant.ts): +/// `view` persistence + switching, the `reveal` semantics the annotation flows +/// use (kimi attach → open + kimi tab; companion handoff → open + companion +/// tab), and the kimi-attach availability condition (started && !detached — +/// the dock may be hidden, the guest stays mounted). +/// Run locally: `node --test src/state/assistant.test.ts` from `desktop/`. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { kimiAttachable, loadView, useAssistant } from './assistant.ts'; + +/// Minimal in-memory localStorage stand-in (node has no Web Storage). +function stubLocalStorage(): Map { + const m = new Map(); + (globalThis as { localStorage?: unknown }).localStorage = { + getItem: (k: string) => m.get(k) ?? null, + setItem: (k: string, v: string) => void m.set(k, String(v)), + removeItem: (k: string) => void m.delete(k), + clear: () => m.clear(), + }; + return m; +} + +test('loadView: parses the persisted tab, defaults to kimi', () => { + const store = stubLocalStorage(); + assert.equal(loadView(), 'kimi'); + store.set('termipod.assistant.view', 'companion'); + assert.equal(loadView(), 'companion'); + store.set('termipod.assistant.view', 'kimi'); + assert.equal(loadView(), 'kimi'); + // Anything unrecognized degrades to kimi. + store.set('termipod.assistant.view', 'garbage'); + assert.equal(loadView(), 'kimi'); +}); + +test('setView: switches the tab and persists it (like dockSide)', () => { + const store = stubLocalStorage(); + useAssistant.getState().setView('companion'); + assert.equal(useAssistant.getState().view, 'companion'); + assert.equal(store.get('termipod.assistant.view'), 'companion'); + useAssistant.getState().setView('kimi'); + assert.equal(useAssistant.getState().view, 'kimi'); + assert.equal(store.get('termipod.assistant.view'), 'kimi'); +}); + +test('reveal: opens the dock (starting it if needed) on the given tab', () => { + stubLocalStorage(); + useAssistant.setState({ open: false, started: false, detached: false, view: 'kimi' }); + // The companion-handoff case: the dock must MOUNT (started) so the + // companion is there to take the chip, and land on the companion tab. + useAssistant.getState().reveal('companion'); + assert.deepEqual( + { + open: useAssistant.getState().open, + started: useAssistant.getState().started, + view: useAssistant.getState().view, + }, + { open: true, started: true, view: 'companion' }, + ); + // The kimi-attach case: from a hidden-but-started dock back onto kimi. + useAssistant.setState({ open: false, view: 'companion' }); + useAssistant.getState().reveal('kimi'); + assert.deepEqual( + { open: useAssistant.getState().open, started: useAssistant.getState().started, view: useAssistant.getState().view }, + { open: true, started: true, view: 'kimi' }, + ); +}); + +test('kimiAttachable: started && !detached — open is irrelevant (the guest hides, never unmounts)', () => { + assert.equal(kimiAttachable({ started: true, detached: false }), true); + assert.equal(kimiAttachable({ started: false, detached: false }), false); // never opened: no guest + assert.equal(kimiAttachable({ started: true, detached: true }), false); // popped out: dock has no guest + assert.equal(kimiAttachable({ started: false, detached: true }), false); +}); + +test('close: still resets the daemon bits but keeps the persisted view', () => { + stubLocalStorage(); + useAssistant.setState({ open: true, started: true, detached: false, view: 'companion' }); + useAssistant.getState().close(); + assert.equal(useAssistant.getState().open, false); + assert.equal(useAssistant.getState().started, false); + assert.equal(useAssistant.getState().view, 'companion'); // the tab choice survives a close +}); diff --git a/desktop/src/state/assistant.ts b/desktop/src/state/assistant.ts index cc5f7774e..408460802 100644 --- a/desktop/src/state/assistant.ts +++ b/desktop/src/state/assistant.ts @@ -1,6 +1,6 @@ import { create } from 'zustand'; -/// App-level assistant (kimi-web) dock state — the terminal-dock pattern +/// App-level assistant dock state — the terminal-dock pattern /// (terminal/store.ts): the dock is a persistent, daemon-like panel that HIDES /// on toggle rather than closing. `started` flips on first open and stays true /// until an explicit, confirmed Close — while it's true the dock keeps its @@ -8,10 +8,17 @@ import { create } from 'zustand'; /// `kimi web` server it refcounts) keeps running underneath, exactly like /// terminal sessions under a hidden terminal dock. `detached` mirrors the /// main-process popped-out window (kimiwebwin.ts). +/// +/// The dock is TABBED (the unified assistant dock): `view` picks the kimi-web +/// tab or the Companion tab (the dock's AgentCompanion); both render once +/// `started` and are CSS-hidden by `view`, never unmounted. `view` persists +/// like `dockSide`. export type AssistantDockSide = 'bottom' | 'right'; +export type AssistantView = 'kimi' | 'companion'; const SIDE_KEY = 'termipod.assistant.dockSide'; +const VIEW_KEY = 'termipod.assistant.view'; function loadSide(): AssistantDockSide { try { @@ -21,6 +28,25 @@ function loadSide(): AssistantDockSide { } } +/// Exported for `node --test` (the store reads it once at module load, before +/// a test can stub localStorage). +export function loadView(): AssistantView { + try { + return localStorage.getItem(VIEW_KEY) === 'companion' ? 'companion' : 'kimi'; + } catch { + return 'kimi'; + } +} + +/// Kimi-attach availability for the annotation overlay (D2.2 amendment): the +/// guest is mounted (`started`) and embedded (not detached to its own window). +/// The dock need NOT be open — the kimi tab hides, it never unmounts, so a +/// hidden dock's guest can still take an injection; a successful attach then +/// REVEALS the dock on the kimi tab (`reveal`). +export function kimiAttachable(s: { started: boolean; detached: boolean }): boolean { + return s.started && !s.detached; +} + interface AssistantState { /// Dock visible right now (hide keeps everything mounted + running). open: boolean; @@ -30,10 +56,18 @@ interface AssistantState { /// The SPA is popped out into its own native window (dock shows a placeholder). detached: boolean; dockSide: AssistantDockSide; + /// Which tab the dock shows: the embedded kimi web SPA or the Companion. + view: AssistantView; toggle: () => void; setOpen: (v: boolean) => void; setDetached: (v: boolean) => void; setDockSide: (s: AssistantDockSide) => void; + setView: (v: AssistantView) => void; + /// Reveal the dock on a tab — open + started (mounts the dock if it never + /// opened) + switch the tab. The annotation flows land the user in the + /// right composer: 'kimi' after a kimi attach, 'companion' when a handoff + /// goes to the dock companion. + reveal: (v: AssistantView) => void; /// The explicit close: unmounts the panel (releasing the server's last dock /// hold) and hides the dock. Callers confirm first — this stops the daemon. close: () => void; @@ -44,6 +78,7 @@ export const useAssistant = create((set) => ({ started: false, detached: false, dockSide: loadSide(), + view: loadView(), toggle: () => set((s) => ({ open: !s.open, started: s.started || !s.open })), setOpen: (v) => set((s) => ({ open: v, started: s.started || v })), setDetached: (v) => set({ detached: v }), @@ -55,5 +90,14 @@ export const useAssistant = create((set) => ({ } set({ dockSide: side }); }, + setView: (view) => { + try { + localStorage.setItem(VIEW_KEY, view); + } catch { + /* preference only */ + } + set({ view }); + }, + reveal: (view) => set({ open: true, started: true, view }), close: () => set({ open: false, started: false, detached: false }), })); diff --git a/desktop/src/state/companionBinding.test.ts b/desktop/src/state/companionBinding.test.ts new file mode 100644 index 000000000..026b3649f --- /dev/null +++ b/desktop/src/state/companionBinding.test.ts @@ -0,0 +1,46 @@ +/// Tests for the dock companion's binding-key migration +/// (state/companionBinding.ts): the unified dock's `termipod.dock.agent` key +/// falls back to the retired per-surface mounts' keys so an existing agent +/// binding survives the move. +/// Run locally: `node --test src/state/companionBinding.test.ts` from +/// `desktop/`. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { DOCK_COMPANION_KEY, loadCompanionBinding } from './companionBinding.ts'; + +const get = + (m: Record) => + (k: string): string | null => + m[k] ?? null; + +test('the dock key wins when set (no fallback)', () => { + const m = { [DOCK_COMPANION_KEY]: 'ag_new', 'termipod.read.agent': 'ag_old' }; + assert.equal(loadCompanionBinding(get(m), DOCK_COMPANION_KEY), 'ag_new'); +}); + +test('unset dock key falls back to the retired Read mount', () => { + assert.equal(loadCompanionBinding(get({ 'termipod.read.agent': 'ag_r' }), DOCK_COMPANION_KEY), 'ag_r'); +}); + +test('unset dock key falls back to the retired Author mount', () => { + assert.equal(loadCompanionBinding(get({ 'termipod.author.agent': 'ag_a' }), DOCK_COMPANION_KEY), 'ag_a'); +}); + +test('both legacy keys present: Read wins (priority order)', () => { + const m = { 'termipod.read.agent': 'ag_r', 'termipod.author.agent': 'ag_a' }; + assert.equal(loadCompanionBinding(get(m), DOCK_COMPANION_KEY), 'ag_r'); +}); + +test('nothing anywhere: unbound', () => { + assert.equal(loadCompanionBinding(get({}), DOCK_COMPANION_KEY), ''); +}); + +test('an empty-string dock binding counts as unset and falls back', () => { + const m = { [DOCK_COMPANION_KEY]: '', 'termipod.author.agent': 'ag_a' }; + assert.equal(loadCompanionBinding(get(m), DOCK_COMPANION_KEY), 'ag_a'); +}); + +test('non-dock mounts do NOT fall back (their own key or nothing)', () => { + assert.equal(loadCompanionBinding(get({ 'termipod.read.agent': 'ag_r' }), 'some.other.key'), ''); + assert.equal(loadCompanionBinding(get({ 'some.other.key': 'ag_x' }), 'some.other.key'), 'ag_x'); +}); diff --git a/desktop/src/state/companionBinding.ts b/desktop/src/state/companionBinding.ts new file mode 100644 index 000000000..bbd902a29 --- /dev/null +++ b/desktop/src/state/companionBinding.ts @@ -0,0 +1,27 @@ +/// The dock companion's agent-binding storage key + the one-time migration +/// from the retired per-surface mounts (Read's aside, Author's aside). The +/// unified assistant dock (ui/AssistantDock.tsx) hosts the ONE AgentCompanion +/// now; users who bound an agent on a retired mount keep that binding through +/// the fallback below. Import-free so `node --test` covers the contract. + +export const DOCK_COMPANION_KEY = 'termipod.dock.agent'; + +/// The retired mounts' binding keys, in fallback priority order — Read's +/// aside wins over Author's when both exist ("whichever exists", first hit). +/// The reader tab's own key (`termipod.read.reader.agent`) is deliberately +/// not migrated: it was a second, reader-scoped binding, not the primary one. +const LEGACY_COMPANION_KEYS = ['termipod.read.agent', 'termipod.author.agent'] as const; + +/// Load the bound agent id for a companion mount. For the dock companion an +/// unset key falls back to the retired mounts' keys so an existing binding +/// survives the move (read-only: the next explicit pick writes the new key). +export function loadCompanionBinding(get: (key: string) => string | null, storageKey: string): string { + const cur = get(storageKey); + if (cur !== null && cur !== '') return cur; + if (storageKey !== DOCK_COMPANION_KEY) return ''; + for (const key of LEGACY_COMPANION_KEYS) { + const legacy = get(key); + if (legacy !== null && legacy !== '') return legacy; + } + return ''; +} diff --git a/desktop/src/state/companionContext.test.ts b/desktop/src/state/companionContext.test.ts new file mode 100644 index 000000000..c9176034a --- /dev/null +++ b/desktop/src/state/companionContext.test.ts @@ -0,0 +1,83 @@ +/// Tests for the surface-context provider registry (state/companionContext.ts) +/// feeding the dock companion: register / replace / focus order / unregister / +/// insert passthrough. The registry backs the unified assistant dock — the +/// companion reads the ACTIVE provider (the most-recently-registered surface). +/// Run locally: `node --test src/state/companionContext.test.ts` from +/// `desktop/`. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + activeProvider, + useCompanionContext, + type CompanionContextProvider, +} from './companionContext.ts'; + +function provider(label: string, block = `ctx:${label}`): CompanionContextProvider { + return { label, build: () => block }; +} + +function reset(): void { + useCompanionContext.setState({ providers: {}, order: [] }); +} + +test('register: the first provider is active', () => { + reset(); + useCompanionContext.getState().register('read', provider('paper')); + assert.equal(activeProvider(useCompanionContext.getState())?.label, 'paper'); +}); + +test('focus order: the most-recently-registered surface wins', () => { + reset(); + const { register } = useCompanionContext.getState(); + register('read', provider('paper')); + register('author', provider('doc')); + assert.equal(activeProvider(useCompanionContext.getState())?.label, 'doc'); + // A selection change on Read re-registers it → Read tops the stack again. + register('read', provider('paper-2')); + assert.equal(activeProvider(useCompanionContext.getState())?.label, 'paper-2'); +}); + +test('replace: re-registering a job swaps the provider, no duplicate', () => { + reset(); + const { register } = useCompanionContext.getState(); + register('author', provider('doc')); + register('author', provider('doc-renamed')); + const s = useCompanionContext.getState(); + assert.deepEqual(s.order, ['author']); + assert.equal(activeProvider(s)?.label, 'doc-renamed'); +}); + +test('unregister: drops the provider and falls back to the next-most-recent', () => { + reset(); + const { register, unregister } = useCompanionContext.getState(); + register('read', provider('paper')); + register('author', provider('doc')); + unregister('author'); + assert.equal(activeProvider(useCompanionContext.getState())?.label, 'paper'); + unregister('read'); + assert.equal(activeProvider(useCompanionContext.getState()), null); + // Unregistering an unknown job is a no-op (state identity preserved). + const before = useCompanionContext.getState(); + unregister('nope'); + assert.equal(useCompanionContext.getState(), before); +}); + +test('insert passthrough: the active provider carries its insert target', () => { + reset(); + const inserted: string[] = []; + useCompanionContext.getState().register('author', { + label: 'doc', + build: () => 'ctx', + insert: (text) => inserted.push(text), + }); + const p = activeProvider(useCompanionContext.getState()); + assert.equal(typeof p?.insert, 'function'); + p?.insert?.('reply text'); + assert.deepEqual(inserted, ['reply text']); +}); + +test('a provider without insert stays insert-less (structured bodies)', () => { + reset(); + useCompanionContext.getState().register('author', provider('diagram')); + assert.equal(activeProvider(useCompanionContext.getState())?.insert, undefined); +}); diff --git a/desktop/src/state/companionContext.ts b/desktop/src/state/companionContext.ts new file mode 100644 index 000000000..430ff0cbb --- /dev/null +++ b/desktop/src/state/companionContext.ts @@ -0,0 +1,66 @@ +import { create } from 'zustand'; + +/// Surface-provided context for the dock companion (the unified assistant +/// dock — ui/AssistantDock.tsx). Each surface that can feed the companion +/// registers a provider keyed by its job: a `label` for the context chip, a +/// `build()` that produces the context block prepended to a sent message, and +/// an optional `insert()` for the "insert reply into the surface" affordance. +/// The dock's AgentCompanion reads the ACTIVE provider and uses it for its +/// context chip + onInsert. +/// +/// Focus tracking — the sanctioned approximation: Read/Author have no focus +/// event of their own, so a surface REGISTERS on mount and again on +/// selection / active-document change, and UNREGISTERS on unmount (or when it +/// no longer has a context to offer — e.g. no paper selected). Registration +/// moves the job to the top of the focus-order stack, so the active provider +/// is the surface whose context the user most recently touched. In a split +/// (Read | Author both mounted) simply editing without a selection change +/// does not re-signal focus — cheap and documented, per the approved design. + +export interface CompanionContextProvider { + /// Shown in the companion's context chip (paper/doc title). + label: string; + /// The context block prepended to a sent message. Read LIVE at send time + /// (providers close over the store, not a render snapshot). + build: () => string; + /// The "insert reply" target — absent when the surface has no safe insert + /// (e.g. a structured document body). + insert?: (text: string) => void; +} + +interface CompanionContextState { + providers: Readonly>; + /// Focus order, least → most recent; the active provider tops the stack. + order: readonly string[]; + /// Upsert the job's provider AND mark it most-recently focused. + register: (job: string, provider: CompanionContextProvider) => void; + unregister: (job: string) => void; +} + +/// The provider the dock companion should use right now: the most recently +/// registered surface's. Returns the stored object (stable reference, safe as +/// a zustand selector result). +export function activeProvider(s: { + providers: Readonly>; + order: readonly string[]; +}): CompanionContextProvider | null { + const top = s.order[s.order.length - 1]; + return top !== undefined ? (s.providers[top] ?? null) : null; +} + +export const useCompanionContext = create((set) => ({ + providers: {}, + order: [], + register: (job, provider) => + set((s) => ({ + providers: { ...s.providers, [job]: provider }, + order: [...s.order.filter((j) => j !== job), job], + })), + unregister: (job) => + set((s) => { + if (!(job in s.providers)) return s; + const providers = { ...s.providers }; + delete providers[job]; + return { providers, order: s.order.filter((j) => j !== job) }; + }), +})); diff --git a/desktop/src/styles/partials/02-job-surfaces.css b/desktop/src/styles/partials/02-job-surfaces.css index b667dafec..f4002c993 100644 --- a/desktop/src/styles/partials/02-job-surfaces.css +++ b/desktop/src/styles/partials/02-job-surfaces.css @@ -454,16 +454,8 @@ button > .ui-icon:not(:only-child):first-child, .author-nav-show { border-left: none; } -.author-agent, -.read-agent { - flex: 0 0 auto; - min-width: 0; - border-left: 1px solid var(--border); - background: var(--surface); - display: flex; -} - -/* AgentCompanion — hub-attached assistant panel (Read + Author). */ +/* AgentCompanion — hub-attached assistant panel (the unified assistant dock's + Companion tab; the per-surface Read/Author mounts are retired). */ .companion { flex: 1; min-width: 0; diff --git a/desktop/src/styles/partials/04-library-nav.css b/desktop/src/styles/partials/04-library-nav.css index a1c380aab..df58c224d 100644 --- a/desktop/src/styles/partials/04-library-nav.css +++ b/desktop/src/styles/partials/04-library-nav.css @@ -255,17 +255,6 @@ display: flex; flex-direction: column; } -/* The reader's assistant tab fills the side panel (the companion owns its own - scroll); no .scroll wrapper so the feed + composer lay out full-height. */ -.ref-assistant-body { - flex: 1; - min-height: 0; - display: flex; -} -.ref-assistant-body .companion { - flex: 1; - min-width: 0; -} .ref-form { padding: var(--spacing-s16); display: flex; diff --git a/desktop/src/styles/partials/08-plan-terminal-dock.css b/desktop/src/styles/partials/08-plan-terminal-dock.css index 6cadbfcdf..228e99652 100644 --- a/desktop/src/styles/partials/08-plan-terminal-dock.css +++ b/desktop/src/styles/partials/08-plan-terminal-dock.css @@ -1119,6 +1119,15 @@ visibility: hidden; pointer-events: none; } +/* Annotating = the annotation overlay is armed (any origin): the dock steps + aside WITHOUT its `open` state flipping — the overlay covers it at z-200, + but the dock must also stay out of the captured pixels and give the drag an + unobstructed surface. Same hide mechanics as .hidden (the webview hazard + applies identically); cancel / target pick restores the dock as it was. */ +.assistant-dock.annotating { + visibility: hidden; + pointer-events: none; +} .assistant-dock.right { top: 0; bottom: 0; @@ -1157,9 +1166,9 @@ padding: var(--spacing-s4) var(--spacing-s8); border-bottom: 1px solid var(--border); } -.assistant-dock-title { - font-size: var(--font-size-body-small); - font-weight: 600; +/* The tab strip (kimi web | Companion) leads the header. */ +.assistant-dock-tabs { + flex: 0 0 auto; } .assistant-dock-close:hover { color: var(--danger); @@ -1168,6 +1177,23 @@ flex: 1; min-height: 0; display: flex; + position: relative; +} +/* Both tabs render once the dock has started; the inactive one is hidden by + `view` — absolutely stacked so the hidden pane takes no layout space, and + visibility-hidden (never display:none) for the webview hazard above. */ +.assistant-dock-pane { + position: absolute; + inset: 0; + display: flex; +} +.assistant-dock-pane.hidden { + visibility: hidden; + pointer-events: none; +} +.assistant-dock-pane .companion { + flex: 1; + min-width: 0; } .assistant-dock-body .web-panel { flex: 1; diff --git a/desktop/src/surfaces/AuthorSurface.tsx b/desktop/src/surfaces/AuthorSurface.tsx index 3735ec287..fefff9b9b 100644 --- a/desktop/src/surfaces/AuthorSurface.tsx +++ b/desktop/src/surfaces/AuthorSurface.tsx @@ -1,7 +1,8 @@ import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { invoke } from '../bridge'; -import { useT } from '../i18n'; +import { useT, tStatic } from '../i18n'; import { isShell, revealPath } from '../platform'; +import { useCompanionContext } from '../state/companionContext'; import { toast } from '../state/toast'; import { bodyToFile, @@ -17,7 +18,6 @@ import { import { figureBySpec, FIGURES, type FigureSpec } from '../state/figures'; import { useWorkspace } from '../state/workspace'; import { NEW_BASE, uniqueWorkspacePath, writeDocToWorkspace } from '../state/workspaceFiles'; -import { AgentCompanion } from '../ui/AgentCompanion'; import { useContextMenu, type MenuItem } from '../ui/ContextMenu'; import { docKindIcon, Icon, type IconName } from '../ui/Icon'; import { useTextPrompt } from '../ui/PromptModal'; @@ -74,8 +74,10 @@ function useDebounced(value: T, ms: number): T { /// the active document lives. The landscape doc's posture is EMBED BlockNote + /// Quarto/Typst export for the reproducible-report path; a draw.io `diagram` /// document kind lands once drawio is bundled offline (see the -/// author-agent-assist-and-diagrams discussion). Agent-assisted writing (the -/// side panel) is deferred pending the host-OS discussion. +/// author-agent-assist-and-diagrams discussion). Agent-assisted writing lives +/// in the unified assistant dock's Companion tab — this surface feeds it the +/// active document's context through the provider registry +/// (state/companionContext.ts). function baseName(path: string): string { const parts = path.split(/[\\/]/); @@ -303,9 +305,6 @@ export function AuthorSurface(): JSX.Element { // Left file/workspace tree. On by default; width persisted. const [showNav, setShowNav] = useState(() => localStorage.getItem('termipod.author.showNav') !== '0'); const [navW, setNavW] = useState(() => loadW('termipod.author.navW', 240)); - // Agent-assist side panel (hub-attached). Off by default; width persisted. - const [showAgent, setShowAgent] = useState(false); - const [agentW, setAgentW] = useState(() => loadW('termipod.author.agentW', 380)); // Two-step close arm — `window.confirm` is unreliable in the Tauri webview, so // the first × click arms (turns into a confirm ×), the second closes. const [confirmClose, setConfirmClose] = useState(null); @@ -316,6 +315,61 @@ export function AuthorSurface(): JSX.Element { const active = docs.find((d) => d.id === activeId); const tauri = isShell(); + // The dock companion's context provider (the unified assistant dock, D2.2 — + // state/companionContext.ts): the active document. Register on mount + + // active-document change, unregister on unmount / no active document (the + // sanctioned no-focus-event approximation). `build`/`insert` read the + // documents store LIVE at call time, so the effect re-runs only when the + // label/kind changes, not on every keystroke. Insert semantics: prose + // APPENDS to a markdown body and REPLACES a figure body (a figure is a + // single spec source — an append would corrupt it; the agent answers with + // the whole diagram, `unfence`d). A diagram/canvas/table body is structured + // (XML/JSON) with no safe text insert, so no `insert` is registered for it. + const registerCtx = useCompanionContext((s) => s.register); + const unregisterCtx = useCompanionContext((s) => s.unregister); + const activeTitle = active?.title; + const activeKind = active?.kind; + useEffect(() => { + if (activeId === null) { + unregisterCtx('author'); + return; + } + const id = activeId; + const live = (): Doc | undefined => useDocuments.getState().docs.find((d) => d.id === id); + registerCtx('author', { + label: activeTitle !== undefined && activeTitle !== '' ? activeTitle : tStatic('author.untitled'), + build: () => { + const d = live(); + if (d === undefined) return ''; + return d.kind === 'markdown' + ? `I'm writing a document titled "${d.title}". Current draft:\n\n${d.body}` + : d.kind === 'figure' + ? `I'm authoring a ${d.spec ?? 'figure'} diagram titled "${d.title}", rendered from the \`\`\`${d.spec ?? ''}\`\`\` fenced syntax. Reply with ONLY the ${d.spec ?? 'figure'} source (optionally in a fenced block). Current source:\n\n${d.body}` + : `I'm editing a ${d.kind} document titled "${d.title}"${ + d.filePath !== undefined ? ` (file: ${d.filePath})` : '' + }.`; + }, + ...(activeKind === 'markdown' || activeKind === 'figure' + ? { + insert: (text: string) => { + const d = live(); + if (d === undefined) return; + if (d.kind === 'markdown') { + useDocuments.getState().update(id, { + body: d.body.trimEnd() === '' ? text : `${d.body.trimEnd()}\n\n${text}`, + }); + } else if (d.kind === 'figure') { + useDocuments.getState().update(id, { body: unfence(text) }); + } + }, + } + : {}), + }); + }, [activeId, activeTitle, activeKind, registerCtx, unregisterCtx]); + // Unmount removal lives in its own effect — riding the registration effect's + // cleanup would turn every re-register into remove+append churn. + useEffect(() => () => unregisterCtx('author'), [unregisterCtx]); + // Fold state for the workspace pane (persisted); the header chevron folds it, // a slim edge button re-opens it. function setNav(next: boolean): void { @@ -534,13 +588,6 @@ export function AuthorSurface(): JSX.Element { )} - } > @@ -646,55 +693,6 @@ export function AuthorSurface(): JSX.Element { ) : ( )} - {/* The agent panel is available for every document kind. Its - "insert reply" affordance appends prose to a markdown body and - REPLACES a figure body (a figure is a single spec source, so an - append would corrupt it — the agent answers with the whole diagram). - A diagram/canvas/table body is structured (XML/JSON) and has no safe - text insert, so for those the panel is read/assist-only. */} - {showAgent && ( - <> - - setAgentW((w) => { - const n = clamp(w - dx, 280, 720); - try { - localStorage.setItem('termipod.author.agentW', String(n)); - } catch { - /* ignore */ - } - return n; - }) - } - /> -
- - active.kind === 'markdown' - ? `I'm writing a document titled "${active.title}". Current draft:\n\n${active.body}` - : active.kind === 'figure' - ? `I'm authoring a ${active.spec ?? 'figure'} diagram titled "${active.title}", rendered from the \`\`\`${active.spec ?? ''}\`\`\` fenced syntax. Reply with ONLY the ${active.spec ?? 'figure'} source (optionally in a fenced block). Current source:\n\n${active.body}` - : `I'm editing a ${active.kind} document titled "${active.title}"${ - active.filePath !== undefined ? ` (file: ${active.filePath})` : '' - }.`, - }} - onInsert={ - active.kind === 'markdown' - ? (text) => - update(active.id, { - body: active.body.trimEnd() === '' ? text : `${active.body.trimEnd()}\n\n${text}`, - }) - : active.kind === 'figure' - ? (text) => update(active.id, { body: unfence(text) }) - : undefined - } - /> -
- - )} ) : (
diff --git a/desktop/src/surfaces/ReadSurface.tsx b/desktop/src/surfaces/ReadSurface.tsx index b509fc385..ad706926a 100644 --- a/desktop/src/surfaces/ReadSurface.tsx +++ b/desktop/src/surfaces/ReadSurface.tsx @@ -39,8 +39,8 @@ import { type ScrapeSeed, } from '../discovery'; import { hostOf, isShell, revealPath } from '../platform'; +import { useCompanionContext } from '../state/companionContext'; import { BrowserView } from './BrowserView'; -import { AgentCompanion } from '../ui/AgentCompanion'; import { Markdown } from '../ui/Markdown'; import { MarkdownReader } from '../ui/MarkdownReader'; import { NoteTab } from '../ui/NoteTab'; @@ -87,7 +87,7 @@ function saveWidth(key: string, v: number): void { /// Undermind patterns) are specced in `reference-library-and-reading.md`. type Mode = 'library' | 'discover'; -type Tab = 'info' | 'read' | 'notes' | 'cite' | 'meta' | 'assistant'; +type Tab = 'info' | 'read' | 'notes' | 'cite' | 'meta'; const ALL = '__all__'; // An open tab in the reader region: a PDF reader (a library item) or an in-app @@ -964,24 +964,8 @@ function Inspector({ { id: 'notes', label: t('read.tabNotes') }, { id: 'cite', label: t('read.tabCite') }, { id: 'meta', label: t('read.tabMeta') }, - // The assistant sits alongside the other tabs in the reader so it can be used - // while reading the PDF; it's reader-only (embedded) to keep the list side lean. - ...(embedded === true ? [{ id: 'assistant' as Tab, label: t('read.tabAssistant') }] : []), ]; - // Context handed to the reader's assistant — the paper's identity + any notes. - const assistantContext = { - label: ref.title !== '' ? ref.title : t('read.untitled'), - build: (): string => { - const parts = [`Paper: "${ref.title}"`]; - if (ref.authors.length > 0) parts.push(`Authors: ${ref.authors.join(', ')}`); - if (ref.year !== undefined) parts.push(`Year: ${ref.year}`); - if (ref.abstract !== undefined && ref.abstract !== '') parts.push(`Abstract: ${ref.abstract}`); - if (ref.notes !== undefined && ref.notes.trim() !== '') parts.push(`My notes so far:\n${ref.notes}`); - return parts.join('\n'); - }, - }; - return (
@@ -1058,18 +1042,6 @@ function Inspector({ )}
- {tab === 'assistant' ? ( -
- { - const prev = ref.notes ?? ''; - update(ref.id, { notes: prev.trim() === '' ? text : `${prev}\n\n${text}` }); - }} - /> -
- ) : (
{tab === 'info' && (
@@ -1385,7 +1357,6 @@ function Inspector({ {tab === 'meta' && void runScrape()} />}
- )}
); } @@ -1844,9 +1815,7 @@ export function ReadSurface(): JSX.Element { const [inspW, setInspW] = useState(() => loadWidth('termipod.read.inspW', 380)); const [railCollapsed, setRailCollapsed] = useState(() => localStorage.getItem('termipod.read.railFold') === '1'); const [inspCollapsed, setInspCollapsed] = useState(() => localStorage.getItem('termipod.read.inspFold') === '1'); - const [showAgent, setShowAgent] = useState(false); const [showWebdav, setShowWebdav] = useState(false); - const [agentW, setAgentW] = useState(() => loadWidth('termipod.read.agentW', 360)); const client = useSession((s) => s.client); const [syncing, setSyncing] = useState(false); const fileRef = useRef(null); @@ -2119,6 +2088,46 @@ export function ReadSurface(): JSX.Element { const activeTabObj = activeTab !== null ? tabs.find((tb) => tb.id === activeTab) : undefined; const selectedRef = selected !== null ? references.find((r) => r.id === selected) : undefined; + // The dock companion's context provider (the unified assistant dock, D2.2 — + // state/companionContext.ts): the selected paper. Read/Author have no focus + // event of their own, so the approximation is register on mount + selection + // change, unregister on unmount / no selection; registration itself marks + // the surface most-recently focused. `build`/`insert` read the library + // store LIVE at call time, so the effect only re-runs when the label-affecting + // fields change, not on every library update. + const registerCtx = useCompanionContext((s) => s.register); + const unregisterCtx = useCompanionContext((s) => s.unregister); + const selectedTitle = selectedRef?.title; + useEffect(() => { + if (selected === null) { + unregisterCtx('read'); + return; + } + const id = selected; + registerCtx('read', { + label: selectedTitle !== undefined && selectedTitle !== '' ? selectedTitle : tStatic('read.untitled'), + build: () => { + const r = useLibrary.getState().references.find((x) => x.id === id); + if (r === undefined) return ''; + const parts = [`Paper: "${r.title}"`]; + if (r.authors.length > 0) parts.push(`Authors: ${r.authors.join(', ')}`); + if (r.year !== undefined) parts.push(`Year: ${r.year}`); + if (r.abstract !== undefined && r.abstract !== '') parts.push(`Abstract: ${r.abstract}`); + return parts.join('\n'); + }, + insert: (text) => { + const lib = useLibrary.getState(); + const r = lib.references.find((x) => x.id === id); + if (r === undefined) return; + const prev = r.notes ?? ''; + lib.updateReference(id, { notes: prev.trim() === '' ? text : `${prev}\n\n${text}` }); + }, + }); + }, [selected, selectedTitle, registerCtx, unregisterCtx]); + // Unmount removal lives in its own effect — riding the registration effect's + // cleanup would turn every re-register into remove+append churn. + useEffect(() => () => unregisterCtx('read'), [unregisterCtx]); + // A stable mirror of the current selection so the download listener (mounted // once) reads the LIVE value, not the selection captured at mount. const selRef = useRef(selected); @@ -2276,13 +2285,6 @@ export function ReadSurface(): JSX.Element { {syncing ? t('read.syncing') : t('read.syncHub')} )} - } > @@ -2664,39 +2666,6 @@ export function ReadSurface(): JSX.Element { )} - )} - {showAgent && ( - <> - - setAgentW((w) => { - const n = clamp(w - dx, 280, 720); - saveWidth('termipod.read.agentW', n); - return n; - }) - } - /> - - )}
diff --git a/desktop/src/ui/AgentCompanion.tsx b/desktop/src/ui/AgentCompanion.tsx index 073c09b7b..17d3c6e87 100644 --- a/desktop/src/ui/AgentCompanion.tsx +++ b/desktop/src/ui/AgentCompanion.tsx @@ -5,6 +5,7 @@ import type { InputAttachments } from '../hub/client'; import { useT } from '../i18n'; import { isShell } from '../platform'; import { useAnnotation } from '../state/annotation'; +import { loadCompanionBinding } from '../state/companionBinding'; import { useSession } from '../state/session'; import { useUiContext } from '../state/uiContext'; import { useAgents } from '../hub/queries'; @@ -18,11 +19,13 @@ import { LocalAgentLauncher } from './LocalAgentLauncher'; // Cap per-mention file text so a large file can't blow the message context. const MENTION_MAX = 100_000; -/// The **AgentCompanion** — a hub-attached assistant panel that mounts alongside a -/// surface (Read J1, Author J2). It reuses the hub SDK's agent stream + the shared -/// Composer + EventCard, so it's a focused, surface-scoped view of one agent's -/// conversation with a composer that injects the surface's context (the current -/// paper / the current document) into each message. +/// The **AgentCompanion** — a hub-attached assistant panel. It lives in the +/// unified assistant dock (ui/AssistantDock.tsx, the Companion tab; the +/// per-surface Read/Author mounts are retired) and reuses the hub SDK's agent +/// stream + the shared Composer + EventCard, so it's a focused view of one +/// agent's conversation with a composer that injects the ACTIVE surface's +/// context (the current paper / the current document — the provider registry +/// in state/companionContext.ts) into each message. /// /// Hub-attached is the default; when no hub is bound it degrades to an explanatory /// empty state (the offline *local* runner — a desktop-local agent over ConPTY — @@ -54,7 +57,11 @@ export function AgentCompanion({ const client = useSession((s) => s.client); const agentsQ = useAgents(); const agents = agentsQ.data ?? []; - const [agentId, setAgentId] = useState(() => localStorage.getItem(storageKey) ?? ''); + const [agentId, setAgentId] = useState(() => + // The dock companion's key falls back to the retired per-surface mounts' + // keys (state/companionBinding.ts) so an existing binding survives. + loadCompanionBinding((k) => localStorage.getItem(k), storageKey), + ); // Hub-attached (an agent on some host) vs a local agent running on THIS machine. // Local is desktop-only; the choice is persisted per mount point. const [source, setSource] = useState<'hub' | 'local'>(() => diff --git a/desktop/src/ui/AnnotationOverlay.tsx b/desktop/src/ui/AnnotationOverlay.tsx index c47f93651..a78607084 100644 --- a/desktop/src/ui/AnnotationOverlay.tsx +++ b/desktop/src/ui/AnnotationOverlay.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { invoke } from '../bridge'; import { useT } from '../i18n'; import { useAnnotation, resolveTargets, type AnnotationCapture } from '../state/annotation'; -import { useAssistant } from '../state/assistant'; +import { kimiAttachable, useAssistant } from '../state/assistant'; import { toast } from '../state/toast'; import { useUiContext } from '../state/uiContext'; import { uiPolicyFor } from '../state/ui_policy'; @@ -22,15 +22,19 @@ import { Icon } from './Icon'; /// per-guest iframe is needed — and none would be allowed (webtab.ts strips /// preloads). /// -/// Flow (plan §3.4 steps 2-4): armed by an AgentCompanion's "Ask agent" +/// Flow (plan §3.4 steps 2-4): armed by the dock AgentCompanion's "Ask agent" /// button, or GLOBALLY by the status-bar chip / palette entry (D2.1 — no /// companion origin) → drag a rect (Esc cancels: nothing attached, no event, /// no audit) → `annotation_capture` (a rect fully inside a capture:refuse /// region — the Settings pane, which also holds the vault — is refused with a /// hint and the user re-selects) → the target row: "Attach to kimi web" first -/// when the kimi-web panel is open, then "Send to " (the arming -/// companion when bound; for a global arm the first registered bound -/// companion), an optional one-line note, Cancel (deletes the temp crop). +/// when the kimi guest is mounted + embedded (started && !detached — the dock +/// may be hidden), then "Send to " (the arming companion when bound; +/// for a global arm the first registered bound companion), an optional +/// one-line note, Cancel (deletes the temp crop). While armed, the assistant +/// dock steps aside (the `annotating` CSS hide) without flipping its `open` +/// state; a kimi attach reveals the dock on the kimi tab, a companion handoff +/// on the Companion tab (D2.2). interface Rect { x: number; @@ -107,11 +111,14 @@ function collectSurfaceRegions(): Array<{ surface: string; rect: Rect }> { return out; } -/// The kimi-web dock's guest, when the panel is open and embedded (not -/// detached to its own window, not hidden). The target row offers "Attach to -/// kimi web" first — plan §3.4 step 4. +/// The kimi-web dock's guest, when the panel is mounted and embedded (not +/// detached to its own window). The dock need not be OPEN — its kimi tab +/// hides (visibility) but never unmounts, so the guest keeps its webContents +/// id and main can inject into it; a successful attach then reveals the dock +/// on the kimi tab. The target row offers "Attach to kimi web" first — +/// plan §3.4 step 4. function kimiGuestId(): number | null { - const el = document.querySelector('.assistant-dock:not(.hidden) webview'); + const el = document.querySelector('.assistant-dock webview'); if (el === null) return null; const id = (el as WebviewLike).getWebContentsId?.(); return typeof id === 'number' ? id : null; @@ -130,7 +137,11 @@ export function AnnotationOverlay(): JSX.Element | null { const captured = useAnnotation((s) => s.captured); const handOffToCompanion = useAnnotation((s) => s.handOffToCompanion); const companions = useAnnotation((s) => s.companions); - const assistantOpen = useAssistant((s) => s.open && !s.detached); + // Kimi attach availability (D2.2 amendment): guest mounted (started) and + // embedded — the dock may be hidden or showing the Companion tab; a + // successful attach reveals it on the kimi tab. + const kimiReady = useAssistant(kimiAttachable); + const revealAssistant = useAssistant((s) => s.reveal); const [drag, setDrag] = useState<{ x1: number; y1: number; x2: number; y2: number } | null>(null); const [busy, setBusy] = useState(false); @@ -267,7 +278,7 @@ export function AnnotationOverlay(): JSX.Element | null { } // phase === 'target': the crop is captured; pick where it goes. - const kimiId = assistantOpen ? kimiGuestId() : null; + const kimiId = kimiReady ? kimiGuestId() : null; const targets = resolveTargets({ kimiOpen: kimiId !== null, origin, companions }); async function attachKimi(): Promise { @@ -278,11 +289,14 @@ export function AnnotationOverlay(): JSX.Element | null { if (r.ok === true && r.injected === true) { toast.success(t('annotate.attached')); cancel(); // the temp file stays (the SPA may read lazily; the LRU reaps) + // Land the user in kimi's composer to review and send (D2.2). + revealAssistant('kimi'); } else if (r.ok === true && r.fallback === 'clipboard') { // The paste key by platform — ⌘V is a lie on Linux/Windows. const mac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform); toast.info(t('annotate.clipboard').replace('{key}', mac ? '⌘V' : 'Ctrl+V')); cancel(); + revealAssistant('kimi'); // the clipboard hold counts as attached — reveal too } else { // Includes fallback === 'clipboard-failed': nothing was attached AND // nothing was copied — an info toast here would claim a clipboard diff --git a/desktop/src/ui/AssistantDock.tsx b/desktop/src/ui/AssistantDock.tsx index 32d700b7e..337a5a1b9 100644 --- a/desktop/src/ui/AssistantDock.tsx +++ b/desktop/src/ui/AssistantDock.tsx @@ -3,20 +3,33 @@ import { useT } from '../i18n'; import { Icon } from './Icon'; import { invoke } from '../bridge'; import { isShell } from '../platform'; +import { dockHiddenForPhase, useAnnotation } from '../state/annotation'; import { useAssistant } from '../state/assistant'; +import { DOCK_COMPANION_KEY } from '../state/companionBinding'; +import { activeProvider, useCompanionContext } from '../state/companionContext'; import { useWorkbench } from '../state/workbench'; +import { AgentCompanion } from './AgentCompanion'; import { useConfirm } from './ConfirmModal'; import { webPanelById } from './webPanels'; import { WebPanel } from '../surfaces/WebPanel'; -/// The app-level assistant (kimi-web) dock — the terminal dock's shape -/// (terminal/TerminalPanel.tsx dock mode) applied to the embedded assistant: -/// always mounted in the app shell, overlaying the active surface at the -/// bottom or right edge, CSS-hidden (never unmounted) on toggle so the SPA and -/// its backing `kimi web` server keep running like a daemon. Close is an +/// The unified assistant dock — the terminal dock's shape +/// (terminal/TerminalPanel.tsx dock mode) applied to the assistant: always +/// mounted in the app shell, overlaying the active surface at the bottom or +/// right edge, CSS-hidden (never unmounted) on toggle so the SPA and its +/// backing `kimi web` server keep running like a daemon. Close is an /// explicit, confirmed action (it stops this dock's hold on the server); /// detach pops the SPA into its own native window (kimiwebwin.ts) and the dock /// shows a re-attach placeholder meanwhile. +/// +/// TABBED (D2.2): the header's segmented strip switches between the embedded +/// kimi web SPA and the Companion (the dock's one AgentCompanion, fed by the +/// surface context registry — state/companionContext.ts). Both render once +/// `started` and are CSS-hidden by `view`, never unmounted; the kimi +/// lifecycle (detach/attach, confirmed close) applies only to the kimi tab, +/// and the Companion tab stays usable while kimi is detached. While the +/// annotation overlay is armed the dock steps aside via the `annotating` +/// hide class — `open` is untouched, so cancel/target-pick restores it. const H_KEY = 'termipod.assistant.dockH'; const W_KEY = 'termipod.assistant.dockW'; @@ -40,7 +53,16 @@ function saveSize(key: string, v: number): void { export function AssistantDock(): JSX.Element | null { const t = useT(); const shell = isShell(); - const { open, started, detached, dockSide, setOpen, setDetached, setDockSide, close } = useAssistant(); + const { open, started, detached, dockSide, view, setOpen, setDetached, setDockSide, setView, close } = + useAssistant(); + // The dock steps aside while the annotation overlay runs (any origin): out + // of the captured pixels, and an unobstructed selection surface. Derived + // from the annotation phase — `open` is NOT flipped, so the dock returns + // exactly as it was on cancel / target pick. + const annotating = useAnnotation((s) => dockHiddenForPhase(s.phase)); + // The Companion tab's context chip + insert target come from the active + // surface-registered provider (state/companionContext.ts). + const provider = useCompanionContext((s) => activeProvider(s)); const setJob = useWorkbench((s) => s.setJob); const confirm = useConfirm(); const panel = webPanelById('kimi'); @@ -132,7 +154,11 @@ export function AssistantDock(): JSX.Element | null { const style = dockSide === 'right' ? { width: sizeRef.current.w } : { height: sizeRef.current.h }; return ( -
+
@@ -143,8 +169,24 @@ export function AssistantDock(): JSX.Element | null { } />
- - {t('assistant.title')} +
+ + +
- {detached ? ( -
- {t('assistant.detachedNote')} - -
- ) : ( - - )} + {/* Both tabs stay mounted once `started` (CSS-hidden by `view`) — the + kimi SPA keeps its server hold, the companion keeps its SSE stream + and its staged compose-box state. The kimi tab's detach placeholder + is the kimi lifecycle only; the Companion tab works detached. */} +
+ {detached ? ( +
+ {t('assistant.detachedNote')} + +
+ ) : ( + + )} +
+
+ +
{confirm.node}
From 90550ccffb341274c3580c40d051d35dbe02a6af Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Thu, 30 Jul 2026 13:46:37 +0000 Subject: [PATCH 2/2] fix(desktop): the close confirm owns the Companion tab; drop the stale (kimi) hint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fixes on the unified dock (#483): close() now also unmounts the Companion tab — a staged compose draft (crop chip, note) is discarded — but the confirm copy only spoke of the kimi panel. Both locales now say so. The toggle hint's '(kimi)' predates the tabbed dock. Co-Authored-By: Claude Fable 5 --- desktop/src/i18n/index.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/desktop/src/i18n/index.ts b/desktop/src/i18n/index.ts index cb322f416..521af5bef 100644 --- a/desktop/src/i18n/index.ts +++ b/desktop/src/i18n/index.ts @@ -476,7 +476,7 @@ const en: Dict = { 'settings.catNetwork': 'Network', 'settings.catAssistant': 'Assistant', 'assistant.title': 'Assistant', - 'assistant.toggleHint': 'Toggle the assistant dock (kimi) — hides, keeps running', + 'assistant.toggleHint': 'Toggle the assistant dock — hides, keeps running', 'assistant.tabKimi': 'kimi web', 'assistant.tabCompanion': 'Companion', 'assistant.settings': 'Assistant settings', @@ -487,7 +487,7 @@ const en: Dict = { 'assistant.detachedNote': 'The assistant is open in its own window.', 'assistant.hide': 'Hide (keeps running)', 'assistant.close': 'Close the assistant', - 'assistant.closeConfirm': 'Close the assistant? This stops its embedded session panel — the kimi web server shuts down when its last panel closes.', + 'assistant.closeConfirm': 'Close the assistant? This stops the embedded kimi panel (the kimi web server shuts down when its last panel closes) and unmounts the Companion tab — an unsent draft there is discarded.', 'assistant.closeConfirmBtn': 'Close assistant', 'assistant.desktopOnly': 'The embedded assistant needs the desktop shell.', 'assistant.settingsLead': 'The embedded kimi-code assistant: server status, and where its customization lives — config, MCP servers, skills, agents/prompts, plugins. Files open in Inspect for review; edit them with the assistant itself or your editor.', @@ -2527,7 +2527,7 @@ const zh: Dict = { 'settings.catNetwork': '网络', 'settings.catAssistant': '助手', 'assistant.title': '助手', - 'assistant.toggleHint': '切换助手停靠面板(kimi)— 隐藏后继续运行', + 'assistant.toggleHint': '切换助手停靠面板 — 隐藏后继续运行', 'assistant.tabKimi': 'kimi web', 'assistant.tabCompanion': '智能体', 'assistant.settings': '助手设置', @@ -2538,7 +2538,7 @@ const zh: Dict = { 'assistant.detachedNote': '助手已在独立窗口中打开。', 'assistant.hide': '隐藏(继续运行)', 'assistant.close': '关闭助手', - 'assistant.closeConfirm': '关闭助手?这会停止其内嵌会话面板 — 最后一个面板关闭时 kimi web 服务器将停止。', + 'assistant.closeConfirm': '关闭助手?这会停止内嵌 kimi 面板(最后一个面板关闭时 kimi web 服务器将停止),并卸载智能体标签页 — 其中未发送的草稿将被丢弃。', 'assistant.closeConfirmBtn': '关闭助手', 'assistant.desktopOnly': '内嵌助手需要桌面外壳。', 'assistant.settingsLead': '内嵌 kimi-code 助手:服务器状态,以及其自定义配置所在 — 配置、MCP 服务器、技能、代理/提示词、插件。文件在检视页打开供查看;编辑请使用助手本身或你的编辑器。',