diff --git a/.agents/notes/implemented/feature/2026-08-01-goal-command-input-projection.i18n.yaml b/.agents/notes/implemented/feature/2026-08-01-goal-command-input-projection.i18n.yaml new file mode 100644 index 0000000000..d40fbd1884 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-01-goal-command-input-projection.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-01-goal-command-input-projection.md +2026-08-01-goal-command-input-projection.md: 02836de7ea2d79122d7650d1b37406802ee1489f +2026-08-01-goal-command-input-projection.zh.md: 8c28ba1c90be5942aa526fb9d8d2d8b1f014742e diff --git a/.agents/notes/implemented/feature/2026-08-01-goal-command-input-projection.md b/.agents/notes/implemented/feature/2026-08-01-goal-command-input-projection.md new file mode 100644 index 0000000000..02836de7ea --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-01-goal-command-input-projection.md @@ -0,0 +1,39 @@ +# Agent Note: Goal command input projection + +Status: implemented + +English | [中文](2026-08-01-goal-command-input-projection.zh.md) + +## Problem + +Human commands execute outside the model turn and persist as `command/run` plus `command/done`. The Web transcript rendered only their result row. On a fresh session, `/goal` therefore cleared the composer and completed successfully while the page stayed on the empty hero; its result became visible only after later conversation content activated Chat. Appending an ordinary `user/message` from the handler would change model-visible history and command semantics. + +## Decision + +The command registry and durable command lifecycle remain unchanged. `command/run` records the parser-owned name, optional verbatim arguments, source, and invocation id; `command/done` records settlement. Neither event carries browser presentation intent. + +The `ui-goal` client plugin registers a Goal-owned Conversation Definition beside the generic command Definition. Both match the same `/goal` `command/run`: the generic Definition retains the durable result row, while the Goal Definition builds a separate `command-input` Chat Node at an earlier fractional anchor. The Goal plugin also registers the keyed React renderer for that Node. Its local component copies only the user bubble's right-aligned geometry and semantic tokens, uses 14px/22px monospace text, and mounts no timestamp, copy, or branch actions. + +`Session.composerPhase` treats visible non-command Chat Nodes as conversation content, so `command-input` activates the current conversation while a generic command row alone does not. The Host `summary.blank` bit remains turn-based, so list hiding and blank-session reuse do not change. + +The Goal Definition derives `/` from the structured run: separator and internal multiline input survive, while the claimed bare form whose arguments contain one space displays `/goal`. A history window containing only `command/done` has no matching Goal Context, so it keeps the generic result row without inventing an input bubble; loading the older run restores both Nodes. + +The model boundary is unchanged. The Goal projection creates no `user/message`, `turn/start`, `step/start`, or `request/header`. Accepted goal mutations reach the model only through the goal domain's existing `` snapshot or clear tombstone, independently of the command-input Node. + +## Verification + +Goal client tests pin the dual Definition output, ordering, other-command exclusion, bare and multiline text, done-only cuts, renderer semantics, disposal, and fresh-session phase selection. The keyless assembled Web scenario submits bare `/goal` in a fresh session with no model adapter, verifies both rows and the absence of model-surface events, then reloads and verifies the persisted transcript. + +## Alternatives considered + +**Append `user/message` in the `/goal` handler.** Rejected because the command would become model input and could trigger or alter a later request. + +**Add presentation intent to the command registry and durable event.** Rejected because one Goal view would widen the generic command interface and make Session, Chat, and every command fixture carry browser presentation state. The existing `command/run` name and arguments already let the composed Goal client reconstruct its own view. + +**Teach the generic command renderer about `/goal`.** Rejected because command-specific view construction belongs to the Goal client plugin. Composing that plugin out must remove the bubble without changing command execution or the generic result row. + +**Render every command input as a user bubble.** Rejected because existing control commands deliberately leave a fresh session on the hero; changing them would broaden interaction semantics without a feature-owned Conversation Definition. + +## Consequences + +One durable `/goal` run feeds two independently owned view Contexts without changing the command capability. Composing `ui-goal` out leaves ordinary command execution and its result row intact. Live tabs and cold reloads agree because both views derive from the same run. A page cut that retains only `command/done` temporarily shows only the result row; if that command is the session's only content, the hero hides the row until an older page restores the run. The session remains list-hidden and reusable until a model turn starts because Host blank semantics remain turn-based. diff --git a/.agents/notes/implemented/feature/2026-08-01-goal-command-input-projection.zh.md b/.agents/notes/implemented/feature/2026-08-01-goal-command-input-projection.zh.md new file mode 100644 index 0000000000..8c28ba1c90 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-01-goal-command-input-projection.zh.md @@ -0,0 +1,39 @@ +# Agent Note: Goal 命令输入投影 + +Status: implemented + +[English](2026-08-01-goal-command-input-projection.md) | 中文 + +## 问题 + +面向用户的命令在模型轮次之外执行,并持久化为 `command/run` 与 `command/done`。Web transcript(文本记录)此前只渲染结果行。因此,在新会话中,`/goal` 会清空编辑器并成功完成,但页面仍停留在空白 Hero;只有后续对话内容激活 Chat 后,结果才会显示。若处理器追加普通 `user/message`,将改变模型可见历史与命令语义。 + +## 决策 + +命令注册表与持久命令生命周期保持不变。`command/run` 记录由解析器提供的名称、可选的原样参数、来源和调用 id;`command/done` 记录结算。两条事件都不携带浏览器呈现意图。 + +`ui-goal` 客户端插件会在通用命令 Definition 之外注册一个归 Goal 所有的 Conversation Definition。两者都匹配同一条 `/goal` `command/run`:通用 Definition 保留持久结果行,Goal Definition 则在更早的分数锚点构建独立的 `command-input` Chat Node。Goal 插件还为该 Node 注册 keyed React renderer。它的本地组件只复用用户气泡的右对齐几何形态和语义 token,使用 14px/22px 等宽字体文本,并且不挂载时间戳、复制或分支操作。 + +`Session.composerPhase` 把可见的非命令 Chat Node 视为对话内容,因此 `command-input` 会激活当前对话,而仅有通用命令行时不会。Host 的 `summary.blank` 位仍以轮次为基础,因此列表隐藏和空白会话复用保持不变。 + +Goal Definition 根据结构化 run 派生 `/`:分隔符与内部多行输入保持不变;在已认领的裸命令形式中,参数只有一个空格时显示 `/goal`。仅包含 `command/done` 的历史窗口没有匹配的 Goal Context,因此会保留通用结果行,而不会虚构输入气泡;加载包含更早 run 的页面后,两个 Node 都会恢复。 + +模型边界保持不变。Goal 投影不会创建 `user/message`、`turn/start`、`step/start` 或 `request/header`。已接受的 goal 变更只会通过 goal 领域现有的 `` 快照或 clear tombstone 到达模型,与 `command-input` Node 无关。 + +## 验证 + +Goal 客户端测试固定双 Definition 输出、顺序、排除其他命令、裸命令与多行文本、仅含 done 的切分窗口、renderer 语义、资源释放和新会话 phase 选择。无密钥的完整组装 Web 场景在不含模型适配器的新会话中提交裸 `/goal`,验证两行都显示且不存在面向模型的事件,然后重新加载并验证持久化后的 transcript。 + +## 备选方案 + +**在 `/goal` 处理器中追加 `user/message`。**不予采纳,因为该命令会变成模型输入,并可能触发或改变后续请求。 + +**向命令注册表与持久事件添加呈现意图。**不予采纳,因为一个 Goal 视图会扩大通用命令接口,并要求 Session、Chat 和每个命令 fixture(测试前置数据)都携带浏览器呈现状态。现有 `command/run` 的名称和参数已足以让组合后的 Goal 客户端重建自有视图。 + +**让通用命令 renderer 识别 `/goal`。**不予采纳,因为命令专用视图的构建归 Goal 客户端插件所有。在组合中移除该插件后,气泡必须随之消失,且命令执行和通用结果行不能改变。 + +**把每条命令输入都渲染为用户气泡。**不予采纳,因为现有控制命令会有意让新会话停留在 Hero;这样修改会在没有功能自有 Conversation Definition 的情况下扩大交互语义。 + +## 后果 + +一条持久 `/goal` run 会向两个各自独立归属的视图 Context 提供数据,而不改变命令能力。在组合中移除 `ui-goal` 后,普通命令执行及其结果行保持不变。实时标签页与冷重载会得到一致结果,因为两个视图都派生自同一条 run。页面切分只保留 `command/done` 时,会暂时只显示结果行;如果该命令是会话中的唯一内容,Hero 会隐藏该行,直到加载更早页面恢复 run。由于 Host 的 blank 语义仍以轮次为基础,会话在模型轮次开始前仍从列表中隐藏,并且可以复用。 diff --git a/apps/web/tests/goal-command-presentation.e2e.ts b/apps/web/tests/goal-command-presentation.e2e.ts new file mode 100644 index 0000000000..6117fb8ace --- /dev/null +++ b/apps/web/tests/goal-command-presentation.e2e.ts @@ -0,0 +1,123 @@ +// Web e2e: /goal opts its command input into the human transcript while the +// command remains log-only. The shipped composition runs with no model adapter, +// so an accidental turn fails loud in addition to the event-level assertions. +import { fileURLToPath } from 'node:url' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import type { SessionEvent } from '@deepseek-ai/dsh-session/types' +import type {} from '@deepseek-ai/dsh-commands/types' +import { + acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, + compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, + type WebScaffold, +} from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/goal-command-presentation', import.meta.url)) +const UI_EXPECTED = fileURLToPath(new URL( + './snapshots/goal-command-presentation/ui.expected.md', import.meta.url, +)) +const MODE = webSnapshotMode() + +describe('web e2e: /goal human transcript presentation', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + const events: SessionEvent[] = [] + + beforeAll(async () => { + scaffold = await launchWebScaffold() + scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { events.push(event) }) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('shows the bare input and result from a fresh session without a model turn', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-goal-command-presentation')) + await expect.poll(() => page.getByText('Into the Unknown', { exact: false }).count(), { + timeout: 15_000, + }).toBe(1) + const input = page.locator('textarea').first() + await input.fill('/goal') + await input.press('Enter') + await expect.poll(() => input.inputValue()).toBe('/goal ') + await input.press('Enter') + + const commandInput = page.locator('[data-command-input]') + await commandInput.waitFor({ timeout: 10_000 }) + await expect.poll(() => commandInput.textContent()).toBe('/goal') + expect(await commandInput.getAttribute('role')).toBe('group') + expect(await commandInput.getAttribute('aria-label')).toBe('Command input') + expect(await commandInput.getByRole('button').count()).toBe(0) + const typography = await commandInput.evaluate((element) => { + const bubble = element.firstElementChild?.firstElementChild + if (!(bubble instanceof HTMLElement)) throw new Error('command input bubble is missing') + const rootStyle = getComputedStyle(element) + const bubbleStyle = getComputedStyle(bubble) + return { + fontFamily: bubbleStyle.fontFamily, + parentFontFamily: rootStyle.fontFamily, + fontSize: bubbleStyle.fontSize, + lineHeight: bubbleStyle.lineHeight, + } + }) + expect(typography).toMatchObject({ fontSize: '14px', lineHeight: '22px' }) + expect(typography.fontFamily).not.toBe(typography.parentFontFamily) + const resultRow = page.locator('[data-variant="others"]').filter({ hasText: 'No goal is currently set.' }) + await expect.poll(() => resultRow.count(), { timeout: 10_000 }).toBe(1) + expect(await resultRow.getByText('goal', { exact: true }).count()).toBe(1) + await expect.poll(() => page.locator('[data-phase="active"]').count()).toBe(1) + expect(await page.getByText('Into the Unknown', { exact: false }).count()).toBe(0) + + const run = events.find(event => event.type === 'command/run') + expect(run).toMatchObject({ + type: 'command/run', + data: { name: 'goal', args: ' ', source: { kind: 'user' } }, + }) + expect(events.some(event => event.type === 'command/done')).toBe(true) + expect(events.some(event => event.type === 'user/message')).toBe(false) + expect(events.some(event => event.type === 'turn/start')).toBe(false) + expect(events.some(event => event.type === 'step/start')).toBe(false) + expect(events.some(event => event.type === 'request/header')).toBe(false) + + const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + }, 60_000) + + it('reloads the same bubble and result from the persisted command lifecycle', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-goal-command-presentation-reload')) + const warningStart = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + acknowledgeReloadConnectionLoss(tripwire, warningStart) + + await expect.poll(() => page.locator('[data-command-input]').textContent(), { timeout: 15_000 }).toBe('/goal') + const resultRow = page.locator('[data-variant="others"]').filter({ hasText: 'No goal is currently set.' }) + await expect.poll(() => resultRow.count(), { timeout: 10_000 }).toBe(1) + await expect.poll(() => page.locator('[data-phase="active"]').count()).toBe(1) + + const sessions = scaffold.ctx.sessions.list() + expect(sessions).toHaveLength(1) + const persisted = sessions[0]?.events ?? [] + expect(persisted.filter(event => event.type === 'command/run' || event.type === 'command/done') + .map(event => event.type)).toEqual(['command/run', 'command/done']) + expect(persisted.some(event => event.type === 'user/message')).toBe(false) + expect(persisted.some(event => event.type === 'turn/start')).toBe(false) + expect(persisted.some(event => event.type === 'step/start')).toBe(false) + expect(persisted.some(event => event.type === 'request/header')).toBe(false) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md']) + }, 90_000) +}) diff --git a/apps/web/tests/snapshots/goal-command-presentation/ui.expected.md b/apps/web/tests/snapshots/goal-command-presentation/ui.expected.md new file mode 100644 index 0000000000..e3f026066f --- /dev/null +++ b/apps/web/tests/snapshots/goal-command-presentation/ui.expected.md @@ -0,0 +1,21 @@ +- banner: + - navigation "Session hierarchy": + - button "workspace" [disabled] + - img + - text: Standard mode + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- group "Command input": /goal +- 'button "goal No goal is currently set. Usage: /goal [|clear|edit |pause|resume]"': + - img + - img + - text: "goal No goal is currently set. Usage: /goal [|clear|edit |pause|resume]" +- textbox "Message the agent" +- button "Commands": + - img +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Select model, current DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash + - img +- button "Send message" [disabled] diff --git a/apps/web/tests/snapshots/goal-multi-turn-actions/ui.expected.md b/apps/web/tests/snapshots/goal-multi-turn-actions/ui.expected.md index c0ece71be8..737bd5b591 100644 --- a/apps/web/tests/snapshots/goal-multi-turn-actions/ui.expected.md +++ b/apps/web/tests/snapshots/goal-multi-turn-actions/ui.expected.md @@ -6,6 +6,7 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" +- group "Command input": /goal 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的 - 'button "goal Goal created Status: active Objective: 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的 Rounds: 0/256 Activation: armed Commands: /goal edit , /goal pause, /goal clear"': - img - img diff --git a/apps/web/tests/snapshots/queue-actions/layout.expected.md b/apps/web/tests/snapshots/queue-actions/layout.expected.md index 9996bdcd0b..49db39a1a5 100644 --- a/apps/web/tests/snapshots/queue-actions/layout.expected.md +++ b/apps/web/tests/snapshots/queue-actions/layout.expected.md @@ -6,6 +6,7 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" +- group "Command input": /goal Keep the composer context panels aligned - 'button "goal Goal created Status: active Objective: Keep the composer context panels aligned Rounds: 0/256 Activation: armed Commands: /goal edit , /goal pause, /goal clear"': - img - img diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 5ca8bb884d..45218c165b 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -70,6 +70,7 @@ "tests/produced-files.e2e.ts", "tests/produced-file-mentions.e2e.ts", "tests/goal-bar.e2e.ts", + "tests/goal-command-presentation.e2e.ts", "tests/subagent-conversation.e2e.ts", "tests/subagent-interrupt.e2e.ts", "tests/subagent-interrupt-ui.e2e.ts", diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 0d0f7bee09..cfcaa0803d 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: 91c202f0dae1bfbe7b5801f4e9f666e9d8866d87 -module-graph.zh.md: 2609c1a961133567344a8e1ba65bf21a526a62f5 +module-graph.md: 719e8f4cc28f84086d1cfad5588f8cf78bcfcf0c +module-graph.zh.md: c17ad236b083ef08ab3927670a03c9e5df0c496d diff --git a/docs/module-graph.md b/docs/module-graph.md index 91c202f0da..719e8f4cc2 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -1148,6 +1148,7 @@ flowchart TD pkg_client_ui_goal --> pkg_client_ui_conversation pkg_client_ui_goal --> pkg_client_ui_primitives pkg_client_ui_goal --> pkg_client_ui_slots + pkg_client_ui_goal --> pkg_commands pkg_client_ui_goal --> pkg_goal pkg_client_ui_goal --> pkg_invariants pkg_client_ui_plan --> pkg_client_connection @@ -1462,7 +1463,7 @@ flowchart TD | [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | | [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | +| [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | | [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | | [`client-ui-task`](../packages/client/ui-task) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 2609c1a961..c17ad236b0 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -1150,6 +1150,7 @@ flowchart TD pkg_client_ui_goal --> pkg_client_ui_conversation pkg_client_ui_goal --> pkg_client_ui_primitives pkg_client_ui_goal --> pkg_client_ui_slots + pkg_client_ui_goal --> pkg_commands pkg_client_ui_goal --> pkg_goal pkg_client_ui_goal --> pkg_invariants pkg_client_ui_plan --> pkg_client_connection @@ -1464,7 +1465,7 @@ flowchart TD | [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | | [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | +| [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | | [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | | [`client-ui-task`](../packages/client/ui-task) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 3d3c29e757..a58d0960d2 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/runtime/README.md -README.md: 7c835deb58db149710495f97a2553c3de58d99da -README.zh.md: edf4473bec7df2253c032c3da86da878cdeade09 +README.md: 69634d4ca577e9fa5c508a5fb2b50333290154b1 +README.zh.md: 9e03cc1903b5e9dc1d13e07bf8394a6e7aee9209 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 7c835deb58..69634d4ca5 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -33,6 +33,8 @@ SlotsService gives the renderer separate bare observables for `useSessions` and `WorkspacesService.connectWorkspace(workspaceId)` resolves the session a New Session flow lands in: it reuses the workspace's existing blank session from the list mirror (`blank && cwd == workspace.path && sessionIds.includes(id)` — the host's own membership rule, never cwd alone, so a cwd-matching unaccounted blank session is never hijacked) or calls `session.create({workspaceId})`, returning the session id for the caller to open. `SessionSummary.blank` mirrors the host's derived empty-log bit and only ever lowers on the client: seeded by `session.list` / the `host/session-added` frame, flipped false by the first ACCEPTED local `prompt()` (on the RPC success response — acceptance proves the user message is in the host log; a rejected first prompt keeps the session blank and reusable) and by any `running: true` status frame, re-aligned by every list re-pull. List surfaces hide blank rows; the store carries every row. `SessionsService.create` accepts an optional caller-preallocated SessionId and throws `SessionCreateError` (carrying `requestedSessionId`) on failure. +`Session.composerPhase` treats any visible non-command Chat Node as conversation content, so a client plugin can project durable human input without opening a turn while a window containing only generic command rows retains the Host blank posture. List hiding and blank-session reuse still follow the Host blank bit. A history window that lacks the plugin-owned input Node returns to that blank posture until an older page restores it. + ## Pending queue projection `ConversationSnapshot.queue` is the Host's authoritative transient snapshot of `agent.inbox.nextTurn`; pending next-step steering stays outside this projection. Each row carries its `MessageId`, complete editable text when every content block is text, and a flattened preview. The Host derives whole `session/queue` snapshots from durable `agent/inbox/spliced` mutations and sends a baseline on reconnect; the message-local `agent/inbox/inserted`, `claimed`, and `discarded` notifications are not used to reconstruct this projection. `Session.updateQueue()` sends edit/remove operations through Host-side `Inbox.splice()` without optimistic client mutation, so the next Host snapshot is the sole visible commit and a claim race can surface `queue-item-not-found`. diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index edf4473bec..9e03cc1903 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -33,6 +33,8 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 `WorkspacesService.connectWorkspace(workspaceId)` 解析 New Session 流程最终落入的会话:先在列表镜像中复用该 workspace 的既有空会话(`blank && cwd == workspace.path && sessionIds.includes(id)`——host 自己的成员规则,绝不只按 cwd,避免劫持 cwd 匹配但未入账的空白会话),未命中则调用 `session.create({workspaceId})`,返回会话 id 由调用方 open。`SessionSummary.blank` 镜像主机派生的空日志位,在客户端只降不升:由 `session.list`/`host/session-added` 帧播种,本地首次获 Host 接受的 `prompt()`(RPC 成功响应时——受理即证明用户消息已入主机日志;首讯被拒则会话保持 blank、保持可复用)与任何 `running: true` 状态帧翻为 false,每次列表重拉重新对齐。列表界面隐藏 blank 行;store 保留全部行。`SessionsService.create` 接受可选的、由调用方预先分配的 SessionId,失败时抛出 `SessionCreateError`(携带 `requestedSessionId`)。 +`Session.composerPhase` 把任何可见的非命令 Chat Node 视为对话内容,因此客户端插件可以在不打开轮次的情况下投影持久用户输入,而仅包含通用命令行的窗口仍保持 Host blank 状态。列表隐藏和空白会话复用仍遵循 Host blank 位。缺少插件输入 Node 的历史窗口会恢复该空白状态,直到加载更早页面后该 Node 恢复。 + ## 待处理队列投影 `ConversationSnapshot.queue` 是 Host 提供的 `agent.inbox.nextTurn` 权威瞬态快照;待处理的 next-step steering(中途引导)不进入此投影。每行携带其 `MessageId`、所有内容块均为文本时的完整可编辑文本,以及扁平化预览。Host 根据持久 `agent/inbox/spliced` 变更派生完整 `session/queue` 快照,并在重连时发送基线;面向单条消息的 `agent/inbox/inserted`、`claimed` 与 `discarded` 通知不用于重建该投影。`Session.updateQueue()` 经 Host 侧 `Inbox.splice()` 发送编辑/移除操作,客户端不做乐观变更,因此下一份 Host 快照是唯一可见的提交结果,claim 竞态则会返回 `queue-item-not-found`。 diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index 4397013dab..7972ce5e50 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -324,8 +324,9 @@ export type OpenState = 'cold' | 'loading' | 'open' | 'error' * - `engaging`: a first prompt was attempted, but no accepted turn or other * authoritative activity signal has arrived — the UI keeps the composer * visible through admission and error frames. - * - `active`: the session is non-blank beyond its pending first prompt, is - * running, or owns a pending interaction — the ordinary conversation view. + * - `active`: the session is non-blank beyond its pending first prompt, + * contains visible non-command Chat content, is running, or owns a pending + * interaction — the ordinary conversation view. * * A failed first prompt stays `engaging` (composer + error strip — retry * semantics; returning to the hero would discard the error context). diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index d9bf097e36..3cdac2b1f8 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -741,7 +741,8 @@ export class Session implements SessionFace { ? null : { address: this.address, parentAvailable: this.parentAvailable }, composerPhase: derivePhase( - (!this.blankBit && !this.firstPromptPendingTurn) + hasVisibleConversationContent(chat) + || (!this.blankBit && !this.firstPromptPendingTurn) || this.running || this.pendingCache.value.length > 0, this.promptAttempted, @@ -774,13 +775,18 @@ function conversationInput(entry: HistoryEntry): ConversationEventInput { return { event: entry.event, view: entry.view } } +/** A generic command row alone remains control-plane content; every other visible Chat Node activates the conversation. */ +function hasVisibleConversationContent(chat: ChatSnapshot): boolean { + return chat.order.some(key => chat.nodes.get(key)?.kind !== 'command') +} + /** * The composerPhase judgment — the single site that knows the predicate * (consumers switch on the result, never re-derive). A failed first prompt * stays engaging until an authoritative accepted-turn, running, or pending * signal arrives (retry semantics — see ComposerPhase). * @param hasContent - authoritative non-blank activity beyond a pending first - * prompt, a running turn, or a pending interaction. + * prompt, visible non-command Chat content, a running turn, or a pending interaction. * @param promptAttempted - a prompt was initiated on this session object. * @returns the derived phase. */ diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index 0795d9a849..32a9314149 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -8,6 +8,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' +import type {} from '@deepseek-ai/dsh-commands/types' import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' import { Session } from '../src/client/sessions/session.ts' import type { @@ -132,7 +133,11 @@ const TEST_EVENT_DEFINITION: ConversationNodeDefinition = { if (context.state === undefined || context.start === undefined) return null return { key: context.key, - kind: 'runtime-test-event', + kind: context.start.event.type === 'command/run' && context.start.event.data.name === 'goal' + ? 'command-input' + : context.start.event.type === 'command/run' || context.start.event.type === 'command/done' + ? 'command' + : 'runtime-test-event', id: context.id, target: 'chat', anchorSeq: context.start.event.seq, @@ -272,6 +277,24 @@ describe('live event path', () => { expect(snapshot.composerPhase).toBe('blank') }) + it('activates a fresh conversation for a command-input View Node without opening a model turn', async () => { + const { session } = await opened([]) + session.handleBlank(true) + const feed = (event: SessionEvent) => { + session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) + } + feed(ev.commandRun(0, 'cmd-goal', 'goal', ' ')) + feed(ev.commandDone(1, 'cmd-goal', 'success', 'No goal is currently set.')) + + expect(session.getSnapshot()).toMatchObject({ + blank: true, + composerPhase: 'active', + }) + expect(session.getSnapshot().chat.order.map( + key => session.getSnapshot().chat.nodes.get(key)?.kind, + )).toContain('command-input') + }) + it('publishes animation-frame Definitions once per frame and lets an immediate event supersede the pending frame', async () => { const frames: FrameRequestCallback[] = [] vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { diff --git a/packages/client/ui-goal/README.i18n.yaml b/packages/client/ui-goal/README.i18n.yaml index 2fb8dfdc0e..0dab3370b0 100644 --- a/packages/client/ui-goal/README.i18n.yaml +++ b/packages/client/ui-goal/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-goal/README.md -README.md: f0446aa0637bc181f7fdc22e5d0d3192e0ac20cf -README.zh.md: 1ad9f50aee5b103f6455e4d4b7d29fa9eb29a108 +README.md: c79d6f5a68f1b4b40f4b57f5745feeed63a25fcd +README.zh.md: c2d000dd8141a989c67f2e8dc6786ed2b5067a6b diff --git a/packages/client/ui-goal/README.md b/packages/client/ui-goal/README.md index f0446aa063..c79d6f5a68 100644 --- a/packages/client/ui-goal/README.md +++ b/packages/client/ui-goal/README.md @@ -4,6 +4,8 @@ English | [中文](README.zh.md) Goal surface plugin, browser half: the `GoalBar` strip is the second standalone card in the `conversation.input.dock` composer-context stack (order 10, after Todo and before Queue). The live goal arrives through `useProjection('goal')` — the host-computed whole value seeded by the history tail page and updated by `session/projection` frames — so the plugin owns no domain store, refresh chain, or event listener. The slot inject face carries only the four mutation verbs (edit / pause / resume / clear through `ctx.remote.goals` — an active goal offers the pause action, a paused one resume); each reads the CAS ref from the session's current projected value at call time and surfaces the rejected Remote error inline. The strip single-flights mutations synchronously because React's pending render cannot fence same-frame clicks; after a successful clear it immediately suppresses that exact goal id while the authoritative null projection catches up. Goal creation stays on the `/goal` host command; loading, absent, completed, and successfully cleared goals render nothing. +The plugin separately projects each durable `/goal` `command/run` through its own Conversation Definition. It builds a `command-input` Chat Node before the generic command result Node and registers that Node's keyed renderer as a right-aligned 14px/22px monospace user-style bubble with the localized group name `Command input` / `命令输入` and no timestamp, copy, or branch actions. The visible non-command Node activates fresh Chat; reload reconstructs it from the run, while a history window containing only `command/done` keeps only the generic result row. This projection never creates `user/message` or a model turn. + The `/client` exports are the plugin body (`apply`/`inject`), the `GoalBar`/`GoalDock` components, and the injected verb face types. ## Model Experience diff --git a/packages/client/ui-goal/README.zh.md b/packages/client/ui-goal/README.zh.md index 1ad9f50aee..c2d000dd81 100644 --- a/packages/client/ui-goal/README.zh.md +++ b/packages/client/ui-goal/README.zh.md @@ -4,6 +4,8 @@ Goal 界面插件(浏览器端部分):`GoalBar` 条带是 `conversation.input.dock` composer 上下文堆栈中的第二张独立卡片(order 10,位于 Todo 之后、Queue 之前)。活值经 `useProjection('goal')` 到达——host 计算的全量值由历史尾页播种、由 `session/projection` 帧更新——因此本插件不持有领域 store、不设刷新链、不挂事件监听。slot 注入面只携带四个变更动词(edit / pause / resume / clear,经 `ctx.remote.goals` 调用——active 的 goal 提供暂停动作,paused 的提供恢复);每个动词在调用时从会话当前投影值读取 CAS ref,并将 Remote 调用的拒绝错误内联呈现。由于 React 的 pending 渲染无法拦住同一帧内的点击,横条会同步为变更建立 single-flight 防护;清除成功后,会立即抑制该 goal id 对应的目标显示,直到权威的 null 投影追上。goal 的创建仍归 `/goal` host 命令;加载中、无 goal、已完成和已成功清除的 goal 一律不渲染。 +该插件还会通过自有 Conversation Definition 投影每条持久 `/goal` `command/run`。它在通用命令结果 Node 之前构建一个 `command-input` Chat Node,并为该 Node 注册 keyed renderer;renderer 将其呈现为右对齐、使用 14px/22px 等宽字体的用户样式气泡,使用本地化分组名称 `Command input`/`命令输入`,且不含时间戳、复制或分支操作。可见的非命令 Node 会激活新 Chat;重新加载时会根据 run 重建该 Node,而仅包含 `command/done` 的历史窗口只保留通用结果行。该投影绝不会创建 `user/message` 或模型轮次。 + `/client` 的导出接口包括插件本体(`apply`/`inject`)、`GoalBar`/`GoalDock` 组件与注入动词面类型。 ## 模型体验 diff --git a/packages/client/ui-goal/package.json b/packages/client/ui-goal/package.json index 9216af858a..28dfd38d56 100644 --- a/packages/client/ui-goal/package.json +++ b/packages/client/ui-goal/package.json @@ -52,6 +52,7 @@ "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^", @@ -65,6 +66,7 @@ "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@testing-library/react": "^16.1.0", diff --git a/packages/client/ui-goal/src/client/GoalCommandInputView.module.css b/packages/client/ui-goal/src/client/GoalCommandInputView.module.css new file mode 100644 index 0000000000..7bdb83889e --- /dev/null +++ b/packages/client/ui-goal/src/client/GoalCommandInputView.module.css @@ -0,0 +1,25 @@ +.row { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 6px; +} + +.stack { + display: flex; + flex-direction: column; + align-items: flex-end; + min-width: 0; + max-width: min(525px, 82%); +} + +.bubble { + max-width: 100%; + padding: 10px 16px; + overflow-wrap: anywhere; + border-radius: 22px; + background: var(--dsw-specific-bubble); + color: var(--dsw-alias-label-primary); + font: var(--dsw-font-markdown-code); + white-space: pre-wrap; +} diff --git a/packages/client/ui-goal/src/client/GoalCommandInputView.tsx b/packages/client/ui-goal/src/client/GoalCommandInputView.tsx new file mode 100644 index 0000000000..6d2345042b --- /dev/null +++ b/packages/client/ui-goal/src/client/GoalCommandInputView.tsx @@ -0,0 +1,30 @@ +import { memo } from 'react' +import { MessageText } from '@deepseek-ai/dsh-client-ui-primitives' +import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { GoalCommandInputData } from './goal-command-input.ts' +import css from './GoalCommandInputView.module.css' + +type GoalCommandInputViewProps = + PropsRuntime<'conversation.chat.node', 'command-input'> + & PropsLocale<'goal'> + +/** Right-aligned `/goal` input bubble without ordinary message actions. */ +export const GoalCommandInputView = memo(function GoalCommandInputView({ + node, t, +}: GoalCommandInputViewProps) { + const data: GoalCommandInputData = node.data + return ( +
+
+
+ +
+
+
+ ) +}) diff --git a/packages/client/ui-goal/src/client/goal-command-input.ts b/packages/client/ui-goal/src/client/goal-command-input.ts new file mode 100644 index 0000000000..7d58a5305d --- /dev/null +++ b/packages/client/ui-goal/src/client/goal-command-input.ts @@ -0,0 +1,71 @@ +import type { SessionEvent } from '@deepseek-ai/dsh-session/types' +import type { CommandId } from '@deepseek-ai/dsh-commands/brand' +import type {} from '@deepseek-ai/dsh-commands/types' +import type { + ConversationNodeDefinition, +} from '@deepseek-ai/dsh-client-runtime/client' + +/** Goal-owned human command input projected independently of model messages. */ +export interface GoalCommandInputData { + readonly commandId: CommandId + readonly text: string + readonly time: number +} + +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { + interface ChatNodeDataMap { + /** Human-entered `/goal` command input. */ + 'command-input': GoalCommandInputData + } +} + +interface GoalCommandInputState extends GoalCommandInputData { + readonly seq: number +} + +/** + * Derive the visible command line from its structured durable run. + * @param event - `/goal` command run. + * @returns command text with trailing parser whitespace removed. + */ +export function goalCommandText(event: SessionEvent<'command/run'>): string { + return `/${event.data.name}${(event.data.args ?? '').trimEnd()}` +} + +/** Goal-owned command input projection; the generic command Definition retains the result row. */ +export const goalCommandInputDefinition: ConversationNodeDefinition = { + kind: 'goal-command-input', + target: 'chat', + match: event => event.type === 'command/run' && event.data.name === 'goal' + ? { id: String(event.data.commandId), role: 'start' } + : null, + start: (_context, match) => { + if (match.event.type !== 'command/run') { + throw new Error('goal-command-input start requires command/run') + } + return { + commandId: match.event.data.commandId, + seq: match.event.seq, + time: match.event.time, + text: goalCommandText(match.event), + } + }, + update: context => context.state, + buildViewNode: (context) => { + if (context.state === undefined) return null + return { + key: context.key, + kind: 'command-input', + id: context.id, + target: 'chat', + anchorSeq: context.state.seq - 0.1, + location: context.start?.location ?? { kind: 'unresolved' }, + visibility: 'visible', + data: { + commandId: context.state.commandId, + text: context.state.text, + time: context.state.time, + }, + } + }, +} diff --git a/packages/client/ui-goal/src/client/index.ts b/packages/client/ui-goal/src/client/index.ts index d9b185b8fd..d66e025ffd 100644 --- a/packages/client/ui-goal/src/client/index.ts +++ b/packages/client/ui-goal/src/client/index.ts @@ -19,6 +19,8 @@ import type {} from '@deepseek-ai/dsh-client-locale/client' import type { GoalProjection, GoalRef } from '@deepseek-ai/dsh-goal/client' import type { GoalActionResult, GoalBarActions } from './slots.ts' import { GoalDock } from './GoalBar.tsx' +import { GoalCommandInputView } from './GoalCommandInputView.tsx' +import { goalCommandInputDefinition } from './goal-command-input.ts' import { en, zh, type GoalKey } from './locales.ts' export { GoalBar, GoalDock } from './GoalBar.tsx' @@ -35,8 +37,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Dictionary namespace owned by this plugin. */ const NS = 'goal' -/** Required services: slots for the dock entry, sessions for the projected ref, API for Remote mutations, locale for the copy. */ -export const inject = ['slots', 'sessions', 'remote', 'remote.goals', 'locale'] +/** Required services for the Goal dock, command-input projection, Remote mutations, and copy. */ +export const inject = ['slots', 'sessions', 'remote', 'remote.goals', 'locale', 'conversationEvents'] /** Map one generated Remote call, including synchronous namespace lookup failures, to the fields rendered by the goal strip. */ async function settle(invoke: () => Promise): Promise { @@ -68,8 +70,15 @@ function isRemoteError(value: unknown): value is { readonly code: string; readon * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { + ctx.conversationEvents.register(goalCommandInputDefinition) ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-goal: dictionaries') + ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({ + name: 'conversation.chat.node', + key: 'command-input', + locale: NS, + }, GoalCommandInputView)) + const sessions = ctx.sessions /** The session's current projected CAS ref, read at verb call time (no staleness fence: the RPC's CAS is the guard). */ diff --git a/packages/client/ui-goal/src/client/locales.ts b/packages/client/ui-goal/src/client/locales.ts index 72c9af658d..5fd6411573 100644 --- a/packages/client/ui-goal/src/client/locales.ts +++ b/packages/client/ui-goal/src/client/locales.ts @@ -6,6 +6,7 @@ export const zh = { 'phase.paused': '已暂停的目标', 'phase.blocked': '受阻的目标', 'objective.aria': '目标内容', + 'commandInput.aria': '命令输入', 'action.save': '保存目标', 'action.cancel': '取消编辑', 'action.pause': '暂停目标', @@ -23,6 +24,7 @@ export const en = { 'phase.paused': 'Paused Goal', 'phase.blocked': 'Blocked Goal', 'objective.aria': 'Goal objective', + 'commandInput.aria': 'Command input', 'action.save': 'Save goal', 'action.cancel': 'Cancel edit', 'action.pause': 'Pause goal', diff --git a/packages/client/ui-goal/tests/browser-plugin.spec.tsx b/packages/client/ui-goal/tests/browser-plugin.spec.tsx index 9ead151b65..793a6e3681 100644 --- a/packages/client/ui-goal/tests/browser-plugin.spec.tsx +++ b/packages/client/ui-goal/tests/browser-plugin.spec.tsx @@ -15,6 +15,7 @@ import { describe, expect, it, vi } from 'vitest' import { cleanup, render } from '@testing-library/react' import { afterEach } from 'vitest' import { SlotsService, type SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import { ConversationEventRegistry } from '@deepseek-ai/dsh-client-runtime/src/client/conversation/event-registry.ts' import type { GoalProjection } from '@deepseek-ai/dsh-goal/client' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' @@ -52,6 +53,7 @@ async function bench(options: { } = {}) { const ctx = new Context() const calls: { method: string; args: unknown[] }[] = [] + const conversationEvents = new ConversationEventRegistry(ctx) function answer(method: string, value: T) { return (...args: unknown[]) => { calls.push({ method, args }) @@ -85,7 +87,10 @@ async function bench(options: { }) await ctx.plugin(SlotsService).await() ctx.slots.register({ - name: 'root', children: { 'conversation.input.dock': { kind: 'list', scope: 'session' } }, + name: 'root', children: { + 'conversation.input.dock': { kind: 'list', scope: 'session' }, + 'conversation.chat.node': { kind: 'keyed', scope: 'session' }, + }, } as never, (() => null) as never) ctx.provide('locale', new LocaleService(ctx)) ctx.provide('sessions', { @@ -103,6 +108,7 @@ async function bench(options: { ctx, fiber, calls, + definitions: () => conversationEvents.entries(), remountGoals: () => { activeGoals = goals('remounted-goals') }, unmountGoals: () => { activeGoals = undefined }, entry: () => { @@ -114,15 +120,19 @@ async function bench(options: { inject: entry.inject as unknown as ((sessionId: SessionId) => GoalBarActions) | undefined, } }, + chatEntry: () => ctx.slots.entries('conversation.chat.node')[0], } } describe('ui-goal browser plugin', () => { - it('registers the GoalBar dock entry with the documented id and order', async () => { + it('registers the GoalBar dock, command input Definition, and keyed Chat renderer', async () => { const b = await bench() await b.fiber.await() expect(b.entry()).toMatchObject({ id: 'goal', order: 10, locale: 'goal' }) expect(b.entry()?.inject).toBeTypeOf('function') + expect(b.definitions().map(definition => definition.kind)).toEqual(['goal-command-input']) + expect(b.chatEntry()?.options).toMatchObject({ key: 'command-input' }) + expect(b.chatEntry()?.locale).toBe('goal') }) it('verbs read the CAS ref from the current projected value at call time', async () => { @@ -199,8 +209,12 @@ describe('ui-goal browser plugin', () => { const b = await bench() await b.fiber.await() expect(b.entry()).toBeDefined() + expect(b.chatEntry()).toBeDefined() + expect(b.definitions()).toHaveLength(1) await b.fiber.dispose() expect(b.entry()).toBeUndefined() + expect(b.chatEntry()).toBeUndefined() + expect(b.definitions()).toHaveLength(0) }) }) diff --git a/packages/client/ui-goal/tests/goal-command-input.spec.tsx b/packages/client/ui-goal/tests/goal-command-input.spec.tsx new file mode 100644 index 0000000000..c867e516d6 --- /dev/null +++ b/packages/client/ui-goal/tests/goal-command-input.spec.tsx @@ -0,0 +1,134 @@ +// @vitest-environment jsdom +import { cleanup, render, within } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import type { + ChatConversationViewNode, ChatSnapshot, ConversationEventInput, + ConversationNodeDefinition, ConversationViewDefinition, +} from '@deepseek-ai/dsh-client-runtime/client' +import { ConversationNodeAssembler } from '@deepseek-ai/dsh-client-runtime/client' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' +import type { SessionEvent } from '@deepseek-ai/dsh-session/types' +import { commandDefinition } from '@deepseek-ai/dsh-client-ui-conversation/src/client/conversation-nodes/command.ts' +import { chatViewDefinition } from '@deepseek-ai/dsh-client-ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts' +import { GoalCommandInputView } from '../src/client/GoalCommandInputView.tsx' +import { + goalCommandInputDefinition, goalCommandText, +} from '../src/client/goal-command-input.ts' +import { zh } from '../src/client/locales.ts' + +afterEach(cleanup) + +class TestEventDefinitions { + entries(): readonly ConversationNodeDefinition[] { + return [commandDefinition, goalCommandInputDefinition] + } + + fallbackEntry(): undefined { + return undefined + } +} + +class TestViewDefinitions { + entries(): readonly ConversationViewDefinition[] { + return [chatViewDefinition] + } +} + +function entry(seq: number, type: string, data: unknown): ConversationEventInput { + return { + event: { seq, time: 1_700_000_000_000 + seq, type, data } as ConversationEventInput['event'], + view: undefined, + } +} + +function snapshot(entries: readonly ConversationEventInput[], hasMore = false): ChatSnapshot { + const assembler = new ConversationNodeAssembler(new TestEventDefinitions(), new TestViewDefinitions()) + assembler.replaceWindow(entries, hasMore) + assembler.flush() + const value = assembler.snapshot('chat') as ChatSnapshot | undefined + if (value === undefined) throw new Error('chat view was not registered') + return value +} + +function node(value: ChatSnapshot, kind: string): ChatConversationViewNode | undefined { + return value.nodes.values().find(candidate => candidate.kind === kind) +} + +describe('goal command input projection', () => { + it('builds a separate input Node before the generic command result and restores it on replay', () => { + const run = entry(1, 'command/run', { + commandId: 'command-goal', name: 'goal', args: ' ', source: { kind: 'user' }, + }) + const done = entry(2, 'command/done', { + commandId: 'command-goal', kind: 'success', text: 'No goal is currently set.', + }) + const value = snapshot([run, done]) + + expect(value.order.map(key => value.nodes.get(key)?.kind)).toEqual(['command-input', 'command']) + expect(node(value, 'command-input')).toMatchObject({ + anchorSeq: 0.9, + data: { commandId: 'command-goal', text: '/goal' }, + }) + expect(node(value, 'command')?.data).toMatchObject({ + name: 'goal', args: ' ', outcome: { kind: 'success', text: 'No goal is currently set.' }, + }) + + const doneOnly = snapshot([done], true) + expect(node(doneOnly, 'command-input')).toBeUndefined() + expect(node(doneOnly, 'command')?.data).toMatchObject({ name: null, args: null }) + }) + + it('ignores other commands and preserves internal multiline arguments', () => { + const plan = entry(1, 'command/run', { + commandId: 'command-plan', name: 'plan', args: '', source: { kind: 'user' }, + }) + const goal = entry(2, 'command/run', { + commandId: 'command-goal', name: 'goal', args: '\nfirst line\nsecond line \n', source: { kind: 'user' }, + }) + + expect(goalCommandInputDefinition.match(plan.event)).toBeNull() + expect(goalCommandText(goal.event as SessionEvent<'command/run'>)) + .toBe('/goal\nfirst line\nsecond line') + }) + + it('keeps the Definition total across required interface and window fallback paths', () => { + const run = entry(3, 'command/run', { + commandId: 'command-goal', name: 'goal', source: { kind: 'user' }, + }) + const match = { + ...run, + role: 'start' as const, + location: { kind: 'session' as const }, + } + const state = goalCommandInputDefinition.start({} as never, match, {} as never) + + expect(state.text).toBe('/goal') + expect(goalCommandInputDefinition.update({ state } as never, match)).toBe(state) + expect(goalCommandInputDefinition.buildViewNode!({ state: undefined } as never)).toBeNull() + expect(goalCommandInputDefinition.buildViewNode!({ + key: 'goal-command-input', id: 'command-goal', state, start: undefined, + } as never)).toMatchObject({ location: { kind: 'unresolved' } }) + + const done = entry(4, 'command/done', { commandId: 'command-goal', kind: 'success' }) + expect(() => goalCommandInputDefinition.start({} as never, { + ...done, role: 'start', location: { kind: 'session' }, + } as never, {} as never)).toThrow('goal-command-input start requires command/run') + }) + + it('renders the user-style command bubble without ordinary message actions', () => { + const t = makeTranslate(zh, commonZh) + const props = { + node: { + key: 'goal-command-input:one', + data: { commandId: 'command-goal', text: '/goal ship it', time: 1_700_000_000_000 }, + }, + t, + } as unknown as Parameters[0] + const view = render() + const bubble = view.getByRole('group', { name: '命令输入' }) + + expect(bubble.textContent).toBe('/goal ship it') + expect(within(bubble).queryByRole('button')).toBeNull() + }) +}) diff --git a/packages/client/ui-goal/tsconfig.json b/packages/client/ui-goal/tsconfig.json index 1c89771abf..78335538bb 100644 --- a/packages/client/ui-goal/tsconfig.json +++ b/packages/client/ui-goal/tsconfig.json @@ -29,6 +29,9 @@ { "path": "../ui-slots" }, + { + "path": "../../interaction/commands" + }, { "path": "../../goal/goal" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 56b1701434..adb6aa6c5c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2128,6 +2128,9 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots + '@deepseek-ai/dsh-commands': + specifier: workspace:^ + version: link:../../interaction/commands '@deepseek-ai/dsh-goal': specifier: workspace:^ version: link:../../goal/goal diff --git a/tsconfig.host.json b/tsconfig.host.json index d56a0af8fb..be794ba04b 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -54,6 +54,7 @@ "apps/web/tests/shipped-composition.e2e.ts", "apps/web/tests/goal-bar.e2e.ts", "apps/web/tests/feedback-command.e2e.ts", + "apps/web/tests/goal-command-presentation.e2e.ts", "apps/web/tests/startup-auto-selection.e2e.ts", "apps/web/tests/produced-files.e2e.ts", "apps/web/tests/produced-file-mentions.e2e.ts",