Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 34 additions & 17 deletions desktop/electron/e2e/annotation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <agent>").
/// bound dock companion ("Send to <agent>").
///
/// 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 <agent>" 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 <agent>" 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,
Expand Down Expand Up @@ -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);
Expand All @@ -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();
Expand All @@ -207,31 +219,36 @@ 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);
await page.mouse.down();
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 });
await expect(bar.locator('.annot-thumb')).toBeVisible();
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();
Expand Down
18 changes: 8 additions & 10 deletions desktop/src/i18n/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -476,7 +476,9 @@ 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',
'assistant.dockRight': 'Dock right',
'assistant.dockBottom': 'Dock bottom',
Expand All @@ -485,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.',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -2528,7 +2527,9 @@ const zh: Dict = {
'settings.catNetwork': '网络',
'settings.catAssistant': '助手',
'assistant.title': '助手',
'assistant.toggleHint': '切换助手停靠面板(kimi)— 隐藏后继续运行',
'assistant.toggleHint': '切换助手停靠面板 — 隐藏后继续运行',
'assistant.tabKimi': 'kimi web',
'assistant.tabCompanion': '智能体',
'assistant.settings': '助手设置',
'assistant.dockRight': '停靠右侧',
'assistant.dockBottom': '停靠底部',
Expand All @@ -2537,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 服务器、技能、代理/提示词、插件。文件在检视页打开供查看;编辑请使用助手本身或你的编辑器。',
Expand Down Expand Up @@ -3404,7 +3405,6 @@ const zh: Dict = {
'read.adding': '添加中…',
'read.idNotFound': '无法解析该标识符。',
'read.tabMeta': '元数据',
'read.tabAssistant': '✦ 助手',
'read.scrape': '抓取',
'read.scraping': '抓取中…',
'read.rescrape': '重新抓取',
Expand Down Expand Up @@ -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': '表格',
Expand Down
50 changes: 50 additions & 0 deletions desktop/src/state/annotation.test.ts
Original file line number Diff line number Diff line change
@@ -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',
});
});
34 changes: 25 additions & 9 deletions desktop/src/state/annotation.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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).
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -112,8 +124,8 @@ export const useAnnotation = create<AnnotationState>((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',
Expand All @@ -131,6 +143,10 @@ export const useAnnotation = create<AnnotationState>((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) })),
Expand Down
13 changes: 8 additions & 5 deletions desktop/src/state/annotationTargets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) ───────────────────────────

Expand Down Expand Up @@ -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);
});
Expand All @@ -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);
Expand All @@ -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);
Expand Down
Loading
Loading