From a777000512d2947e3c28e6f86ee7501acd3e248d Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 30 Jul 2026 00:21:47 +0800 Subject: [PATCH 001/129] fix(user-interaction): preserve multi-select custom answers --- ...select-custom-answer-composition.i18n.yaml | 6 + ...-multi-select-custom-answer-composition.md | 25 ++++ ...lti-select-custom-answer-composition.zh.md | 25 ++++ .../user-interaction.i18n.yaml | 6 +- docs/core-data-structures/user-interaction.md | 4 +- .../user-interaction.zh.md | 4 +- .../tests/fixtures/tui-scripted-llm.ts | 7 ++ .../tui-agent/tests/tui-keyless-smoke.e2e.ts | 9 +- packages/client/ui-question/README.i18n.yaml | 4 +- packages/client/ui-question/README.md | 2 +- packages/client/ui-question/README.zh.md | 2 +- .../src/client/QuestionComposer.tsx | 24 ++-- .../tests/question-composer.spec.tsx | 11 +- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 + packages/host/apiproxy/README.zh.md | 2 + packages/host/apiproxy/src/api-proxy.ts | 2 +- .../apiproxy/tests/api-proxy-question.spec.ts | 116 ++++++++++++++++++ packages/ui/tool-ask-user/README.i18n.yaml | 6 +- packages/ui/tool-ask-user/README.md | 2 +- packages/ui/tool-ask-user/README.zh.md | 2 +- .../tool-ask-user/tests/tool-ask-user.spec.ts | 6 +- packages/ui/tui/README.i18n.yaml | 4 +- packages/ui/tui/README.md | 2 +- packages/ui/tui/README.zh.md | 2 +- packages/ui/tui/src/components/dialogs.ts | 20 ++- packages/ui/tui/tests/tui.spec.ts | 6 +- packages/ui/user-interaction/README.i18n.yaml | 6 +- packages/ui/user-interaction/README.md | 2 +- packages/ui/user-interaction/README.zh.md | 2 +- packages/ui/user-interaction/src/types.ts | 2 +- 31 files changed, 269 insertions(+), 48 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.md create mode 100644 .agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.zh.md create mode 100644 packages/host/apiproxy/tests/api-proxy-question.spec.ts diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.i18n.yaml new file mode 100644 index 0000000000..bb081e4be8 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.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/bug-fix/2026-07-30-multi-select-custom-answer-composition.md +2026-07-30-multi-select-custom-answer-composition.md: 7194f4a79f1dd49eba4a9b626d75203fced06544 +2026-07-30-multi-select-custom-answer-composition.zh.md: fac09c8db0ebf2dd4a84ade7aa7868128656025d diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.md b/.agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.md new file mode 100644 index 0000000000..7194f4a79f --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.md @@ -0,0 +1,25 @@ +# Agent Note: Multi-select custom answer composition + +Status: implemented + +English | [中文](2026-07-30-multi-select-custom-answer-composition.zh.md) + +## Problem + +The user-interaction result vocabulary carries selected option labels and optional custom text in separate fields, but its original semantics made them mutually exclusive for every question. On a multi-select question, opening or typing the custom answer discarded labels the user had already selected. The TUI returned only the custom text, and the Web host rejected a client response that preserved both fields. + +## Decision + +For a question with `multiSelect: true`, one answer item may contain both a non-empty `selected` array and non-empty `custom` text. Web drafts preserve both values regardless of whether the user selects an option or types custom text first; the TUI projects its checked option set when custom text is submitted; and the Web host accepts the combined response after applying its existing id, label, uniqueness, batch, and non-empty-text validation. + +Single-select and optionless questions keep exclusive semantics: custom text overrides any selected option. The result shape remains `{ id, selected, custom? }`, so no wire or tool-output schema changes. + +## Alternatives considered + +**Encode custom text as another `selected` label.** Rejected because it would erase the distinction between caller-provided option labels and human-authored text, weakening validation and forcing consumers to infer which value was custom. + +**Allow `selected` and `custom` together for every question.** Rejected because a single-select question represents one answer; permitting a selected option plus custom text would make its cardinality ambiguous. The combined form is limited to questions that explicitly opt into multiple answers. + +## Consequences + +Multi-select UIs can represent the user's complete answer without discarding either source. Providers and consumers retain the existing DTO, while request-aware validators interpret the allowed combination from `multiSelect`. Web, TUI, host-response, tool-projection, and assembled keyless TUI coverage pin the combined result; single-select host coverage pins the remaining exclusivity rule. diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.zh.md b/.agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.zh.md new file mode 100644 index 0000000000..fac09c8db0 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.zh.md @@ -0,0 +1,25 @@ +# Agent Note: 多选题自定义答案组合 + +Status: implemented + +[English](2026-07-30-multi-select-custom-answer-composition.md) | 中文 + +## 问题 + +用户交互结果的词汇分别通过不同字段携带选中的选项标签和可选的自定义文本,但最初的语义要求每个问题的这两个字段互斥。对于多选题,打开自定义答案或输入文本会丢弃用户已选中的标签。TUI 只返回自定义文本,而 Web 宿主会拒绝同时保留两个字段的客户端响应。 + +## 决策 + +对于 `multiSelect: true` 的问题,一个回答项可以同时包含非空 `selected` 数组与非空 `custom` 文本。无论用户先选择选项还是先输入自定义文本,Web 草稿都会保留两个值;提交自定义文本时,TUI 会投影其已勾选的选项集合;Web 宿主则在应用现有的 id、标签、唯一性、批次和非空文本校验后接受组合响应。 + +单选题和无选项问题仍保持互斥语义:自定义文本会覆盖任何已选中的选项。结果形状仍为 `{ id, selected, custom? }`,因此协议或工具输出 schema 均无需变更。 + +## 考虑过的替代方案 + +**把自定义文本编码为另一个 `selected` 标签。** 不予采纳,因为这样会抹去调用方提供的选项标签与用户填写文本之间的区别,削弱校验,并迫使消费方推断哪个值属于自定义内容。 + +**允许所有问题同时使用 `selected` 与 `custom`。** 不予采纳,因为单选题只表示一个回答;允许选中选项与自定义文本并存会使其基数含义模糊。组合形式仅适用于显式选择多项回答的问题。 + +## 后果 + +多选 UI 可以完整表达用户的回答,不会丢弃任一来源。提供方和消费方继续使用现有 DTO,而请求感知的校验器会根据 `multiSelect` 判断是否允许组合。Web、TUI、宿主响应、工具投影和组装后的无密钥 TUI 覆盖会固定组合结果;单选题的宿主覆盖则固定其余的互斥规则。 diff --git a/docs/core-data-structures/user-interaction.i18n.yaml b/docs/core-data-structures/user-interaction.i18n.yaml index 66cb12815e..f764e9ca23 100644 --- a/docs/core-data-structures/user-interaction.i18n.yaml +++ b/docs/core-data-structures/user-interaction.i18n.yaml @@ -1,6 +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 -user-interaction.md: 798a9790f424683775284a98421be08e6e1399e3 -user-interaction.zh.md: 12bfcffe4fe4caaacb54e90126eac55e333d64a5 +# pnpm run verify-translation-pairing --write docs/core-data-structures/user-interaction.md +user-interaction.md: db6ac5010ada9d02319bf148566792659711d2e4 +user-interaction.zh.md: a8306b421a03563ba9ae2ee48d04898d00eb668e diff --git a/docs/core-data-structures/user-interaction.md b/docs/core-data-structures/user-interaction.md index 798a9790f4..db6ac5010a 100644 --- a/docs/core-data-structures/user-interaction.md +++ b/docs/core-data-structures/user-interaction.md @@ -60,14 +60,14 @@ interface AskUserQuestionRequest { ## Answer -Providers return one answer item per question id. `selected` contains selected option labels, and `custom` carries a free-form "Other" answer when the user typed one. When `custom` is present, `selected` is empty; custom text is an answer override, not a supplement to selected choices. A UI may also use an item with empty `selected` and no `custom` to preserve a skipped question in an otherwise completed batch. +Providers return one answer item per question id. `selected` contains selected option labels, and `custom` carries a free-form "Other" answer when the user typed one. For a single-select question, `custom` overrides the selected choice and `selected` is empty. For a multi-select question, `custom` may supplement the labels in `selected`. A UI may also use an item with empty `selected` and no `custom` to preserve a skipped question in an otherwise completed batch. ```ts type-equiv /** Answer to one question. */ interface AskUserQuestionAnswerItem { /** The answered question id. */ id: string - /** Selected option labels. Empty for custom or unanswered choices. */ + /** Selected option labels. May accompany custom text for a multi-select question. */ selected: string[] /** Optional free-text "Other" answer. */ custom?: string diff --git a/docs/core-data-structures/user-interaction.zh.md b/docs/core-data-structures/user-interaction.zh.md index 12bfcffe4f..a8306b421a 100644 --- a/docs/core-data-structures/user-interaction.zh.md +++ b/docs/core-data-structures/user-interaction.zh.md @@ -60,14 +60,14 @@ interface AskUserQuestionRequest { ## 回答 -提供方为每个问题 id 返回一个回答项。`selected` 包含选中的选项标签,`custom` 在用户输入自由文本时携带「其他」回答。当 `custom` 存在时,`selected` 为空;自定义文本是对选中项的覆盖,而非补充。UI 也可以使用 `selected` 为空且不含 `custom` 的回答项,在其余问题均已完成的批次中保留被跳过的问题。 +提供方为每个问题 id 返回一个回答项。`selected` 包含选中的选项标签,`custom` 在用户输入自由文本时携带「其他」回答。对于单选题,`custom` 会覆盖选中的选项,且 `selected` 为空。对于多选题,`custom` 可以补充 `selected` 中的标签。UI 也可以使用 `selected` 为空且不含 `custom` 的回答项,在其余问题均已完成的批次中保留被跳过的问题。 ```ts type-equiv /** Answer to one question. */ interface AskUserQuestionAnswerItem { /** The answered question id. */ id: string - /** Selected option labels. Empty for custom or unanswered choices. */ + /** Selected option labels. May accompany custom text for a multi-select question. */ selected: string[] /** Optional free-text "Other" answer. */ custom?: string diff --git a/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts b/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts index 517fa6adab..121bde9278 100644 --- a/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts +++ b/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts @@ -110,6 +110,12 @@ class ScriptedTuiAdapter extends LlmAdapter { const hasToolResult = lastMessage?.content.some(block => block.type === 'tool-result') ?? false if (hasToolResult) { + const toolResultText = lastMessage?.content.flatMap(block => block.type === 'tool-result' + ? block.content.flatMap(content => content.type === 'text' ? [content.text] : []) + : []).join('\n') ?? '' + if (toolResultText !== '{"answers":[{"id":"mode","selected":["Safe"],"custom":"Release notes"}]}') { + throw new Error(`the scripted TUI request received an unexpected question answer: ${toolResultText}`) + } for (const chunk of textChunks(FINAL_TEXT)) yield chunk return } @@ -119,6 +125,7 @@ class ScriptedTuiAdapter extends LlmAdapter { id: 'mode', header: 'Execution mode', question: 'How should the scripted run proceed?', + multi_select: true, options: [ { label: 'Safe', description: 'Use the guarded path.' }, { label: 'Fast', description: 'Use the shorter path.' }, diff --git a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts index 7ae98167de..0201ccebe8 100644 --- a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts +++ b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts @@ -138,6 +138,7 @@ const SELECT_PRO_MODEL = [ { waitFor: 'scripted TUI ready.', send: '/model\r' }, { waitFor: 'Select model', send: '\x1b[B\x1b[Z\r' }, ] as const +const ANSWER_MULTI_WITH_CUSTOM = ' \tRelease notes\r' describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { it('boots pi-tui, sweeps the borderless banner in, enters plan mode, and restores the terminal', async () => { @@ -174,7 +175,10 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { // The question text first appears in the streamed tool-call card. Wait // for the dialog's input legend so Enter cannot arrive before it owns // terminal input when pre-dispatch policy yields. - { waitFor: 'Tab custom answer • ↑/↓ navigate • Enter submit • Esc interrupt', send: '\r' }, + { + waitFor: 'Tab custom answer • ↑/↓ navigate • Space toggle • Enter submit • Esc interrupt', + send: ANSWER_MULTI_WITH_CUSTOM, + }, { waitFor: 'Decision received. Scripted TUI run complete.', send: '' }, // Session title: the first user message drives the first-message-llm // provider's tool-less title call; the scripted adapter answers it, the @@ -200,6 +204,7 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { expect(output).not.toContain('\u001B[999CMODEL_CURSOR') expect(output).not.toContain('\u009B31mMODEL_C1') expect(output).toContain('Safe') + expect(output).toContain('Release notes') expect(output).toContain('\u001B]0;scripted session title — DeepSeek Harness\u0007') expect(output).toContain('Session status') expect(output).toContain('Title') @@ -395,7 +400,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { actions: [ ...SELECT_PRO_MODEL, { waitFor: 'Model selected: tui-scripted/tui-scripted-model-pro.', send: 'exercise the TUI\r' }, - { waitFor: 'How should the scripted run proceed?', send: '\r' }, + { waitFor: 'How should the scripted run proceed?', send: ANSWER_MULTI_WITH_CUSTOM }, { waitFor: 'Decision received. Scripted TUI run complete.', send: '/exit\r' }, ], inspect: async (cwd) => { context = await readLoggedRequestContext(cwd) }, diff --git a/packages/client/ui-question/README.i18n.yaml b/packages/client/ui-question/README.i18n.yaml index a58cd055a7..7657062501 100644 --- a/packages/client/ui-question/README.i18n.yaml +++ b/packages/client/ui-question/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-question/README.md -README.md: 3a3cd639fc2834685230aca7c8087583e0a48c71 -README.zh.md: 1330578577da7ed7d0890595f675fd272fd5ebc7 +README.md: c36f1474e175b52c7d35af6b479ab5bfeabcd9ff +README.zh.md: 8986dee718a98920a20757aafb1bd4b54ac8f782 diff --git a/packages/client/ui-question/README.md b/packages/client/ui-question/README.md index 3a3cd639fc..c36f1474e1 100644 --- a/packages/client/ui-question/README.md +++ b/packages/client/ui-question/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Web `ask_user_question` feature plugin. Its host half mounts `dsh-tool-ask-user` only when the Web feature is selected; its browser half registers the `question` entry in the conversation-owned `conversation.composer` keyed slot. -The component renders one question at a time with progress navigation, single- and multi-select choices, recommendation badges derived from label suffixes, and custom answers. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps its title, navigation, and submission actions fixed while long detail and choices share an internal scroll region. Single-select choices advance immediately, and Enter submits once every question is answered or skipped; Enter during IME composition confirms the input candidate without advancing. It submits one structured answer batch for the whole request: “Skip this question” retains other drafts and emits the existing blank `{ selected: [] }` shape for that item, while close rejects the whole wait as `ASK_CANCELLED`. +The component renders one question at a time with progress navigation, single- and multi-select choices, recommendation badges derived from label suffixes, and custom answers. A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps its title, navigation, and submission actions fixed while long detail and choices share an internal scroll region. Single-select choices advance immediately, and Enter submits once every question is answered or skipped; Enter during IME composition confirms the input candidate without advancing. It submits one structured answer batch for the whole request: “Skip this question” retains other drafts and emits the existing blank `{ selected: [] }` shape for that item, while close rejects the whole wait as `ASK_CANCELLED`. Selection state is local to a component keyed by the request rpcId. A replay with the same id preserves a still-mounted draft, while `question/resolved` from the host removes the composer. The host remains authoritative: successful HTTP delivery does not remove pending state locally. diff --git a/packages/client/ui-question/README.zh.md b/packages/client/ui-question/README.zh.md index 1330578577..8986dee718 100644 --- a/packages/client/ui-question/README.zh.md +++ b/packages/client/ui-question/README.zh.md @@ -4,7 +4,7 @@ Web `ask_user_question` 功能插件。只有选择 Web 功能时,其主机侧才会挂载 `dsh-tool-ask-user`;浏览器侧会把 `question` 配置项注册到会话拥有的 `conversation.composer` 键控 slot 中。 -组件每次渲染一个问题,提供进度导航、单选和多选选项、由标签后缀派生的推荐徽标,以及自定义答案。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信内容策略。封顶卡片保持标题、导航与提交动作固定,超长的详情与选项共享内部滚动区。单选选项会立即前进;所有问题均已回答或跳过后,Enter 会提交;IME 输入法组合期间按 Enter 只会确认输入候选,不会前进。组件为整个请求提交一批结构化答案:「跳过此问题」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 形状;关闭则以 `ASK_CANCELLED` 拒绝整个等待。 +组件每次渲染一个问题,提供进度导航、单选和多选选项、由标签后缀派生的推荐徽标,以及自定义答案。用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信内容策略。封顶卡片保持标题、导航与提交动作固定,超长的详情与选项共享内部滚动区。单选选项会立即前进;所有问题均已回答或跳过后,Enter 会提交;IME 输入法组合期间按 Enter 只会确认输入候选,不会前进。组件为整个请求提交一批结构化答案:「跳过此问题」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 形状;关闭则以 `ASK_CANCELLED` 拒绝整个等待。 选择状态只存在于以请求 rpcId 为 key 的组件本地。使用相同 id 回放时,只要组件仍挂载,就会保留草稿;主机发出的 `question/resolved` 则会移除编辑器。主机仍具有最终决定权:HTTP 交付成功不会在本地移除待处理状态。 diff --git a/packages/client/ui-question/src/client/QuestionComposer.tsx b/packages/client/ui-question/src/client/QuestionComposer.tsx index 542a24b935..ebf2caf22f 100644 --- a/packages/client/ui-question/src/client/QuestionComposer.tsx +++ b/packages/client/ui-question/src/client/QuestionComposer.tsx @@ -86,12 +86,13 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) { const choose = (label: string): void => { updateDraft((current) => { - const selected = question.multiSelect === true - ? current.selected.includes(label) + if (question.multiSelect === true) { + const selected = current.selected.includes(label) ? current.selected.filter(item => item !== label) : [...current.selected, label] - : [label] - return { selected, custom: '', customOpen: false, skipped: false } + return { ...current, selected, skipped: false } + } + return { selected: [label], custom: '', customOpen: false, skipped: false } }) if (question.multiSelect !== true && index < questions.length - 1) { setIndex(current => current + 1) @@ -99,7 +100,12 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) { } const openCustom = (): void => { - updateDraft(current => ({ ...current, selected: [], customOpen: true, skipped: false })) + updateDraft(current => ({ + ...current, + selected: question.multiSelect === true ? current.selected : [], + customOpen: true, + skipped: false, + })) } const answered = (item: DraftAnswer): boolean => @@ -121,7 +127,7 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) { const custom = value.custom.trim() return { id: item.id, - selected: custom === '' ? value.selected : [], + selected: custom === '' || item.multiSelect === true ? value.selected : [], ...(custom === '' ? {} : { custom }), } }), @@ -269,7 +275,11 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) { onChange={(event) => { const value = event.target.value updateDraft(current => ({ - ...current, selected: [], custom: value, customOpen: true, skipped: false, + ...current, + selected: question.multiSelect === true ? current.selected : [], + custom: value, + customOpen: true, + skipped: false, })) }} onKeyDown={(event) => { diff --git a/packages/client/ui-question/tests/question-composer.spec.tsx b/packages/client/ui-question/tests/question-composer.spec.tsx index 7df9f2bde9..3154eba0ee 100644 --- a/packages/client/ui-question/tests/question-composer.spec.tsx +++ b/packages/client/ui-question/tests/question-composer.spec.tsx @@ -96,13 +96,20 @@ describe('QuestionComposer', () => { fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' })) fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' })) fireEvent.click(screen.getByRole('checkbox', { name: '代码质量' })) - fireEvent.keyDown(screen.getByRole('checkbox', { name: '代码质量' }), { key: 'Enter' }) + fireEvent.click(screen.getByRole('button', { name: '其他,请填写自定义答案' })) + const multiCustom = screen.getByPlaceholderText('输入你的答案') + fireEvent.change(multiCustom, { target: { value: '沟通能力' } }) + fireEvent.click(screen.getByRole('checkbox', { name: '产品判断' })) + expect(screen.getByRole('checkbox', { name: '系统设计' }).getAttribute('aria-checked')).toBe('true') + expect(screen.getByRole('checkbox', { name: '代码质量' }).getAttribute('aria-checked')).toBe('true') + expect((multiCustom as HTMLTextAreaElement).value).toBe('沟通能力') + fireEvent.keyDown(multiCustom, { key: 'Enter' }) // The domain face encoded the whole batch into one carrier envelope. expect(respond).toHaveBeenCalledWith(answeredEnvelope('question-1', [ { id: 'profile', selected: ['工程落地型 (Recommended)'] }, { id: 'detail', selected: [], custom: '要能独立排查线上问题' }, - { id: 'signals', selected: ['系统设计', '代码质量'] }, + { id: 'signals', selected: ['系统设计', '代码质量', '产品判断'], custom: '沟通能力' }, ])) expect(screen.getByRole('button', { name: '正在提交…' }).disabled).toBe(true) }) diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 73b0845370..258ee74183 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/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/host/apiproxy/README.md -README.md: ca4471454f5be5d3fcba38ce665d4fb3fbd85e74 -README.zh.md: 953539e1198a52b2bf7cdd9ca1b0d263cc2ae6f9 +README.md: d517608404239809df03b089e150dbbecbf6d7cc +README.zh.md: f37427205fc72ef60f923d9d938adee0d4aa241c diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index ca4471454f..d517608404 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -10,6 +10,8 @@ Wire messages form a four-quadrant discriminated union — who initiates × requ The layering/protocol decisions are recorded in the [GUI layering and RPC protocol RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md); the browser-side consumption architecture in the [web client architecture RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md). +Question responses are validated against their pending request before the first answer claims it. A multi-select item may carry both requested option labels in `selected` and non-empty `custom` text; a single-select item must use one or the other. Duplicate labels, unknown labels, mismatched ids, incomplete batches, and empty custom text are rejected as `bad-response`. + `session.history`'s tail page (`beforeSeq` absent) additionally carries an optional `projections` block — the watermark snapshot of every unit registered on `ctx.sessionProjections` (`@deepseek-ai/dsh-session-projection`), with `asOfSeq` = the last event seq the values reflect (`-1` on an empty log). The gateway also subscribes to the registry's change feed and mints a `session/projection` mux frame per changed unit (`{sessionId, key, value, seq}` — live push state, never logged; clients hold one generic per-session value store under higher-seq-wins). The carrier holds zero domain knowledge (each value passed its unit's own schema inside the registry; the wire schemas keep `values`/`value` wide); loadOlder pages never carry the block, and a composition without the registry serves histories without either surface. Session titles ride the generic projection pair like every other domain — the history-tail `projections` block plus `session/projection` frames under the `title` key (the bespoke `session/title` frame is retired). Titles do not join `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 953539e119..f37427205f 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -10,6 +10,8 @@ 分层与协议决策记录在 [GUI 分层与 RPC 协议 RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)中;浏览器侧消费架构记录在 [Web 客户端架构 RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md)中。 +首个回答认领待处理请求之前,系统会对照该请求校验问题响应。多选题的回答项可以同时携带 `selected` 中的请求选项标签与非空 `custom` 文本;单选题的回答项必须二选一。标签重复、标签未知、id 不匹配、批次不完整以及自定义文本为空都会以 `bad-response` 拒绝。 + `session.history` 的尾页(不带 `beforeSeq`)额外携带一个可选的 `projections` 块——`ctx.sessionProjections`(`@deepseek-ai/dsh-session-projection`)上每个已注册单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq(空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元铸造一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。载体不持有任何领域知识(每个值在注册表内部已过其单元自己的 schema;协议 schema 对 `values`/`value` 保持宽松);loadOlder 页永不携带该块,未装注册表的组合则两个面都不提供。 会话标题与其他所有领域一样搭乘这对通用投影机制——历史尾页的 `projections` 块外加 `title` 键下的 `session/projection` 帧(专设的 `session/title` 帧已下线)。标题不会加入 `session.list`;冷会话在其中仍只有元数据,直到打开或恢复操作附加其日志。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index f178bfefd0..585a6df305 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -275,7 +275,7 @@ function matchesQuestions(payload: QuestionResponsePayload, pending: PendingQues if (new Set(answer.selected).size !== answer.selected.length) return false const custom = answer.custom?.trim() if (custom !== undefined && custom === '') return false - if (custom !== undefined && answer.selected.length > 0) return false + if (custom !== undefined && answer.selected.length > 0 && question.multiSelect !== true) return false if (question.multiSelect !== true && answer.selected.length > 1) return false const labels = new Set(question.options?.map(option => option.label) ?? []) return answer.selected.every(label => labels.has(label)) diff --git a/packages/host/apiproxy/tests/api-proxy-question.spec.ts b/packages/host/apiproxy/tests/api-proxy-question.spec.ts new file mode 100644 index 0000000000..e8eaae813f --- /dev/null +++ b/packages/host/apiproxy/tests/api-proxy-question.spec.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import SessionStore from '@deepseek-ai/dsh-session' +import UserInteractionService from '@deepseek-ai/dsh-user-interaction' +import type { ApiProxy, MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api' +import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' +import { createApiProxy } from '../src/api-proxy.ts' + +async function harness(): Promise<{ ctx: Context; api: ApiProxy }> { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(UserInteractionService) + return { + ctx, + api: createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }), + } +} + +function agent(id: string): Agent { + return { id } as unknown as Agent +} + +function openMux(api: ApiProxy, abort: AbortController): { + envelopes: RpcRequest[] + waitForQuestion(): Promise>> +} { + const envelopes: RpcRequest[] = [] + let resolveQuestion!: (value: RpcRequest>) => void + const question = new Promise>>((resolve) => { + resolveQuestion = resolve + }) + void (async () => { + for await (const envelope of api.events.mux({ rpcId: RpcId('question-mux'), payload: {} }, abort.signal)) { + envelopes.push(envelope) + if (envelope.payload.type === 'question/requested') { + resolveQuestion(envelope as RpcRequest>) + } + } + })() + return { envelopes, waitForQuestion: () => question } +} + +function answer( + envelope: RpcRequest>, + selected: string[], + custom?: string, +): Parameters[0] { + return { + type: 'client-response', + rpcId: envelope.rpcId, + result: { + ok: true, + value: { + sessionId: envelope.payload.sessionId, + answer: { + answers: [{ + id: envelope.payload.questions[0]?.id, + selected, + ...custom === undefined ? {} : { custom }, + }], + }, + }, + }, + } +} + +describe('question response validation', () => { + it('accepts selected options with custom text for multi-select questions', async () => { + const { ctx, api } = await harness() + const abort = new AbortController() + const mux = openMux(api, abort) + const asked = ctx.userInteraction.ask({ + agent: agent('session-multi'), + questions: [{ + id: 'targets', + question: 'Choose targets and add another', + multiSelect: true, + options: [{ label: 'Code' }, { label: 'Docs' }], + }], + }) + const envelope = await mux.waitForQuestion() + + expect(await api.respond(answer(envelope, ['Code', 'Docs'], 'Release notes'))) + .toEqual({ accepted: true }) + await expect(asked).resolves.toEqual({ + answers: [{ id: 'targets', selected: ['Code', 'Docs'], custom: 'Release notes' }], + }) + expect(mux.envelopes.some(item => item.payload.type === 'question/resolved')).toBe(true) + abort.abort() + }) + + it('keeps selected options and custom text mutually exclusive for single-select questions', async () => { + const { ctx, api } = await harness() + const abort = new AbortController() + const mux = openMux(api, abort) + const asked = ctx.userInteraction.ask({ + agent: agent('session-single'), + questions: [{ + id: 'target', + question: 'Choose one target', + options: [{ label: 'Code' }, { label: 'Docs' }], + }], + }) + const envelope = await mux.waitForQuestion() + + expect(await api.respond(answer(envelope, ['Code'], 'Release notes'))) + .toEqual({ accepted: false, reason: 'bad-response' }) + expect(await api.respond(answer(envelope, [], 'Release notes'))) + .toEqual({ accepted: true }) + await expect(asked).resolves.toEqual({ + answers: [{ id: 'target', selected: [], custom: 'Release notes' }], + }) + abort.abort() + }) +}) diff --git a/packages/ui/tool-ask-user/README.i18n.yaml b/packages/ui/tool-ask-user/README.i18n.yaml index a03a7326fa..09c111ba9d 100644 --- a/packages/ui/tool-ask-user/README.i18n.yaml +++ b/packages/ui/tool-ask-user/README.i18n.yaml @@ -1,6 +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 -README.md: 8e779f4025c20cd200344efb7cb8cd6bc09ba64d -README.zh.md: fe1dc5559882532c4f44e705cc6daa2c7f4f8905 +# pnpm run verify-translation-pairing --write packages/ui/tool-ask-user/README.md +README.md: 64da4d75d01a0df0ae51b1557ed1c796317b906f +README.zh.md: 8a1eb3ee4f9e9ccc2ea2fe433bf85158c76d3549 diff --git a/packages/ui/tool-ask-user/README.md b/packages/ui/tool-ask-user/README.md index 8e779f4025..64da4d75d0 100644 --- a/packages/ui/tool-ask-user/README.md +++ b/packages/ui/tool-ask-user/README.md @@ -15,7 +15,7 @@ Model-facing `ask_user_question` tool over `ctx.userInteraction`. It lets the mo - `options` — optional choices with `label` and `description`. If recommending a choice, put it first and append `(Recommended)` to that label. - `multi_select` — whether that question may return more than one selected option. -The tool calls `ctx.userInteraction.ask()` and returns canonical `{ answers: [{ id, selected, custom? }] }`. `selected` contains option labels; `custom` is present only for a free-form answer and overrides selected choices. The Native renderer preserves the compact JSON text shape `{ "answers": [{ "id": "...", "selected": ["..."], "custom": "..." }] }`. +The tool calls `ctx.userInteraction.ask()` and returns canonical `{ answers: [{ id, selected, custom? }] }`. `selected` contains option labels; `custom` carries a free-form answer, supplementing `selected` for a multi-select question and overriding it for a single-select question. The Native renderer preserves the compact JSON text shape `{ "answers": [{ "id": "...", "selected": ["..."], "custom": "..." }] }`. ## Role diff --git a/packages/ui/tool-ask-user/README.zh.md b/packages/ui/tool-ask-user/README.zh.md index fe1dc55598..8a1eb3ee4f 100644 --- a/packages/ui/tool-ask-user/README.zh.md +++ b/packages/ui/tool-ask-user/README.zh.md @@ -15,7 +15,7 @@ - `options`:可选选项,包含 `label` 和 `description`。如需推荐某个选项,请将其置于首位,并在该标签末尾追加 `(Recommended)`。 - `multi_select`:该问题是否可以返回多个选中的选项。 -工具调用 `ctx.userInteraction.ask()`,并返回规范的 `{ answers: [{ id, selected, custom? }] }`。`selected` 包含选项标签;仅当用户自由填写回答时才会出现 `custom`,并覆盖选中的选项。Native renderer 会保留紧凑的 JSON 文本形式 `{ "answers": [{ "id": "...", "selected": ["..."], "custom": "..." }] }`。 +工具调用 `ctx.userInteraction.ask()`,并返回规范的 `{ answers: [{ id, selected, custom? }] }`。`selected` 包含选项标签;`custom` 携带自由填写的回答,对于多选题会补充 `selected`,对于单选题则会覆盖它。Native renderer 会保留紧凑的 JSON 文本形式 `{ "answers": [{ "id": "...", "selected": ["..."], "custom": "..." }] }`。 ## 职责 diff --git a/packages/ui/tool-ask-user/tests/tool-ask-user.spec.ts b/packages/ui/tool-ask-user/tests/tool-ask-user.spec.ts index 395986aed1..7d019a520a 100644 --- a/packages/ui/tool-ask-user/tests/tool-ask-user.spec.ts +++ b/packages/ui/tool-ask-user/tests/tool-ask-user.spec.ts @@ -140,7 +140,7 @@ describe('ask_user_question tool', () => { async ask() { return { answers: [ - { id: 'targets', selected: ['tests', 'docs'] }, + { id: 'targets', selected: ['tests', 'docs'], custom: 'release notes' }, { id: 'notes', selected: [], custom: 'ship today' }, ], } @@ -168,13 +168,13 @@ describe('ask_user_question tool', () => { if (result.isError) throw new Error('expected ask_user_question success') expect(result.value).toEqual({ answers: [ - { id: 'targets', selected: ['tests', 'docs'] }, + { id: 'targets', selected: ['tests', 'docs'], custom: 'release notes' }, { id: 'notes', selected: [], custom: 'ship today' }, ], }) expect(result.content).toEqual([{ type: 'text', - text: '{"answers":[{"id":"targets","selected":["tests","docs"]},{"id":"notes","selected":[],"custom":"ship today"}]}', + text: '{"answers":[{"id":"targets","selected":["tests","docs"],"custom":"release notes"},{"id":"notes","selected":[],"custom":"ship today"}]}', }]) }) diff --git a/packages/ui/tui/README.i18n.yaml b/packages/ui/tui/README.i18n.yaml index 8ab63910fa..b62fb70da3 100644 --- a/packages/ui/tui/README.i18n.yaml +++ b/packages/ui/tui/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/ui/tui/README.md -README.md: 0b358520b863f0b9ee7a128cf4807f582fc46d8d -README.zh.md: 7e89197bd82d16dfbabeb715e953275e2f6dd68b +README.md: 3c847828a3d560b85e74809f984bc9ea581e417f +README.zh.md: 8872484a5de376e41564756332200198e587d2b3 diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index 0b358520b8..3c847828a3 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -153,7 +153,7 @@ Append-only; newly visible content follows the reusable request prefix and does #### What the model sees -When a consumer calls `ctx.userInteraction.ask()`, this provider presents each question in order and returns selected option labels or `custom` text. Abort, cancellation, or UI disposal becomes `Error: ask_user_question was interrupted before the user answered` through `dsh-tool-ask-user`. +When a consumer calls `ctx.userInteraction.ask()`, this provider presents each question in order and returns selected option labels, `custom` text, or both for a multi-select question. Abort, cancellation, or UI disposal becomes `Error: ask_user_question was interrupted before the user answered` through `dsh-tool-ask-user`. #### Token effect diff --git a/packages/ui/tui/README.zh.md b/packages/ui/tui/README.zh.md index 7e89197bd8..8872484a5d 100644 --- a/packages/ui/tui/README.zh.md +++ b/packages/ui/tui/README.zh.md @@ -153,7 +153,7 @@ Paths prefixed with @ are files explicitly referenced by the user. Use the read #### 模型看到的内容 -消费方调用 `ctx.userInteraction.ask()` 时,此提供方会按顺序显示各个问题,并返回选中选项标签或 `custom` 文本。中止、取消或 UI dispose 会变为 `Error: ask_user_question was interrupted before the user answered`;该转换由 `dsh-tool-ask-user` 完成。 +消费方调用 `ctx.userInteraction.ask()` 时,此提供方会按顺序显示各个问题,并返回选中选项标签、`custom` 文本,或为多选题同时返回两者。中止、取消或 UI dispose 会变为 `Error: ask_user_question was interrupted before the user answered`;该转换由 `dsh-tool-ask-user` 完成。 #### Token 影响 diff --git a/packages/ui/tui/src/components/dialogs.ts b/packages/ui/tui/src/components/dialogs.ts index 5e9237574a..ffdfb83c5a 100644 --- a/packages/ui/tui/src/components/dialogs.ts +++ b/packages/ui/tui/src/components/dialogs.ts @@ -799,12 +799,14 @@ export class QuestionDialog implements Component, Focusable { if (this.selected.has(this.selectedIndex)) this.selected.delete(this.selectedIndex) else this.selected.add(this.selectedIndex) } else if (matchesKey(data, Key.enter)) { - const indices = this.question.multiSelect ? [...this.selected].sort((a, b) => a - b) : [this.selectedIndex] - if (indices.length === 0) { + const selected = this.question.multiSelect + ? this.selectedOptionLabels() + : [options[this.selectedIndex]?.label].filter((label): label is string => label !== undefined) + if (selected.length === 0) { this.error = 'Select at least one option, or press Tab for a custom answer.' return } - this.done({ selected: indices.map(index => options[index]?.label).filter((label): label is string => label !== undefined) }) + this.done({ selected }) } else if (matchesKey(data, Key.tab) || data.toLowerCase() === 'c') { this.mode = 'custom' this.error = '' @@ -819,7 +821,17 @@ export class QuestionDialog implements Component, Focusable { this.error = 'Enter an answer before submitting.' return } - this.done({ selected: [], custom }) + this.done({ + selected: this.question.multiSelect ? this.selectedOptionLabels() : [], + custom, + }) + } + + private selectedOptionLabels(): string[] { + return [...this.selected] + .sort((a, b) => a - b) + .map(index => this.options[index]?.label) + .filter((label): label is string => label !== undefined) } render(width: number): string[] { diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index e918f9b299..1ee0b4fe38 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -4436,8 +4436,12 @@ describe('TUI user-interaction dialogs', () => { result.terminal.send(' ') result.terminal.send('\x1b[B') result.terminal.send(' ') + result.terminal.send('\t') + result.terminal.send('Tests') result.terminal.send('\r') - await expect(multi).resolves.toEqual({ answers: [{ id: 'targets', selected: ['Code', 'Docs'] }] }) + await expect(multi).resolves.toEqual({ + answers: [{ id: 'targets', selected: ['Code', 'Docs'], custom: 'Tests' }], + }) const custom = result.ctx.userInteraction.ask({ questions: [{ id: 'other', question: 'Choose or type', options: [{ label: 'Default' }] }], diff --git a/packages/ui/user-interaction/README.i18n.yaml b/packages/ui/user-interaction/README.i18n.yaml index 2a3b525012..c9ff2845e5 100644 --- a/packages/ui/user-interaction/README.i18n.yaml +++ b/packages/ui/user-interaction/README.i18n.yaml @@ -1,6 +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 -README.md: d234d6677bdd772f1bbd2c979c0d41f90aef5c32 -README.zh.md: b70a61d6491e0bb0e52215cdeaeea3d728f7f153 +# pnpm run verify-translation-pairing --write packages/ui/user-interaction/README.md +README.md: 2ff29f5fd6244ebcf7e29b86f5de1cde30944532 +README.zh.md: 7d0d1b06db5be4353e42d1905c71d5dff963b97d diff --git a/packages/ui/user-interaction/README.md b/packages/ui/user-interaction/README.md index d234d6677b..2ff29f5fd6 100644 --- a/packages/ui/user-interaction/README.md +++ b/packages/ui/user-interaction/README.md @@ -19,7 +19,7 @@ Abstract user-interaction seam. It owns `ctx.userInteraction`, the service a mod - `UserInteractionProvider` — UI implementation with `ask(request)`. - `UserInteractionError` — `HarnessError` subclass with codes such as `EMPTY_QUESTIONS`, `NO_PROVIDER`, `DUPLICATE_PROVIDER`, and `ASK_ABORTED`. -When an answer includes `custom`, `selected` is empty; custom text is an override rather than a supplement to selected choices. A UI may preserve a skipped item as `{ id, selected: [] }`, keeping the existing answer shape while retaining other answers in the batch. +For a single-select question, `custom` overrides the selected choice and `selected` is empty. For a multi-select question, `custom` may supplement the labels in `selected`. A UI may preserve a skipped item as `{ id, selected: [] }`, keeping the existing answer shape while retaining other answers in the batch. ## Role diff --git a/packages/ui/user-interaction/README.zh.md b/packages/ui/user-interaction/README.zh.md index b70a61d649..7d0d1b06db 100644 --- a/packages/ui/user-interaction/README.zh.md +++ b/packages/ui/user-interaction/README.zh.md @@ -19,7 +19,7 @@ - `UserInteractionProvider`:包含 `ask(request)` 的 UI 实现。 - `UserInteractionError`:`HarnessError` 的子类,包含 `EMPTY_QUESTIONS`、`NO_PROVIDER`、`DUPLICATE_PROVIDER` 和 `ASK_ABORTED` 等代码。 -当回答包含 `custom` 时,`selected` 为空;自定义文本会覆盖所选选项,而不是补充它们。UI 可以把跳过的条目保留为 `{ id, selected: [] }`,既维持现有回答形态,也保留该批次中的其他回答。 +对于单选题,`custom` 会覆盖选中的选项,且 `selected` 为空。对于多选题,`custom` 可以补充 `selected` 中的标签。UI 可以把跳过的条目保留为 `{ id, selected: [] }`,既维持现有回答形态,也保留该批次中的其他回答。 ## 职责 diff --git a/packages/ui/user-interaction/src/types.ts b/packages/ui/user-interaction/src/types.ts index ddf3e43489..435782a8f5 100644 --- a/packages/ui/user-interaction/src/types.ts +++ b/packages/ui/user-interaction/src/types.ts @@ -33,7 +33,7 @@ export interface AskUserQuestionItem { export interface AskUserQuestionAnswerItem { /** The answered question id. */ id: string - /** Selected option labels. Empty for custom or unanswered choices. */ + /** Selected option labels. May accompany custom text for a multi-select question. */ selected: string[] /** Optional free-text "Other" answer. */ custom?: string From 7401587ac26e5b15774c24c263db90206e6c400b Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Thu, 30 Jul 2026 01:12:48 -0700 Subject: [PATCH 002/129] fix(ui-workspace): show approval-waiting sessions --- packages/client/ui-workspace/README.i18n.yaml | 4 +-- packages/client/ui-workspace/README.md | 2 ++ packages/client/ui-workspace/README.zh.md | 2 ++ .../ui-workspace/src/client/rows/Rows.tsx | 17 ++++++--- .../client/ui-workspace/src/client/tree.ts | 3 ++ .../client/ui-workspace/tests/rows.spec.tsx | 36 +++++++++++++++---- .../client/ui-workspace/tests/tree.spec.ts | 8 +++++ 7 files changed, 59 insertions(+), 13 deletions(-) diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 536911a16a..bada1e738d 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/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-workspace/README.md -README.md: a1b58f4abe0925be3b426d10344777e46caa9ba0 -README.zh.md: a472507bc45549c8feb55a75d294cbd7b3138cc5 +README.md: 1497f816a295e2cd156af9b779bce0b42759e1c7 +README.zh.md: be496412db9790b0625b40f0bbb06c1d406af015 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index a1b58f4abe..1497f816a2 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -6,6 +6,8 @@ Shared Workspace picker plugin. `WorkspaceBrowser` is registered into the sideba The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the [`-native`](../../host/directory-picker-native/README.md) backend's renderless OS-chooser driver today, an in-app browsing dialog under a `-browse` composition. The flat **Open local folder...** action renders only while the surface's hole is occupied (occupancy read per menu render; an empty hole means the composition has no picking affordance — the seam's documented no-flow default). This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors land in the retryable folder dialog whose **Choose again** reopens the flow. **Create a new workspace** retains the name dialog and disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. The Session row's Rename action opens the same browser-owned dialog pattern prefilled with the row's display title: no client-side conflict rule exists (the host normalizes and may reject with `title-invalid`, rendered in the dialog alert), and confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. +Session rows project the runtime's live `waitingApproval` fact: an amber warning dot takes precedence over the blue running indicator, and the hover card reports **Waiting for approval** until the request is resolved. Running and idle presentation is unchanged when no approval is pending. + Both target slots are declared by other plugins, so `apply` registers through declaration-aware deferral and re-registers after a declaring slot is restored. ## Model Experience diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index a472507bc4..be496412db 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -6,6 +6,8 @@ 该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。每个注册各自声明一个**目录流子洞**(`single` kind:`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **打开本地文件夹…** 操作仅在本表层的洞被占用时渲染(每次菜单渲染读取占用状态;洞为空意味着该组合没有选目录能力——seam 文档化的无流程默认行为)。本包持有触发与接纳:占用者经洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`)每次打开上报一个所选路径,owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,错误落入可重试的文件夹对话框,其 **重新选择** 会重新打开流程。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。Session 行内的 Rename 操作打开同款浏览器持有的对话框,并以该行的显示标题预填:客户端不设名称冲突规则(host 负责规范化,可能以 `title-invalid` 拒绝,错误渲染在对话框告警区);确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。 +Session 行会投影 runtime 的实时 `waitingApproval` 状态:琥珀色警告点优先于蓝色运行指示器,hover 卡片在请求解决前显示 **Waiting for approval**。没有待审批请求时,运行与空闲展示保持不变。 + 两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。 ## 模型体验 diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index d75fabdd8b..4f823d531f 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -121,15 +121,23 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions }: { * @param props.onToggle - unfold/fold a subtree by id. * @returns the node's row followed by its children. */ -/** Hover-card body: full title, relative time, and the status line (running/idle until wire status lands). */ +/** Session status presentation; approval waiting outranks the underlying running state. */ +function sessionStatus(node: SessionNode): { state: 'warning' | 'ongoing' | 'done'; label: string } { + if (node.waitingApproval) return { state: 'warning', label: 'Waiting for approval' } + if (node.running) return { state: 'ongoing', label: 'Running' } + return { state: 'done', label: 'Idle' } +} + +/** Hover-card body: full title, relative time, and approval/running/idle status. */ function SessionHoverContent({ node, now }: { node: SessionNode; now: number }) { + const status = sessionStatus(node) return (
{node.title}
{`${formatRelativeTime(node.updatedAt, now)} ago`}
- - {node.running ? 'Running' : 'Idle'} + + {status.label}
) @@ -175,6 +183,7 @@ export function SessionNodeItem({ node, depth, currentId, now, onOpen, onRename, }) { const row = node const selected = node.id === currentId + const status = sessionStatus(node) const [menuOpen, setMenuOpen] = useState(false) // Rail (figma session cell: pad 8, twist slot 16, status slot 16, gap 4 to // the title): both slots are always reserved so titles align whether or not @@ -226,7 +235,7 @@ export function SessionNodeItem({ node, depth, currentId, now, onOpen, onRename, ) : null} - {row.running && } + {(row.waitingApproval || row.running) && } {row.title} {formatRelativeTime(row.updatedAt, now)} diff --git a/packages/client/ui-workspace/src/client/tree.ts b/packages/client/ui-workspace/src/client/tree.ts index c0adfadd6f..af2c6cd051 100644 --- a/packages/client/ui-workspace/src/client/tree.ts +++ b/packages/client/ui-workspace/src/client/tree.ts @@ -20,6 +20,8 @@ export interface SessionNode { /** The session HAS children in the data (the twist renders even while folded). */ hasChildren: boolean expanded: boolean + /** A pending approval takes display precedence over the running state. */ + waitingApproval: boolean running: boolean updatedAt: number } @@ -183,6 +185,7 @@ function sessionNode(s: SessionSummary, children: readonly SessionNode[], hasChi children, hasChildren, expanded, + waitingApproval: s.waitingApproval, running: s.running, updatedAt: s.updatedAt, } diff --git a/packages/client/ui-workspace/tests/rows.spec.tsx b/packages/client/ui-workspace/tests/rows.spec.tsx index bfaa8a36dd..0b6837c0bc 100644 --- a/packages/client/ui-workspace/tests/rows.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.spec.tsx @@ -59,11 +59,11 @@ describe('workspace browser rows', () => { it('renders and operates selected, running, recursive Session nodes', () => { const child: SessionNode = { id: sid('child'), title: 'Child', children: [], hasChildren: false, - expanded: false, running: false, updatedAt: 0, + expanded: false, waitingApproval: false, running: false, updatedAt: 0, } const parent: SessionNode = { id: sid('parent'), title: 'Parent', children: [child], hasChildren: true, - expanded: true, running: true, updatedAt: 0, + expanded: true, waitingApproval: false, running: true, updatedAt: 0, } const onOpen = vi.fn() const onToggle = vi.fn() @@ -142,7 +142,7 @@ describe('workspace browser rows', () => { const onRename = vi.fn() const node: SessionNode = { id: sid('s1'), title: 'One', children: [], hasChildren: false, - expanded: false, running: false, updatedAt: 0, + expanded: false, waitingApproval: false, running: false, updatedAt: 0, } render() @@ -169,7 +169,7 @@ describe('workspace browser rows', () => { it('flat variant renders no twist even for a parent and ignores toggling', () => { const node: SessionNode = { id: sid('p'), title: 'Parent', children: [], hasChildren: true, - expanded: false, running: false, updatedAt: 0, + expanded: false, waitingApproval: false, running: false, updatedAt: 0, } render() @@ -181,7 +181,7 @@ describe('workspace browser rows', () => { try { const node: SessionNode = { id: sid('s1'), title: 'Hovered', children: [], hasChildren: false, - expanded: false, running: true, updatedAt: 0, + expanded: false, waitingApproval: false, running: true, updatedAt: 0, } render() @@ -203,12 +203,34 @@ describe('workspace browser rows', () => { } }) + it('shows approval waiting as warning ahead of the running state', () => { + vi.useFakeTimers() + try { + const node: SessionNode = { + id: sid('approval'), title: 'Needs approval', children: [], hasChildren: false, + expanded: false, waitingApproval: true, running: true, updatedAt: 0, + } + render() + const row = screen.getByRole('treeitem') + expect(row.querySelector('[data-state="warning"]')).toBeTruthy() + expect(row.querySelector('[data-state="ongoing"]')).toBeNull() + + fireEvent.pointerEnter(row.parentElement as HTMLElement) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.getByText('Waiting for approval')).toBeTruthy() + expect(document.querySelectorAll('[data-state="warning"]')).toHaveLength(2) + } finally { + vi.useRealTimers() + } + }) + it('idle hover card shows the Idle status line', () => { vi.useFakeTimers() try { const node: SessionNode = { id: sid('s1'), title: 'Quiet', children: [], hasChildren: false, - expanded: false, running: false, updatedAt: 0, + expanded: false, waitingApproval: false, running: false, updatedAt: 0, } render() @@ -224,7 +246,7 @@ describe('workspace browser rows', () => { it('draggable row wires start/end and gates hover/drop on an active same-group drag', () => { const node: SessionNode = { id: sid('s1'), title: 'Drag me', children: [], hasChildren: false, - expanded: false, running: false, updatedAt: 0, + expanded: false, waitingApproval: false, running: false, updatedAt: 0, } const inactive = dragProps() const { rerender } = render( diff --git a/packages/client/ui-workspace/tests/tree.spec.ts b/packages/client/ui-workspace/tests/tree.spec.ts index eb34f633d8..2af6c1a6ab 100644 --- a/packages/client/ui-workspace/tests/tree.spec.ts +++ b/packages/client/ui-workspace/tests/tree.spec.ts @@ -33,6 +33,14 @@ describe('deriveGroups', () => { expect(groups[0]!.sessions.map(session => session.id)).toEqual([sid('older'), sid('newer')]) }) + it('projects approval-waiting state into grouped and flat rows', () => { + const awaiting = { ...summary('awaiting', 10), waitingApproval: true, running: true } + const sessions = list(awaiting) + const grouped = deriveGroups(sessions, [workspace('project', ['awaiting'])], view(['project'])) + expect(grouped[0]!.sessions[0]).toMatchObject({ waitingApproval: true, running: true }) + expect(deriveFlat(sessions, { query: '' })[0]).toMatchObject({ waitingApproval: true, running: true }) + }) + it('puts only real unaccounted Sessions in the trailing Ungrouped group', () => { const sessions = list(summary('owned', 1, '/projects/first'), summary('loose', 9, '/other')) const groups = deriveGroups(sessions, [workspace('first', ['owned'])], view([UNGROUPED_KEY])) From 61803f1a462467d49ec06b1f1b107ba00e40bf03 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Thu, 30 Jul 2026 02:07:36 -0700 Subject: [PATCH 003/129] fix(ui-workspace): expose session status accessibly --- packages/client/ui-sidebar/README.i18n.yaml | 4 +-- packages/client/ui-sidebar/README.md | 2 +- packages/client/ui-sidebar/README.zh.md | 2 +- packages/client/ui-workspace/README.i18n.yaml | 4 +-- packages/client/ui-workspace/README.md | 3 +- packages/client/ui-workspace/README.zh.md | 3 +- .../src/client/rows/Rows.module.css | 9 +++++ .../ui-workspace/src/client/rows/Rows.tsx | 36 ++++++++++++------- .../client/ui-workspace/tests/rows.spec.tsx | 15 +++++--- 9 files changed, 52 insertions(+), 26 deletions(-) diff --git a/packages/client/ui-sidebar/README.i18n.yaml b/packages/client/ui-sidebar/README.i18n.yaml index 6c5f1735e3..00b33602d0 100644 --- a/packages/client/ui-sidebar/README.i18n.yaml +++ b/packages/client/ui-sidebar/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-sidebar/README.md -README.md: 93a1f15a5802f94a0ebe930dda1dbd4fbc7343c9 -README.zh.md: 8c8545a5d7d8cb4d58772abf867d7ee82c31bf1d +README.md: d2c0c3332f2202986f1daf3a45c84cc1e65eee6d +README.zh.md: 03cb86842d8a28f3a18250a9d77dd0a0a217d7b9 diff --git a/packages/client/ui-sidebar/README.md b/packages/client/ui-sidebar/README.md index 93a1f15a58..d2c0c3332f 100644 --- a/packages/client/ui-sidebar/README.md +++ b/packages/client/ui-sidebar/README.md @@ -22,6 +22,6 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **State dots have two live data states (running/none)** — the done/error/amber sources arrive with P-II approvals and notifications; the four-color primitive is already wired. +- **State dots have approval-waiting/running/none live states** — approval waiting is amber and outranks running; done/error notification sources remain deferred. - **Group-by menu ships by-workspace only** — Update/Status grouping strategies are drawn without specs and deferred. - **"New task completed" unread marking is local viewing state** — completion-time > last-seen never reaches the host. diff --git a/packages/client/ui-sidebar/README.zh.md b/packages/client/ui-sidebar/README.zh.md index 8c8545a5d7..03cb86842d 100644 --- a/packages/client/ui-sidebar/README.zh.md +++ b/packages/client/ui-sidebar/README.zh.md @@ -22,6 +22,6 @@ New Session 会启动运行时的页面局部前端 Session Intent;真实 Work ## 已知限制与暂缓事项 -- **状态点只有两种实时数据状态(running/none)**:done/error/amber 的数据源将随 P-II 审批与通知功能一并提供;四色原语已接入。 +- **状态点具有待审批/running/none 三种实时状态**:待审批使用琥珀色并优先于 running;done/error 的通知数据源仍暂缓实现。 - **分组选单只提供按 Workspace 分组**:Update/Status 分组策略只有图稿而没有规范,暂缓实现。 - **「New task completed」未读标记是本地查看状态**:完成时间 > 上次查看时间这一事实永远不会到达宿主。 diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index bada1e738d..27cb783db7 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/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-workspace/README.md -README.md: 1497f816a295e2cd156af9b779bce0b42759e1c7 -README.zh.md: be496412db9790b0625b40f0bbb06c1d406af015 +README.md: 4ca836e4f1beeb164716e5fc4741253719d2700c +README.zh.md: 2a5448a12d58184b027c99b5301510370ba63a83 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index 1497f816a2..4ca836e4f1 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -6,7 +6,7 @@ Shared Workspace picker plugin. `WorkspaceBrowser` is registered into the sideba The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the [`-native`](../../host/directory-picker-native/README.md) backend's renderless OS-chooser driver today, an in-app browsing dialog under a `-browse` composition. The flat **Open local folder...** action renders only while the surface's hole is occupied (occupancy read per menu render; an empty hole means the composition has no picking affordance — the seam's documented no-flow default). This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors land in the retryable folder dialog whose **Choose again** reopens the flow. **Create a new workspace** retains the name dialog and disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. The Session row's Rename action opens the same browser-owned dialog pattern prefilled with the row's display title: no client-side conflict rule exists (the host normalizes and may reject with `title-invalid`, rendered in the dialog alert), and confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. -Session rows project the runtime's live `waitingApproval` fact: an amber warning dot takes precedence over the blue running indicator, and the hover card reports **Waiting for approval** until the request is resolved. Running and idle presentation is unchanged when no approval is pending. +Session rows distinguish the runtime's live `waitingApproval` fact from an otherwise blue in-flight Session: an amber warning dot takes precedence over the running indicator, an accompanying visually hidden label exposes the state to assistive technology, and the hover card reports **Waiting for approval** until the request is resolved. Running and idle presentation is unchanged when no approval is pending. Both target slots are declared by other plugins, so `apply` registers through declaration-aware deferral and re-registers after a declaring slot is restored. @@ -21,4 +21,5 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work - **No Session deletion or fork control** — the Session menu's Fork and Delete rows remain visual-only (Rename is wired); Workspace registration deletion does not delete Sessions. +- **Approval waiting is not aggregated into hidden ancestors** — a waiting child Session under a folded parent, or any waiting row inside a collapsed group, becomes visible only after that container is expanded. - **Native folder selection depends on the local Host carrier** — under the `-native` composition, fixture-only or remote browser deployments cannot open a local operating-system dialog; platform failures are shown in a retryable modal. Remote-capable picking is the `-browse` composition's in-app flow. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index be496412db..2a5448a12d 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -6,7 +6,7 @@ 该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。每个注册各自声明一个**目录流子洞**(`single` kind:`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **打开本地文件夹…** 操作仅在本表层的洞被占用时渲染(每次菜单渲染读取占用状态;洞为空意味着该组合没有选目录能力——seam 文档化的无流程默认行为)。本包持有触发与接纳:占用者经洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`)每次打开上报一个所选路径,owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,错误落入可重试的文件夹对话框,其 **重新选择** 会重新打开流程。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。Session 行内的 Rename 操作打开同款浏览器持有的对话框,并以该行的显示标题预填:客户端不设名称冲突规则(host 负责规范化,可能以 `title-invalid` 拒绝,错误渲染在对话框告警区);确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。 -Session 行会投影 runtime 的实时 `waitingApproval` 状态:琥珀色警告点优先于蓝色运行指示器,hover 卡片在请求解决前显示 **Waiting for approval**。没有待审批请求时,运行与空闲展示保持不变。 +Session 行会把 runtime 的实时 `waitingApproval` 状态与原本显示为蓝色的进行中 Session 区分开:琥珀色警告点优先于运行指示器,随附的视觉隐藏标签会向辅助技术公开这一状态,hover 卡片则在请求解决前显示 **Waiting for approval**。没有待审批请求时,运行与空闲展示保持不变。 两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。 @@ -21,4 +21,5 @@ Session 行会投影 runtime 的实时 `waitingApproval` 状态:琥珀色警 ## 已知限制与暂缓事项 - **没有 Session 删除与 fork 控件**:Session 菜单的 Fork 与 Delete 行仍仅提供视觉效果(Rename 已接线);删除 Workspace 注册记录不会删除 Session。 +- **待审批状态不会聚合到隐藏的祖先节点**:折叠父节点下正在等待的子 Session,或折叠分组内的任何等待行,只有在对应容器展开后才可见。 - **原生文件夹选择依赖本地 Host 载体**:在 `-native` 组合下,仅使用 fixture(测试前置数据)的部署或远程浏览器部署无法打开本地操作系统对话框;模态框会显示平台故障,并允许重试。可远程的选取是 `-browse` 组合的应用内流程。 diff --git a/packages/client/ui-workspace/src/client/rows/Rows.module.css b/packages/client/ui-workspace/src/client/rows/Rows.module.css index 7b19284b66..6d5e90beeb 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.module.css +++ b/packages/client/ui-workspace/src/client/rows/Rows.module.css @@ -68,6 +68,15 @@ color: var(--dsw-alias-label-tertiary); } +.visuallyHidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} + .folderActive { color: var(--dsw-alias-state-business-primary); diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index 4f823d531f..92796c409e 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -109,18 +109,6 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions }: { ) } -/** - * One session subtree: the node's own 34px row (indent by depth, expand - * twist when it has children, running dot, relative time) plus its visible - * children, recursively — the component tree mirrors the derived tree. - * @param props.node - derived session node. - * @param props.depth - 0 = directly under the group header. - * @param props.currentId - selected session id (row highlight). - * @param props.now - epoch ms for relative-time formatting. - * @param props.onOpen - open a session by id. - * @param props.onToggle - unfold/fold a subtree by id. - * @returns the node's row followed by its children. - */ /** Session status presentation; approval waiting outranks the underlying running state. */ function sessionStatus(node: SessionNode): { state: 'warning' | 'ongoing' | 'done'; label: string } { if (node.waitingApproval) return { state: 'warning', label: 'Waiting for approval' } @@ -167,6 +155,21 @@ function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' | return e.clientY < rect.top + rect.height / 2 ? 'before' : 'after' } +/** + * One session subtree: the node's own 34px row (indent by depth, expand + * twist when it has children, status dot, relative time) plus its visible + * children, recursively — the component tree mirrors the derived tree. + * @param props.node - derived session node. + * @param props.depth - 0 = directly under the group header. + * @param props.currentId - selected session id (row highlight). + * @param props.now - epoch ms for relative-time formatting. + * @param props.onOpen - open a session by id. + * @param props.onRename - rename a session by id and current title. + * @param props.onToggle - unfold/fold a subtree by id. + * @param props.drag - optional root-row drag wiring. + * @param props.flat - omit tree indentation controls for a flat list. + * @returns the node's row followed by its children. + */ export function SessionNodeItem({ node, depth, currentId, now, onOpen, onRename, onToggle, drag, flat = false }: { node: SessionNode depth: number @@ -235,7 +238,14 @@ export function SessionNodeItem({ node, depth, currentId, now, onOpen, onRename, ) : null} - {(row.waitingApproval || row.running) && } + + {status.state !== 'done' && ( + <> + + {status.label} + + )} + {row.title} {formatRelativeTime(row.updatedAt, now)} diff --git a/packages/client/ui-workspace/tests/rows.spec.tsx b/packages/client/ui-workspace/tests/rows.spec.tsx index 0b6837c0bc..f9caa54c0b 100644 --- a/packages/client/ui-workspace/tests/rows.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.spec.tsx @@ -191,7 +191,7 @@ describe('workspace browser rows', () => { // Card body: full title + relative time + running status. expect(screen.getAllByText('Hovered')).toHaveLength(2) expect(screen.getByText('1min ago')).toBeTruthy() - expect(screen.getByText('Running')).toBeTruthy() + expect(screen.getAllByText('Running')).toHaveLength(2) fireEvent.pointerLeave(wrapper) // Menu open (disabled=true) suppresses the card for the same hover. fireEvent.click(screen.getByRole('button', { name: 'Session actions for Hovered' })) @@ -210,15 +210,20 @@ describe('workspace browser rows', () => { id: sid('approval'), title: 'Needs approval', children: [], hasChildren: false, expanded: false, waitingApproval: true, running: true, updatedAt: 0, } - render() const row = screen.getByRole('treeitem') expect(row.querySelector('[data-state="warning"]')).toBeTruthy() expect(row.querySelector('[data-state="ongoing"]')).toBeNull() - - fireEvent.pointerEnter(row.parentElement as HTMLElement) - act(() => { vi.advanceTimersByTime(500) }) expect(screen.getByText('Waiting for approval')).toBeTruthy() + + view.rerender() + expect(screen.getByRole('treeitem').querySelector('[data-state="warning"]')).toBeTruthy() + + fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.getAllByText('Waiting for approval')).toHaveLength(2) expect(document.querySelectorAll('[data-state="warning"]')).toHaveLength(2) } finally { vi.useRealTimers() From 8014abffa011d4b8b4d983f27b0ce2a1776d0fa7 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Thu, 30 Jul 2026 02:13:02 -0700 Subject: [PATCH 004/129] test(web): cover waiting approval in built graph --- apps/web/tests/built-boot.snapshot.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/web/tests/built-boot.snapshot.ts b/apps/web/tests/built-boot.snapshot.ts index 69d5d5cfae..018a9f2180 100644 --- a/apps/web/tests/built-boot.snapshot.ts +++ b/apps/web/tests/built-boot.snapshot.ts @@ -102,6 +102,14 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) await within(tree).findByText('4 sessions') + // The resident approval fixture proves the assembled workspace plugin + // distinguishes a blocked running session from an ordinarily busy one. + const waitingTitle = await within(tree).findByText('Fixture 历史会话') + const waitingRow = waitingTitle.closest('[role="treeitem"]') + expect(waitingRow?.querySelector('[data-state="warning"]')).not.toBeNull() + expect(waitingRow?.querySelector('[data-state="ongoing"]')).toBeNull() + expect(within(waitingRow as HTMLElement).getByText('Waiting for approval')).not.toBeNull() + // Opening a session reaches chat content through the fixture transport. fireEvent.click(await within(tree).findByText('Fixture 历史会话')) await waitFor(() => { From 472ba33cd941ace8d0ab15aa6f89932206926c3c Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Thu, 30 Jul 2026 02:17:14 -0700 Subject: [PATCH 005/129] refactor(ui-workspace): reuse status dot vocabulary --- packages/client/ui-workspace/src/client/rows/Rows.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index f9bd7f3eaf..fbde2b9522 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -12,6 +12,7 @@ import { IconFolderClose16, IconFolderOpen16, IconPlusOutline16, IconTrashOutline16, IconTriangleRightFill14, Menu, StateDot, } from '@deepseek-ai/dsh-client-ui-primitives' +import type { StateDotState } from '@deepseek-ai/dsh-client-ui-primitives' import type { GroupNode, SessionNode } from '../tree.ts' import { formatRelativeTime } from '../tree.ts' import css from './Rows.module.css' @@ -135,7 +136,7 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions }: { } /** Session status presentation; approval waiting outranks the underlying running state. */ -function sessionStatus(node: SessionNode): { state: 'warning' | 'ongoing' | 'done'; label: string } { +function sessionStatus(node: SessionNode): { state: StateDotState; label: string } { if (node.waitingApproval) return { state: 'warning', label: 'Waiting for approval' } if (node.running) return { state: 'ongoing', label: 'Running' } return { state: 'done', label: 'Idle' } From 31a498b1dbd3f8b658970426652a01175a537108 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 30 Jul 2026 17:39:28 +0800 Subject: [PATCH 006/129] test(web): follow inline custom answer input --- packages/client/ui-question/tests/question-composer.spec.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/client/ui-question/tests/question-composer.spec.tsx b/packages/client/ui-question/tests/question-composer.spec.tsx index 40006c2c9c..87eb275b7d 100644 --- a/packages/client/ui-question/tests/question-composer.spec.tsx +++ b/packages/client/ui-question/tests/question-composer.spec.tsx @@ -103,13 +103,12 @@ describe('QuestionComposer', () => { fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' })) fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' })) fireEvent.click(screen.getByRole('checkbox', { name: '代码质量' })) - fireEvent.click(screen.getByRole('button', { name: '其他,请填写自定义答案' })) const multiCustom = screen.getByPlaceholderText('输入你的答案') fireEvent.change(multiCustom, { target: { value: '沟通能力' } }) fireEvent.click(screen.getByRole('checkbox', { name: '产品判断' })) expect(screen.getByRole('checkbox', { name: '系统设计' }).getAttribute('aria-checked')).toBe('true') expect(screen.getByRole('checkbox', { name: '代码质量' }).getAttribute('aria-checked')).toBe('true') - expect((multiCustom as HTMLTextAreaElement).value).toBe('沟通能力') + expect((multiCustom as HTMLInputElement).value).toBe('沟通能力') fireEvent.keyDown(multiCustom, { key: 'Enter' }) // The domain face encoded the whole batch into one carrier envelope. From 285cd60744e0fbebcec20e5f50605c3ea3dc7f8b Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Thu, 30 Jul 2026 02:40:31 -0700 Subject: [PATCH 007/129] docs(ui-workspace): align approval status contracts --- apps/web/tests/built-boot.snapshot.ts | 20 ++++++++++--------- packages/client/ui-workspace/README.i18n.yaml | 4 ++-- packages/client/ui-workspace/README.md | 2 +- packages/client/ui-workspace/README.zh.md | 2 +- .../client/ui-workspace/src/client/tree.ts | 2 +- 5 files changed, 16 insertions(+), 14 deletions(-) diff --git a/apps/web/tests/built-boot.snapshot.ts b/apps/web/tests/built-boot.snapshot.ts index 018a9f2180..d436d41866 100644 --- a/apps/web/tests/built-boot.snapshot.ts +++ b/apps/web/tests/built-boot.snapshot.ts @@ -6,10 +6,10 @@ // layers, per-plugin CSS injection, and a rendered journey reaching chat // content from the keyless FixtureApiClient transport. // -// Behavior assertions do NOT belong here: component and wiring behavior is -// pinned by the per-package suites (SlotTestRuntime benches over src), which -// this smoke's plugin set cannot influence — bundling, module-table -// resolution, and boot layering are the only failure modes left to it. +// Component behavior remains owned by per-package suites (SlotTestRuntime +// benches over src). This smoke additionally pins the resident approval +// fixture's cross-plugin projection because only the built connection/runtime/ +// workspace graph can prove that transport-to-row path end to end. import { readFileSync } from 'node:fs' import { join } from 'node:path' import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react' @@ -105,13 +105,15 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn // The resident approval fixture proves the assembled workspace plugin // distinguishes a blocked running session from an ordinarily busy one. const waitingTitle = await within(tree).findByText('Fixture 历史会话') - const waitingRow = waitingTitle.closest('[role="treeitem"]') - expect(waitingRow?.querySelector('[data-state="warning"]')).not.toBeNull() - expect(waitingRow?.querySelector('[data-state="ongoing"]')).toBeNull() - expect(within(waitingRow as HTMLElement).getByText('Waiting for approval')).not.toBeNull() + const waitingRow = waitingTitle.closest('[role="treeitem"]') + expect(waitingRow).not.toBeNull() + if (waitingRow === null) throw new Error('fixture Session title must belong to a tree row') + expect(waitingRow.querySelector('[data-state="warning"]')).not.toBeNull() + expect(waitingRow.querySelector('[data-state="ongoing"]')).toBeNull() + expect(within(waitingRow).getByText('Waiting for approval')).not.toBeNull() // Opening a session reaches chat content through the fixture transport. - fireEvent.click(await within(tree).findByText('Fixture 历史会话')) + fireEvent.click(waitingTitle) await waitFor(() => { expect(document.querySelector('[data-sample="bash-global"]')).not.toBeNull() }, { timeout: 10_000 }) diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 27cb783db7..25a2713cfb 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/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-workspace/README.md -README.md: 4ca836e4f1beeb164716e5fc4741253719d2700c -README.zh.md: 2a5448a12d58184b027c99b5301510370ba63a83 +README.md: 7109de680f98ede4d8374444cf50b439317ce128 +README.zh.md: 874d9e3d190e0488d95362ce1eea260341d6a23e diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index 4ca836e4f1..7109de680f 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -6,7 +6,7 @@ Shared Workspace picker plugin. `WorkspaceBrowser` is registered into the sideba The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the [`-native`](../../host/directory-picker-native/README.md) backend's renderless OS-chooser driver today, an in-app browsing dialog under a `-browse` composition. The flat **Open local folder...** action renders only while the surface's hole is occupied (occupancy read per menu render; an empty hole means the composition has no picking affordance — the seam's documented no-flow default). This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors land in the retryable folder dialog whose **Choose again** reopens the flow. **Create a new workspace** retains the name dialog and disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. The Session row's Rename action opens the same browser-owned dialog pattern prefilled with the row's display title: no client-side conflict rule exists (the host normalizes and may reject with `title-invalid`, rendered in the dialog alert), and confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. -Session rows distinguish the runtime's live `waitingApproval` fact from an otherwise blue in-flight Session: an amber warning dot takes precedence over the running indicator, an accompanying visually hidden label exposes the state to assistive technology, and the hover card reports **Waiting for approval** until the request is resolved. Running and idle presentation is unchanged when no approval is pending. +Session rows distinguish the runtime's live `waitingApproval` approval-request fact from an otherwise blue in-flight Session: an amber warning dot takes precedence over the running indicator, and the hover card reports **Waiting for approval** until the request is resolved. Every lit state carries a visually hidden label (`Waiting for approval` or `Running`) for assistive technology; an idle row leaves the reserved status slot empty. Question waits are tracked separately and do not set `waitingApproval`. Both target slots are declared by other plugins, so `apply` registers through declaration-aware deferral and re-registers after a declaring slot is restored. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index 2a5448a12d..874d9e3d19 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -6,7 +6,7 @@ 该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。每个注册各自声明一个**目录流子洞**(`single` kind:`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **打开本地文件夹…** 操作仅在本表层的洞被占用时渲染(每次菜单渲染读取占用状态;洞为空意味着该组合没有选目录能力——seam 文档化的无流程默认行为)。本包持有触发与接纳:占用者经洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`)每次打开上报一个所选路径,owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,错误落入可重试的文件夹对话框,其 **重新选择** 会重新打开流程。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。Session 行内的 Rename 操作打开同款浏览器持有的对话框,并以该行的显示标题预填:客户端不设名称冲突规则(host 负责规范化,可能以 `title-invalid` 拒绝,错误渲染在对话框告警区);确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。 -Session 行会把 runtime 的实时 `waitingApproval` 状态与原本显示为蓝色的进行中 Session 区分开:琥珀色警告点优先于运行指示器,随附的视觉隐藏标签会向辅助技术公开这一状态,hover 卡片则在请求解决前显示 **Waiting for approval**。没有待审批请求时,运行与空闲展示保持不变。 +Session 行会把 runtime 的实时 `waitingApproval` 审批请求状态与原本显示为蓝色的进行中 Session 区分开:琥珀色警告点优先于运行指示器,hover 卡片则在请求解决前显示 **Waiting for approval**。每种点亮状态都带有面向辅助技术的视觉隐藏标签(`Waiting for approval` 或 `Running`);空闲行会保留空的状态槽位。问题等待由另一套状态跟踪,不会设置 `waitingApproval`。 两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。 diff --git a/packages/client/ui-workspace/src/client/tree.ts b/packages/client/ui-workspace/src/client/tree.ts index 210148c72f..763818334f 100644 --- a/packages/client/ui-workspace/src/client/tree.ts +++ b/packages/client/ui-workspace/src/client/tree.ts @@ -20,7 +20,7 @@ export interface SessionNode { /** The session HAS children in the data (the twist renders even while folded). */ hasChildren: boolean expanded: boolean - /** A pending approval takes display precedence over the running state. */ + /** The runtime Session list reports a pending approval request for this Session. */ waitingApproval: boolean running: boolean updatedAt: number From 51711a37720144172125d6713330a886ddf65b6f Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Thu, 30 Jul 2026 02:41:32 -0700 Subject: [PATCH 008/129] docs(ui-sidebar): defer session status ownership --- packages/client/ui-sidebar/README.i18n.yaml | 4 ++-- packages/client/ui-sidebar/README.md | 2 +- packages/client/ui-sidebar/README.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/client/ui-sidebar/README.i18n.yaml b/packages/client/ui-sidebar/README.i18n.yaml index 00b33602d0..c1f5d5df03 100644 --- a/packages/client/ui-sidebar/README.i18n.yaml +++ b/packages/client/ui-sidebar/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-sidebar/README.md -README.md: d2c0c3332f2202986f1daf3a45c84cc1e65eee6d -README.zh.md: 03cb86842d8a28f3a18250a9d77dd0a0a217d7b9 +README.md: 19c2d1033de4475816249aa8429f4a589eeb6481 +README.zh.md: b8c154586570cf1b9fd4bf776bc09b36ab5ee7d2 diff --git a/packages/client/ui-sidebar/README.md b/packages/client/ui-sidebar/README.md index d2c0c3332f..19c2d1033d 100644 --- a/packages/client/ui-sidebar/README.md +++ b/packages/client/ui-sidebar/README.md @@ -22,6 +22,6 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **State dots have approval-waiting/running/none live states** — approval waiting is amber and outranks running; done/error notification sources remain deferred. +- **Session state-dot rendering is owned by [ui-workspace](../ui-workspace/README.md)** — done/error notification sources remain deferred. - **Group-by menu ships by-workspace only** — Update/Status grouping strategies are drawn without specs and deferred. - **"New task completed" unread marking is local viewing state** — completion-time > last-seen never reaches the host. diff --git a/packages/client/ui-sidebar/README.zh.md b/packages/client/ui-sidebar/README.zh.md index 03cb86842d..b8c1545865 100644 --- a/packages/client/ui-sidebar/README.zh.md +++ b/packages/client/ui-sidebar/README.zh.md @@ -22,6 +22,6 @@ New Session 会启动运行时的页面局部前端 Session Intent;真实 Work ## 已知限制与暂缓事项 -- **状态点具有待审批/running/none 三种实时状态**:待审批使用琥珀色并优先于 running;done/error 的通知数据源仍暂缓实现。 +- **Session 状态点渲染由 [ui-workspace](../ui-workspace/README.md) 持有**:done/error 的通知数据源仍暂缓实现。 - **分组选单只提供按 Workspace 分组**:Update/Status 分组策略只有图稿而没有规范,暂缓实现。 - **「New task completed」未读标记是本地查看状态**:完成时间 > 上次查看时间这一事实永远不会到达宿主。 From 3ba4d40e6a5fa670871f3fad176645a9913e9565 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 30 Jul 2026 17:59:37 +0800 Subject: [PATCH 009/129] fix(user-interaction): address review feedback --- ...select-custom-answer-composition.i18n.yaml | 4 +- ...-multi-select-custom-answer-composition.md | 4 +- ...lti-select-custom-answer-composition.zh.md | 4 +- apps/web/tests/question-composer.e2e.ts | 37 +++++++++++++++---- .../question-composer/answered.expected.md | 3 +- .../question-composer/composed.expected.md | 17 +++++++++ .../snapshots/question-composer/session.jsonl | 12 +++--- .../question-composer/ui.expected.md | 6 +-- .../tests/question-composer.spec.tsx | 5 +++ packages/host/apiproxy/src/api-proxy.ts | 6 ++- .../tool-ask-user/tests/tool-ask-user.spec.ts | 10 ++++- packages/ui/tui/README.i18n.yaml | 4 +- packages/ui/tui/README.md | 2 +- packages/ui/tui/README.zh.md | 2 +- packages/ui/tui/src/components/dialogs.ts | 12 ++++-- packages/ui/tui/tests/tui.spec.ts | 28 ++++++++++++-- 16 files changed, 119 insertions(+), 37 deletions(-) create mode 100644 apps/web/tests/snapshots/question-composer/composed.expected.md diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.i18n.yaml index bb081e4be8..2f06390bdf 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.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 .agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.md -2026-07-30-multi-select-custom-answer-composition.md: 7194f4a79f1dd49eba4a9b626d75203fced06544 -2026-07-30-multi-select-custom-answer-composition.zh.md: fac09c8db0ebf2dd4a84ade7aa7868128656025d +2026-07-30-multi-select-custom-answer-composition.md: 581beec89a0f0018ec2df687f5dfe1b1b5b86d22 +2026-07-30-multi-select-custom-answer-composition.zh.md: 5c9cb59822aca3fbf49fbbdf522c76f963df3480 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.md b/.agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.md index 7194f4a79f..581beec89a 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.md @@ -10,7 +10,7 @@ The user-interaction result vocabulary carries selected option labels and option ## Decision -For a question with `multiSelect: true`, one answer item may contain both a non-empty `selected` array and non-empty `custom` text. Web drafts preserve both values regardless of whether the user selects an option or types custom text first; the TUI projects its checked option set when custom text is submitted; and the Web host accepts the combined response after applying its existing id, label, uniqueness, batch, and non-empty-text validation. +For a question with `multiSelect: true`, one answer item may contain both a non-empty `selected` array and non-empty `custom` text. Web drafts preserve both values regardless of whether the user selects an option or types custom text first; the TUI retains pending custom text across option/custom mode switches and projects it with checked labels from either submit mode; and the Web host accepts the combined response after applying its existing id, label, uniqueness, batch, and non-empty-text validation. Single-select and optionless questions keep exclusive semantics: custom text overrides any selected option. The result shape remains `{ id, selected, custom? }`, so no wire or tool-output schema changes. @@ -22,4 +22,4 @@ Single-select and optionless questions keep exclusive semantics: custom text ove ## Consequences -Multi-select UIs can represent the user's complete answer without discarding either source. Providers and consumers retain the existing DTO, while request-aware validators interpret the allowed combination from `multiSelect`. Web, TUI, host-response, tool-projection, and assembled keyless TUI coverage pin the combined result; single-select host coverage pins the remaining exclusivity rule. +Multi-select UIs can represent the user's complete answer without discarding either source. Providers and consumers retain the existing DTO, while request-aware validators interpret the allowed combination from `multiSelect`. Web component and assembled-browser coverage, TUI coverage, host-response coverage, and tool-projection coverage pin the combined result. Web, TUI, and tool-projection coverage also retain labels-only answers; assembled keyless TUI coverage pins the combined terminal flow, and single-select host coverage pins the remaining exclusivity rule. diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.zh.md b/.agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.zh.md index fac09c8db0..5c9cb59822 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -对于 `multiSelect: true` 的问题,一个回答项可以同时包含非空 `selected` 数组与非空 `custom` 文本。无论用户先选择选项还是先输入自定义文本,Web 草稿都会保留两个值;提交自定义文本时,TUI 会投影其已勾选的选项集合;Web 宿主则在应用现有的 id、标签、唯一性、批次和非空文本校验后接受组合响应。 +对于 `multiSelect: true` 的问题,一个回答项可以同时包含非空 `selected` 数组与非空 `custom` 文本。无论用户先选择选项还是先输入自定义文本,Web 草稿都会保留两个值;TUI 在选项与自定义模式之间切换时会保留待提交的自定义文本,并在任一模式提交时将其与已勾选的标签一同投影;Web 宿主则在应用现有的 id、标签、唯一性、批次和非空文本校验后接受组合响应。 单选题和无选项问题仍保持互斥语义:自定义文本会覆盖任何已选中的选项。结果形状仍为 `{ id, selected, custom? }`,因此协议或工具输出 schema 均无需变更。 @@ -22,4 +22,4 @@ Status: implemented ## 后果 -多选 UI 可以完整表达用户的回答,不会丢弃任一来源。提供方和消费方继续使用现有 DTO,而请求感知的校验器会根据 `multiSelect` 判断是否允许组合。Web、TUI、宿主响应、工具投影和组装后的无密钥 TUI 覆盖会固定组合结果;单选题的宿主覆盖则固定其余的互斥规则。 +多选 UI 可以完整表达用户的回答,不会丢弃任一来源。提供方和消费方继续使用现有 DTO,而请求感知的校验器会根据 `multiSelect` 判断是否允许组合。Web 组件与组装浏览器的覆盖率、TUI 覆盖率、宿主响应覆盖率和工具投影覆盖率共同固定组合结果。Web、TUI 与工具投影覆盖率还固定了仅含标签的回答形态;组装后的无密钥 TUI 覆盖率固定终端中的组合回答流程,单选题的宿主覆盖率则固定其余的互斥规则。 diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index ac4be25299..983f1c4812 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -23,15 +23,16 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/question-composer', import.meta.url)) const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md') -// Second golden: the answered transcript — the question resolved into its -// tool round trip and the final reply, the state the waiting golden cannot see. +const COMPOSED_EXPECTED = join(SNAPSHOT_DIR, 'composed.expected.md') +// Final golden: the answered transcript — the question resolved into its tool +// round trip and the final reply, the state the composer goldens cannot see. const ANSWERED_EXPECTED = join(SNAPSHOT_DIR, 'answered.expected.md') const MODE = webSnapshotMode() // The options carry long descriptions on purpose: the squeeze assertion below // needs option copy that WRAPS, which is the only shape that reproduces a // collapsed row painting its copy outside its own box. -const PROMPT = 'Use the ask_user_question tool to ask me exactly one question with id "color", question "Which color do you prefer?", header "Pick one", and two options: label "Blue" with description "A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.", and label "Green" with description "A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions." After I answer, reply with the single word DONE and stop.' +const PROMPT = 'Use the ask_user_question tool to ask me exactly one multi-select question with id "color", question "Which color do you prefer?", header "Pick one", and two options: label "Blue" with description "A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.", and label "Green" with description "A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions." Set multi_select to true. After I answer, reply with the single word DONE and stop.' describe('web e2e: resident question composer round trip', () => { let scaffold: WebScaffold @@ -124,9 +125,17 @@ describe('web e2e: resident question composer round trip', () => { await page.setViewportSize(original) } - await composer.getByRole('radio', { name: 'Blue' }).click() - // Submit: Enter on the focused option (the composer's documented submit). - await composer.getByRole('radio', { name: 'Blue' }).press('Enter') + const blue = composer.getByRole('checkbox', { name: 'Blue' }) + await blue.click() + const custom = composer.getByRole('textbox') + await custom.fill('Include accessibility notes') + expect(await blue.getAttribute('aria-checked')).toBe('true') + expect(await custom.inputValue()).toBe('Include accessibility notes') + if (MODE !== 'record') { + const snapshot = await captureStableAria(page, '[data-question-key]', scaffold.workspaceCwd) + await compareOrRefreshGolden(COMPOSED_EXPECTED, snapshot, MODE) + } + await custom.press('Enter') const sessionId = await settled if (MODE === 'record') { @@ -135,7 +144,14 @@ describe('web e2e: resident question composer round trip', () => { } // World state: the tool result carries the chosen answer, and DONE lands. const results = sessionEvents.filter(e => e.type === 'tool/result') - expect(JSON.stringify(results.at(-1))).toContain('Blue') + const answerText = results.flatMap(event => event.data.message.content.flatMap(block => + block.type === 'tool-result' + ? block.content.filter(item => item.type === 'text').map(item => item.text) + : [], + )).at(-1) + expect(JSON.parse(answerText ?? '')).toEqual({ + answers: [{ id: 'color', selected: ['Blue'], custom: 'Include accessibility notes' }], + }) await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1) // Composer gone; regular input restored. expect(await page.locator('[data-question-key]').count()).toBe(0) @@ -149,6 +165,11 @@ describe('web e2e: resident question composer round trip', () => { }, 200_000) it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { - await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'ui.expected.md', 'answered.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, [ + 'session.jsonl', + 'ui.expected.md', + 'composed.expected.md', + 'answered.expected.md', + ]) }) }) diff --git a/apps/web/tests/snapshots/question-composer/answered.expected.md b/apps/web/tests/snapshots/question-composer/answered.expected.md index 36752c783a..7f7603eb8a 100644 --- a/apps/web/tests/snapshots/question-composer/answered.expected.md +++ b/apps/web/tests/snapshots/question-composer/answered.expected.md @@ -4,13 +4,14 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- text: "Use the ask_user_question tool to ask me exactly one question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" After I answer, reply with the single word DONE and stop. {{clock}}" +- text: "Use the ask_user_question tool to ask me exactly one multi-select question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" Set multi_select to true. After I answer, reply with the single word DONE and stop. {{clock}}" - button "复制": - img - button "在新对话中分支": - img - button "编辑": - img +- button "▸ 上下文注入" - button "Think The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that.": - img - img diff --git a/apps/web/tests/snapshots/question-composer/composed.expected.md b/apps/web/tests/snapshots/question-composer/composed.expected.md new file mode 100644 index 0000000000..c18e6225c6 --- /dev/null +++ b/apps/web/tests/snapshots/question-composer/composed.expected.md @@ -0,0 +1,17 @@ +- region "Which color do you prefer?": + - text: Pick one + - heading "Which color do you prefer?" [level=2] + - button "Dismiss all questions": + - img + - group: + - checkbox "Blue" [checked]: Blue A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards. + - checkbox "Green": Green A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions. + - textbox "Type your answer": Include accessibility notes + - button "Previous question" [disabled]: + - img + - text: 1 / 1 + - button "Next question" [disabled]: + - img + - status + - button "Skip this question" + - button "Submit" diff --git a/apps/web/tests/snapshots/question-composer/session.jsonl b/apps/web/tests/snapshots/question-composer/session.jsonl index b13a84e22c..0a5107d23f 100644 --- a/apps/web/tests/snapshots/question-composer/session.jsonl +++ b/apps/web/tests/snapshots/question-composer/session.jsonl @@ -1,20 +1,20 @@ {"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785150167878,"cwd":"{{cwd}}/workspace"} {"type":"turn/start","seq":0,"time":1785150167924,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}} -{"type":"user/message","seq":1,"time":1785150167925,"data":{"content":[{"type":"text","text":"Use the ask_user_question tool to ask me exactly one question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" After I answer, reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"},"role":"user","id":"6deba879-8787-4853-a5f2-0d108a08eb2d"},"surfaceOp":"append"} +{"type":"user/message","seq":1,"time":1785150167925,"data":{"content":[{"type":"text","text":"Use the ask_user_question tool to ask me exactly one multi-select question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" Set multi_select to true. After I answer, reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"},"role":"user","id":"6deba879-8787-4853-a5f2-0d108a08eb2d"},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1785150167927,"data":{"title":"Use the ask_user_question tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1785150167928,"data":{"turn":1,"step":1}} {"type":"request/header","seq":4,"time":1785150167929,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":1785150168452,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} {"type":"reasoning-chunks","seq0":6,"time0":1785150168452,"data":{"turn":1,"step":1,"index":0,"dt":[87,26,1,0,0,0,38,0,0,0,0,1,12,27,0,27,0,0,1,25,0],"texts":["The"," user"," wants"," me"," to"," use"," the"," ask","_user","_","question"," tool"," with"," specific"," parameters","."," Let"," me"," do"," exactly"," that","."]}} {"type":"assistant/chunk","seq":28,"time":1785150168775,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","seq0":29,"time0":1785150168776,"data":{"turn":1,"step":1,"index":1,"dt":[25,1,0,0,0,25,0,0,0,26,1,0,0,0,0,25,1,0,0,0,0,25,1,0,0,0,0,25,1,0,0,0,1,25,0,0,0,0,1,25,1,0,0,0,0,25,1,0,0,26,0,0,1,0,24,1,0,0,0,1,26,1,0,0,0,0,25,0,1,0,0,0,25,1,0,0,25,0,0,0,0,1,25,0,0,1,0,0,25,0,0,0,1,0,26,1,24],"id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","args":["","{","\"","questions","\"",": ","[","{\"","id","\":"," \"","color","\","," \"","question","\":"," \"","Which"," color"," do"," you"," prefer","?\","," \"","header","\":"," \"","Pick"," one","\","," \"","options","\":"," [","{\"","label","\":"," \"","Blue","\","," \"","description","\":"," \"","A"," cool"," recessive"," hue"," that"," reads"," as"," calm"," and"," trustworthy"," in"," long"," reading"," sessions"," and"," dense"," dash","boards",".\"","},"," {\"","label","\":"," \"","Green","\","," \"","description","\":"," \"","A"," rest","ful"," mid","-spect","rum"," hue"," with"," the"," highest"," perceived"," brightness",","," easiest"," on"," the"," eye"," over"," long"," sessions",".\"","}]","}]","}"]}} +{"type":"tool-call-chunks","seq0":29,"time0":1785150168776,"data":{"turn":1,"step":1,"index":1,"dt":[25,1,0,0,0,25,0,0,0,26,1,0,0,0,0,25,1,0,0,0,0,25,1,0,0,0,0,25,1,0,0,0,1,25,0,0,0,0,1,25,1,0,0,0,0,25,1,0,0,26,0,0,1,0,24,1,0,0,0,1,26,1,0,0,0,0,25,0,1,0,0,0,25,1,0,0,25,0,0,0,0,1,25,0,0,1,0,0,25,0,0,0,1,0,26,1,24],"id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","args":["","{","\"","questions","\"",": ","[","{\"","id","\":"," \"","color","\","," \"","question","\":"," \"","Which"," color"," do"," you"," prefer","?\","," \"","header","\":"," \"","Pick"," one","\", \"multi_select\": true,"," \"","options","\":"," [","{\"","label","\":"," \"","Blue","\","," \"","description","\":"," \"","A"," cool"," recessive"," hue"," that"," reads"," as"," calm"," and"," trustworthy"," in"," long"," reading"," sessions"," and"," dense"," dash","boards",".\"","},"," {\"","label","\":"," \"","Green","\","," \"","description","\":"," \"","A"," rest","ful"," mid","-spect","rum"," hue"," with"," the"," highest"," perceived"," brightness",","," easiest"," on"," the"," eye"," over"," long"," sessions",".\"","}]","}]","}"]}} {"type":"assistant/chunk","seq":127,"time":1785150169308,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that."}}}} -{"type":"assistant/chunk","seq":128,"time":1785150169308,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}}}} +{"type":"assistant/chunk","seq":128,"time":1785150169308,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"multi_select\": true, \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}}}} {"type":"assistant/chunk","seq":129,"time":1785150169308,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":113,"outputTokens":158,"cacheReadTokens":4096,"reasoningTokens":22}}}} {"type":"assistant/chunk","seq":130,"time":1785150169308,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":131,"time":1785150169311,"data":{"turn":1,"step":1,"usage":{"inputTokens":113,"outputTokens":158,"cacheReadTokens":4096,"reasoningTokens":22},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that."},{"type":"tool-call","id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"cdb1676c-e781-41ee-8f28-a3595371d729"}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130],"surfaceOp":"append"} -{"type":"tool/call","seq":132,"time":1785150169312,"data":{"turn":1,"step":1,"callId":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}} -{"type":"tool/result","seq":133,"time":1785150169787,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_Cijldc88LYmVPCXYUsRq1617"},"content":[{"type":"tool-result","toolCallId":"call_00_Cijldc88LYmVPCXYUsRq1617","content":[{"type":"text","text":"{\"answers\":[{\"id\":\"color\",\"selected\":[\"Blue\"]}]}"}],"isError":false}],"role":"user","id":"c69ef39f-6f62-439f-b3f8-e8d10fba572f"}},"sourceEventSeqs":[132],"surfaceOp":"append"} +{"type":"assistant/message","seq":131,"time":1785150169311,"data":{"turn":1,"step":1,"usage":{"inputTokens":113,"outputTokens":158,"cacheReadTokens":4096,"reasoningTokens":22},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that."},{"type":"tool-call","id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"multi_select\": true, \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"cdb1676c-e781-41ee-8f28-a3595371d729"}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130],"surfaceOp":"append"} +{"type":"tool/call","seq":132,"time":1785150169312,"data":{"turn":1,"step":1,"callId":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"multi_select\": true, \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}} +{"type":"tool/result","seq":133,"time":1785150169787,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_Cijldc88LYmVPCXYUsRq1617"},"content":[{"type":"tool-result","toolCallId":"call_00_Cijldc88LYmVPCXYUsRq1617","content":[{"type":"text","text":"{\"answers\":[{\"id\":\"color\",\"selected\":[\"Blue\"],\"custom\":\"Include accessibility notes\"}]}"}],"isError":false}],"role":"user","id":"c69ef39f-6f62-439f-b3f8-e8d10fba572f"}},"sourceEventSeqs":[132],"surfaceOp":"append"} {"type":"step/end","seq":134,"time":1785150169790,"data":{"turn":1,"step":1}} {"type":"step/start","seq":135,"time":1785150169790,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":136,"time":1785150170605,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/apps/web/tests/snapshots/question-composer/ui.expected.md b/apps/web/tests/snapshots/question-composer/ui.expected.md index 894f84d9ba..c2ee767319 100644 --- a/apps/web/tests/snapshots/question-composer/ui.expected.md +++ b/apps/web/tests/snapshots/question-composer/ui.expected.md @@ -3,9 +3,9 @@ - heading "Which color do you prefer?" [level=2] - button "Dismiss all questions": - img - - radiogroup: - - radio "Blue": 1 Blue A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards. - - radio "Green": 2 Green A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions. + - group: + - checkbox "Blue": Blue A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards. + - checkbox "Green": Green A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions. - textbox "Type your answer" - button "Previous question" [disabled]: - img diff --git a/packages/client/ui-question/tests/question-composer.spec.tsx b/packages/client/ui-question/tests/question-composer.spec.tsx index 87eb275b7d..adc32fc86d 100644 --- a/packages/client/ui-question/tests/question-composer.spec.tsx +++ b/packages/client/ui-question/tests/question-composer.spec.tsx @@ -238,6 +238,11 @@ describe('QuestionComposer', () => { fireEvent.keyDown(custom, { key: 'Enter' }) fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' })) fireEvent.click(screen.getByRole('button', { name: '提交' })) + expect(respond).toHaveBeenNthCalledWith(1, answeredEnvelope('second', [ + { id: 'profile', selected: ['工程落地型 (Recommended)'] }, + { id: 'detail', selected: [], custom: 'x' }, + { id: 'signals', selected: ['系统设计'] }, + ])) expect(await screen.findByText('网络中断')).toBeTruthy() expect(screen.getByRole('button', { name: '提交' }).disabled).toBe(false) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 73685c1f0a..b23e4178bb 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -280,8 +280,10 @@ function matchesQuestions(payload: QuestionResponsePayload, pending: PendingQues if (new Set(answer.selected).size !== answer.selected.length) return false const custom = answer.custom?.trim() if (custom !== undefined && custom === '') return false - if (custom !== undefined && answer.selected.length > 0 && question.multiSelect !== true) return false - if (question.multiSelect !== true && answer.selected.length > 1) return false + if (question.multiSelect !== true) { + if (custom !== undefined && answer.selected.length > 0) return false + if (answer.selected.length > 1) return false + } const labels = new Set(question.options?.map(option => option.label) ?? []) return answer.selected.every(label => labels.has(label)) }) diff --git a/packages/ui/tool-ask-user/tests/tool-ask-user.spec.ts b/packages/ui/tool-ask-user/tests/tool-ask-user.spec.ts index 7d019a520a..0c55e33ed7 100644 --- a/packages/ui/tool-ask-user/tests/tool-ask-user.spec.ts +++ b/packages/ui/tool-ask-user/tests/tool-ask-user.spec.ts @@ -141,6 +141,7 @@ describe('ask_user_question tool', () => { return { answers: [ { id: 'targets', selected: ['tests', 'docs'], custom: 'release notes' }, + { id: 'labels-only', selected: ['tests'] }, { id: 'notes', selected: [], custom: 'ship today' }, ], } @@ -159,6 +160,12 @@ describe('ask_user_question tool', () => { options: [{ label: 'tests' }, { label: 'docs' }], multi_select: true, }, + { + id: 'labels-only', + question: 'Which labels should I keep?', + options: [{ label: 'tests' }, { label: 'docs' }], + multi_select: true, + }, { id: 'notes', question: 'Any note?' }, ], }, @@ -169,12 +176,13 @@ describe('ask_user_question tool', () => { expect(result.value).toEqual({ answers: [ { id: 'targets', selected: ['tests', 'docs'], custom: 'release notes' }, + { id: 'labels-only', selected: ['tests'] }, { id: 'notes', selected: [], custom: 'ship today' }, ], }) expect(result.content).toEqual([{ type: 'text', - text: '{"answers":[{"id":"targets","selected":["tests","docs"],"custom":"release notes"},{"id":"notes","selected":[],"custom":"ship today"}]}', + text: '{"answers":[{"id":"targets","selected":["tests","docs"],"custom":"release notes"},{"id":"labels-only","selected":["tests"]},{"id":"notes","selected":[],"custom":"ship today"}]}', }]) }) diff --git a/packages/ui/tui/README.i18n.yaml b/packages/ui/tui/README.i18n.yaml index 548372998f..eedf9e945c 100644 --- a/packages/ui/tui/README.i18n.yaml +++ b/packages/ui/tui/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/ui/tui/README.md -README.md: c8eb81b2d76c1616647baba37692ed8cd42e89dc -README.zh.md: 680bb89f12cbbad871010ed025cfa6d4369bb0a3 +README.md: 3b1c67dceadfe18a8d72bedc6a321a3fa86a3c90 +README.zh.md: a87858833eeb9220c709748eb5bbee3ff132eb8f diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index c8eb81b2d7..3b1c67dcea 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -153,7 +153,7 @@ Append-only; newly visible content follows the reusable request prefix and does #### What the model sees -When a consumer calls `ctx.userInteraction.ask()`, this provider presents each question in order and returns selected option labels, `custom` text, or both for a multi-select question. Abort, cancellation, or UI disposal becomes `Error: ask_user_question was interrupted before the user answered` through `dsh-tool-ask-user`. +When a consumer calls `ctx.userInteraction.ask()`, this provider presents each question in order and returns selected option labels, `custom` text, or both for a multi-select question. Pending custom text survives switching back to options and joins checked labels on a later options-mode submit. Abort, cancellation, or UI disposal becomes `Error: ask_user_question was interrupted before the user answered` through `dsh-tool-ask-user`. #### Token effect diff --git a/packages/ui/tui/README.zh.md b/packages/ui/tui/README.zh.md index 680bb89f12..a87858833e 100644 --- a/packages/ui/tui/README.zh.md +++ b/packages/ui/tui/README.zh.md @@ -153,7 +153,7 @@ Paths prefixed with @ are files explicitly referenced by the user. Use the read #### 模型看到的内容 -消费方调用 `ctx.userInteraction.ask()` 时,此提供方会按顺序显示各个问题,并返回选中选项标签、`custom` 文本,或为多选题同时返回两者。中止、取消或 UI dispose 会变为 `Error: ask_user_question was interrupted before the user answered`;该转换由 `dsh-tool-ask-user` 完成。 +消费方调用 `ctx.userInteraction.ask()` 时,此提供方会按顺序显示各个问题,并返回选中选项标签、`custom` 文本,或为多选题同时返回两者。切回选项后,待提交的自定义文本仍会保留,并在之后从选项模式提交时与已勾选的标签一同返回。中止、取消或 UI dispose 会变为 `Error: ask_user_question was interrupted before the user answered`;该转换由 `dsh-tool-ask-user` 完成。 #### Token 影响 diff --git a/packages/ui/tui/src/components/dialogs.ts b/packages/ui/tui/src/components/dialogs.ts index 0dac957cf3..59ce8fd3d7 100644 --- a/packages/ui/tui/src/components/dialogs.ts +++ b/packages/ui/tui/src/components/dialogs.ts @@ -804,11 +804,12 @@ export class QuestionDialog implements Component, Focusable { const selected = this.question.multiSelect ? this.selectedOptionLabels() : [options[this.selectedIndex]?.label].filter((label): label is string => label !== undefined) - if (selected.length === 0) { + const custom = this.question.multiSelect ? this.input.getValue().trim() : '' + if (selected.length === 0 && custom === '') { this.error = 'Select at least one option, or press Tab for a custom answer.' return } - this.done({ selected }) + this.done({ selected, ...(custom === '' ? {} : { custom }) }) } else if (matchesKey(data, Key.tab) || data.toLowerCase() === 'c') { this.mode = 'custom' this.error = '' @@ -854,7 +855,12 @@ export class QuestionDialog implements Component, Focusable { push('') if (this.mode === 'custom') { for (const line of this.input.render(innerWidth)) push(line) - push(this.palette.dim(this.options.length > 0 ? 'Enter submit • Esc options' : 'Enter submit • Esc cancel')) + const controls = [ + ...(this.options.length > 0 && this.question.multiSelect ? [`${this.selected.size} selected`] : []), + 'Enter submit', + this.options.length > 0 ? 'Esc options' : 'Esc cancel', + ] + push(this.palette.dim(controls.join(' • '))) } else { const options = this.options const start = Math.max(0, Math.min( diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 72d35dd4b2..b2dfcb9536 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -4697,12 +4697,29 @@ describe('TUI user-interaction dialogs', () => { result.terminal.send('\x1b[B') result.terminal.send(' ') result.terminal.send('\t') + await tick() + expect(result.terminal.output).toContain('2 selected • Enter submit • Esc options') result.terminal.send('Tests') result.terminal.send('\r') await expect(multi).resolves.toEqual({ answers: [{ id: 'targets', selected: ['Code', 'Docs'], custom: 'Tests' }], }) + const labelsOnly = result.ctx.userInteraction.ask({ + questions: [{ + id: 'labels-only', + question: 'Pick one target', + multiSelect: true, + options: [{ label: 'Code' }, { label: 'Docs' }], + }], + }) + await tick() + result.terminal.send(' ') + result.terminal.send('\r') + await expect(labelsOnly).resolves.toEqual({ + answers: [{ id: 'labels-only', selected: ['Code'] }], + }) + const custom = result.ctx.userInteraction.ask({ questions: [{ id: 'other', question: 'Choose or type', options: [{ label: 'Default' }] }], }) @@ -4744,7 +4761,6 @@ describe('TUI user-interaction dialogs', () => { options: [{ label: 'One', description: 'first' }, { label: 'Two' }], }], }) - const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) await tick() result.terminal.send('\x1b[A') result.terminal.send('\x1b[B') @@ -4760,11 +4776,17 @@ describe('TUI user-interaction dialogs', () => { }) result.terminal.send('c') await tick() + result.terminal.send('keep this') + await tick() + expect(result.terminal.output).toContain('0 selected • Enter submit • Esc options') result.terminal.send('\x1b') await tick() expect(result.terminal.output).toContain('Space toggle') - result.terminal.send('\x03') - await rejected + result.terminal.send(' ') + result.terminal.send('\r') + await expect(answer).resolves.toEqual({ + answers: [{ id: 'options', selected: ['One'], custom: 'keep this' }], + }) await dispose(result) }) From eb101230154e40dea237209e047759acf9d47cb0 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Thu, 30 Jul 2026 03:57:57 -0700 Subject: [PATCH 010/129] test(web): simplify approval snapshot assertions --- apps/web/tests/built-boot.snapshot.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/web/tests/built-boot.snapshot.ts b/apps/web/tests/built-boot.snapshot.ts index d436d41866..7ca9b5fbbb 100644 --- a/apps/web/tests/built-boot.snapshot.ts +++ b/apps/web/tests/built-boot.snapshot.ts @@ -106,11 +106,10 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn // distinguishes a blocked running session from an ordinarily busy one. const waitingTitle = await within(tree).findByText('Fixture 历史会话') const waitingRow = waitingTitle.closest('[role="treeitem"]') - expect(waitingRow).not.toBeNull() if (waitingRow === null) throw new Error('fixture Session title must belong to a tree row') expect(waitingRow.querySelector('[data-state="warning"]')).not.toBeNull() expect(waitingRow.querySelector('[data-state="ongoing"]')).toBeNull() - expect(within(waitingRow).getByText('Waiting for approval')).not.toBeNull() + within(waitingRow).getByText('Waiting for approval') // Opening a session reaches chat content through the fixture transport. fireEvent.click(waitingTitle) From e1fe6696dfe01ee03a42dae529bc162286a1c58e Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 30 Jul 2026 19:52:46 +0800 Subject: [PATCH 011/129] test(web): refresh question composer disclosure --- .../tests/snapshots/question-composer/answered.expected.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/web/tests/snapshots/question-composer/answered.expected.md b/apps/web/tests/snapshots/question-composer/answered.expected.md index 7f7603eb8a..692e0a5968 100644 --- a/apps/web/tests/snapshots/question-composer/answered.expected.md +++ b/apps/web/tests/snapshots/question-composer/answered.expected.md @@ -11,7 +11,10 @@ - img - button "编辑": - img -- button "▸ 上下文注入" +- button "上下文注入": + - img + - img + - text: 上下文注入 - button "Think The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that.": - img - img From e55a904e1d0b534a133cd998cc0918720fff0f5f Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 30 Jul 2026 20:26:33 +0800 Subject: [PATCH 012/129] feat(web): render remote Markdown images --- ...026-07-23-web-assistant-markdown.i18n.yaml | 6 +- .../2026-07-23-web-assistant-markdown.md | 4 +- .../2026-07-23-web-assistant-markdown.zh.md | 4 +- ...07-30-web-remote-markdown-images.i18n.yaml | 6 + .../2026-07-30-web-remote-markdown-images.md | 29 +++ ...026-07-30-web-remote-markdown-images.zh.md | 29 +++ apps/web/tests/markdown-images.e2e.ts | 205 ++++++++++++++++++ .../snapshots/markdown-images/ui.expected.md | 33 +++ apps/web/tsconfig.json | 1 + .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 3 +- .../src/markdown/MarkdownText.module.css | 11 + .../src/markdown/MarkdownText.tsx | 27 ++- .../ui-primitives/tests/markdown.spec.tsx | 32 ++- tsconfig.host.json | 1 + 16 files changed, 381 insertions(+), 16 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-30-web-remote-markdown-images.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-30-web-remote-markdown-images.md create mode 100644 .agents/notes/implemented/feature/2026-07-30-web-remote-markdown-images.zh.md create mode 100644 apps/web/tests/markdown-images.e2e.ts create mode 100644 apps/web/tests/snapshots/markdown-images/ui.expected.md diff --git a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml index 1f52492649..656a52d300 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml @@ -1,6 +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 -2026-07-23-web-assistant-markdown.md: 38d193271d88b3a8f32ba1b191e8a6d432176281 -2026-07-23-web-assistant-markdown.zh.md: be3cd041c6012af142fc27934fda125dfc4cf6de +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md +2026-07-23-web-assistant-markdown.md: d5074e6090699229f5c43dd93eef0fdfbfedab76 +2026-07-23-web-assistant-markdown.zh.md: 31f0fd6835c9921f544f4b6217a0c834dff79859 diff --git a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md index 38d193271d..d5074e6090 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md @@ -20,7 +20,7 @@ The dependency is explicit in `ui-primitives`; because that pure library is seed ## Untrusted output policy -Assistant-authored destinations are restricted to absolute HTTP, HTTPS, and mailto URLs. HTTP(S) links open in a new tab with `rel="noopener noreferrer"`; relative destinations and other protocols render as non-navigable text. Markdown images render only their alt text, so model output cannot initiate a remote image request. Raw HTML remains inert source text because no HTML parser enters the pipeline. Shiki output is a static span tree generated from the fence text (no scripts or user HTML). +Assistant-authored link destinations are restricted to absolute HTTP, HTTPS, and mailto URLs. HTTP(S) links open in a new tab with `rel="noopener noreferrer"`; relative destinations and other protocols render as non-navigable text. Markdown images follow the separate [remote-image policy](2026-07-30-web-remote-markdown-images.md). Raw HTML remains inert source text because no HTML parser enters the pipeline. Shiki output is a static span tree generated from the fence text (no scripts or user HTML). Fenced code and GFM tables own horizontal overflow so long content cannot widen the conversation column. @@ -32,7 +32,7 @@ Fenced code and GFM tables own horizontal overflow so long content cannot widen **Parse Markdown into session snapshots.** This would make React nodes or presentation ASTs durable runtime state and reintroduce a final-versus-streaming mode boundary. Parsing stays at the presentation leaf instead. -**Enable raw HTML or remote images with sanitization.** Neither capability has a current product need, while both enlarge the executable or network privacy boundary. They remain disabled rather than adding sanitizer and image-policy dependencies. +**Enable raw HTML with sanitization.** Raw HTML has no current product need and would enlarge the executable-content boundary, so it remains disabled rather than adding a sanitizer dependency. Remote images are governed by the later [image policy](2026-07-30-web-remote-markdown-images.md). **Port deepsuite Prism `highlight.css` and the mdast pipeline.** Appearance parity is owned by CSS Modules and shared `--dsw-*` tokens; highlighting stays on the existing shiki allowlist so the client does not take a second highlighter or Prism class contract. diff --git a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md index be3cd041c6..31f0fd6835 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md @@ -20,7 +20,7 @@ Web 对话通过会话事件、历史回放与流式累积保留 assistant Markd ## 不受信任输出策略 -assistant 生成的目标地址仅限绝对 HTTP、HTTPS 与 mailto URL。HTTP(S) 链接会在新标签页中打开,并带有 `rel="noopener noreferrer"`;相对目标地址与其他协议会渲染为不可导航的文本。Markdown 图片仅渲染替代文本,因此模型输出无法发起远程图片请求。由于管线中未引入 HTML 解析器,原始 HTML 仍是不会生效的源文本。Shiki 输出是由围栏文本生成的静态 span 树(不含脚本或用户 HTML)。 +assistant 生成的链接目标地址仅限绝对 HTTP、HTTPS 与 mailto URL。HTTP(S) 链接会在新标签页中打开,并带有 `rel="noopener noreferrer"`;相对目标地址与其他协议会渲染为不可导航的文本。Markdown 图片遵循独立的[远程图片策略](2026-07-30-web-remote-markdown-images.md)。由于流水线中未引入 HTML 解析器,原始 HTML 仍是不会生效的源文本。Shiki 输出是由围栏文本生成的静态 span 树(不含脚本或用户 HTML)。 围栏代码与 GFM 表格各自处理横向溢出,因此较长内容无法撑宽对话栏。 @@ -32,7 +32,7 @@ assistant 生成的目标地址仅限绝对 HTTP、HTTPS 与 mailto URL。HTTP(S **将 Markdown 解析为会话快照。**这会让 React 节点或呈现层 AST 成为持久的运行时状态,并重新引入最终输出与流式输出之间的模式边界。解析仍留在呈现层的叶节点中。 -**通过净化启用原始 HTML 或远程图片。**当前产品并不需要这两项功能,但二者都会扩大可执行行为或网络隐私边界。因此它们保持禁用,无需增加净化器与图片策略依赖。 +**通过净化启用原始 HTML。** 原始 HTML 当前没有产品需求,并且会扩大可执行内容边界,因此保持禁用,无需增加净化器依赖。远程图片由后续的[图片策略](2026-07-30-web-remote-markdown-images.md)约束。 **移植 deepsuite 的 Prism `highlight.css` 与 mdast 管线。**外观一致性由 CSS Modules 与共享的 `--dsw-*` token 负责;高亮仍走现有的 shiki 允许列表,使客户端不必引入第二套高亮器或 Prism class 契约。 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-remote-markdown-images.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-remote-markdown-images.i18n.yaml new file mode 100644 index 0000000000..afe776d402 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-web-remote-markdown-images.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-07-30-web-remote-markdown-images.md +2026-07-30-web-remote-markdown-images.md: 23dc699aab87597b7b5bfee83d0d745d4798303e +2026-07-30-web-remote-markdown-images.zh.md: 54db9b25e9f558d77c84bb67c05fadea2c9086ac diff --git a/.agents/notes/implemented/feature/2026-07-30-web-remote-markdown-images.md b/.agents/notes/implemented/feature/2026-07-30-web-remote-markdown-images.md new file mode 100644 index 0000000000..23dc699aab --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-web-remote-markdown-images.md @@ -0,0 +1,29 @@ +# Agent Note: Remote Web Markdown images + +Status: implemented + +English | [中文](2026-07-30-web-remote-markdown-images.zh.md) + +## Problem + +Assistant Markdown can name diagrams and screenshots with standard image syntax, but the Web renderer replaces every image with italic alt text. Even absolute HTTP(S) destinations therefore lose ordinary Markdown behavior. + +## Decision + +`MarkdownText` renders absolute HTTP(S) image destinations as lazy, responsive `` elements with asynchronous decoding and `referrerPolicy="no-referrer"`. Relative paths, absolute local paths, `file:` URLs, and unsupported schemes retain the existing alt-text fallback. Raw HTML stays disabled, so an assistant cannot bypass the Markdown image component with a hand-authored ``. + +The image component reuses the renderer's absolute-URL policy without adding a host proxy, local-file route, Session dependency, sanitizer, or image fetcher. Finalized history, streaming output, interrupted partials, and every other `MarkdownText` consumer receive the same behavior. + +## Alternatives considered + +**Keep all images as alt text.** This preserves the smallest network boundary but defeats the product need to inspect network-hosted visual artifacts inline. + +**Proxy remote images through the host.** A proxy could hide the browser's network address from the image origin, but it would make the host perform arbitrary outbound fetches and require a separate redirect, DNS, size, and content policy. Direct HTTP(S) loading keeps that request visible to browser controls; omitting the referrer limits conversation-origin disclosure. + +**Support local paths in the same change.** Web origins cannot directly load host files. A safe implementation needs a separately reviewed authority boundary, so relative paths, absolute local paths, and `file:` URLs remain disabled. + +**Allow `data:` images.** Large data URLs duplicate binary content into durable transcript text. The HTTP(S)-only policy covers the current need without expanding session logs. + +## Consequences + +Assistant replies display remote images during streaming and replay without changing session events or host protocols. Remote origins still observe the image request, client network address, and any credentials that browser policy permits for that origin. Local and unsupported destinations remain inert alt text. diff --git a/.agents/notes/implemented/feature/2026-07-30-web-remote-markdown-images.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-remote-markdown-images.zh.md new file mode 100644 index 0000000000..54db9b25e9 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-web-remote-markdown-images.zh.md @@ -0,0 +1,29 @@ +# Agent Note: Web 中的远程 Markdown 图片 + +Status: implemented + +[English](2026-07-30-web-remote-markdown-images.md) | 中文 + +## 问题 + +assistant Markdown 可以使用标准图片语法引用图表和截图,但 Web 渲染器会把每张图片替换为斜体替代文本。因此,即使目标地址是绝对 HTTP(S) URL,也无法获得普通的 Markdown 图片行为。 + +## 决策 + +`MarkdownText` 将绝对 HTTP(S) 图片目标地址渲染为延迟加载的响应式 `` 元素,并使用异步解码与 `referrerPolicy="no-referrer"`。相对路径、绝对本地路径、`file:` URL 与不支持的协议继续沿用现有的替代文本回退。原始 HTML 保持禁用,因此 assistant 无法通过手写 `` 绕过 Markdown 图片组件。 + +图片组件复用渲染器的绝对 URL 策略,不新增主机代理、本地文件路由、Session 依赖、净化器或图片抓取器。已完成的历史消息、流式输出、被中断的部分输出以及其他所有 `MarkdownText` 消费方均获得同一行为。 + +## 考虑过的替代方案 + +**将所有图片都保留为替代文本。** 这种方案维持了最小的网络边界,但无法满足在行内查看网络托管的视觉产物这一产品需求。 + +**通过主机代理远程图片。** 代理可以向图片源站隐藏浏览器的网络地址,但这会让主机执行任意出站请求,并且需要单独制定重定向、DNS、大小与内容策略。直接加载 HTTP(S) 图片可让浏览器控制机制继续观察该请求;不发送 referrer 可减少对话来源信息的暴露。 + +**在同一变更中支持本地路径。** Web 源无法直接加载主机文件。安全的实现需要单独评审的权限边界,因此相对路径、绝对本地路径与 `file:` URL 保持禁用。 + +**允许 `data:` 图片。** 大型 data URL 会将二进制内容以文本形式重复写入持久化的 transcript(文本记录)。仅允许 HTTP(S) 的策略足以满足当前需求,且不会扩大会话日志。 + +## 后果 + +assistant 回复会在流式输出与回放期间显示远程图片,且不改变会话事件或主机协议。远程源站仍可观察到图片请求、客户端网络地址,以及浏览器策略允许发送给该源站的任何凭据。本地及不支持的目标地址仍只显示不会发起请求的替代文本。 diff --git a/apps/web/tests/markdown-images.e2e.ts b/apps/web/tests/markdown-images.e2e.ts new file mode 100644 index 0000000000..8dce7f405b --- /dev/null +++ b/apps/web/tests/markdown-images.e2e.ts @@ -0,0 +1,205 @@ +// Web e2e scenario: absolute HTTP(S) Markdown images. A validated session +// assembled through the Session API is seeded cold into the real web +// composition, then a separate image origin proves that the browser receives +// a real network image while local-path Markdown remains inert alt text. +import { createServer, type Server } from 'node:http' +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 { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import { + SESSION_FORMAT_VERSION, + Session, + SessionId, +} from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-session-title' +import { + assertFixtureInventory, + captureStableAria, + compareOrRefreshGolden, + launchWebScaffold, + seedSession, + watchConsole, + webSnapshotMode, + type WebScaffold, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/markdown-images', import.meta.url)) +const UI_EXPECTED = fileURLToPath(new URL('./snapshots/markdown-images/ui.expected.md', import.meta.url)) +const MODE = webSnapshotMode() +const SEED_ID = 'markdown-images-web-e2e' +const REMOTE_ALT = 'Remote test image' +const LOCAL_ALT = 'Local test image' +const PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', + 'base64', +) + +interface ImageOrigin { + server: Server + url: string + requests: Array<{ path: string | undefined; referer: string | undefined }> +} + +/** Start the deterministic remote image origin used by this browser scenario. */ +async function startImageOrigin(): Promise { + const requests: ImageOrigin['requests'] = [] + const server = createServer((request, response) => { + requests.push({ path: request.url, referer: request.headers.referer }) + response.writeHead(200, { + 'cache-control': 'no-store', + 'content-length': PNG.length, + 'content-type': 'image/png', + }) + response.end(PNG) + }) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', resolve) + }) + const address = server.address() + if (address === null || typeof address === 'string') { + throw new Error('image origin did not expose an IP socket') + } + return { + server, + url: `http://127.0.0.1:${String(address.port)}/image.png`, + requests, + } +} + +/** Stop one image origin after the browser and host release their requests. */ +async function stopServer(server: Server): Promise { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error === undefined) resolve() + else reject(error) + }) + }) +} + +/** Build one closed, invariant-checked session fixture with remote and local image Markdown. */ +function markdownImageFixture(remoteUrl: string): string { + const session = new Session(SessionId('markdown-image-source')) + session.append('turn/start', { + turn: 1, + trigger: { kind: 'message', source: { kind: 'user' } }, + }) + const user = session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'Show the Markdown image policy.' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + session.append('session/title', { + title: 'Markdown image policy', + messageSeqs: [user.seq], + source: { kind: 'fallback' }, + }) + session.append('step/start', { turn: 1, step: 1 }) + session.append('assistant/message', { + turn: 1, + step: 1, + message: createMessage({ + role: 'assistant', + content: [{ + type: 'text', + text: [ + '## Markdown images', + '', + `![${REMOTE_ALT}](${remoteUrl})`, + '', + `![${LOCAL_ALT}](./local-image.png)`, + '', + 'REMOTE_IMAGE_DONE', + ].join('\n'), + }], + source: { kind: 'model', provider: 'fixture', model: 'fixture' }, + }), + }, { surfaceOp: 'append' }) + session.append('step/end', { turn: 1, step: 1 }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + + const header = { + type: 'session', + version: SESSION_FORMAT_VERSION, + id: '{{sessionId}}', + createdAt: 0, + cwd: '{{cwd}}', + } + return [ + JSON.stringify(header), + ...session.events.map(event => JSON.stringify(event)), + '', + ].join('\n') +} + +describe('web e2e: remote Markdown image rendering', () => { + let scaffold: WebScaffold + let imageOrigin: ImageOrigin + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + imageOrigin = await startImageOrigin() + scaffold = await launchWebScaffold({}) + await seedSession(scaffold, markdownImageFixture(imageOrigin.url), SEED_ID) + 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 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + await stopServer(imageOrigin.server) + }) + + it.skipIf(MODE === 'record')('loads only the remote image and matches the conversation golden', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-images')) + const groupRow = page.locator('[role="treeitem"]').first() + await groupRow.waitFor({ timeout: 15_000 }) + await groupRow.click() + const sessionRow = page.locator('[role="treeitem"]').nth(1) + await sessionRow.waitFor({ timeout: 10_000 }) + await sessionRow.click() + await expect.poll(() => page.getByText('REMOTE_IMAGE_DONE', { exact: true }).count(), { + timeout: 15_000, + }).toBe(1) + + const image = page.getByRole('img', { name: REMOTE_ALT }) + await image.waitFor({ timeout: 10_000 }) + await expect.poll(() => image.evaluate(element => (element as HTMLImageElement).naturalWidth), { + timeout: 10_000, + }).toBeGreaterThan(0) + expect(await image.evaluate((element) => { + const computed = getComputedStyle(element) + return { + borderRadius: computed.borderRadius, + decoding: element.getAttribute('decoding'), + loading: element.getAttribute('loading'), + maxWidth: computed.maxWidth, + referrerPolicy: element.getAttribute('referrerpolicy'), + } + })).toEqual({ + borderRadius: '8px', + decoding: 'async', + loading: 'lazy', + maxWidth: '100%', + referrerPolicy: 'no-referrer', + }) + expect(await page.getByRole('img', { name: LOCAL_ALT }).count()).toBe(0) + expect(await page.getByText(LOCAL_ALT, { exact: true }).count()).toBe(1) + expect(imageOrigin.requests).toEqual([{ path: '/image.png', referer: undefined }]) + + const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) + .split(SEED_ID).join('{{seededId}}') + await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md']) + }, 60_000) +}) diff --git a/apps/web/tests/snapshots/markdown-images/ui.expected.md b/apps/web/tests/snapshots/markdown-images/ui.expected.md new file mode 100644 index 0000000000..cd33a09eb9 --- /dev/null +++ b/apps/web/tests/snapshots/markdown-images/ui.expected.md @@ -0,0 +1,33 @@ +- banner: + - navigation "Session hierarchy": + - button "Markdown image policy" [disabled] + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- text: Show the Markdown image policy. {{clock}} +- button "复制": + - img +- button "在新对话中分支": + - img +- button "编辑": + - img +- heading "Markdown images" [level=2] +- paragraph: + - img "Remote test image" +- paragraph: Local test image +- paragraph: REMOTE_IMAGE_DONE +- button "复制": + - img +- button "在新对话中分支": + - img +- text: {{clock}} +- textbox "Message the agent" +- button "Add attachment": + - img +- 'button "Access mode, current: Danger Full Access"': Danger Full Access +- button "Plan mode off, press to turn on": Plan off +- button "Select model, current deepseek-v4-flash": + - text: deepseek-v4-flash + - img +- button "Send message" [disabled] +- text: 1 turns · 1 steps Input 0 tok · Output 0 tok diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 7a7f228fb0..ce1f2105af 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -37,6 +37,7 @@ "tests/code-mode-round.e2e.ts", "tests/cordis-tool-round.e2e.ts", "tests/message-actions.e2e.ts", + "tests/markdown-images.e2e.ts", "tests/queue-actions.e2e.ts", "tests/skill-invocation-policy.e2e.ts" ], diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index b5e4b5c078..e24142ebcf 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/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-primitives/README.md -README.md: 0ef3c20f848b3d331c007911d0837f11cd72c024 -README.zh.md: af94551bfb9e12dbadcef6a96a54f9bf7ea71299 +README.md: 929d2edd9ddbd4610f84dd901486f74700cbf4e2 +README.zh.md: 5c6df2ce58e274a53b7792597259749f9a45d4ca diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 0ef3c20f84..929d2edd9d 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -6,7 +6,7 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/ ## Markdown rendering -`MarkdownText` renders GFM from untrusted assistant output through React elements. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders image alt text without loading remote resources; `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars). +`MarkdownText` renders GFM from untrusted assistant output through React elements. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders absolute HTTP(S) images without a referrer; relative paths, absolute local paths, `file:` URLs, and unsupported schemes retain their alt text. `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, responsive images, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars). ## Terminal output diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index af94551bfb..5c6df2ce58 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -6,7 +6,8 @@ ## Markdown 渲染 -`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并只渲染图片 alt 文本而不加载远程资源;`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。 +`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并在不携带 referrer 的情况下渲染绝对 HTTP(S) 图片;相对路径、绝对本地路径、`file:` URL 与不支持的协议仍保留其替代文本。`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、响应式图片、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。 + ## 终端输出 `TerminalBlock` 将一条 shell 命令渲染为终端表层:命令的每一行各占一个提示行(缩短后的 `cwd` 标签只出现在第一行,因为视图只知道一个工作目录,而一个 `cd` 就会让后面的行去到别处,标签之后是该行)、命令输出、非零退出码或终止信号对应的状态胶囊,以及写入原始 `output` prop 的复制控件。一枚运行状态 `StateDot` 为整次调用标记一次,位于第一行,以脱离文档流的方式落在卡片以自身左内边距预留的落区中,因此它位于卡片盒之内、提示文字之左。它用到 `StateDot` 的三种状态——`running` 期间为追逐动画,与渲染状态胶囊相同的退出状态为红色,其余为绿色——因此卡片直接陈述其命令是否仍在运行,而不是让人从有无输出中推断;由于 `StateDot` 是 `aria-hidden`,它携带一处视觉隐藏的文本标签。无论多少行都只有一枚状态点是有意为之:退出状态属于整次调用,因此每行一枚就会声称一个视图并不携带的逐行结果。命令文本使用 `white-space: pre`,因此重复空格、制表符与缩进续行都原样呈现,同时该行仍保持单行并以省略号截断。ANSI 转义序列通过运行时依赖 `anser` 解析为 React span;光标移动在剥除无显示意义控制符之前先重放进逐行的列缓冲,因为回车与退格**只移动**光标:单是 `100%` 加回车再加 `OK` 显示为 `OK0%`,而 spinner 随重绘写出的 `\x1b[K` 会擦掉尾巴,因此 `100%\r\x1b[KOK` 显示为 `OK`。行内擦除的三种参数形式都被遵循,光标按终端列推进(8 列制表位;emoji 与 CJK 占两列;组合标记不占列),SGR 状态按单元格归一化存储,与终端一致,并跨行延续、在行结束时的状态处收束;基础 16 色前景色映射到 `--dsw-*` token,而 256 色板与真彩色值按字面 rgb 透传。输出保持 `white-space: pre` 并支持横向滚动,因此按列对齐的输出保留其对齐而不会软换行;超过 `maxLines`(默认 16,与 TUI 转录相同的切分算法)时折叠为头部切片加尾部切片,由展开按钮控制。原理:[Web 终端卡片笔记](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)。 diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css index a189528bc9..03bec019bd 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css @@ -224,3 +224,14 @@ color: var(--dsw-alias-label-tertiary); font-style: italic; } + +.image { + display: block; + width: auto; + max-width: 100%; + height: auto; + margin: 0; + border-radius: 8px; + background: var(--dsw-alias-bg-base); + object-fit: contain; +} diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx index 639f53dbb1..ff6f543ebc 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx @@ -24,6 +24,15 @@ function sanitizeUrl(url: string): string { const safeUrl: UrlTransform = url => sanitizeUrl(url) +function remoteImageUrl(url: string): string | undefined { + try { + const protocol = new URL(url).protocol + return protocol === 'http:' || protocol === 'https:' ? url : undefined + } catch { + return undefined + } +} + /** Build the component table; while `streaming`, fences render the plain arm (see CodeBlock). */ function buildComponents(streaming: boolean): Components { return { @@ -40,7 +49,20 @@ function buildComponents(streaming: boolean): Components { ) }, - img: ({ alt = '' }) => {alt}, + img: ({ alt = '', src = '' }) => { + const imageSrc = remoteImageUrl(src) + if (imageSrc === undefined) return {alt} + return ( + {alt} + ) + }, table: ({ children }) => (
{children}
@@ -74,7 +96,8 @@ const streamingComponents = buildComponents(true) * Render untrusted assistant-authored Markdown as semantic React elements. * @param props - Markdown source text preserved by the session projection; * `streaming` renders fences plain (highlighting lands on the finalize swap). - * @returns A GFM document with raw HTML, relative links, unsafe protocols, and remote images disabled. + * @returns A GFM document with raw HTML, relative destinations, and unsafe + * protocols disabled; absolute HTTP(S) images render directly. */ export function MarkdownText({ text, streaming = false }: { text: string; streaming?: boolean }) { return ( diff --git a/packages/client/ui-primitives/tests/markdown.spec.tsx b/packages/client/ui-primitives/tests/markdown.spec.tsx index b7f665c78a..40295e16cb 100644 --- a/packages/client/ui-primitives/tests/markdown.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown.spec.tsx @@ -94,13 +94,35 @@ describe('MarkdownText', () => { expect(done.container.querySelector('pre.shiki')).not.toBeNull() }) - it('neutralizes raw HTML, unsafe or relative links, and remote images', () => { + it('renders absolute HTTP(S) images with bounded presentation', () => { + const markdown = [ + '![secure diagram](https://example.com/secure.png)', + '![plain diagram](http://example.com/plain.png)', + ].join('\n\n') + const { container } = render() + const images = [...container.querySelectorAll('img')] + expect(images.map(image => image.getAttribute('src'))).toEqual([ + 'https://example.com/secure.png', + 'http://example.com/plain.png', + ]) + for (const image of images) { + expect(image.getAttribute('loading')).toBe('lazy') + expect(image.getAttribute('decoding')).toBe('async') + expect(image.getAttribute('referrerpolicy')).toBe('no-referrer') + } + }) + + it('neutralizes raw HTML, unsafe or relative links, and unsupported images', () => { const markdown = [ '', '', '[script](javascript:alert(1)) [relative](/settings)', '[mail](mailto:dev@example.com) [web](http://example.com) [upper](HTTPS://example.com)', - '![remote diagram](https://example.com/private.png)', + '![relative diagram](private.png)', + '![absolute diagram](/workspace/private.png)', + '![file diagram](file:///workspace/private.png)', + '![script diagram](javascript:alert(1))', + '![mail diagram](mailto:dev@example.com)', ].join('\n\n') const { container } = render() @@ -112,7 +134,11 @@ describe('MarkdownText', () => { expect(screen.getByRole('link', { name: 'mail' }).getAttribute('target')).toBeNull() expect(screen.getByRole('link', { name: 'web' }).getAttribute('rel')).toBe('noopener noreferrer') expect(screen.getByRole('link', { name: 'upper' }).getAttribute('target')).toBe('_blank') - expect(screen.getByText('remote diagram')).toBeTruthy() + expect(screen.getByText('relative diagram')).toBeTruthy() + expect(screen.getByText('absolute diagram')).toBeTruthy() + expect(screen.getByText('file diagram')).toBeTruthy() + expect(screen.getByText('script diagram')).toBeTruthy() + expect(screen.getByText('mail diagram')).toBeTruthy() }) it('keeps incomplete streaming Markdown renderable', () => { diff --git a/tsconfig.host.json b/tsconfig.host.json index b32c410961..795f5ae8d5 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -24,6 +24,7 @@ "apps/web/tests/code-mode-round.e2e.ts", "apps/web/tests/cordis-tool-round.e2e.ts", "apps/web/tests/message-actions.e2e.ts", + "apps/web/tests/markdown-images.e2e.ts", "apps/web/tests/queue-actions.e2e.ts", "apps/web/tests/skill-invocation-policy.e2e.ts", "apps/cli/tests/**/*.ts", From e98cd522eef808f62f67dd21f656c523b654af69 Mon Sep 17 00:00:00 2001 From: kingwl Date: Fri, 31 Jul 2026 11:41:16 +0800 Subject: [PATCH 013/129] fix TUI diff context line accounting --- ...tui-diff-context-line-accounting.i18n.yaml | 6 ++ ...-07-31-tui-diff-context-line-accounting.md | 29 ++++++++++ ...-31-tui-diff-context-line-accounting.zh.md | 29 ++++++++++ packages/ui/tui/README.i18n.yaml | 4 +- packages/ui/tui/README.md | 2 +- packages/ui/tui/README.zh.md | 2 +- packages/ui/tui/package.json | 1 + packages/ui/tui/src/components/transcript.ts | 49 +++++++++++++--- .../advanced-cards-collapsed.expected.txt | 4 +- .../advanced-cards-expanded.expected.txt | 56 +++++++++---------- packages/ui/tui/tests/tui.spec.ts | 17 ++++-- pnpm-lock.yaml | 3 + 12 files changed, 153 insertions(+), 49 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.md create mode 100644 .agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.i18n.yaml new file mode 100644 index 0000000000..2d43863567 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.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/bug-fix/2026-07-31-tui-diff-context-line-accounting.md +2026-07-31-tui-diff-context-line-accounting.md: 71593022b56d9e675025f3a7a6d1e5e3edfa9b57 +2026-07-31-tui-diff-context-line-accounting.zh.md: df374c23b73fc5667cf733b4916d9d0d2ecb9198 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.md b/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.md new file mode 100644 index 0000000000..71593022b5 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.md @@ -0,0 +1,29 @@ +# Agent Note: TUI diff context lines stay neutral + +Status: implemented + +English | [中文](2026-07-31-tui-diff-context-line-accounting.zh.md) + +## Problem + +Result-time filesystem diffs carry the applied change with three surrounding context lines in each `FileDiff.oldText` and `FileDiff.newText`. The TUI rendered every old-side row as removed and every new-side row as added, including the identical context present on both sides. A one-line edit therefore appeared as seven removals plus seven additions, and the footer repeated those inflated totals. + +## Decision + +The TUI compares each non-create `FileDiff.oldText` and `FileDiff.newText` at render time. Added and removed rows retain their green `+` and red `-` markers; equal context rows use the recessed body tone with a neutral two-space prefix. The footer sums only the rows classified as added or removed. A create (`oldText: null`) continues to classify every non-empty new-content row as added. + +This remains a consumer-side interpretation of the existing `FileDiff` contract. Filesystem tools continue to persist contextual before/after snippets, so other consumers keep their placement context and existing session logs replay with corrected TUI presentation. The TUI uses the same maintained `diff` package as `dsh-tool-fs` instead of introducing a second line-diff implementation. + +## Alternatives considered + +**Remove context from filesystem result metadata.** Rejected: contextual applied hunks are intentional producer output used by capable editors, and changing them would weaken every consumer while leaving old session logs misleading in the TUI. + +**Extend `FileDiff` with persisted per-line tags.** Rejected: the tags can be derived deterministically from the existing before/after pair; persisting them would widen the cross-package and session-log contract solely for one renderer. + +**Match equal lines by position without a diff algorithm.** Rejected: insertions and deletions shift subsequent context, so positional pairing would misclassify valid hunks. + +## Consequences + +TUI diff cards distinguish evidence-bearing context from the mutation itself, and their `+A -R` footer reports the actual line delta. Replaying an existing contextual diff gains the corrected rendering without a migration. Rendering performs one additional line comparison per non-create hunk; result-time hunks are already context-bounded, while create cards bypass the comparison. + +The focused TUI test covers neutral context and exact totals. The assembled `advanced-cards` terminal snapshots pin the neutral context style, semantic change colors, and `+1 -1` footer through collapsed and expanded card states. diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.zh.md b/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.zh.md new file mode 100644 index 0000000000..df374c23b7 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.zh.md @@ -0,0 +1,29 @@ +# Agent Note: TUI diff 上下文行保持中性 + +Status: implemented + +[English](2026-07-31-tui-diff-context-line-accounting.md) | 中文 + +## 问题 + +文件系统 diff 返回结果时,每个 `FileDiff.oldText` 和 `FileDiff.newText` 都会包含已应用的变更及其前后各 3 行上下文。TUI 将旧侧的每一行都渲染为删除行,将新侧的每一行都渲染为新增行,其中包括两侧相同的上下文。因此,一行编辑会显示为删除 7 行并新增 7 行,页脚还会重复这些虚高的合计值。 + +## 决策 + +对于每个不对应文件创建的 `FileDiff`,TUI 在渲染时比较 `FileDiff.oldText` 和 `FileDiff.newText`。新增行和删除行仍分别使用绿色 `+` 和红色 `-` 标记;相同的上下文行则使用弱化的正文色调,并带有由两个空格构成的中性前缀。页脚只汇总归类为新增或删除的行。创建操作(`oldText: null`)仍将新内容中的每个非空行归类为新增行。 + +该行为仍然只是消费方对现有 `FileDiff` 契约的解释。文件系统工具仍会持久化带上下文的变更前后片段,因此其他消费方仍能获得定位上下文,已有会话日志在回放时也会采用修正后的 TUI 呈现。TUI 与 `dsh-tool-fs` 共用同一个受维护的 `diff` 包(package),无需引入第二套逐行 diff 实现。 + +## 考虑过的替代方案 + +**从文件系统结果元数据中移除上下文。** 不予采纳:带上下文的已应用 hunk 是有意保留的生产方输出,供具备相应能力的编辑器使用;更改这些内容会让所有消费方丢失信息,同时旧会话日志在 TUI 中仍会产生误导。 + +**为 `FileDiff` 扩展持久化的逐行标签。** 不予采纳:这些标签可以根据现有的变更前后文本对确定性派生;仅为一个渲染器持久化标签,会扩大跨包契约和会话日志契约。 + +**不使用 diff 算法,按位置匹配相同行。** 不予采纳:插入和删除会使后续上下文发生位移,因此按位置配对会把有效 hunk 错误分类。 + +## 后果 + +TUI diff 卡片会区分用于佐证的上下文与变更本身,其 `+A -R` 页脚报告实际的行变更量。回放已有的上下文 diff 无需迁移即可获得修正后的渲染。渲染每个不对应文件创建的 hunk 时,会额外执行一次逐行比较;结果时刻的 hunk 本就受上下文范围限制,创建卡片则会跳过比较。 + +聚焦的 TUI 测试覆盖中性上下文和精确合计值。组装后的 `advanced-cards` 终端快照在卡片折叠和展开状态下固定了中性上下文样式、变更行的语义色彩,以及 `+1 -1` 页脚。 diff --git a/packages/ui/tui/README.i18n.yaml b/packages/ui/tui/README.i18n.yaml index e94be1a857..92bfb18e29 100644 --- a/packages/ui/tui/README.i18n.yaml +++ b/packages/ui/tui/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/ui/tui/README.md -README.md: 63c888b1d51c02fa85a8f0cc1617874debd87c4e -README.zh.md: ca5efc9ae26a9833d271991f73a21c607d8fb09d +README.md: b021789d660fd831c3fa0dad20d0bc174538eb57 +README.zh.md: b9cd7210932558a3a2feb0d5c1bfaf7e115703f6 diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index 63c888b1d5..b021789d66 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -83,7 +83,7 @@ Every SGR code the TUI emits lives in one table, `paletteSpec` in `components/th There is one role per visual meaning: `dim` is the single recessed tone and `accent` the single emphasis color, while `success` and `error` double as a diff's added and removed lines. Colors and attributes are separately typed, so `bold(accent(x))` compiles and `accent(error(x))` does not — SGR has no color stack, so nesting one color inside another silently drops the outer color at the inner one's close. Attributes occupy independent SGR groups and compose with any color in either order. Run `/palette` to see every role as your terminal renders it, with its SGR pair. -Grouped regions (user prompts, assistant replies, tool cards) are separated by a bold, underlined role header in the role color and blank-line spacing rather than a filled block or a per-line prefix, so a mouse drag-select copies the message text without any leading bar or indent; a tool card's status (pending, error, success) shows in its colored, underlined title glyph and title. Inside a tool card, the whole body — presenter title, a terminal `$` command and cwd, and the tool's own output — renders in one dim tone, so only the status-colored header carries color and the body reads as one recessed block instead of a run of competing shades; an injected-context card's prose is the same tone as its header. A diff card's `+`/`-` lines and a `[signal …]` marker stay colored, because there the color is the meaning rather than emphasis. The question panel emphasizes its active row with bold accent text, while selectors use reverse video. These treatments are foreground-only, so they never collide with the terminal background. Set `color: false` to strip all styling. +Grouped regions (user prompts, assistant replies, tool cards) are separated by a bold, underlined role header in the role color and blank-line spacing rather than a filled block or a per-line prefix, so a mouse drag-select copies the message text without any leading bar or indent; a tool card's status (pending, error, success) shows in its colored, underlined title glyph and title. Inside a tool card, the whole body — presenter title, a terminal `$` command and cwd, and the tool's own output — renders in one dim tone, so only the status-colored header carries color and the body reads as one recessed block instead of a run of competing shades; an injected-context card's prose is the same tone as its header. A diff card colors and counts only added `+` and removed `-` lines; unchanged context stays dim and uncounted. A `[signal …]` marker remains colored because there the color is the meaning rather than emphasis. The question panel emphasizes its active row with bold accent text, while selectors use reverse video. These treatments are foreground-only, so they never collide with the terminal background. Set `color: false` to strip all styling. ## Model Experience diff --git a/packages/ui/tui/README.zh.md b/packages/ui/tui/README.zh.md index ca5efc9ae2..b9cd721093 100644 --- a/packages/ui/tui/README.zh.md +++ b/packages/ui/tui/README.zh.md @@ -83,7 +83,7 @@ TUI 发出的所有 SGR 代码都集中在一个表中,即 `components/theme.t 每种视觉语义只对应一个角色:`dim` 是唯一的弱化色调,`accent` 是唯一的强调色,`success` 和 `error` 还分别充当 diff 的新增行与删除行。颜色和属性分属不同类型,因此 `bold(accent(x))` 可以通过编译,`accent(error(x))` 则不行——SGR 没有颜色栈;在一种颜色内嵌套另一种颜色时,内层颜色闭合时会静默丢弃外层颜色。各属性占用彼此独立的 SGR 组,可以按任一顺序与任何颜色组合。运行 `/palette` 可查看每个角色在你的终端上的实际渲染效果及其 SGR 码对。 -成组区域(用户提示词、assistant 回复、工具卡片)通过以角色色渲染的粗体带下划线角色标题和空行分隔,而非填充背景块或逐行前缀,因此用鼠标框选复制时不会带上任何左侧竖条或缩进;工具卡片的状态(进行中、错误、成功)由其彩色带下划线的标题字形与标题体现。在工具卡片内部,整个正文——presenter 标题、终端 `$` 命令与 cwd,以及工具自身的输出——统一以同一种暗色渲染,因此只有带状态色的表头携带颜色,正文读作一个整体弱化的区块,而不是一串互相竞争的色调;注入上下文卡片的正文与其表头也是同一种色调。diff 卡片的 `+`/`-` 行与 `[signal …]` 标记保留颜色,因为那里的颜色本身就是语义,而非强调。问题面板使用粗体强调色文本突出活跃行,选择器则使用反色。所有效果都只作用于前景色,因此不会与终端背景冲突。设置 `color: false` 可移除所有样式。 +成组区域(用户提示词、assistant 回复、工具卡片)通过以角色色渲染的粗体带下划线角色标题和空行分隔,而非填充背景块或逐行前缀,因此用鼠标框选复制时不会带上任何左侧竖条或缩进;工具卡片的状态(进行中、错误、成功)由其彩色带下划线的标题字形与标题体现。在工具卡片内部,整个正文——presenter 标题、终端 `$` 命令与 cwd,以及工具自身的输出——统一以同一种暗色渲染,因此只有带状态色的表头携带颜色,正文读作一个整体弱化的区块,而不是一串互相竞争的色调;注入上下文卡片的正文与其表头也是同一种色调。diff 卡片只为新增的 `+` 行和删除的 `-` 行着色并计数;未变更的上下文保持暗色且不纳入计数。`[signal …]` 标记仍保留颜色,因为那里的颜色本身就是语义,而非强调。问题面板使用粗体强调色文本突出活跃行,选择器则使用反色。所有效果都只作用于前景色,因此不会与终端背景冲突。设置 `color: false` 可移除所有样式。 ## 模型体验 diff --git a/packages/ui/tui/package.json b/packages/ui/tui/package.json index c3506ea338..a68fe7968a 100644 --- a/packages/ui/tui/package.json +++ b/packages/ui/tui/package.json @@ -66,6 +66,7 @@ }, "dependencies": { "@earendil-works/pi-tui": "0.80.7", + "diff": "^9.0.0", "saxes": "6.0.0", "schemastery": "^3.18.0" }, diff --git a/packages/ui/tui/src/components/transcript.ts b/packages/ui/tui/src/components/transcript.ts index 774e982f81..5c8b9bf749 100644 --- a/packages/ui/tui/src/components/transcript.ts +++ b/packages/ui/tui/src/components/transcript.ts @@ -15,6 +15,7 @@ import { type Component, type MarkdownTheme, } from '@earendil-works/pi-tui' +import { diffLines as compareLines } from 'diff' import type { Agent } from '@deepseek-ai/dsh-agent' import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm' import type { JsonValue, SessionEvent, TodoItem } from '@deepseek-ai/dsh-session' @@ -52,16 +53,45 @@ function pretty(value: unknown): string { return displayText(serialized ?? String(value)) } -/** A file diff as colored `+`/`-` lines, optionally prefixed with its path. */ -function diffLines(diff: FileDiff, palette: Palette): string[] { +interface RenderedDiff { + lines: string[] + added: number + removed: number +} + +/** Split one diff change into display rows without counting its trailing line terminator. */ +function diffValueLines(value: string): string[] { + if (value === '') return [] + const safe = displayText(value) + return (safe.endsWith('\n') ? safe.slice(0, -1) : safe).split('\n') +} + +/** A file diff whose unchanged context stays neutral and does not affect change totals. */ +function renderDiff(diff: FileDiff, palette: Palette): RenderedDiff { // The card header is a fixed `Tool / ` frame that never names a file, so // each hunk always carries its own path header (no redundancy to suppress). const lines = [palette.bold(displayText(diff.path))] - if (diff.oldText !== null) { - for (const line of displayText(diff.oldText).split('\n')) lines.push(palette.error(`- ${line}`)) + let added = 0 + let removed = 0 + if (diff.oldText === null) { + const newLines = diffValueLines(diff.newText) + added = newLines.length + for (const line of newLines) lines.push(palette.success(`+ ${line}`)) + return { lines, added, removed } } - for (const line of displayText(diff.newText).split('\n')) lines.push(palette.success(`+ ${line}`)) - return lines + for (const change of compareLines(diff.oldText, diff.newText)) { + const changedLines = diffValueLines(change.value) + if (change.added) { + added += changedLines.length + for (const line of changedLines) lines.push(palette.success(`+ ${line}`)) + } else if (change.removed) { + removed += changedLines.length + for (const line of changedLines) lines.push(palette.error(`- ${line}`)) + } else { + for (const line of changedLines) lines.push(palette.dim(` ${line}`)) + } + } + return { lines, added, removed } } /** @@ -505,9 +535,10 @@ export class ToolCardComponent implements Component { let added = 0 let removed = 0 const hunks = view.diffs.flatMap((diff, index) => { - if (diff.oldText !== null) removed += displayText(diff.oldText).split('\n').length - added += displayText(diff.newText).split('\n').length - return [...index > 0 ? [''] : [], ...diffLines(diff, this.palette)] + const rendered = renderDiff(diff, this.palette) + added += rendered.added + removed += rendered.removed + return [...index > 0 ? [''] : [], ...rendered.lines] }) const files = view.diffs.length const footer = this.palette.dim(`└ +${added} -${removed} · ${files} file${files === 1 ? '' : 's'}`) diff --git a/packages/ui/tui/tests/snapshots/advanced-cards-collapsed.expected.txt b/packages/ui/tui/tests/snapshots/advanced-cards-collapsed.expected.txt index 2a005383e1..62f69c641f 100644 --- a/packages/ui/tui/tests/snapshots/advanced-cards-collapsed.expected.txt +++ b/packages/ui/tui/tests/snapshots/advanced-cards-collapsed.expected.txt @@ -31,9 +31,9 @@ buffer style 0-10 bold 14| "- old line " style 0-9 fg=red -15| "… +3 lines (Ctrl+O to expand) " +15| "… +2 lines (Ctrl+O to expand) " style 0-28 dim -16| "└ +2 -2 · 1 file " +16| "└ +1 -1 · 1 file " style 0-15 dim 17| 18| "● Tool / subagent" diff --git a/packages/ui/tui/tests/snapshots/advanced-cards-expanded.expected.txt b/packages/ui/tui/tests/snapshots/advanced-cards-expanded.expected.txt index 6f9aa094f3..55479a6f34 100644 --- a/packages/ui/tui/tests/snapshots/advanced-cards-expanded.expected.txt +++ b/packages/ui/tui/tests/snapshots/advanced-cards-expanded.expected.txt @@ -1,7 +1,7 @@ -terminal 100x40 buffer=normal length=43 base=3 viewport=3 +terminal 100x40 buffer=normal length=42 base=2 viewport=2 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=7 viewportRow=39 bufferRow=42 +cursor hidden column=7 viewportRow=39 bufferRow=41 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-magenta bold @@ -37,54 +37,52 @@ buffer style 0-10 bold 17| "- old line " style 0-9 fg=red -18| "- keep " - style 0-5 fg=red -19| "+ new line " +18| "+ new line " style 0-9 fg=green -20| "+ keep " - style 0-5 fg=green -21| "└ +2 -2 · 1 file " +19| " keep " + style 0-5 dim +20| "└ +1 -1 · 1 file " style 0-15 dim -22| -23| "● Tool / subagent" +21| +22| "● Tool / subagent" style 0-16 fg=green -24| "Delegate renderer audit " +23| "Delegate renderer audit " style 0-99 dim -25| "The renderer has explicit lifecycle ownership. " +24| "The renderer has explicit lifecycle ownership. " style 0-99 dim -26| -27| "● Tool / task_output" +25| +26| "● Tool / task_output" style 0-19 fg=green -28| "Read output from background task subagent-7 " +27| "Read output from background task subagent-7 " style 0-99 dim -29| " " -30| "console " +28| " " +29| "console " style 0-6 dim -31| " started background task bash-5 " +30| " started background task bash-5 " style 0-1 dim style 2-31 fg=cyan dim style 32-99 dim -32| " " -33| -34| "● Tool / skill" +31| " " +32| +33| "● Tool / skill" style 0-13 fg=green -35| "Load skill dsh-code-review " +34| "Load skill dsh-code-review " style 0-99 dim -36| "Loaded review instructions. " +35| "Loaded review instructions. " style 0-99 dim -37| "Model wait 0.0s " +36| "Model wait 0.0s " style 0-14 dim -38| -39| "Tool and context cards expanded. " +37| +38| "Tool and context cards expanded. " style 0-31 dim -40| -41| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" +39| +40| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" style 0-17 fg=bright-magenta bold style 18-31 dim style 34-50 dim style 53-57 dim style 60-69 dim -42| " dsh > " +41| " dsh > " style 1-3 fg=bright-magenta bold style 5-6 dim style 7-7 inverse diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 79aa6f4f97..1b28fa65c5 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -4315,7 +4315,11 @@ describe('tool cards and surface replay', () => { presentCall: () => ({ card: 'diff', title: 'Edit src/only.ts', - diffs: [{ path: 'src/only.ts', oldText: 'old', newText: 'new' }], + diffs: [{ + path: 'src/only.ts', + oldText: 'my: my-MM\nne: ne-NP\nnl: nl-NL\nnb: no-NO\npa: pa-Guru-IN\npl: pl-PL\npt_pt: pt-PT', + newText: 'my: my-MM\nne: ne-NP\nnl: nl-NL\nnb: nb-NO\npa: pa-Guru-IN\npl: pl-PL\npt_pt: pt-PT', + }], }), }, generic: { @@ -4622,7 +4626,7 @@ describe('tool cards and surface replay', () => { }) it('names a single-file diff in the body once, under a fixed Tool header', async () => { - const result = await setup({ tools }) + const result = await setup({ tools, config: { maxToolOutputLines: 20 } }) appendUser(result.session, 'edit one file') appendAssistant(result.session, [ { type: 'text', text: 'Editing' }, @@ -4638,9 +4642,12 @@ describe('tool cards and surface replay', () => { expect(output).toContain('Tool / singleDiff') expect(output).not.toContain('Edit src/only.ts') expect(output.split('src/only.ts').length - 1).toBe(1) - expect(output).toContain('- old') - expect(output).toContain('+ new') - expect(output).toContain('· 1 file') + expect(output).toContain(' my: my-MM') + expect(output).not.toContain('- my: my-MM') + expect(output).not.toContain('+ my: my-MM') + expect(output).toContain('- nb: no-NO') + expect(output).toContain('+ nb: nb-NO') + expect(output).toContain('└ +1 -1 · 1 file') await dispose(result) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5ca1ec67f6..300b2731a7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5458,6 +5458,9 @@ importers: '@earendil-works/pi-tui': specifier: 0.80.7 version: 0.80.7(patch_hash=6c30c5386c0159131e1361023cddf31377f5728962524841964373312c1ed946) + diff: + specifier: ^9.0.0 + version: 9.0.0 saxes: specifier: 6.0.0 version: 6.0.0 From 81ff2894ca63c1474d68829a4df98bf8b2c4f488 Mon Sep 17 00:00:00 2001 From: kingwl Date: Fri, 31 Jul 2026 12:58:30 +0800 Subject: [PATCH 014/129] fix(tui): bound diff rendering work --- ...tui-diff-context-line-accounting.i18n.yaml | 4 +- ...-07-31-tui-diff-context-line-accounting.md | 10 +- ...-31-tui-diff-context-line-accounting.zh.md | 10 +- docs/config-catalog.md | 4 +- packages/ui/tui/README.i18n.yaml | 4 +- packages/ui/tui/README.md | 4 +- packages/ui/tui/README.zh.md | 4 +- packages/ui/tui/src/components/transcript.ts | 50 +++++++--- packages/ui/tui/src/config.ts | 7 ++ packages/ui/tui/src/index.ts | 11 ++- .../advanced-cards-collapsed.expected.txt | 24 +++-- .../advanced-cards-expanded.expected.txt | 37 ++++++-- packages/ui/tui/tests/tui.snapshot.ts | 27 +++++- packages/ui/tui/tests/tui.spec.ts | 95 +++++++++++++++++++ 14 files changed, 246 insertions(+), 45 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.i18n.yaml index 2d43863567..6cdec24e63 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.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 .agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.md -2026-07-31-tui-diff-context-line-accounting.md: 71593022b56d9e675025f3a7a6d1e5e3edfa9b57 -2026-07-31-tui-diff-context-line-accounting.zh.md: df374c23b73fc5667cf733b4916d9d0d2ecb9198 +2026-07-31-tui-diff-context-line-accounting.md: d1bc72ea030abd46f809ca3e746e6043f717baeb +2026-07-31-tui-diff-context-line-accounting.zh.md: a2a1dcce1325bca68c92cb4f206bfa35671e9d86 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.md b/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.md index 71593022b5..d1bc72ea03 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.md @@ -10,7 +10,9 @@ Result-time filesystem diffs carry the applied change with three surrounding con ## Decision -The TUI compares each non-create `FileDiff.oldText` and `FileDiff.newText` at render time. Added and removed rows retain their green `+` and red `-` markers; equal context rows use the recessed body tone with a neutral two-space prefix. The footer sums only the rows classified as added or removed. A create (`oldText: null`) continues to classify every non-empty new-content row as added. +The TUI compares each `FileDiff` whose old and new text are both available. Added and removed rows retain their green `+` and red `-` markers; equal context rows use the recessed body tone with a neutral two-space prefix. The footer sums only the rows classified as added or removed. `maxDiffEditLength` bounds the exact comparison by its combined added and removed line count; the default is 1000. Exceeding the bound renders the complete old side as removed and the complete new side as added, marks the footer approximate, and caches that result so redraws do not repeat the comparison. + +When `oldText` is `null`, the renderer cannot distinguish a create from a pending overwrite or an argument fallback whose prior text is unavailable. It therefore shows every non-empty new-side row as added, without claiming those rows were absent from an existing file. Empty new content renders no synthetic added row. This remains a consumer-side interpretation of the existing `FileDiff` contract. Filesystem tools continue to persist contextual before/after snippets, so other consumers keep their placement context and existing session logs replay with corrected TUI presentation. The TUI uses the same maintained `diff` package as `dsh-tool-fs` instead of introducing a second line-diff implementation. @@ -22,8 +24,10 @@ This remains a consumer-side interpretation of the existing `FileDiff` contract. **Match equal lines by position without a diff algorithm.** Rejected: insertions and deletions shift subsequent context, so positional pairing would misclassify valid hunks. +**Run every comparison to completion.** Rejected: pending tool views can contain unrestricted model-authored old and new strings, and an unbounded Myers comparison can block the synchronous terminal renderer. + ## Consequences -TUI diff cards distinguish evidence-bearing context from the mutation itself, and their `+A -R` footer reports the actual line delta. Replaying an existing contextual diff gains the corrected rendering without a migration. Rendering performs one additional line comparison per non-create hunk; result-time hunks are already context-bounded, while create cards bypass the comparison. +TUI diff cards distinguish evidence-bearing context from the mutation itself, and an exact `+A -R` footer reports the actual line delta. Replaying an existing contextual diff gains the corrected rendering without a migration. Result-time filesystem hunks are context-bounded; unrestricted pending views either complete within the configured edit-length budget or degrade to an explicitly approximate linear rendering. -The focused TUI test covers neutral context and exact totals. The assembled `advanced-cards` terminal snapshots pin the neutral context style, semantic change colors, and `+1 -1` footer through collapsed and expanded card states. +The focused TUI tests cover neutral context, exact totals, an empty create, bounded fallback, and cache reuse. The assembled `advanced-cards` terminal snapshots pin the neutral context style, semantic change colors, exact footer, and approximate fallback through collapsed and expanded card states. diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.zh.md b/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.zh.md index df374c23b7..a2a1dcce13 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-tui-diff-context-line-accounting.zh.md @@ -10,7 +10,9 @@ Status: implemented ## 决策 -对于每个不对应文件创建的 `FileDiff`,TUI 在渲染时比较 `FileDiff.oldText` 和 `FileDiff.newText`。新增行和删除行仍分别使用绿色 `+` 和红色 `-` 标记;相同的上下文行则使用弱化的正文色调,并带有由两个空格构成的中性前缀。页脚只汇总归类为新增或删除的行。创建操作(`oldText: null`)仍将新内容中的每个非空行归类为新增行。 +TUI 会比较每个变更前后文本均可用的 `FileDiff`。新增行和删除行仍分别使用绿色 `+` 和红色 `-` 标记;相同的上下文行则使用弱化的正文色调,并带有由两个空格构成的中性前缀。页脚只汇总归类为新增或删除的行。`maxDiffEditLength` 以新增行与删除行的合计数为精确比较设置上限,默认值为 1000。超过上限时,TUI 会把完整旧侧渲染为删除内容、把完整新侧渲染为新增内容,将页脚标记为近似结果,并缓存该结果,避免后续重绘重复比较。 + +当 `oldText` 为 `null` 时,渲染器无法区分文件创建、待处理覆写,以及旧文本不可用的参数回退。因此,它会把新侧的每个非空行显示并计作新增行,但不会声称这些行原先不存在于已有文件中。新内容为空时,不会渲染虚构的新增行。 该行为仍然只是消费方对现有 `FileDiff` 契约的解释。文件系统工具仍会持久化带上下文的变更前后片段,因此其他消费方仍能获得定位上下文,已有会话日志在回放时也会采用修正后的 TUI 呈现。TUI 与 `dsh-tool-fs` 共用同一个受维护的 `diff` 包(package),无需引入第二套逐行 diff 实现。 @@ -22,8 +24,10 @@ Status: implemented **不使用 diff 算法,按位置匹配相同行。** 不予采纳:插入和删除会使后续上下文发生位移,因此按位置配对会把有效 hunk 错误分类。 +**让所有比较都运行至完成。** 不予采纳:待处理工具视图可能包含由模型生成且长度不受限制的新旧字符串,无界的 Myers 比较可能阻塞同步终端渲染器。 + ## 后果 -TUI diff 卡片会区分用于佐证的上下文与变更本身,其 `+A -R` 页脚报告实际的行变更量。回放已有的上下文 diff 无需迁移即可获得修正后的渲染。渲染每个不对应文件创建的 hunk 时,会额外执行一次逐行比较;结果时刻的 hunk 本就受上下文范围限制,创建卡片则会跳过比较。 +TUI diff 卡片会区分用于佐证的上下文与变更本身,精确的 `+A -R` 页脚会报告实际的行变更量。回放已有的上下文 diff 无需迁移即可获得修正后的渲染。结果时刻的文件系统 hunk 受上下文范围限制;不受限制的待处理视图要么在配置的编辑长度预算内完成比较,要么降级为明确标注为近似结果的线性渲染。 -聚焦的 TUI 测试覆盖中性上下文和精确合计值。组装后的 `advanced-cards` 终端快照在卡片折叠和展开状态下固定了中性上下文样式、变更行的语义色彩,以及 `+1 -1` 页脚。 +聚焦的 TUI 测试覆盖中性上下文、精确合计值、空文件创建、有界回退和缓存复用。组装后的 `advanced-cards` 终端快照在卡片折叠和展开状态下固定了中性上下文样式、变更行的语义色彩、精确结果页脚和近似回退。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 14446b91d8..0c76ccb449 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2015,6 +2015,8 @@ export interface TuiConfig { showReasoning?: boolean /** Maximum tool-card body lines retained in its collapsed head/tail preview. */ maxToolOutputLines?: number + /** Maximum added and removed lines explored while deriving an exact line diff. */ + maxDiffEditLength?: number /** Maximum options visible at once in a user-question panel. */ maxQuestionOptions?: number /** Maximum models visible at once in the model selector. */ @@ -2060,7 +2062,7 @@ export interface TuiThemeConfig { } ``` -Source: [`packages/ui/tui/src/config.ts:117`](../packages/ui/tui/src/config.ts) +Source: [`packages/ui/tui/src/config.ts:121`](../packages/ui/tui/src/config.ts) ## `@deepseek-ai/dsh-typert-loader` diff --git a/packages/ui/tui/README.i18n.yaml b/packages/ui/tui/README.i18n.yaml index 92bfb18e29..95332df16e 100644 --- a/packages/ui/tui/README.i18n.yaml +++ b/packages/ui/tui/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/ui/tui/README.md -README.md: b021789d660fd831c3fa0dad20d0bc174538eb57 -README.zh.md: b9cd7210932558a3a2feb0d5c1bfaf7e115703f6 +README.md: 837e072ec63752d5f0f1b93bcff16871d32ad615 +README.zh.md: 15f3ad49f2d5b7cdd438532d7443b066a1eac87d diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index b021789d66..837e072ec6 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -50,6 +50,7 @@ A launcher can seed a fresh session's first turn by providing `INITIAL_SKILL_KEY | `sessionId` | `main` | Exact shared agent/session identity driven by the terminal | | `showReasoning` | `true` | Render reasoning blocks | | `maxToolOutputLines` | `6` | Output lines retained across a collapsed tool card's head/tail preview | +| `maxDiffEditLength` | `1000` | Maximum added and removed lines explored for an exact diff before whole-side fallback | | `maxQuestionOptions` | `8` | Visible options in a question panel | | `maxModelOptions` | `8` | Visible models in the model selector | | `maxResumeOptions` | `8` | Visible sessions in the resume selector | @@ -72,6 +73,7 @@ A launcher can seed a fresh session's first turn by providing `INITIAL_SKILL_KEY sessionId: main-session-123 showReasoning: true maxToolOutputLines: 6 + maxDiffEditLength: 1000 fileSearchExcludedDirectories: ['.git', 'node_modules', 'dist'] ``` @@ -83,7 +85,7 @@ Every SGR code the TUI emits lives in one table, `paletteSpec` in `components/th There is one role per visual meaning: `dim` is the single recessed tone and `accent` the single emphasis color, while `success` and `error` double as a diff's added and removed lines. Colors and attributes are separately typed, so `bold(accent(x))` compiles and `accent(error(x))` does not — SGR has no color stack, so nesting one color inside another silently drops the outer color at the inner one's close. Attributes occupy independent SGR groups and compose with any color in either order. Run `/palette` to see every role as your terminal renders it, with its SGR pair. -Grouped regions (user prompts, assistant replies, tool cards) are separated by a bold, underlined role header in the role color and blank-line spacing rather than a filled block or a per-line prefix, so a mouse drag-select copies the message text without any leading bar or indent; a tool card's status (pending, error, success) shows in its colored, underlined title glyph and title. Inside a tool card, the whole body — presenter title, a terminal `$` command and cwd, and the tool's own output — renders in one dim tone, so only the status-colored header carries color and the body reads as one recessed block instead of a run of competing shades; an injected-context card's prose is the same tone as its header. A diff card colors and counts only added `+` and removed `-` lines; unchanged context stays dim and uncounted. A `[signal …]` marker remains colored because there the color is the meaning rather than emphasis. The question panel emphasizes its active row with bold accent text, while selectors use reverse video. These treatments are foreground-only, so they never collide with the terminal background. Set `color: false` to strip all styling. +Grouped regions (user prompts, assistant replies, tool cards) are separated by a bold, underlined role header in the role color and blank-line spacing rather than a filled block or a per-line prefix, so a mouse drag-select copies the message text without any leading bar or indent; a tool card's status (pending, error, success) shows in its colored, underlined title glyph and title. Inside a tool card, the whole body — presenter title, a terminal `$` command and cwd, and the tool's own output — renders in one dim tone, so only the status-colored header carries color and the body reads as one recessed block instead of a run of competing shades; an injected-context card's prose is the same tone as its header. A diff card with both sides available colors and counts exact added `+` and removed `-` lines, while unchanged context stays dim and uncounted. If exact comparison exceeds `maxDiffEditLength`, the card renders each old-side row as removed and each new-side row as added, marks the footer approximate, and caches that fallback for later redraws. When `oldText` is unavailable, including pending writes and replay fallbacks as well as creates, every non-empty new-side row is shown and counted as added; that count does not prove the rows were absent from an existing file. Empty new content produces no synthetic `+ ` row. A `[signal …]` marker remains colored because there the color is the meaning rather than emphasis. The question panel emphasizes its active row with bold accent text, while selectors use reverse video. These treatments are foreground-only, so they never collide with the terminal background. Set `color: false` to strip all styling. ## Model Experience diff --git a/packages/ui/tui/README.zh.md b/packages/ui/tui/README.zh.md index b9cd721093..15f3ad49f2 100644 --- a/packages/ui/tui/README.zh.md +++ b/packages/ui/tui/README.zh.md @@ -50,6 +50,7 @@ Footer 将会话报告的用量汇总为 `↑`;任 | `sessionId` | `main` | 由终端驱动的精确共享 agent/会话身份 | | `showReasoning` | `true` | 渲染 reasoning 块 | | `maxToolOutputLines` | `6` | 折叠工具卡片的头尾预览所保留的输出行数 | +| `maxDiffEditLength` | `1000` | 回退到整侧展示前,精确 diff 最多探索的新增与删除行总数 | | `maxQuestionOptions` | `8` | 问题面板中可见的选项数 | | `maxModelOptions` | `8` | 模型选择器中可见的模型数 | | `maxResumeOptions` | `8` | 恢复选择器中可见的会话数 | @@ -72,6 +73,7 @@ Footer 将会话报告的用量汇总为 `↑`;任 sessionId: main-session-123 showReasoning: true maxToolOutputLines: 6 + maxDiffEditLength: 1000 fileSearchExcludedDirectories: ['.git', 'node_modules', 'dist'] ``` @@ -83,7 +85,7 @@ TUI 发出的所有 SGR 代码都集中在一个表中,即 `components/theme.t 每种视觉语义只对应一个角色:`dim` 是唯一的弱化色调,`accent` 是唯一的强调色,`success` 和 `error` 还分别充当 diff 的新增行与删除行。颜色和属性分属不同类型,因此 `bold(accent(x))` 可以通过编译,`accent(error(x))` 则不行——SGR 没有颜色栈;在一种颜色内嵌套另一种颜色时,内层颜色闭合时会静默丢弃外层颜色。各属性占用彼此独立的 SGR 组,可以按任一顺序与任何颜色组合。运行 `/palette` 可查看每个角色在你的终端上的实际渲染效果及其 SGR 码对。 -成组区域(用户提示词、assistant 回复、工具卡片)通过以角色色渲染的粗体带下划线角色标题和空行分隔,而非填充背景块或逐行前缀,因此用鼠标框选复制时不会带上任何左侧竖条或缩进;工具卡片的状态(进行中、错误、成功)由其彩色带下划线的标题字形与标题体现。在工具卡片内部,整个正文——presenter 标题、终端 `$` 命令与 cwd,以及工具自身的输出——统一以同一种暗色渲染,因此只有带状态色的表头携带颜色,正文读作一个整体弱化的区块,而不是一串互相竞争的色调;注入上下文卡片的正文与其表头也是同一种色调。diff 卡片只为新增的 `+` 行和删除的 `-` 行着色并计数;未变更的上下文保持暗色且不纳入计数。`[signal …]` 标记仍保留颜色,因为那里的颜色本身就是语义,而非强调。问题面板使用粗体强调色文本突出活跃行,选择器则使用反色。所有效果都只作用于前景色,因此不会与终端背景冲突。设置 `color: false` 可移除所有样式。 +成组区域(用户提示词、assistant 回复、工具卡片)通过以角色色渲染的粗体带下划线角色标题和空行分隔,而非填充背景块或逐行前缀,因此用鼠标框选复制时不会带上任何左侧竖条或缩进;工具卡片的状态(进行中、错误、成功)由其彩色带下划线的标题字形与标题体现。在工具卡片内部,整个正文——presenter 标题、终端 `$` 命令与 cwd,以及工具自身的输出——统一以同一种暗色渲染,因此只有带状态色的表头携带颜色,正文读作一个整体弱化的区块,而不是一串互相竞争的色调;注入上下文卡片的正文与其表头也是同一种色调。当前后两侧文本均可用时,diff 卡片会为精确识别出的新增 `+` 行和删除 `-` 行着色并计数;未变更的上下文保持暗色且不纳入计数。如果精确比较超出 `maxDiffEditLength`,卡片会把旧侧每一行渲染为删除行、把新侧每一行渲染为新增行,将页脚标记为近似结果,并缓存该回退结果供后续重绘使用。当 `oldText` 不可用时(包括待处理写入、回放回退以及文件创建),新侧的每个非空行都会显示并计作新增行;该计数不能证明这些行原先不存在于已有文件中。新内容为空时,不会补出虚构的 `+ ` 行。`[signal …]` 标记仍保留颜色,因为那里的颜色本身就是语义,而非强调。问题面板使用粗体强调色文本突出活跃行,选择器则使用反色。所有效果都只作用于前景色,因此不会与终端背景冲突。设置 `color: false` 可移除所有样式。 ## 模型体验 diff --git a/packages/ui/tui/src/components/transcript.ts b/packages/ui/tui/src/components/transcript.ts index 5c8b9bf749..1bdc73e250 100644 --- a/packages/ui/tui/src/components/transcript.ts +++ b/packages/ui/tui/src/components/transcript.ts @@ -57,6 +57,7 @@ interface RenderedDiff { lines: string[] added: number removed: number + approximate: boolean } /** Split one diff change into display rows without counting its trailing line terminator. */ @@ -66,8 +67,12 @@ function diffValueLines(value: string): string[] { return (safe.endsWith('\n') ? safe.slice(0, -1) : safe).split('\n') } -/** A file diff whose unchanged context stays neutral and does not affect change totals. */ -function renderDiff(diff: FileDiff, palette: Palette): RenderedDiff { +/** + * A file diff whose unchanged context stays neutral and does not affect exact + * change totals. Comparisons beyond the edit-distance budget fall back to + * whole-side rendering so a model-authored pending edit cannot stall the TUI. + */ +function renderDiff(diff: FileDiff, maxDiffEditLength: number, palette: Palette): RenderedDiff { // The card header is a fixed `Tool / ` frame that never names a file, so // each hunk always carries its own path header (no redundancy to suppress). const lines = [palette.bold(displayText(diff.path))] @@ -77,9 +82,20 @@ function renderDiff(diff: FileDiff, palette: Palette): RenderedDiff { const newLines = diffValueLines(diff.newText) added = newLines.length for (const line of newLines) lines.push(palette.success(`+ ${line}`)) - return { lines, added, removed } + return { lines, added, removed, approximate: false } } - for (const change of compareLines(diff.oldText, diff.newText)) { + const changes = compareLines(diff.oldText, diff.newText, { maxEditLength: maxDiffEditLength }) + if (changes === undefined) { + const oldLines = diffValueLines(diff.oldText) + const newLines = diffValueLines(diff.newText) + lines.push(palette.dim(`[exact line diff omitted: >${maxDiffEditLength} changed lines]`)) + removed = oldLines.length + added = newLines.length + for (const line of oldLines) lines.push(palette.error(`- ${line}`)) + for (const line of newLines) lines.push(palette.success(`+ ${line}`)) + return { lines, added, removed, approximate: true } + } + for (const change of changes) { const changedLines = diffValueLines(change.value) if (change.added) { added += changedLines.length @@ -91,7 +107,7 @@ function renderDiff(diff: FileDiff, palette: Palette): RenderedDiff { for (const line of changedLines) lines.push(palette.dim(` ${line}`)) } } - return { lines, added, removed } + return { lines, added, removed, approximate: false } } /** @@ -354,12 +370,14 @@ export class ToolCardComponent implements Component { private visibility: ToolCardVisibility = 'collapsed' private callView: ToolCallView private resultView: ToolResultView | undefined + private diffBodyCache: { view: ToolCallView | ToolResultView; body: CardBody } | undefined constructor( private readonly name: string, private readonly parsed: ParsedArguments, private readonly definition: ToolDefinition | undefined, private readonly maxOutputLines: number, + private readonly maxDiffEditLength: number, private readonly palette: Palette, private readonly mdTheme: MarkdownTheme, ) { @@ -530,21 +548,27 @@ export class ToolCardComponent implements Component { return { prelude: prelude.filter(Boolean), lines: lines.filter(Boolean) } } if (view.card === 'diff') { + if (this.diffBodyCache?.view === view) return this.diffBodyCache.body // The header no longer names the file, so each diff keeps its own path // header. A trailing footer summarizes the change (`+A -R · N file(s)`). - let added = 0 - let removed = 0 - const hunks = view.diffs.flatMap((diff, index) => { - const rendered = renderDiff(diff, this.palette) - added += rendered.added - removed += rendered.removed + const renderedDiffs = view.diffs.map(diff => + renderDiff(diff, this.maxDiffEditLength, this.palette), + ) + const added = renderedDiffs.reduce((total, rendered) => total + rendered.added, 0) + const removed = renderedDiffs.reduce((total, rendered) => total + rendered.removed, 0) + const approximate = renderedDiffs.some(rendered => rendered.approximate) + const hunks = renderedDiffs.flatMap((rendered, index) => { return [...index > 0 ? [''] : [], ...rendered.lines] }) const files = view.diffs.length - const footer = this.palette.dim(`└ +${added} -${removed} · ${files} file${files === 1 ? '' : 's'}`) + const footer = this.palette.dim( + `└ +${added} -${removed} · ${files} file${files === 1 ? '' : 's'}${approximate ? ' · approximate' : ''}`, + ) // A diff's own `+`/`-` colors carry its meaning, so it renders verbatim // rather than under the dim result-output color. - return { prelude: [...hunks, footer], lines: [] } + const body = { prelude: [...hunks, footer], lines: [] } + this.diffBodyCache = { view, body } + return body } // The web card carries no `content` copy, so a `web` result view falls back // to the raw result content here (`view.card === 'generic'` narrows the diff --git a/packages/ui/tui/src/config.ts b/packages/ui/tui/src/config.ts index def548861f..43c8404fee 100644 --- a/packages/ui/tui/src/config.ts +++ b/packages/ui/tui/src/config.ts @@ -34,6 +34,8 @@ export interface TuiConfig { showReasoning?: boolean /** Maximum tool-card body lines retained in its collapsed head/tail preview. */ maxToolOutputLines?: number + /** Maximum added and removed lines explored while deriving an exact line diff. */ + maxDiffEditLength?: number /** Maximum options visible at once in a user-question panel. */ maxQuestionOptions?: number /** Maximum models visible at once in the model selector. */ @@ -64,6 +66,7 @@ export interface TuiConfig { const showReasoningSchema = z.boolean().default(true) const maxToolOutputLinesSchema = z.number().step(1).min(1).default(6) +const maxDiffEditLengthSchema = z.number().step(1).min(1).default(1000) const maxQuestionOptionsSchema = z.number().step(1).min(1).default(8) const maxModelOptionsSchema = z.number().step(1).min(1).default(8) const maxResumeOptionsSchema = z.number().step(1).min(1).default(8) @@ -95,6 +98,7 @@ const titleSchema = z.string().default('DeepSeek Harness') const tuiConfigSchemaFields = { showReasoning: showReasoningSchema, maxToolOutputLines: maxToolOutputLinesSchema, + maxDiffEditLength: maxDiffEditLengthSchema, maxQuestionOptions: maxQuestionOptionsSchema, maxModelOptions: maxModelOptionsSchema, maxResumeOptions: maxResumeOptionsSchema, @@ -135,6 +139,7 @@ export const Config: z = z.object({ initialSkill: z.string(), showReasoning: tuiConfigSchemaFields.showReasoning, maxToolOutputLines: tuiConfigSchemaFields.maxToolOutputLines, + maxDiffEditLength: tuiConfigSchemaFields.maxDiffEditLength, maxQuestionOptions: tuiConfigSchemaFields.maxQuestionOptions, maxModelOptions: tuiConfigSchemaFields.maxModelOptions, maxResumeOptions: tuiConfigSchemaFields.maxResumeOptions, @@ -164,6 +169,7 @@ export interface ResolvedTuiThemeConfig { export interface ResolvedTuiConfig { showReasoning: boolean maxToolOutputLines: number + maxDiffEditLength: number maxQuestionOptions: number maxModelOptions: number maxResumeOptions: number @@ -189,6 +195,7 @@ export function resolveTuiConfig(config: TuiConfig | undefined): ResolvedTuiConf return { showReasoning: config?.showReasoning ?? true, maxToolOutputLines: config?.maxToolOutputLines ?? 6, + maxDiffEditLength: config?.maxDiffEditLength ?? 1000, maxQuestionOptions: config?.maxQuestionOptions ?? 8, maxModelOptions: config?.maxModelOptions ?? 8, maxResumeOptions: config?.maxResumeOptions ?? 8, diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index a1250bb5b3..9745f1f30f 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -605,6 +605,7 @@ export function createTuiChat( parsed, ctx.tools.get(event.data.name, agent), resolved.maxToolOutputLines, + resolved.maxDiffEditLength, palette, mdTheme, ) @@ -748,7 +749,15 @@ export function createTuiChat( const callId = event.data.message.source.callId let card = toolCards.get(callId) if (card === undefined) { - card = new ToolCardComponent('tool', { value: {}, valid: true }, undefined, resolved.maxToolOutputLines, palette, mdTheme) + card = new ToolCardComponent( + 'tool', + { value: {}, valid: true }, + undefined, + resolved.maxToolOutputLines, + resolved.maxDiffEditLength, + palette, + mdTheme, + ) card.setVisibility(toolsVisibility) chat.addChild(card) allToolCards.add(card) diff --git a/packages/ui/tui/tests/snapshots/advanced-cards-collapsed.expected.txt b/packages/ui/tui/tests/snapshots/advanced-cards-collapsed.expected.txt index 62f69c641f..20ec3ab324 100644 --- a/packages/ui/tui/tests/snapshots/advanced-cards-collapsed.expected.txt +++ b/packages/ui/tui/tests/snapshots/advanced-cards-collapsed.expected.txt @@ -1,7 +1,7 @@ -terminal 100x40 buffer=normal length=40 base=0 viewport=0 +terminal 100x40 buffer=normal length=41 base=1 viewport=1 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=7 viewportRow=34 bufferRow=34 +cursor hidden column=7 viewportRow=39 bufferRow=40 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-magenta bold @@ -58,17 +58,27 @@ buffer style 0-99 dim 30| "Loaded review instructions. " style 0-99 dim -31| "Model wait 0.0s " +31| +32| "● Tool / large_edit" + style 0-18 fg=green +33| "src/large.ts " + style 0-11 bold +34| "[exact line diff omitted: >2 changed lines] " + style 0-42 dim +35| "… +6 lines (Ctrl+O to expand) " + style 0-28 dim +36| "└ +3 -3 · 1 file · approximate " + style 0-29 dim +37| "Model wait 0.0s " style 0-14 dim -32| -33| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" +38| +39| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" style 0-17 fg=bright-magenta bold style 18-31 dim style 34-50 dim style 53-57 dim style 60-69 dim -34| " dsh > " +40| " dsh > " style 1-3 fg=bright-magenta bold style 5-6 dim style 7-7 inverse -35-39| diff --git a/packages/ui/tui/tests/snapshots/advanced-cards-expanded.expected.txt b/packages/ui/tui/tests/snapshots/advanced-cards-expanded.expected.txt index 55479a6f34..7752484db7 100644 --- a/packages/ui/tui/tests/snapshots/advanced-cards-expanded.expected.txt +++ b/packages/ui/tui/tests/snapshots/advanced-cards-expanded.expected.txt @@ -1,7 +1,7 @@ -terminal 100x40 buffer=normal length=42 base=2 viewport=2 +terminal 100x40 buffer=normal length=53 base=13 viewport=13 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=7 viewportRow=39 bufferRow=41 +cursor hidden column=7 viewportRow=39 bufferRow=52 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-magenta bold @@ -70,19 +70,40 @@ buffer style 0-99 dim 35| "Loaded review instructions. " style 0-99 dim -36| "Model wait 0.0s " +36| +37| "● Tool / large_edit" + style 0-18 fg=green +38| "src/large.ts " + style 0-11 bold +39| "[exact line diff omitted: >2 changed lines] " + style 0-42 dim +40| "- old one " + style 0-8 fg=red +41| "- old two " + style 0-8 fg=red +42| "- old three " + style 0-10 fg=red +43| "+ new one " + style 0-8 fg=green +44| "+ new two " + style 0-8 fg=green +45| "+ new three " + style 0-10 fg=green +46| "└ +3 -3 · 1 file · approximate " + style 0-29 dim +47| "Model wait 0.0s " style 0-14 dim -37| -38| "Tool and context cards expanded. " +48| +49| "Tool and context cards expanded. " style 0-31 dim -39| -40| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" +50| +51| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" style 0-17 fg=bright-magenta bold style 18-31 dim style 34-50 dim style 53-57 dim style 60-69 dim -41| " dsh > " +52| " dsh > " style 1-3 fg=bright-magenta bold style 5-6 dim style 7-7 inverse diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts index 18f0a9a793..db72a7290a 100644 --- a/packages/ui/tui/tests/tui.snapshot.ts +++ b/packages/ui/tui/tests/tui.snapshot.ts @@ -269,13 +269,32 @@ const ADVANCED_CARD_TOOLS: Record = { edit: visualTool( 'edit', () => ({ card: 'diff', title: 'Edit src/view.ts', diffs: [{ path: 'src/view.ts', oldText: 'old line', newText: 'new line' }] }), - // The real edit/write tools produce exactly one diff whose path the title - // already names, so the card omits the redundant per-file header. + // The fixed tool header never names a path, so the hunk retains its path. (): ToolResultView => ({ card: 'diff', diffs: [{ path: 'src/view.ts', oldText: 'old line\nkeep', newText: 'new line\nkeep' }], }), ), + large_edit: visualTool( + 'large_edit', + () => ({ + card: 'diff', + title: 'Edit src/large.ts', + diffs: [{ + path: 'src/large.ts', + oldText: 'old one\nold two\nold three', + newText: 'new one\nnew two\nnew three', + }], + }), + (): ToolResultView => ({ + card: 'diff', + diffs: [{ + path: 'src/large.ts', + oldText: 'old one\nold two\nold three', + newText: 'new one\nnew two\nnew three', + }], + }), + ), subagent: visualTool('subagent', args => ({ card: 'generic', title: 'Delegate renderer audit', @@ -585,7 +604,7 @@ describe('TUI terminal-state snapshots', () => { it('pins terminal, diff, subagent, task, skill, collapsed, and expanded cards', async () => { const harness = await setupSnapshot({ tools: ADVANCED_CARD_TOOLS, - config: { maxToolOutputLines: 3 }, + config: { maxToolOutputLines: 3, maxDiffEditLength: 2 }, }, { columns: 100, rows: 40 }) const calls = [ { id: 'advanced-1', name: 'bash', arguments: { command: 'pnpm run test:coverage' } }, @@ -593,6 +612,7 @@ describe('TUI terminal-state snapshots', () => { { id: 'advanced-3', name: 'subagent', arguments: { prompt: 'Review renderer ownership and report only gaps.' } }, { id: 'advanced-4', name: 'task_output', arguments: { task_id: 'subagent-7', wait: true } }, { id: 'advanced-5', name: 'skill', arguments: { name: 'dsh-code-review' } }, + { id: 'advanced-6', name: 'large_edit', arguments: { file_path: 'src/large.ts' } }, ] await renderAfter(harness, () => { appendToolCalls(harness.session, calls) @@ -601,6 +621,7 @@ describe('TUI terminal-state snapshots', () => { appendToolResult(harness.session, 'advanced-3', [{ type: 'text', text: 'The renderer has explicit lifecycle ownership.' }]) appendToolResult(harness.session, 'advanced-4', [{ type: 'text', text: 'audit complete\n[status: completed]' }]) appendToolResult(harness.session, 'advanced-5', [{ type: 'text', text: 'Loaded review instructions.' }]) + appendToolResult(harness.session, 'advanced-6', [{ type: 'text', text: 'large edit complete' }]) }) await checkpoint('advanced-cards-collapsed', harness.terminal, { includeScrollback: true }) diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 1b28fa65c5..213169a89e 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -178,6 +178,7 @@ describe('TUI config', () => { expect(resolveTuiConfig(undefined)).toEqual({ showReasoning: true, maxToolOutputLines: 6, + maxDiffEditLength: 1000, maxQuestionOptions: 8, maxModelOptions: 8, maxResumeOptions: 8, @@ -202,6 +203,7 @@ describe('TUI config', () => { expect(resolveTuiConfig({ showReasoning: false, maxToolOutputLines: 2, + maxDiffEditLength: 12, maxQuestionOptions: 3, maxModelOptions: 4, maxResumeOptions: 5, @@ -218,6 +220,7 @@ describe('TUI config', () => { })).toEqual({ showReasoning: false, maxToolOutputLines: 2, + maxDiffEditLength: 12, maxQuestionOptions: 3, maxModelOptions: 4, maxResumeOptions: 5, @@ -4651,6 +4654,98 @@ describe('tool cards and surface replay', () => { await dispose(result) }) + it('renders an empty create without a synthetic added row', async () => { + const emptyCreate: Record = { + emptyCreate: { + name: 'emptyCreate', + description: '', + parameters: {}, + output: UNUSED_TOOL_OUTPUT, + execute: async () => [], + presentCall: () => ({ + card: 'diff', + title: 'Write empty.txt', + diffs: [{ path: 'empty.txt', oldText: null, newText: '' }], + }), + }, + } + const result = await setup({ + tools: emptyCreate, + config: { maxToolOutputLines: 20, theme: { color: false } }, + }) + appendAssistant(result.session, [ + { type: 'tool-call', id: 'empty-create' as never, name: 'emptyCreate', arguments: '{}' }, + ]) + result.session.append('tool/call', { + turn: 1, + step: 1, + callId: 'empty-create' as never, + name: 'emptyCreate', + arguments: '{}', + }) + await tick() + const rows = result.terminal.output.split('\n').map(row => row.trim()) + expect(result.terminal.output).toContain('empty.txt') + expect(result.terminal.output).toContain('└ +0 -0 · 1 file') + expect(rows).not.toContain('+') + await dispose(result) + }) + + it('bounds and caches exact diff comparison before whole-side fallback', async () => { + let oldTextReads = 0 + const boundedDiff = { + path: 'bounded.txt', + get oldText() { + oldTextReads += 1 + return 'old one\nold two' + }, + newText: 'new one\nnew two', + } + const bounded: Record = { + bounded: { + name: 'bounded', + description: '', + parameters: {}, + output: UNUSED_TOOL_OUTPUT, + execute: async () => [], + presentCall: () => ({ + card: 'diff', + title: 'Edit bounded.txt', + diffs: [boundedDiff], + }), + }, + } + const result = await setup({ + tools: bounded, + config: { + maxToolOutputLines: 20, + maxDiffEditLength: 1, + theme: { color: false }, + }, + }) + appendAssistant(result.session, [ + { type: 'tool-call', id: 'bounded-diff' as never, name: 'bounded', arguments: '{}' }, + ]) + result.session.append('tool/call', { + turn: 1, + step: 1, + callId: 'bounded-diff' as never, + name: 'bounded', + arguments: '{}', + }) + await tick() + expect(result.terminal.output).toContain('[exact line diff omitted: >1 changed lines]') + expect(result.terminal.output).toContain('- old one') + expect(result.terminal.output).toContain('+ new one') + expect(result.terminal.output).toContain('└ +2 -2 · 1 file · approximate') + const readsAfterFirstRender = oldTextReads + expect(readsAfterFirstRender).toBeGreaterThan(0) + result.terminal.resize(87) + await tick() + expect(oldTextReads).toBe(readsAfterFirstRender) + await dispose(result) + }) + it('drops blank rows from a terminal card result that the dim styling wraps', async () => { const blankRowTools: Record = { trailing: { From 8d3635315738ac45d91fce35a32ab90e2687c090 Mon Sep 17 00:00:00 2001 From: kingwl Date: Fri, 31 Jul 2026 12:58:46 +0800 Subject: [PATCH 015/129] docs: archive superseded TUI path note --- .../2026-07-27-tui-diff-card-redundant-path-header.i18n.yaml | 4 ++-- .../bug-fix/2026-07-27-tui-diff-card-redundant-path-header.md | 1 + .../2026-07-27-tui-diff-card-redundant-path-header.zh.md | 1 + .agents/notes/archived/manifest.json | 3 +++ 4 files changed, 7 insertions(+), 2 deletions(-) rename .agents/notes/{implemented => archived}/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.i18n.yaml (66%) rename .agents/notes/{implemented => archived}/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.md (99%) rename .agents/notes/{implemented => archived}/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.zh.md (99%) diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.i18n.yaml b/.agents/notes/archived/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.i18n.yaml similarity index 66% rename from .agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.i18n.yaml rename to .agents/notes/archived/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.i18n.yaml index a8472075e4..635e3fca62 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.i18n.yaml +++ b/.agents/notes/archived/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.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 .agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.md -2026-07-27-tui-diff-card-redundant-path-header.md: 708e543ff079828b4929d2a50ac697a9c846608a -2026-07-27-tui-diff-card-redundant-path-header.zh.md: 863868ae707f37689bbc202267c5470d8c3163e9 +2026-07-27-tui-diff-card-redundant-path-header.md: 608a11892a20d020087180175eff847021dc0554 +2026-07-27-tui-diff-card-redundant-path-header.zh.md: bf7f1c1eeb994f9940b5f7dfb7db72d422293bd4 diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.md b/.agents/notes/archived/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.md similarity index 99% rename from .agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.md rename to .agents/notes/archived/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.md index 708e543ff0..608a11892a 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.md +++ b/.agents/notes/archived/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.md @@ -1,6 +1,7 @@ # Agent Note: TUI diff card dropped the duplicated file path Status: implemented +Archived: 2026-07-31 English | [中文](2026-07-27-tui-diff-card-redundant-path-header.zh.md) diff --git a/.agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.zh.md b/.agents/notes/archived/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.zh.md similarity index 99% rename from .agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.zh.md rename to .agents/notes/archived/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.zh.md index 863868ae70..bf7f1c1eeb 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.zh.md +++ b/.agents/notes/archived/bug-fix/2026-07-27-tui-diff-card-redundant-path-header.zh.md @@ -1,6 +1,7 @@ # Agent Note: TUI diff 卡片重复打印文件路径 Status: implemented +Archived: 2026-07-31 [English](2026-07-27-tui-diff-card-redundant-path-header.md) | 中文 diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index e46d7c34cd..1ed77225ae 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -46,6 +46,9 @@ "bug-fix/2026-07-26-intent-draft-same-tick-echo.i18n.yaml": "sha256:c623947c4fa00e6d4b51792c7972ba09582bbcb7605beb373725c0dd666f2c81", "bug-fix/2026-07-26-intent-draft-same-tick-echo.md": "sha256:fa8b1417b2cdd3deecbf8e55bdddd73dd3a8c6e3486fd399b0b8bdf317e56373", "bug-fix/2026-07-26-intent-draft-same-tick-echo.zh.md": "sha256:00ce72552dbaa11562fbc541343a5d33f9449edabbe6dd354eb879a7d4d530f8", + "bug-fix/2026-07-27-tui-diff-card-redundant-path-header.i18n.yaml": "sha256:8613a1cfcf4b9c7fafa78a8d8565e2a65ef0335b7b826af9b2bb32097836af55", + "bug-fix/2026-07-27-tui-diff-card-redundant-path-header.md": "sha256:1bd344aec5454d2a2d6e1e6a32eff035c4a99c3df409f2624b39fd32e23ee402", + "bug-fix/2026-07-27-tui-diff-card-redundant-path-header.zh.md": "sha256:0a1747006efb1a4b67feceb9b627a437a0f023158e90ae86e1fe8aef76485384", "feature/2026-06-14-acp-agent-client-protocol.i18n.yaml": "sha256:006795baa43ae962a8d125cc0f1e9f134bc2ee9fb758b6e7669e3fa0126e1918", "feature/2026-06-14-acp-agent-client-protocol.md": "sha256:6828c0af74bb3fb96206ca6b21c0e56a000b50e4744aad4bc2c05092f3a5a31b", "feature/2026-06-14-acp-agent-client-protocol.zh.md": "sha256:ba104e841a1fb84edbd3b6c8119d50445b7785255a7a8d13bb9ac8a2cb4d2e69", From 0c9e529f74ef6bffde7e03acf33a28f8abbd6d1d Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 22:38:21 +0800 Subject: [PATCH 016/129] feat(tui): hidden mode folds a turn's assistant steps into one message The Ctrl+O hidden phase keeps one Assistant header per turn: the first step with visible text/reasoning owns it, later steps render as headerless continuations, and bodiless (tool-only) steps render nothing. Leaving hidden restores per-step headers. Pure TUI presentation; the session log is unchanged. --- ...28-consolidated-tui-presentation.i18n.yaml | 4 +- ...026-07-28-consolidated-tui-presentation.md | 2 +- ...-07-28-consolidated-tui-presentation.zh.md | 2 +- ...9-tui-hidden-mode-assistant-fold.i18n.yaml | 6 + ...26-07-29-tui-hidden-mode-assistant-fold.md | 25 ++++ ...07-29-tui-hidden-mode-assistant-fold.zh.md | 25 ++++ packages/ui/tui/README.i18n.yaml | 4 +- packages/ui/tui/README.md | 2 +- packages/ui/tui/README.zh.md | 2 +- packages/ui/tui/src/components/transcript.ts | 67 +++++++-- packages/ui/tui/src/index.ts | 60 +++++++- .../tool-cards-hidden-folded.expected.txt | 46 +++++++ packages/ui/tui/tests/tui.snapshot.ts | 26 ++++ packages/ui/tui/tests/tui.spec.ts | 128 ++++++++++++++++++ 14 files changed, 376 insertions(+), 23 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-29-tui-hidden-mode-assistant-fold.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-29-tui-hidden-mode-assistant-fold.md create mode 100644 .agents/notes/implemented/feature/2026-07-29-tui-hidden-mode-assistant-fold.zh.md create mode 100644 packages/ui/tui/tests/snapshots/tool-cards-hidden-folded.expected.txt diff --git a/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.i18n.yaml index 629476cf8e..43675cbd56 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.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 .agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.md -2026-07-28-consolidated-tui-presentation.md: f87d543a698d6e77abf9120c6579100df4b60b64 -2026-07-28-consolidated-tui-presentation.zh.md: 005e408f0e75207027315546942f9eab57d595d1 +2026-07-28-consolidated-tui-presentation.md: 8200c8e96cdea7cf5ae54c1328623bb03f841d00 +2026-07-28-consolidated-tui-presentation.zh.md: 852bd413b25f93f3b9096b78d69b21ab420df37b diff --git a/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.md b/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.md index f87d543a69..8200c8e96c 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.md +++ b/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.md @@ -26,7 +26,7 @@ A tool card has one colored `Tool / ` status header over one dim body. Pre Injected context renders as prose in `ContextCardComponent`, not through the XML tree renderer. Exact matched outer `` lines are stripped, but mismatched, unpaired, or inline tag-like text remains verbatim. Model-facing content is unchanged. Folding uses the shared `preview` helper after body assembly, so it depends only on row count, never parser success or payload characters. -`Ctrl+O` cycles collapsed, expanded, and hidden. Tool cards disappear in the hidden state together with their card-owned leading gap. Context cards participate in collapsed and expanded states but fall back to collapsed while tools are hidden, because injected instructions are not disposable tool traffic. +`Ctrl+O` cycles collapsed, expanded, and hidden. Tool cards disappear in the hidden state together with their card-owned leading gap. Context cards participate in collapsed and expanded states but fall back to collapsed while tools are hidden, because injected instructions are not disposable tool traffic. The hidden phase additionally folds each turn's assistant steps into one message; the [hidden-mode assistant fold Agent Note](../feature/2026-07-29-tui-hidden-mode-assistant-fold.md) owns that rule. ### Cross-workspace resume diff --git a/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.zh.md b/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.zh.md index 005e408f0e..852bd413b2 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-consolidated-tui-presentation.zh.md @@ -26,7 +26,7 @@ Status: implemented 注入上下文由 `ContextCardComponent` 按普通文本呈现,不经过 XML 树渲染器。仅移除精确配对的外层 `` 行;不匹配、单边或正文内类似标签的文本都原样保留。面向模型的内容不变。折叠在正文组装完成后使用共享 `preview` 辅助函数,因此只取决于行数,不依赖解析是否成功或载荷包含哪些字符。 -`Ctrl+O` 在折叠、展开和隐藏之间循环。隐藏状态会连同卡片自有的前导间距一起移除工具卡片。上下文卡片参与折叠和展开状态,但工具隐藏时回到折叠状态,因为注入指令不是可丢弃的工具流量。 +`Ctrl+O` 在折叠、展开和隐藏之间循环。隐藏状态会连同卡片自有的前导间距一起移除工具卡片。上下文卡片参与折叠和展开状态,但工具隐藏时回到折叠状态,因为注入指令不是可丢弃的工具流量。隐藏阶段还会把每个轮次的 assistant 步骤折叠为一条消息;该规则由[隐藏模式 assistant 折叠 Agent Note](../feature/2026-07-29-tui-hidden-mode-assistant-fold.md)负责。 ### 跨工作区恢复 diff --git a/.agents/notes/implemented/feature/2026-07-29-tui-hidden-mode-assistant-fold.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-tui-hidden-mode-assistant-fold.i18n.yaml new file mode 100644 index 0000000000..338f90664d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-29-tui-hidden-mode-assistant-fold.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-07-29-tui-hidden-mode-assistant-fold.md +2026-07-29-tui-hidden-mode-assistant-fold.md: e2bae1d4669fd0a4c8f9278c58f641704b425109 +2026-07-29-tui-hidden-mode-assistant-fold.zh.md: 583d6099f9cd5edfc592a9d510d260d7a4172013 diff --git a/.agents/notes/implemented/feature/2026-07-29-tui-hidden-mode-assistant-fold.md b/.agents/notes/implemented/feature/2026-07-29-tui-hidden-mode-assistant-fold.md new file mode 100644 index 0000000000..e2bae1d466 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-29-tui-hidden-mode-assistant-fold.md @@ -0,0 +1,25 @@ +# Agent Note: TUI hidden mode folds a turn's assistant steps into one message + +Status: implemented + +English | [中文](2026-07-29-tui-hidden-mode-assistant-fold.zh.md) + +## Problem + +Ctrl+O's hidden phase ([consolidated TUI presentation](../architecture/2026-07-28-consolidated-tui-presentation.md)) drops tool cards so the transcript reads as a conversation, but each model step still rendered its own `Assistant` header. A multi-step turn (text → tools → text) therefore showed several consecutive `Assistant` blocks with nothing between them — the removed tool cards were the only thing that had justified the repeated headers. Codex-style conversation-only reading wants one assistant message per turn. + +## Decision + +Hidden mode is also a fold rule, applied purely as TUI presentation: per turn, the first step whose rendered content is visible (text, or reasoning while reasoning display is on) owns the turn's single `Assistant` header; every other step renders as a headerless continuation, and a step with no visible body renders nothing at all — a tool-only step neither consumes the header nor leaves a blank segment. Collapsed and expanded phases keep per-step headers; leaving hidden restores them. + +Mechanics: `StreamingAssistantComponent` carries its `StepPosition` and a `setFoldedContinuation` presentation flag; `createTuiChat` keeps a per-turn list of step components and re-derives the fold on Ctrl+O, on each streamed text/reasoning chunk, on message settle, and on retraction of a failed stream (which may hand the header to the next step). Transcript rebuild clears the map and replays the log, so resume, compaction replacement, resize, and theme swaps converge on the same fold. Step timing footers keep their per-step ownership and are unaffected. + +## Alternatives considered + +- **Merge steps into one component** — collides with per-step streaming lifecycle, retry retraction, and timing footers; the flag on existing components changes only the header/spacer. +- **Fold in the session log or `deriveMessages`** — mutates durable/model-visible history for a UI reading mode; the log stays step-shaped. +- **Always fold (all visibility phases)** — collapsed/expanded interleave tool cards between steps, where per-step headers delimit which output belongs to which step. + +## Consequences + +Hidden mode now reads as one assistant message per turn; turns stay separated by their headers. The fold is recomputed state, never stored, so no session or persistence format changes. Coverage: TUI unit specs for the Ctrl+O cycle header counts, tool-only first step header handoff, per-turn separation, and live streaming + rebuild convergence; keyless snapshot `tool-cards-hidden-folded` pins the folded frame. diff --git a/.agents/notes/implemented/feature/2026-07-29-tui-hidden-mode-assistant-fold.zh.md b/.agents/notes/implemented/feature/2026-07-29-tui-hidden-mode-assistant-fold.zh.md new file mode 100644 index 0000000000..583d6099f9 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-29-tui-hidden-mode-assistant-fold.zh.md @@ -0,0 +1,25 @@ +# Agent Note: TUI 隐藏模式把一个轮次的 assistant 步骤折叠为一条消息 + +Status: implemented + +[English](2026-07-29-tui-hidden-mode-assistant-fold.md) | 中文 + +## 问题 + +Ctrl+O 的隐藏阶段([整合的 TUI 展示](../architecture/2026-07-28-consolidated-tui-presentation.md))去掉工具卡片,让 transcript(文本记录)读作一段对话,但每个模型步骤仍渲染自己的 `Assistant` 标题。因此一个多步骤轮次(文本 → 工具 → 文本)会显示多个连续、之间空无一物的 `Assistant` 区块——被移除的工具卡片正是重复标题曾经的唯一理由。Codex 风格的纯对话阅读需要每轮次一条 assistant 消息。 + +## 决定 + +隐藏模式同时也是一条折叠规则,且纯粹作为 TUI 展示实现:在每个轮次内,第一个渲染内容可见(有文本,或在 reasoning 显示开启时有 reasoning)的步骤拥有该轮次唯一的 `Assistant` 标题;其余步骤渲染为无标题的续段,没有可见正文的步骤则完全不渲染——仅有工具调用的步骤既不占用标题,也不留下空白段。折叠与展开阶段保留每步各自的标题;离开隐藏阶段会恢复它们。 + +机制:`StreamingAssistantComponent` 携带自己的 `StepPosition` 和一个 `setFoldedContinuation` 展示标志;`createTuiChat` 维护每轮次的步骤组件列表,并在 Ctrl+O、每个流式 text/reasoning chunk、消息结算,以及失败流被撤回(可能把标题移交给下一个步骤)时重新推导折叠。transcript 重建会清空该映射并重放日志,因此恢复、压缩替换、调整尺寸和主题切换收敛到同一折叠结果。步骤计时页脚保持按步骤归属,不受影响。 + +## 考虑过的替代方案 + +- **把多个步骤合并为一个组件**——与按步骤的流式生命周期、重试撤回和计时页脚冲突;在现有组件上加标志只改变标题与前导间距。 +- **在会话日志或 `deriveMessages` 中折叠**——为一种 UI 阅读模式改变持久 / 模型可见的历史;日志保持按步骤的形状。 +- **所有可见性阶段都折叠**——折叠 / 展开阶段在步骤之间穿插工具卡片,此时每步的标题用来划分哪段输出属于哪个步骤。 + +## 后果 + +隐藏模式现在每轮次读作一条 assistant 消息;轮次之间仍由各自的标题分隔。折叠是重新计算的状态,从不存储,因此会话与持久化格式没有变化。覆盖:TUI 单元测试覆盖 Ctrl+O 循环的标题计数、仅工具的首步骤标题移交、按轮次分隔,以及实时流式 + 重建收敛;无密钥快照 `tool-cards-hidden-folded` 固定折叠后的帧。 diff --git a/packages/ui/tui/README.i18n.yaml b/packages/ui/tui/README.i18n.yaml index e94be1a857..6587bc7624 100644 --- a/packages/ui/tui/README.i18n.yaml +++ b/packages/ui/tui/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/ui/tui/README.md -README.md: 63c888b1d51c02fa85a8f0cc1617874debd87c4e -README.zh.md: ca5efc9ae26a9833d271991f73a21c607d8fb09d +README.md: a65d72b3b80b992fabcb33d4b4345942b58e9147 +README.zh.md: fd056da59335af5cb9fc63fd2fb681266fadeb62 diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index 63c888b1d5..55e7eb3280 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -22,7 +22,7 @@ Typing `@` at a token boundary searches files and directories under the session When optional `ctx.sessionReferences` is mounted, the same `@` menu also offers metadata-only session candidates, inserts `@[label](dsh-session:)`, and prepares the selected snapshots before dispatch. Session references remain structured because the model has no filesystem-like tool for retrieving session snapshots later. Preparation disables duplicate submission and restores the editor input on failure. The TUI chooses `agent.steer()` or `agent.followup()` from the status after that asynchronous preparation, so idle follow-ups still dispatch `agent/prompt-submit` while in-turn steering joins at a checkpoint without that hook. -While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.followup()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path automatically reaches the model. A command producer may explicitly schedule agent work; [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) uses that contract for `/plan [message]`. The TUI registers `/help`, `/model`, `/clear`, `/palette`, `/reload`, `/resume`, `/status`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically, as do `/skill:` completions. A status line above the editor reports the turn phase the TUI derives from session events — waiting for the first token, thinking, responding, or executing tools — with the elapsed time in that phase and the running step total, refreshed each second, and ends with the `Enter sends steering, Esc cancels` hint; while steering messages wait to reach the model it inserts a `N queued ·` badge before the hint that clears as each drains. Ctrl+C or Escape cancels a running turn. Tool and injected-context cards collapse long bodies into a configurable head/tail preview; Ctrl+O cycles tool cards through collapsed preview, full output, and hidden — the hidden phase drops tool cards from the transcript entirely while context cards stay at their preview, since injected instructions are not tool traffic. An injected-context card renders its message as prose with the producer's outer reminder frame stripped, so neither the fold nor the frame stripping depends on the payload's syntax. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle. +While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.followup()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path automatically reaches the model. A command producer may explicitly schedule agent work; [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) uses that contract for `/plan [message]`. The TUI registers `/help`, `/model`, `/clear`, `/palette`, `/reload`, `/resume`, `/status`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically, as do `/skill:` completions. A status line above the editor reports the turn phase the TUI derives from session events — waiting for the first token, thinking, responding, or executing tools — with the elapsed time in that phase and the running step total, refreshed each second, and ends with the `Enter sends steering, Esc cancels` hint; while steering messages wait to reach the model it inserts a `N queued ·` badge before the hint that clears as each drains. Ctrl+C or Escape cancels a running turn. Tool and injected-context cards collapse long bodies into a configurable head/tail preview; Ctrl+O cycles tool cards through collapsed preview, full output, and hidden — the hidden phase drops tool cards from the transcript entirely while context cards stay at their preview, since injected instructions are not tool traffic. The hidden phase also folds each turn's assistant steps into one message: the first step with visible text or reasoning keeps the turn's single `Assistant` header, later steps render as headerless continuations, and a step without a visible body renders nothing; leaving the hidden phase restores the per-step headers. An injected-context card renders its message as prose with the producer's outer reminder frame stripped, so neither the fold nor the frame stripping depends on the payload's syntax. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle. `/model` opens the advisory `ctx.llm` catalog as a keyboard selector: a filter box above the list narrows rows by a case-insensitive substring over each row's `provider/model` label, model name, and description, keeping the highlighted row selected when it survives the filter; Up/Down moves, Shift+Tab cycles the focused model's adapter-advertised reasoning efforts in display order, Enter selects the model and effort, and Escape clears a non-empty filter before a second Escape closes it. When an adapter does not advertise a default effort, the cycle also includes `Default`, which clears an explicit selection and preserves the provider default; models without selectable effort metadata ignore Shift+Tab. The selector renders the exact advertised effort list—including `off` when present—and does not synthesize, clamp, or transfer an effort between models. `/model ` still selects an unambiguous model id directly, while `/model /` selects an exact target and uses its adapter default when one exists. The configured target or latest logged request header initializes the selector, and an unlisted current model remains visible because catalogs are advisory. Selection is local to this TUI session. Prompt assembly snapshots the target for one step, replaces `{{provider}}` and `{{model}}`, and applies the same provider/model/reasoning-effort target through `agent/request`; a switch during assembly therefore starts with a later step. The request header durably records targets that reach the model, while an unused selection remains process-local. diff --git a/packages/ui/tui/README.zh.md b/packages/ui/tui/README.zh.md index ca5efc9ae2..9683f6292e 100644 --- a/packages/ui/tui/README.zh.md +++ b/packages/ui/tui/README.zh.md @@ -22,7 +22,7 @@ TUI 从追加来源的会话事件重建已恢复历史,渲染 Markdown 响应 挂载可选的 `ctx.sessionReferences` 后,同一个 `@` 菜单还会提供仅含元数据的会话候选项,插入 `@[label](dsh-session:)`,并在分派前准备所选快照。会话引用保持结构化,因为模型没有类似文件系统的工具可在稍后检索会话快照。准备期间会禁止重复提交,并在失败时恢复编辑器输入。TUI 会在异步准备后根据状态选择 `agent.steer()` 或 `agent.followup()`,因此空闲 followup 仍会分派 `agent/prompt-submit`,而轮次中的 steering 会在检查点加入且不触发该 hook。 -Agent 运行时,普通编辑器提交会调用 `agent.steer()`;其他时候调用 `agent.followup()`。提交行以斜杠开头时会改为进入 `ctx.commands`:已知命令直接执行,未知命令产生警告,两条路径都不会自动到达模型。命令生产方可以显式调度 agent 工作;[`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) 使用该契约实现 `/plan [message]`。TUI 将 `/help`、`/model`、`/clear`、`/palette`、`/reload`、`/resume`、`/status` 和 `/exit` 注册为 agent 作用域定义;其他所有有效命令都会动态加入自动补全与 `/help`,`/skill:` 补全也相同。编辑器上方的状态行会报告 TUI 从会话事件派生的轮次阶段,包括等待首个 token、思考、响应或执行工具;它显示该阶段已经过时间和运行中的步骤总数,每秒刷新,并以 `Enter sends steering, Esc cancels` 提示结尾。Steering 消息等待到达模型期间,会在提示前插入 `N queued ·` 徽标,每条消息排空后随即清除。Ctrl+C 或 Escape 会取消运行中的轮次。工具卡片与注入上下文卡片都把长主体折叠为可配置的头尾预览;Ctrl+O 让工具卡片在折叠预览、完整输出、隐藏三种状态间循环——隐藏阶段把工具卡片从 transcript 中完全去掉,而上下文卡片保持预览,因为注入的指令不属于工具流量。注入上下文卡片把消息渲染为文本,并去掉生产方的外层提醒外框,因此折叠与去外框都不依赖载荷的语法。Ctrl+R 切换 reasoning,Ctrl+L 重绘,Ctrl+D 在空闲时退出。 +Agent 运行时,普通编辑器提交会调用 `agent.steer()`;其他时候调用 `agent.followup()`。提交行以斜杠开头时会改为进入 `ctx.commands`:已知命令直接执行,未知命令产生警告,两条路径都不会自动到达模型。命令生产方可以显式调度 agent 工作;[`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) 使用该契约实现 `/plan [message]`。TUI 将 `/help`、`/model`、`/clear`、`/palette`、`/reload`、`/resume`、`/status` 和 `/exit` 注册为 agent 作用域定义;其他所有有效命令都会动态加入自动补全与 `/help`,`/skill:` 补全也相同。编辑器上方的状态行会报告 TUI 从会话事件派生的轮次阶段,包括等待首个 token、思考、响应或执行工具;它显示该阶段已经过时间和运行中的步骤总数,每秒刷新,并以 `Enter sends steering, Esc cancels` 提示结尾。Steering 消息等待到达模型期间,会在提示前插入 `N queued ·` 徽标,每条消息排空后随即清除。Ctrl+C 或 Escape 会取消运行中的轮次。工具卡片与注入上下文卡片都把长主体折叠为可配置的头尾预览;Ctrl+O 让工具卡片在折叠预览、完整输出、隐藏三种状态间循环——隐藏阶段把工具卡片从 transcript 中完全去掉,而上下文卡片保持预览,因为注入的指令不属于工具流量。隐藏阶段还会把每个轮次的 assistant 步骤折叠为一条消息:第一个有可见文本或 reasoning 的步骤保留该轮次唯一的 `Assistant` 标题,之后的步骤渲染为无标题的续段,没有可见正文的步骤则不渲染任何内容;离开隐藏阶段会恢复每步各自的标题。注入上下文卡片把消息渲染为文本,并去掉生产方的外层提醒外框,因此折叠与去外框都不依赖载荷的语法。Ctrl+R 切换 reasoning,Ctrl+L 重绘,Ctrl+D 在空闲时退出。 `/model` 将建议性的 `ctx.llm` catalog 打开为键盘选择器:列表上方设有一个过滤框,按对每行 `provider/model` 标签、模型名称和描述的大小写不敏感子串匹配来缩小行集,并在高亮行仍通过过滤时保持其选中状态;Up/Down 移动,Shift+Tab 按显示顺序循环切换适配器为焦点模型公布的推理强度,Enter 选择模型和推理强度,Escape 会先清除非空过滤内容,再次按下才关闭选择器。适配器未公布默认推理强度时,循环还会包含 `Default`,该项会清除显式选择并保留提供方默认行为;没有可选推理强度元数据的模型会忽略 Shift+Tab。选择器会原样呈现公布的推理强度列表(包括存在时的 `off`),不会合成、自动调整或在模型之间转移推理强度。`/model ` 仍可直接选择无歧义的模型 id,`/model /` 则选择精确目标,并在存在时使用其适配器默认值。已配置目标或最新记录的请求 header 会初始化选择器;由于 catalog 仅提供建议,未列出的当前模型仍会显示。选择仅对本 TUI 会话有效。提示词组装会为一个步骤建立目标快照,替换 `{{provider}}` 和 `{{model}}`,并通过 `agent/request` 应用同一个提供方/模型/推理强度目标;因此组装期间的切换会从后续步骤开始生效。请求 header 会持久记录真正到达模型的目标,未使用的选择则只存在于进程本地。 diff --git a/packages/ui/tui/src/components/transcript.ts b/packages/ui/tui/src/components/transcript.ts index c8ba061166..300b39fe08 100644 --- a/packages/ui/tui/src/components/transcript.ts +++ b/packages/ui/tui/src/components/transcript.ts @@ -149,20 +149,28 @@ export class UserMessageComponent extends Container { } } -/** Children of a settled assistant message: optional reasoning block then the response text. */ +/** + * Children of a settled assistant message: optional reasoning block then the + * response text. A folded continuation (a later step of a turn while tool cards + * are hidden) drops the `Assistant` header and renders nothing when it has no + * visible body, so tool-only steps leave no blank segment behind. + */ function assistantMessageChildren( content: readonly ContentBlock[], showReasoning: boolean, + foldedContinuation: boolean, palette: Palette, mdTheme: MarkdownTheme, ): Component[] { const reasoning = displayText(textBlocks(content, 'reasoning').trim()) const text = displayText(textBlocks(content, 'text').trim()) - const children: Component[] = [ - new Spacer(1), - new Text(messageHeader('Assistant', palette.accent, palette), 0, 0), - ] - if (reasoning && showReasoning) { + const showsReasoning = reasoning !== '' && showReasoning + if (foldedContinuation && !showsReasoning && text === '') return [] + const children: Component[] = [new Spacer(1)] + if (!foldedContinuation) { + children.push(new Text(messageHeader('Assistant', palette.accent, palette), 0, 0)) + } + if (showsReasoning) { children.push( new Text(palette.italic(palette.dim('Reasoning')), 0, 0), new Markdown(reasoning, 0, 0, mdTheme, { color: value => palette.dim(value), italic: true }), @@ -220,6 +228,7 @@ interface StreamingBlock { export class StreamingAssistantComponent extends Container { private readonly blocks = new Map() private settledContent: readonly ContentBlock[] | undefined + private foldedContinuation = false /** * The step's timing footer. The renderer keeps it at the tail of the chat so * it trails any tool cards the step appends after this assistant message; it @@ -228,7 +237,8 @@ export class StreamingAssistantComponent extends Container { readonly timing: StepTimingComponent constructor( - position: StepPosition, + /** The step's turn/step coordinates, used to group steps into their turn. */ + readonly position: StepPosition, events: () => readonly SessionEvent[], now: () => number, private showReasoning: boolean, @@ -299,18 +309,49 @@ export class StreamingAssistantComponent extends Container { this.rebuild() } - private rebuild(): void { - this.clear() - const content: readonly ContentBlock[] = this.settledContent ?? [...this.blocks.entries()] + /** + * Mark this step as a folded continuation of its turn: no `Assistant` header, + * and no output at all while the step has no visible body. Used while tool + * cards are hidden so a turn reads as one assistant message. + * @param folded - Whether to render as a headerless continuation. + */ + setFoldedContinuation(folded: boolean): void { + if (this.foldedContinuation === folded) return + this.foldedContinuation = folded + this.rebuild() + } + + /** + * Whether the step currently renders visible reasoning or text. + * @returns `true` when a header-owning render would show a body. + */ + hasVisibleBody(): boolean { + const content = this.presentedContent() + return textBlocks(content, 'text').trim() !== '' + || (this.showReasoning && textBlocks(content, 'reasoning').trim() !== '') + } + + /** The settled content when available, otherwise the streamed blocks in model order. */ + private presentedContent(): readonly ContentBlock[] { + return this.settledContent ?? [...this.blocks.entries()] .sort(([left], [right]) => left - right) .flatMap(([, block]) => { if (block.type === 'text') return [{ type: 'text', text: block.text }] if (block.type === 'reasoning') return [{ type: 'reasoning', text: block.text }] return [] }) - for (const child of assistantMessageChildren(content, this.showReasoning, this.palette, this.mdTheme)) { - this.addChild(child) - } + } + + private rebuild(): void { + this.clear() + const children = assistantMessageChildren( + this.presentedContent(), + this.showReasoning, + this.foldedContinuation, + this.palette, + this.mdTheme, + ) + for (const child of children) this.addChild(child) } } diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index e2eaf89986..3d39c53957 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -335,6 +335,10 @@ export function createTuiChat( let toolsVisibility: ToolCardVisibility = 'collapsed' let streaming: StreamingAssistantComponent | undefined let completedStreaming: StreamingAssistantComponent | undefined + // Assistant step components in model order per turn, for hidden-mode folding: + // with tool cards hidden, a turn keeps one Assistant header and later steps + // render as headerless continuations (see applyTurnFolding). + const assistantSteps = new Map() let runningStatus: RunningStatus | undefined let fadingStatus: FadingStatus | undefined // TUI steering submissions that the inbox has not yet claimed or discarded. @@ -614,6 +618,35 @@ export function createTuiChat( return card } + /** + * Re-derive hidden-mode folding for one turn: the first step with a visible + * body owns the turn's single Assistant header, every other step renders as a + * headerless continuation (empty ones render nothing). Any other visibility + * restores the per-step headers. + */ + const applyTurnFolding = (turn: number): void => { + const steps = assistantSteps.get(turn) + if (steps === undefined) return + let headerSeen = false + for (const step of steps) { + if (toolsVisibility !== 'hidden') { + step.setFoldedContinuation(false) + } else if (!headerSeen && step.hasVisibleBody()) { + headerSeen = true + step.setFoldedContinuation(false) + } else { + step.setFoldedContinuation(true) + } + } + } + + const registerAssistantStep = (component: StreamingAssistantComponent): void => { + const steps = assistantSteps.get(component.position.turn) ?? [] + steps.push(component) + assistantSteps.set(component.position.turn, steps) + applyTurnFolding(component.position.turn) + } + const removeStreaming = (current: StreamingAssistantComponent | undefined): void => { if (current === undefined) return for (const child of [current, current.timing]) { @@ -621,6 +654,15 @@ export function createTuiChat( /* v8 ignore next -- streaming components and their timing footers are retained only while attached to the chat. */ if (index >= 0) chat.children.splice(index, 1) } + const steps = assistantSteps.get(current.position.turn) + /* v8 ignore next -- every attached streaming component is registered in the fold map. */ + if (steps === undefined) return + const index = steps.indexOf(current) + /* v8 ignore next -- registration precedes attachment, so the component is present until this removal. */ + if (index < 0) return + steps.splice(index, 1) + // A retracted step may have owned the turn's hidden-mode header. + applyTurnFolding(current.position.turn) } /** @@ -659,6 +701,7 @@ export function createTuiChat( palette, mdTheme, ) + registerAssistantStep(streaming) chat.addChild(streaming) chat.addChild(streaming.timing) } @@ -722,12 +765,20 @@ export function createTuiChat( startAssistantStep(event.data) break case 'assistant/chunk': - if (options.renderChunks) streaming?.update(event.data.chunk) + if (options.renderChunks && streaming !== undefined) { + streaming.update(event.data.chunk) + // The first streamed text/reasoning may make this step the turn's + // hidden-mode header owner (or a continuation with a visible body). + applyTurnFolding(streaming.position.turn) + } break case 'assistant/message': completedStreaming = undefined if (streaming === undefined || !chat.children.includes(streaming)) startAssistantStep(event.data) - streaming?.settle(event.data.message.content) + if (streaming !== undefined) { + streaming.settle(event.data.message.content) + applyTurnFolding(streaming.position.turn) + } break case 'llm/retry': { retractFailedStreaming() @@ -838,6 +889,7 @@ export function createTuiChat( toolCards.clear() allToolCards.clear() contextCards.clear() + assistantSteps.clear() streaming = undefined todo.update([]) const transcriptCalls = transcriptToolCallIds(agent.session) @@ -956,6 +1008,9 @@ export function createTuiChat( // Context cards carry injected instructions rather than tool traffic, so // they never hide: the hidden phase reads as their collapsed preview. for (const card of contextCards) card.setExpanded(toolsVisibility === 'expanded') + // Hidden mode folds each turn's steps into one assistant message; other + // modes restore the per-step Assistant headers. + for (const turn of assistantSteps.keys()) applyTurnFolding(turn) appendNotice(toolsVisibility === 'hidden' ? 'Tool cards hidden.' : `Tool and context cards ${toolsVisibility}.`) } @@ -967,6 +1022,7 @@ export function createTuiChat( if (activeStreaming !== undefined) { streaming = activeStreaming streaming.setShowReasoning(showReasoning) + registerAssistantStep(activeStreaming) chat.addChild(activeStreaming) chat.addChild(activeStreaming.timing) } diff --git a/packages/ui/tui/tests/snapshots/tool-cards-hidden-folded.expected.txt b/packages/ui/tui/tests/snapshots/tool-cards-hidden-folded.expected.txt new file mode 100644 index 0000000000..2aacdef876 --- /dev/null +++ b/packages/ui/tui/tests/snapshots/tool-cards-hidden-folded.expected.txt @@ -0,0 +1,46 @@ +terminal 100x40 buffer=normal length=40 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=7 viewportRow=20 bufferRow=20 +buffer +0| " DEEPSEEK HARNESS" + style 1-8 fg=bright-magenta bold + style 10-16 bold +1| " Snapshot agent ready." + style 1-21 dim +2| " main-session" + style 1-12 dim +3| +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| "Inspecting the renderer first. " +6| "Model wait 0.0s " + style 0-14 dim +7| +8| "You " + style 0-2 fg=bright-magenta bold underline +9| "Refactor the renderer. " +10| "Model wait 0.0s · Completed 2026-07-29 22:23:17 " + style 0-46 dim +11| +12| "The renderer is sound; no refactor needed. " +13| "Model wait 0.0s · Completed 2026-07-29 22:23:17 " + style 0-46 dim +14| +15| "Tool and context cards expanded. " + style 0-31 dim +16| +17| "Tool cards hidden. " + style 0-17 dim +18| +19| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-magenta bold + style 18-31 dim + style 34-50 dim + style 53-57 dim + style 60-69 dim +20| " dsh > " + style 1-3 fg=bright-magenta bold + style 5-6 dim + style 7-7 inverse +21-39| diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts index 18f0a9a793..3f6c72ab18 100644 --- a/packages/ui/tui/tests/tui.snapshot.ts +++ b/packages/ui/tui/tests/tui.snapshot.ts @@ -44,6 +44,7 @@ const CHECKPOINTS = [ 'cordis-tools-pending', 'advanced-cards-collapsed', 'advanced-cards-expanded', + 'tool-cards-hidden-folded', 'untrusted-controls', 'question-dialog', 'question-dialog-single-option', @@ -609,6 +610,31 @@ describe('TUI terminal-state snapshots', () => { await disposeSnapshot(harness) }) + it('pins the hidden phase folding a multi-step turn into one assistant message', async () => { + const harness = await setupSnapshot({ + tools: ADVANCED_CARD_TOOLS, + config: { maxToolOutputLines: 3 }, + }, { columns: 100, rows: 40 }) + await renderAfter(harness, () => { + appendUser(harness.session, 'Refactor the renderer.') + appendAssistant(harness.session, [{ type: 'text', text: 'Inspecting the renderer first.' }]) + appendToolCalls(harness.session, [ + { id: 'fold-1', name: 'bash', arguments: { command: 'pnpm run test' } }, + ]) + appendToolResult(harness.session, 'fold-1', [{ type: 'text', text: 'all tests pass' }]) + harness.session.append('step/end', { turn: 1, step: 1 }) + harness.session.append('step/start', { turn: 1, step: 2 }) + appendAssistant(harness.session, [{ type: 'text', text: 'The renderer is sound; no refactor needed.' }], undefined, { turn: 1, step: 2 }) + harness.session.append('step/end', { turn: 1, step: 2 }) + harness.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + }) + // collapsed -> expanded -> hidden: one Assistant header, no tool card. + await renderAfter(harness, () => { harness.terminal.send('\x0f') }) + await renderAfter(harness, () => { harness.terminal.send('\x0f') }) + await checkpoint('tool-cards-hidden-folded', harness.terminal, { includeScrollback: true }) + await disposeSnapshot(harness) + }) + it('renders terminal controls as inert text across transcripts, tools, dialogs, diagnostics, and title', async () => { const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(new Date(2026, 6, 21, 15, 0, 0).getTime()) const tools = { diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 8231dc34d1..66540d2cc2 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -4989,6 +4989,134 @@ describe('tool cards and surface replay', () => { expect(mounted).not.toContain('stored model-only payload') await dispose(result) }) + + /** The last repainted frame, with CSI/OSC escapes and carriage returns stripped. */ + const lastFrame = (terminal: FakeTerminal): string => terminal.output + .slice(terminal.output.lastIndexOf('\x1b[2J')) + .replaceAll(/\x1b\[[0-9;]*[A-Za-z]|\x1b\][^\x07]*\x07|\r/g, '') + + const countAssistantHeaders = (frame: string): number => frame.split('\n') + .filter(row => row.trim() === 'Assistant').length + + /** One turn with text -> tool call/result -> text across two steps. */ + const appendTwoStepTurn = (session: Awaited>['session']): void => { + appendUser(session, 'fold me') + appendAssistant(session, [{ type: 'text', text: 'first step text' }]) + session.append('tool/call', { turn: 1, step: 1, callId: 'fold-1' as never, name: 'bash', arguments: '{}' }) + session.append('tool/result', { + turn: 1, step: 1, + message: createToolResultMessage({ + callId: 'fold-1' as never, content: [{ type: 'text', text: 'tool body' }], isError: false, + }), + }, { surfaceOp: 'append' }) + session.append('step/end', { turn: 1, step: 1 }) + session.append('step/start', { turn: 1, step: 2 }) + appendAssistant(session, [{ type: 'text', text: 'second step text' }], undefined, { turn: 1, step: 2 }) + session.append('step/end', { turn: 1, step: 2 }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + } + + it('folds a turn to one Assistant header in hidden mode and restores headers on cycle', async () => { + const result = await setup({ tools }) + appendTwoStepTurn(result.session) + await tick() + + // Collapsed (default): each step keeps its own header. + result.terminal.send('\x0c') + await tick() + expect(countAssistantHeaders(lastFrame(result.terminal))).toBe(2) + + // collapsed -> expanded -> hidden. + result.terminal.send('\x0f') + result.terminal.send('\x0f') + await tick() + result.terminal.send('\x0c') + await tick() + const hidden = lastFrame(result.terminal) + expect(countAssistantHeaders(hidden)).toBe(1) + expect(hidden).toContain('first step text') + expect(hidden).toContain('second step text') + expect(hidden).not.toContain('Tool / bash') + // The fold keeps model order: header text precedes the continuation. + expect(hidden.indexOf('first step text')).toBeLessThan(hidden.indexOf('second step text')) + + // hidden -> collapsed restores per-step headers. + result.terminal.send('\x0f') + await tick() + result.terminal.send('\x0c') + await tick() + expect(countAssistantHeaders(lastFrame(result.terminal))).toBe(2) + await dispose(result) + }) + + it('gives the hidden-mode header to the first step with a visible body and keeps turns separate', async () => { + const result = await setup({ tools }) + // Turn 1, step 1 is tool-only; step 2 carries the turn's text. + appendUser(result.session, 'tool-only first step') + appendAssistant(result.session, [{ type: 'tool-call', id: 'only-1' as never, name: 'bash', arguments: '{}' }]) + result.session.append('tool/call', { turn: 1, step: 1, callId: 'only-1' as never, name: 'bash', arguments: '{}' }) + result.session.append('tool/result', { + turn: 1, step: 1, + message: createToolResultMessage({ + callId: 'only-1' as never, content: [{ type: 'text', text: 'tool body' }], isError: false, + }), + }, { surfaceOp: 'append' }) + result.session.append('step/end', { turn: 1, step: 1 }) + result.session.append('step/start', { turn: 1, step: 2 }) + appendAssistant(result.session, [{ type: 'text', text: 'late turn-one text' }], undefined, { turn: 1, step: 2 }) + result.session.append('step/end', { turn: 1, step: 2 }) + result.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + // Turn 2 keeps its own header. + result.session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) + appendUser(result.session, 'next turn') + result.session.append('step/start', { turn: 2, step: 1 }) + appendAssistant(result.session, [{ type: 'text', text: 'turn-two text' }], undefined, { turn: 2, step: 1 }) + result.session.append('step/end', { turn: 2, step: 1 }) + result.session.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) + await tick() + + result.terminal.send('\x0f') + result.terminal.send('\x0f') + await tick() + result.terminal.send('\x0c') + await tick() + const hidden = lastFrame(result.terminal) + // One header per turn: the tool-only step neither renders a blank segment + // nor consumes turn one's header, which the late text step owns. + expect(countAssistantHeaders(hidden)).toBe(2) + expect(hidden).toContain('late turn-one text') + expect(hidden).toContain('turn-two text') + const rows = hidden.split('\n').map(row => row.trim()) + const turnOneHeader = rows.indexOf('Assistant') + expect(rows[turnOneHeader + 1]).toBe('late turn-one text') + await dispose(result) + }) + + it('folds live hidden-mode streaming once a later step shows text', async () => { + const result = await setup({ tools, status: 'running' }) + result.terminal.send('\x0f') + result.terminal.send('\x0f') + await tick() + result.session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'live first' } }) + result.session.append('step/end', { turn: 1, step: 1 }) + result.session.append('step/start', { turn: 1, step: 2 }) + result.session.append('assistant/chunk', { turn: 1, step: 2, chunk: { type: 'text-delta', index: 0, text: 'live second' } }) + await tick() + result.terminal.send('\x0c') + await tick() + const hidden = lastFrame(result.terminal) + expect(countAssistantHeaders(hidden)).toBe(1) + expect(hidden).toContain('live first') + expect(hidden).toContain('live second') + + // A transcript rebuild (resize) recomputes the same fold from the log. + result.terminal.resize(89) + await tick() + const rebuilt = lastFrame(result.terminal) + expect(countAssistantHeaders(rebuilt)).toBe(1) + expect(rebuilt).toContain('live second') + await dispose(result) + }) }) describe('TUI user-interaction dialogs', () => { From 651417e01caf4e0161ac8c97f0844067225d8b1d Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 22:40:59 +0800 Subject: [PATCH 017/129] test(tui): pin the folded-snapshot clock so replay is keyless-deterministic --- .../tui/tests/snapshots/tool-cards-hidden-folded.expected.txt | 4 ++-- packages/ui/tui/tests/tui.snapshot.ts | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/ui/tui/tests/snapshots/tool-cards-hidden-folded.expected.txt b/packages/ui/tui/tests/snapshots/tool-cards-hidden-folded.expected.txt index 2aacdef876..d4da5ab39c 100644 --- a/packages/ui/tui/tests/snapshots/tool-cards-hidden-folded.expected.txt +++ b/packages/ui/tui/tests/snapshots/tool-cards-hidden-folded.expected.txt @@ -20,11 +20,11 @@ buffer 8| "You " style 0-2 fg=bright-magenta bold underline 9| "Refactor the renderer. " -10| "Model wait 0.0s · Completed 2026-07-29 22:23:17 " +10| "Model wait 0.0s · Completed 2026-07-29 22:30:00 " style 0-46 dim 11| 12| "The renderer is sound; no refactor needed. " -13| "Model wait 0.0s · Completed 2026-07-29 22:23:17 " +13| "Model wait 0.0s · Completed 2026-07-29 22:30:00 " style 0-46 dim 14| 15| "Tool and context cards expanded. " diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts index 3f6c72ab18..d56db82f14 100644 --- a/packages/ui/tui/tests/tui.snapshot.ts +++ b/packages/ui/tui/tests/tui.snapshot.ts @@ -611,6 +611,7 @@ describe('TUI terminal-state snapshots', () => { }) it('pins the hidden phase folding a multi-step turn into one assistant message', async () => { + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(new Date(2026, 6, 29, 22, 30, 0).getTime()) const harness = await setupSnapshot({ tools: ADVANCED_CARD_TOOLS, config: { maxToolOutputLines: 3 }, @@ -632,6 +633,7 @@ describe('TUI terminal-state snapshots', () => { await renderAfter(harness, () => { harness.terminal.send('\x0f') }) await renderAfter(harness, () => { harness.terminal.send('\x0f') }) await checkpoint('tool-cards-hidden-folded', harness.terminal, { includeScrollback: true }) + nowSpy.mockRestore() await disposeSnapshot(harness) }) From 3899a26c87bffcf1b89cdb03ed12f29967d0f076 Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 30 Jul 2026 18:26:26 +0800 Subject: [PATCH 018/129] feat(tui): /details command sets card visibility and reasoning display /details reports the transcript detail state bare, jumps tool cards to collapsed|expanded|hidden, and sets or toggles reasoning blocks, sharing the closure state behind Ctrl+O/Ctrl+R via setToolsVisibility/setReasoning. Also fixes a replay defect the reasoning rebuild exposed: rebuildTranscript reused a settled StreamingAssistantComponent for a later assistant/message of the same step, overwriting the earlier content; the settled check now lives in renderEvent for both live and replay paths (untrusted-controls re-recorded with the previously dropped content present). --- .../2026-07-30-tui-details-command.i18n.yaml | 6 ++ .../feature/2026-07-30-tui-details-command.md | 31 ++++++++++ .../2026-07-30-tui-details-command.zh.md | 31 ++++++++++ packages/ui/tui/README.md | 2 +- packages/ui/tui/README.zh.md | 2 +- packages/ui/tui/src/index.ts | 62 ++++++++++++++++--- .../snapshots/details-command.expected.txt | 45 ++++++++++++++ .../snapshots/disposed-terminal.expected.txt | 54 ++++++++-------- .../snapshots/errors-and-help.expected.txt | 52 +++++++++------- .../snapshots/untrusted-controls.expected.txt | 55 ++++++++-------- packages/ui/tui/tests/tui.snapshot.ts | 33 ++++++++++ packages/ui/tui/tests/tui.spec.ts | 33 ++++++++++ 12 files changed, 316 insertions(+), 90 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-30-tui-details-command.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-30-tui-details-command.md create mode 100644 .agents/notes/implemented/feature/2026-07-30-tui-details-command.zh.md create mode 100644 packages/ui/tui/tests/snapshots/details-command.expected.txt diff --git a/.agents/notes/implemented/feature/2026-07-30-tui-details-command.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.i18n.yaml new file mode 100644 index 0000000000..df7079c9b8 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.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-07-30-tui-details-command.md +2026-07-30-tui-details-command.md: f17c168cb87d546ca158eddef7dd96fb0ab8be2d +2026-07-30-tui-details-command.zh.md: 48e9a8d3f18c51982b8678e39d9315ffe08b015f diff --git a/.agents/notes/implemented/feature/2026-07-30-tui-details-command.md b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.md new file mode 100644 index 0000000000..f17c168cb8 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.md @@ -0,0 +1,31 @@ +# Agent Note: /details command for transcript detail state + +Status: implemented + +English | [中文](2026-07-30-tui-details-command.zh.md) + +## Problem + +The TUI's transcript detail state — tool-card visibility (`collapsed`/`expanded`/`hidden`, per the [consolidated TUI presentation](../architecture/2026-07-28-consolidated-tui-presentation.md)) and reasoning-block display — was reachable only through the Ctrl+O cycle and the Ctrl+R toggle. A user who wants a specific mode must cycle through the others, cannot set both dimensions in one action, and has no way to query the current state; a terminal that swallows those control keys has no fallback at all. + +## Decision + +`dsh-tui` registers `/details` beside its other agent-scoped commands. Bare `/details` reports the current state in one notice. Arguments name target states directly: `collapsed|expanded|hidden` jumps tool cards to that phase, `reasoning on|off` sets reasoning display, bare `reasoning` toggles it, and directives combine in one invocation. An unknown token returns a command error carrying the usage line. The command mutates the same closure state as the shortcuts, refactored so the cycle and toggle are thin wrappers over `setToolsVisibility`/`setReasoning`; the shortcuts and their notices are unchanged. + +A combined invocation applies reasoning before visibility because `setReasoning` rebuilds the transcript from session events, which drops non-durable notice components; applying it last would erase the just-appended visibility notice. + +The reasoning rebuild exposed a replay defect that this change fixes in `renderEvent`: the live path cleared a settled `StreamingAssistantComponent` before a later `assistant/message` of the same step (so the second message got a fresh component), but `rebuildTranscript` replay reused the settled component and `settle()` overwrote its content, silently dropping the earlier message's text. The settled check now lives in `renderEvent`'s `assistant/message` case — one home for both paths — and the previously wrong `untrusted-controls` snapshot (an empty `Assistant` header where reasoning and text had been dropped) was re-recorded with the content present. + +## Alternatives considered + +**Cycle on bare `/details`, mirroring Ctrl+O.** Rejected: the command's value over the shortcut is naming an absolute state; a cycling command is the shortcut with more keystrokes, and bare invocation is more useful as a state report. + +**Separate `/tools` and `/reasoning` commands.** Rejected: both dimensions are one presentation concern ("how much detail does the transcript show"), and a single command keeps the registry and `/help` list small while allowing one combined invocation. + +**Config-key defaults per mode.** Out of scope: `showReasoning` already exists as config; the command is runtime state on top of it, matching the shortcuts. + +## Consequences + +- A user can jump to any detail mode, set both dimensions at once, and query the state — including on terminals that intercept Ctrl+O/Ctrl+R. +- The parser accepts order-free tokens, so `/details reasoning expanded` toggles reasoning and expands cards; last directive wins per dimension. This leniency is deliberate and documented in the README. +- Transcript rebuilds no longer lose assistant messages when a step carries more than one `assistant/message` event; the `details-command` snapshot pins the command surface and the fixed replay together. diff --git a/.agents/notes/implemented/feature/2026-07-30-tui-details-command.zh.md b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.zh.md new file mode 100644 index 0000000000..48e9a8d3f1 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.zh.md @@ -0,0 +1,31 @@ +# Agent Note: 用于 transcript 细节状态的 /details 命令 + +Status: implemented + +[English](2026-07-30-tui-details-command.md) | 中文 + +## Problem + +TUI 的 transcript(文本记录)细节状态——工具卡片可见性(`collapsed`/`expanded`/`hidden`,见[整合的 TUI 展示](../architecture/2026-07-28-consolidated-tui-presentation.md))与 reasoning 块显示——过去只能通过 Ctrl+O 循环和 Ctrl+R 切换来触达。想要某个特定模式的用户必须循环经过其他模式,无法一次操作同时设置两个维度,也无法查询当前状态;吞掉这些控制键的终端更是完全没有替代途径。 + +## Decision + +`dsh-tui` 在其他 agent 作用域命令旁注册 `/details`。裸 `/details` 用一条通知报告当前状态。参数直接命名目标状态:`collapsed|expanded|hidden` 让工具卡片跳到该阶段,`reasoning on|off` 设置 reasoning 显示,裸 `reasoning` 切换它,且指令可在一次调用中组合。未知 token 返回携带用法行的命令错误。命令改动的是与快捷键相同的闭包状态,重构后循环与切换成为 `setToolsVisibility`/`setReasoning` 之上的薄封装;快捷键及其通知保持不变。 + +组合调用先应用 reasoning 再应用可见性,因为 `setReasoning` 会从会话事件重建 transcript,而重建会丢弃非持久的通知组件;若最后才应用它,会抹掉刚追加的可见性通知。 + +reasoning 重建暴露了一个重放缺陷,本变更在 `renderEvent` 中修复:实时路径会在同一步骤的后续 `assistant/message` 之前清除已结算的 `StreamingAssistantComponent`(因此第二条消息获得新组件),但 `rebuildTranscript` 重放复用了已结算组件,`settle()` 覆盖其内容,静默丢掉了前一条消息的文本。已结算检查现在位于 `renderEvent` 的 `assistant/message` 分支——两条路径共用一个归属地——此前错误的 `untrusted-controls` 快照(reasoning 与文本被丢弃后只剩空 `Assistant` 标题)已重录为包含内容的版本。 + +## Alternatives considered + +**裸 `/details` 像 Ctrl+O 一样循环。** 否决:命令相对快捷键的价值在于命名绝对状态;循环命令只是按键更多的快捷键,裸调用作为状态报告更有用。 + +**拆分 `/tools` 与 `/reasoning` 两个命令。** 否决:两个维度同属一个展示关注点(“transcript 显示多少细节”),单一命令让注册表与 `/help` 列表更小,同时允许一次组合调用。 + +**按模式提供配置键默认值。** 超出范围:`showReasoning` 已作为配置存在;命令是其上的运行时状态,与快捷键一致。 + +## Consequences + +- 用户可以跳到任意细节模式、一次设置两个维度并查询状态——包括在拦截 Ctrl+O/Ctrl+R 的终端上。 +- 解析器接受无序 token,因此 `/details reasoning expanded` 会切换 reasoning 并展开卡片;每个维度以最后一个指令为准。这一宽松是刻意的,并记录在 README 中。 +- 当一个步骤携带多条 `assistant/message` 事件时,transcript 重建不再丢失 assistant 消息;`details-command` 快照同时固定命令表面与修复后的重放。 diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index 55e7eb3280..23ca8343ec 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -22,7 +22,7 @@ Typing `@` at a token boundary searches files and directories under the session When optional `ctx.sessionReferences` is mounted, the same `@` menu also offers metadata-only session candidates, inserts `@[label](dsh-session:)`, and prepares the selected snapshots before dispatch. Session references remain structured because the model has no filesystem-like tool for retrieving session snapshots later. Preparation disables duplicate submission and restores the editor input on failure. The TUI chooses `agent.steer()` or `agent.followup()` from the status after that asynchronous preparation, so idle follow-ups still dispatch `agent/prompt-submit` while in-turn steering joins at a checkpoint without that hook. -While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.followup()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path automatically reaches the model. A command producer may explicitly schedule agent work; [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) uses that contract for `/plan [message]`. The TUI registers `/help`, `/model`, `/clear`, `/palette`, `/reload`, `/resume`, `/status`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically, as do `/skill:` completions. A status line above the editor reports the turn phase the TUI derives from session events — waiting for the first token, thinking, responding, or executing tools — with the elapsed time in that phase and the running step total, refreshed each second, and ends with the `Enter sends steering, Esc cancels` hint; while steering messages wait to reach the model it inserts a `N queued ·` badge before the hint that clears as each drains. Ctrl+C or Escape cancels a running turn. Tool and injected-context cards collapse long bodies into a configurable head/tail preview; Ctrl+O cycles tool cards through collapsed preview, full output, and hidden — the hidden phase drops tool cards from the transcript entirely while context cards stay at their preview, since injected instructions are not tool traffic. The hidden phase also folds each turn's assistant steps into one message: the first step with visible text or reasoning keeps the turn's single `Assistant` header, later steps render as headerless continuations, and a step without a visible body renders nothing; leaving the hidden phase restores the per-step headers. An injected-context card renders its message as prose with the producer's outer reminder frame stripped, so neither the fold nor the frame stripping depends on the payload's syntax. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle. +While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.followup()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path automatically reaches the model. A command producer may explicitly schedule agent work; [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) uses that contract for `/plan [message]`. The TUI registers `/help`, `/model`, `/clear`, `/details`, `/palette`, `/reload`, `/resume`, `/status`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically, as do `/skill:` completions. A status line above the editor reports the turn phase the TUI derives from session events — waiting for the first token, thinking, responding, or executing tools — with the elapsed time in that phase and the running step total, refreshed each second, and ends with the `Enter sends steering, Esc cancels` hint; while steering messages wait to reach the model it inserts a `N queued ·` badge before the hint that clears as each drains. Ctrl+C or Escape cancels a running turn. Tool and injected-context cards collapse long bodies into a configurable head/tail preview; Ctrl+O cycles tool cards through collapsed preview, full output, and hidden — the hidden phase drops tool cards from the transcript entirely while context cards stay at their preview, since injected instructions are not tool traffic. The hidden phase also folds each turn's assistant steps into one message: the first step with visible text or reasoning keeps the turn's single `Assistant` header, later steps render as headerless continuations, and a step without a visible body renders nothing; leaving the hidden phase restores the per-step headers. An injected-context card renders its message as prose with the producer's outer reminder frame stripped, so neither the fold nor the frame stripping depends on the payload's syntax. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle. `/details` names the same state those two shortcuts cycle: bare it opens a centered keyboard toggle with one entry per dimension — `Tool cards` and `Reasoning` — showing the live values, where Tab cycles the highlighted entry and applies the change immediately (the transcript behind the dialog is the preview), and Enter, Esc, or Ctrl+C closes; `/details collapsed|expanded|hidden` jumps tool cards to that phase directly, and `/details reasoning [on|off]` sets — or bare `reasoning` toggles — reasoning-block display; arguments combine in one invocation, an unknown argument fails with the usage line, and a combined invocation applies reasoning first so its transcript rebuild never drops the card notice. `/model` opens the advisory `ctx.llm` catalog as a keyboard selector: a filter box above the list narrows rows by a case-insensitive substring over each row's `provider/model` label, model name, and description, keeping the highlighted row selected when it survives the filter; Up/Down moves, Shift+Tab cycles the focused model's adapter-advertised reasoning efforts in display order, Enter selects the model and effort, and Escape clears a non-empty filter before a second Escape closes it. When an adapter does not advertise a default effort, the cycle also includes `Default`, which clears an explicit selection and preserves the provider default; models without selectable effort metadata ignore Shift+Tab. The selector renders the exact advertised effort list—including `off` when present—and does not synthesize, clamp, or transfer an effort between models. `/model ` still selects an unambiguous model id directly, while `/model /` selects an exact target and uses its adapter default when one exists. The configured target or latest logged request header initializes the selector, and an unlisted current model remains visible because catalogs are advisory. Selection is local to this TUI session. Prompt assembly snapshots the target for one step, replaces `{{provider}}` and `{{model}}`, and applies the same provider/model/reasoning-effort target through `agent/request`; a switch during assembly therefore starts with a later step. The request header durably records targets that reach the model, while an unused selection remains process-local. diff --git a/packages/ui/tui/README.zh.md b/packages/ui/tui/README.zh.md index 9683f6292e..40aa849838 100644 --- a/packages/ui/tui/README.zh.md +++ b/packages/ui/tui/README.zh.md @@ -22,7 +22,7 @@ TUI 从追加来源的会话事件重建已恢复历史,渲染 Markdown 响应 挂载可选的 `ctx.sessionReferences` 后,同一个 `@` 菜单还会提供仅含元数据的会话候选项,插入 `@[label](dsh-session:)`,并在分派前准备所选快照。会话引用保持结构化,因为模型没有类似文件系统的工具可在稍后检索会话快照。准备期间会禁止重复提交,并在失败时恢复编辑器输入。TUI 会在异步准备后根据状态选择 `agent.steer()` 或 `agent.followup()`,因此空闲 followup 仍会分派 `agent/prompt-submit`,而轮次中的 steering 会在检查点加入且不触发该 hook。 -Agent 运行时,普通编辑器提交会调用 `agent.steer()`;其他时候调用 `agent.followup()`。提交行以斜杠开头时会改为进入 `ctx.commands`:已知命令直接执行,未知命令产生警告,两条路径都不会自动到达模型。命令生产方可以显式调度 agent 工作;[`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) 使用该契约实现 `/plan [message]`。TUI 将 `/help`、`/model`、`/clear`、`/palette`、`/reload`、`/resume`、`/status` 和 `/exit` 注册为 agent 作用域定义;其他所有有效命令都会动态加入自动补全与 `/help`,`/skill:` 补全也相同。编辑器上方的状态行会报告 TUI 从会话事件派生的轮次阶段,包括等待首个 token、思考、响应或执行工具;它显示该阶段已经过时间和运行中的步骤总数,每秒刷新,并以 `Enter sends steering, Esc cancels` 提示结尾。Steering 消息等待到达模型期间,会在提示前插入 `N queued ·` 徽标,每条消息排空后随即清除。Ctrl+C 或 Escape 会取消运行中的轮次。工具卡片与注入上下文卡片都把长主体折叠为可配置的头尾预览;Ctrl+O 让工具卡片在折叠预览、完整输出、隐藏三种状态间循环——隐藏阶段把工具卡片从 transcript 中完全去掉,而上下文卡片保持预览,因为注入的指令不属于工具流量。隐藏阶段还会把每个轮次的 assistant 步骤折叠为一条消息:第一个有可见文本或 reasoning 的步骤保留该轮次唯一的 `Assistant` 标题,之后的步骤渲染为无标题的续段,没有可见正文的步骤则不渲染任何内容;离开隐藏阶段会恢复每步各自的标题。注入上下文卡片把消息渲染为文本,并去掉生产方的外层提醒外框,因此折叠与去外框都不依赖载荷的语法。Ctrl+R 切换 reasoning,Ctrl+L 重绘,Ctrl+D 在空闲时退出。 +Agent 运行时,普通编辑器提交会调用 `agent.steer()`;其他时候调用 `agent.followup()`。提交行以斜杠开头时会改为进入 `ctx.commands`:已知命令直接执行,未知命令产生警告,两条路径都不会自动到达模型。命令生产方可以显式调度 agent 工作;[`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) 使用该契约实现 `/plan [message]`。TUI 将 `/help`、`/model`、`/clear`、`/details`、`/palette`、`/reload`、`/resume`、`/status` 和 `/exit` 注册为 agent 作用域定义;其他所有有效命令都会动态加入自动补全与 `/help`,`/skill:` 补全也相同。编辑器上方的状态行会报告 TUI 从会话事件派生的轮次阶段,包括等待首个 token、思考、响应或执行工具;它显示该阶段已经过时间和运行中的步骤总数,每秒刷新,并以 `Enter sends steering, Esc cancels` 提示结尾。Steering 消息等待到达模型期间,会在提示前插入 `N queued ·` 徽标,每条消息排空后随即清除。Ctrl+C 或 Escape 会取消运行中的轮次。工具卡片与注入上下文卡片都把长主体折叠为可配置的头尾预览;Ctrl+O 让工具卡片在折叠预览、完整输出、隐藏三种状态间循环——隐藏阶段把工具卡片从 transcript 中完全去掉,而上下文卡片保持预览,因为注入的指令不属于工具流量。隐藏阶段还会把每个轮次的 assistant 步骤折叠为一条消息:第一个有可见文本或 reasoning 的步骤保留该轮次唯一的 `Assistant` 标题,之后的步骤渲染为无标题的续段,没有可见正文的步骤则不渲染任何内容;离开隐藏阶段会恢复每步各自的标题。注入上下文卡片把消息渲染为文本,并去掉生产方的外层提醒外框,因此折叠与去外框都不依赖载荷的语法。Ctrl+R 切换 reasoning,Ctrl+L 重绘,Ctrl+D 在空闲时退出。`/details` 命名的正是这两个快捷键循环的同一份状态:不带参数时打开一个居中的键盘开关,每个维度一个条目——`Tool cards` 与 `Reasoning`——显示实时值,Tab 循环高亮条目并立即应用变更(对话框背后的 transcript 即是预览),Enter、Esc 或 Ctrl+C 关闭;`/details collapsed|expanded|hidden` 让工具卡片直接跳到该阶段,`/details reasoning [on|off]` 设置——或裸 `reasoning` 切换——reasoning 块显示;参数可在一次调用中组合,未知参数会以用法行报错,组合调用先应用 reasoning,使其 transcript 重建不会丢掉卡片通知。 `/model` 将建议性的 `ctx.llm` catalog 打开为键盘选择器:列表上方设有一个过滤框,按对每行 `provider/model` 标签、模型名称和描述的大小写不敏感子串匹配来缩小行集,并在高亮行仍通过过滤时保持其选中状态;Up/Down 移动,Shift+Tab 按显示顺序循环切换适配器为焦点模型公布的推理强度,Enter 选择模型和推理强度,Escape 会先清除非空过滤内容,再次按下才关闭选择器。适配器未公布默认推理强度时,循环还会包含 `Default`,该项会清除显式选择并保留提供方默认行为;没有可选推理强度元数据的模型会忽略 Shift+Tab。选择器会原样呈现公布的推理强度列表(包括存在时的 `off`),不会合成、自动调整或在模型之间转移推理强度。`/model ` 仍可直接选择无歧义的模型 id,`/model /` 则选择精确目标,并在存在时使用其适配器默认值。已配置目标或最新记录的请求 header 会初始化选择器;由于 catalog 仅提供建议,未列出的当前模型仍会显示。选择仅对本 TUI 会话有效。提示词组装会为一个步骤建立目标快照,替换 `{{provider}}` 和 `{{model}}`,并通过 `agent/request` 应用同一个提供方/模型/推理强度目标;因此组装期间的切换会从后续步骤开始生效。请求 header 会持久记录真正到达模型的目标,未使用的选择则只存在于进程本地。 diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 3d39c53957..6470e70114 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -774,7 +774,9 @@ export function createTuiChat( break case 'assistant/message': completedStreaming = undefined - if (streaming === undefined || !chat.children.includes(streaming)) startAssistantStep(event.data) + // A settled component stays attached but never absorbs a later message + // of the same step; both the live and replay paths start a new one. + if (streaming === undefined || streaming.isSettled() || !chat.children.includes(streaming)) startAssistantStep(event.data) if (streaming !== undefined) { streaming.settle(event.data.message.content) applyTurnFolding(streaming.position.turn) @@ -999,11 +1001,8 @@ export function createTuiChat( // same reason. ui.queryTerminalColorScheme({ timeoutMs: 2000 }).catch(() => {}) - const toggleTools = (): void => { - // The cycle order puts the two common reading modes adjacent: preview -> - // full detail -> conversation-only, then back to the preview default. - toolsVisibility = toolsVisibility === 'collapsed' ? 'expanded' - : toolsVisibility === 'expanded' ? 'hidden' : 'collapsed' + const setToolsVisibility = (next: ToolCardVisibility): void => { + toolsVisibility = next for (const card of allToolCards) card.setVisibility(toolsVisibility) // Context cards carry injected instructions rather than tool traffic, so // they never hide: the hidden phase reads as their collapsed preview. @@ -1014,8 +1013,15 @@ export function createTuiChat( appendNotice(toolsVisibility === 'hidden' ? 'Tool cards hidden.' : `Tool and context cards ${toolsVisibility}.`) } - const toggleReasoning = (): void => { - showReasoning = !showReasoning + const toggleTools = (): void => { + // The cycle order puts the two common reading modes adjacent: preview -> + // full detail -> conversation-only, then back to the preview default. + setToolsVisibility(toolsVisibility === 'collapsed' ? 'expanded' + : toolsVisibility === 'expanded' ? 'hidden' : 'collapsed') + } + + const setReasoning = (show: boolean): void => { + showReasoning = show const activeStreaming = streaming rebuildTranscript(false) /* v8 ignore next -- the non-streaming command path is covered; this branch preserves an active stream across rebuild. */ @@ -1029,6 +1035,39 @@ export function createTuiChat( appendNotice(`Reasoning blocks ${showReasoning ? 'shown' : 'hidden'}.`) } + const toggleReasoning = (): void => { setReasoning(!showReasoning) } + + // `/details` names the same transcript-detail state the Ctrl+O cycle and + // Ctrl+R toggle mutate, so a user can jump to a mode without cycling. + const runDetails = (rawInput: string): CommandResult => { + const tokens = rawInput.split(/\s+/u).filter(token => token !== '') + if (tokens.length === 0) { + appendNotice(`Tool and context cards ${toolsVisibility}; reasoning blocks ${showReasoning ? 'shown' : 'hidden'}.`) + return { kind: 'success' } + } + let visibility: ToolCardVisibility | undefined + let reasoning: boolean | undefined + for (let token = tokens.shift(); token !== undefined; token = tokens.shift()) { + if (token === 'collapsed' || token === 'expanded' || token === 'hidden') { + visibility = token + } else if (token === 'reasoning') { + const value = tokens[0] + if (value === 'on' || value === 'off') { + tokens.shift() + reasoning = value === 'on' + } else { + reasoning = !showReasoning + } + } else { + return { kind: 'error', text: `Unknown /details argument "${token}". Usage: /details [collapsed|expanded|hidden] [reasoning [on|off]]` } + } + } + // Reasoning first: its transcript rebuild would drop the visibility notice. + if (reasoning !== undefined) setReasoning(reasoning) + if (visibility !== undefined) setToolsVisibility(visibility) + return { kind: 'success' } + } + const showHelp = (): void => { const commandLines = ctx.commands.list(agent).map((command) => { const input = command.input === undefined ? '' : ` ${command.input.hint}` @@ -1214,6 +1253,12 @@ export function createTuiChat( description: 'Clear the transcript view (session history is unchanged)', handler: () => { chat.clear(); requestRender(); return { kind: 'success' } }, }) + commandCtx.commands.register({ + name: 'details', + description: 'Show or set tool-card visibility and reasoning display', + input: { hint: '[collapsed|expanded|hidden] [reasoning [on|off]]' }, + handler: ({ rawInput }) => runDetails(rawInput), + }) commandCtx.commands.register({ name: 'palette', description: 'Show every color and attribute role this terminal renders', @@ -1553,7 +1598,6 @@ export function createTuiChat( if (event.type === 'tool/result') fileSearch.invalidate() recordEventUsage(tokens, event) if (event.type === 'turn/start' && runningStatus !== undefined) runningStatus.turn = event.data.turn - if (event.type === 'assistant/message' && streaming?.isSettled()) streaming = undefined // A replacement mutates only the model surface, so the rendered transcript // keeps what it already showed; a landed summary checkpoint adds its marker. if (isReplacementSurfaceEvent(event)) { diff --git a/packages/ui/tui/tests/snapshots/details-command.expected.txt b/packages/ui/tui/tests/snapshots/details-command.expected.txt new file mode 100644 index 0000000000..4e84695616 --- /dev/null +++ b/packages/ui/tui/tests/snapshots/details-command.expected.txt @@ -0,0 +1,45 @@ +terminal 100x40 buffer=normal length=40 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=7 viewportRow=19 bufferRow=19 +buffer +0| " DEEPSEEK HARNESS" + style 1-8 fg=bright-magenta bold + style 10-16 bold +1| " Snapshot agent ready." + style 1-21 dim +2| " main-session" + style 1-12 dim +3| +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| "Running the check now. " +6| "Model wait 0.0s " + style 0-14 dim +7| +8| "You " + style 0-2 fg=bright-magenta bold underline +9| "Inspect the renderer. " +10| "Model wait 0.0s · Completed 2026-07-30 18:00:00 " + style 0-46 dim +11| +12| "Reasoning blocks hidden. " + style 0-23 dim +13| +14| "Tool cards hidden. " + style 0-17 dim +15| +16| "Tool and context cards hidden; reasoning blocks hidden. " + style 0-54 dim +17| +18| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-magenta bold + style 18-31 dim + style 34-50 dim + style 53-57 dim + style 60-69 dim +19| " dsh > " + style 1-3 fg=bright-magenta bold + style 5-6 dim + style 7-7 inverse +20-39| diff --git a/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt b/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt index a6a73566d6..aa41ca137d 100644 --- a/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt +++ b/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt @@ -1,7 +1,7 @@ -terminal 92x32 buffer=normal length=37 base=5 viewport=5 +terminal 92x32 buffer=normal length=39 base=7 viewport=7 lifecycle started=1 stopped=1 progress=inactive title "DSH snapshot" -cursor visible column=0 viewportRow=31 bufferRow=36 +cursor visible column=0 viewportRow=31 bufferRow=38 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-magenta bold @@ -29,48 +29,52 @@ buffer 12| " " 13| "/clear — Clear the transcript view (session history is unchanged) " style 0-64 dim -14| "/exit — Exit after the active turn reaches idle " +14| "/details [collapsed|expanded|hidden] [reasoning [on|off]] — Show or set tool-card visibility" + style 0-91 dim +15| "and reasoning display " + style 0-20 dim +16| "/exit — Exit after the active turn reaches idle " style 0-46 dim -15| "/help — Show keyboard shortcuts and commands " +17| "/help — Show keyboard shortcuts and commands " style 0-43 dim -16| "/model [[provider/]model] — Show or switch this session's model " +18| "/model [[provider/]model] — Show or switch this session's model " style 0-62 dim -17| "/palette — Show every color and attribute role this terminal renders " +19| "/palette — Show every color and attribute role this terminal renders " style 0-67 dim -18| "/quit — Exit after the active turn reaches idle " +20| "/quit — Exit after the active turn reaches idle " style 0-46 dim -19| "/reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) " +21| "/reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) " style 0-87 dim -20| "/resume — List this workspace's resumable sessions " +22| "/resume — List this workspace's resumable sessions " style 0-49 dim -21| "/status — Show session diagnostics, system prompt, and registered tools " +23| "/status — Show session diagnostics, system prompt, and registered tools " style 0-70 dim -22| "/skill: [instructions] — load a skill into the conversation " +24| "/skill: [instructions] — load a skill into the conversation " style 0-64 dim -23| -24| "provider stream failed after partial output " - style 0-42 fg=red 25| -26| "The previous process ended during this turn. " - style 0-43 fg=yellow +26| "provider stream failed after partial output " + style 0-42 fg=red 27| -28| "Turn stopped: the agent was disposed. " - style 0-36 fg=yellow +28| "The previous process ended during this turn. " + style 0-43 fg=yellow 29| -30| "Turn ended: plugin-policy. " - style 0-25 fg=yellow +30| "Turn stopped: the agent was disposed. " + style 0-36 fg=yellow 31| -32| "Unknown command: /unknown-advanced-command " - style 0-41 fg=yellow +32| "Turn ended: plugin-policy. " + style 0-25 fg=yellow 33| -34| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" +34| "Unknown command: /unknown-advanced-command " + style 0-41 fg=yellow +35| +36| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" style 0-17 fg=bright-magenta bold style 18-31 dim style 34-50 dim style 53-57 dim style 60-69 dim -35| " dsh > " +37| " dsh > " style 1-3 fg=bright-magenta bold style 5-6 dim style 7-7 inverse -36| +38| diff --git a/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt b/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt index b8b62bca11..48c25cf8fa 100644 --- a/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt +++ b/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt @@ -1,7 +1,7 @@ -terminal 92x32 buffer=normal length=36 base=4 viewport=4 +terminal 92x32 buffer=normal length=38 base=6 viewport=6 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=7 viewportRow=31 bufferRow=35 +cursor hidden column=7 viewportRow=31 bufferRow=37 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-magenta bold @@ -29,47 +29,51 @@ buffer 12| " " 13| "/clear — Clear the transcript view (session history is unchanged) " style 0-64 dim -14| "/exit — Exit after the active turn reaches idle " +14| "/details [collapsed|expanded|hidden] [reasoning [on|off]] — Show or set tool-card visibility" + style 0-91 dim +15| "and reasoning display " + style 0-20 dim +16| "/exit — Exit after the active turn reaches idle " style 0-46 dim -15| "/help — Show keyboard shortcuts and commands " +17| "/help — Show keyboard shortcuts and commands " style 0-43 dim -16| "/model [[provider/]model] — Show or switch this session's model " +18| "/model [[provider/]model] — Show or switch this session's model " style 0-62 dim -17| "/palette — Show every color and attribute role this terminal renders " +19| "/palette — Show every color and attribute role this terminal renders " style 0-67 dim -18| "/quit — Exit after the active turn reaches idle " +20| "/quit — Exit after the active turn reaches idle " style 0-46 dim -19| "/reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) " +21| "/reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) " style 0-87 dim -20| "/resume — List this workspace's resumable sessions " +22| "/resume — List this workspace's resumable sessions " style 0-49 dim -21| "/status — Show session diagnostics, system prompt, and registered tools " +23| "/status — Show session diagnostics, system prompt, and registered tools " style 0-70 dim -22| "/skill: [instructions] — load a skill into the conversation " +24| "/skill: [instructions] — load a skill into the conversation " style 0-64 dim -23| -24| "provider stream failed after partial output " - style 0-42 fg=red 25| -26| "The previous process ended during this turn. " - style 0-43 fg=yellow +26| "provider stream failed after partial output " + style 0-42 fg=red 27| -28| "Turn stopped: the agent was disposed. " - style 0-36 fg=yellow +28| "The previous process ended during this turn. " + style 0-43 fg=yellow 29| -30| "Turn ended: plugin-policy. " - style 0-25 fg=yellow +30| "Turn stopped: the agent was disposed. " + style 0-36 fg=yellow 31| -32| "Unknown command: /unknown-advanced-command " - style 0-41 fg=yellow +32| "Turn ended: plugin-policy. " + style 0-25 fg=yellow 33| -34| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" +34| "Unknown command: /unknown-advanced-command " + style 0-41 fg=yellow +35| +36| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" style 0-17 fg=bright-magenta bold style 18-31 dim style 34-50 dim style 53-57 dim style 60-69 dim -35| " dsh > " +37| " dsh > " style 1-3 fg=bright-magenta bold style 5-6 dim style 7-7 inverse diff --git a/packages/ui/tui/tests/snapshots/untrusted-controls.expected.txt b/packages/ui/tui/tests/snapshots/untrusted-controls.expected.txt index 74cdd416d9..e5a625afc0 100644 --- a/packages/ui/tui/tests/snapshots/untrusted-controls.expected.txt +++ b/packages/ui/tui/tests/snapshots/untrusted-controls.expected.txt @@ -13,46 +13,41 @@ buffer 3| 4| "Assistant " style 0-8 fg=bright-magenta bold underline -5| -6| "You " +5| "Reasoning " + style 0-8 dim italic +6| "Unsafe reasoning \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " + style 0-61 dim italic +7| "Unsafe assistant \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " +8| "Model wait 0.0s " + style 0-14 dim +9| +10| "You " style 0-2 fg=bright-magenta bold underline -7| "Unsafe user \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " -8| -9| "● Tool / unsafe / Unsafe description \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m" +11| "Unsafe user \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " +12| +13| "Assistant " + style 0-8 fg=bright-magenta bold underline +14| +15| "● Tool / unsafe / Unsafe description \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m" style 0-81 fg=green -10| "$ Unsafe title \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " +16| "$ Unsafe title \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " style 0-59 dim -11| "/unsafe/\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " +17| "/unsafe/\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " style 0-52 dim -12| "Unsafe output \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " +18| "Unsafe output \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " style 0-58 dim -13| "[signal SIG\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m] " +19| "[signal SIG\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m] " style 0-56 fg=red -14| "Model wait 0.0s · Completed 2026-07-21 15:00:00 " +20| "Model wait 0.0s · Completed 2026-07-21 15:00:00 " style 0-46 dim -15| -16| "Context · unsafe-\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m" +21| +22| "Context · unsafe-\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m" style 0-61 dim -17| "Unsafe context \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " +23| "Unsafe context \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " style 0-59 dim -18| -19| "Unsafe turn error \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " +24| +25| "Unsafe turn error \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " style 0-62 fg=red -20-21| -22| "Plan" - style 0-3 fg=bright-magenta bold -23| " ● Unsafe todo \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m" - style 2-2 fg=yellow -24| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" - style 0-17 fg=bright-magenta bold - style 18-31 dim - style 34-50 dim - style 53-57 dim - style 60-69 dim -25| " dsh > " - style 1-3 fg=bright-magenta bold - style 5-6 dim - style 7-7 inverse 26| " " 27| " Question 1/1 (1 unanswered) · Unsafe header \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " style 2-90 dim diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts index d56db82f14..abea27e69e 100644 --- a/packages/ui/tui/tests/tui.snapshot.ts +++ b/packages/ui/tui/tests/tui.snapshot.ts @@ -45,6 +45,7 @@ const CHECKPOINTS = [ 'advanced-cards-collapsed', 'advanced-cards-expanded', 'tool-cards-hidden-folded', + 'details-command', 'untrusted-controls', 'question-dialog', 'question-dialog-single-option', @@ -637,6 +638,38 @@ describe('TUI terminal-state snapshots', () => { await disposeSnapshot(harness) }) + it('pins /details jumping card visibility and reasoning display to named states', async () => { + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(new Date(2026, 6, 30, 18, 0, 0).getTime()) + const harness = await setupSnapshot({ + tools: ADVANCED_CARD_TOOLS, + config: { maxToolOutputLines: 3 }, + }, { columns: 100, rows: 40 }) + await renderAfter(harness, () => { + appendUser(harness.session, 'Inspect the renderer.') + appendAssistant(harness.session, [ + { type: 'reasoning', text: 'The tool card and this block vanish under /details hidden reasoning off.' }, + { type: 'text', text: 'Running the check now.' }, + ]) + appendToolCalls(harness.session, [ + { id: 'details-1', name: 'bash', arguments: { command: 'pnpm run test' } }, + ]) + appendToolResult(harness.session, 'details-1', [{ type: 'text', text: 'all tests pass' }]) + harness.session.append('step/end', { turn: 1, step: 1 }) + harness.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + }) + await renderAfter(harness, () => { + harness.terminal.send('/details hidden reasoning off') + harness.terminal.send('\r') + }) + await renderAfter(harness, () => { + harness.terminal.send('/details') + harness.terminal.send('\r') + }) + await checkpoint('details-command', harness.terminal, { includeScrollback: true }) + nowSpy.mockRestore() + await disposeSnapshot(harness) + }) + it('renders terminal controls as inert text across transcripts, tools, dialogs, diagnostics, and title', async () => { const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(new Date(2026, 6, 21, 15, 0, 0).getTime()) const tools = { diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 66540d2cc2..c532c5a67d 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -2510,6 +2510,39 @@ describe('pi-tui chat lifecycle and transcript', () => { await dispose(result) }) + it('/details reports and sets card visibility and reasoning display', async () => { + const result = await setup() + const run = async (line: string): Promise => { + result.terminal.send(line) + result.terminal.send('\r') + await tick() + } + + await run('/details') + expect(result.terminal.output).toContain('Tool and context cards collapsed; reasoning blocks shown.') + + await run('/details hidden') + expect(result.terminal.output).toContain('Tool cards hidden.') + + await run('/details expanded reasoning off') + expect(result.terminal.output).toContain('Tool and context cards expanded.') + expect(result.terminal.output).toContain('Reasoning blocks hidden.') + + await run('/details reasoning on') + expect(result.terminal.output).toContain('Reasoning blocks shown.') + + // Bare `reasoning` toggles: shown -> hidden, confirmed by the status line. + await run('/details reasoning') + await run('/details collapsed') + await run('/details') + expect(result.terminal.output).toContain('Tool and context cards collapsed; reasoning blocks hidden.') + + await run('/details bogus') + expect(result.terminal.output).toContain('Unknown /details argument "bogus"') + + await dispose(result) + }) + it('sends, steers, handles commands, global keys, and disposed-agent input', async () => { const result = await setup() From 2c98f35a86e70ef943b05c11cfdf42d5fc4897bb Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 30 Jul 2026 19:52:58 +0800 Subject: [PATCH 019/129] feat(tui): bare /details opens a keyboard selector DetailsDialog is a centered SelectList over the five transcript-detail states (three tool-card phases, reasoning shown/hidden); it preselects the current phase, marks both current values, applies on Enter, and cancels on Esc/Ctrl+C. Width is the new detailsDialogWidth config key. The argument grammar is unchanged and shares the same setters. --- .../2026-07-30-tui-details-command.i18n.yaml | 4 +- .../feature/2026-07-30-tui-details-command.md | 11 +-- .../2026-07-30-tui-details-command.zh.md | 11 +-- docs/config-catalog.md | 4 +- packages/ui/tui/src/components/dialogs.ts | 58 ++++++++++++++++ packages/ui/tui/src/config.ts | 7 ++ packages/ui/tui/src/index.ts | 32 ++++++++- .../snapshots/details-command.expected.txt | 11 ++- .../snapshots/details-selector.expected.txt | 66 ++++++++++++++++++ .../snapshots/disposed-terminal.expected.txt | 6 +- .../snapshots/errors-and-help.expected.txt | 6 +- packages/ui/tui/tests/tui.snapshot.ts | 6 +- packages/ui/tui/tests/tui.spec.ts | 68 +++++++++++++++++-- 13 files changed, 256 insertions(+), 34 deletions(-) create mode 100644 packages/ui/tui/tests/snapshots/details-selector.expected.txt diff --git a/.agents/notes/implemented/feature/2026-07-30-tui-details-command.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.i18n.yaml index df7079c9b8..33278b66d2 100644 --- a/.agents/notes/implemented/feature/2026-07-30-tui-details-command.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.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 .agents/notes/implemented/feature/2026-07-30-tui-details-command.md -2026-07-30-tui-details-command.md: f17c168cb87d546ca158eddef7dd96fb0ab8be2d -2026-07-30-tui-details-command.zh.md: 48e9a8d3f18c51982b8678e39d9315ffe08b015f +2026-07-30-tui-details-command.md: c14461aa08790033026eead38f53d4b8e686bbbb +2026-07-30-tui-details-command.zh.md: e9c9787a4911f29f49e7709d1ba673dc1a25e8a1 diff --git a/.agents/notes/implemented/feature/2026-07-30-tui-details-command.md b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.md index f17c168cb8..c14461aa08 100644 --- a/.agents/notes/implemented/feature/2026-07-30-tui-details-command.md +++ b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.md @@ -10,7 +10,7 @@ The TUI's transcript detail state — tool-card visibility (`collapsed`/`expande ## Decision -`dsh-tui` registers `/details` beside its other agent-scoped commands. Bare `/details` reports the current state in one notice. Arguments name target states directly: `collapsed|expanded|hidden` jumps tool cards to that phase, `reasoning on|off` sets reasoning display, bare `reasoning` toggles it, and directives combine in one invocation. An unknown token returns a command error carrying the usage line. The command mutates the same closure state as the shortcuts, refactored so the cycle and toggle are thin wrappers over `setToolsVisibility`/`setReasoning`; the shortcuts and their notices are unchanged. +`dsh-tui` registers `/details` beside its other agent-scoped commands. Bare `/details` opens `DetailsDialog`, a centered keyboard selector over the five detail states — the three tool-card phases and reasoning shown/hidden — that preselects the current phase, marks both current values, applies the highlighted state on Enter, and cancels on Esc or Ctrl+C; its width is the `detailsDialogWidth` config key and a second `/details` replaces an open selector, mirroring the `/model` overlay. Arguments name target states directly: `collapsed|expanded|hidden` jumps tool cards to that phase, `reasoning on|off` sets reasoning display, bare `reasoning` toggles it, and directives combine in one invocation. An unknown token returns a command error carrying the usage line. Every entry mutates the same closure state as the shortcuts, refactored so the cycle and toggle are thin wrappers over `setToolsVisibility`/`setReasoning`; the shortcuts and their notices are unchanged. A combined invocation applies reasoning before visibility because `setReasoning` rebuilds the transcript from session events, which drops non-durable notice components; applying it last would erase the just-appended visibility notice. @@ -18,7 +18,9 @@ The reasoning rebuild exposed a replay defect that this change fixes in `renderE ## Alternatives considered -**Cycle on bare `/details`, mirroring Ctrl+O.** Rejected: the command's value over the shortcut is naming an absolute state; a cycling command is the shortcut with more keystrokes, and bare invocation is more useful as a state report. +**Cycle on bare `/details`, mirroring Ctrl+O.** Rejected: the command's value over the shortcut is naming an absolute state; a cycling command is the shortcut with more keystrokes, and bare invocation is more useful as the selector, which shows the current state while offering every target. + +**Bare `/details` as a text-only state report.** Shipped first, replaced by the selector: the report answered "where am I" but still required a second, argument-spelling invocation to change anything, while the selector shows the same state and applies a change in one interaction. The textual grammar remains for scripts, muscle memory, and combined two-dimension changes. **Separate `/tools` and `/reasoning` commands.** Rejected: both dimensions are one presentation concern ("how much detail does the transcript show"), and a single command keeps the registry and `/help` list small while allowing one combined invocation. @@ -26,6 +28,7 @@ The reasoning rebuild exposed a replay defect that this change fixes in `renderE ## Consequences -- A user can jump to any detail mode, set both dimensions at once, and query the state — including on terminals that intercept Ctrl+O/Ctrl+R. +- A user can jump to any detail mode, set both dimensions at once, and see the current state in the selector — including on terminals that intercept Ctrl+O/Ctrl+R. - The parser accepts order-free tokens, so `/details reasoning expanded` toggles reasoning and expands cards; last directive wins per dimension. This leniency is deliberate and documented in the README. -- Transcript rebuilds no longer lose assistant messages when a step carries more than one `assistant/message` event; the `details-command` snapshot pins the command surface and the fixed replay together. +- The selector applies one dimension per confirm; a combined change still needs the argument form. Enter on the already-current row re-applies it idempotently and repeats its notice. +- Transcript rebuilds no longer lose assistant messages when a step carries more than one `assistant/message` event; the `details-command` snapshot pins the argument surface and the fixed replay, and `details-selector` pins the open selector with its current-state markers. diff --git a/.agents/notes/implemented/feature/2026-07-30-tui-details-command.zh.md b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.zh.md index 48e9a8d3f1..e9c9787a49 100644 --- a/.agents/notes/implemented/feature/2026-07-30-tui-details-command.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.zh.md @@ -10,7 +10,7 @@ TUI 的 transcript(文本记录)细节状态——工具卡片可见性(`c ## Decision -`dsh-tui` 在其他 agent 作用域命令旁注册 `/details`。裸 `/details` 用一条通知报告当前状态。参数直接命名目标状态:`collapsed|expanded|hidden` 让工具卡片跳到该阶段,`reasoning on|off` 设置 reasoning 显示,裸 `reasoning` 切换它,且指令可在一次调用中组合。未知 token 返回携带用法行的命令错误。命令改动的是与快捷键相同的闭包状态,重构后循环与切换成为 `setToolsVisibility`/`setReasoning` 之上的薄封装;快捷键及其通知保持不变。 +`dsh-tui` 在其他 agent 作用域命令旁注册 `/details`。裸 `/details` 打开 `DetailsDialog`:一个居中的键盘选择器,列出五个细节状态——三个工具卡片阶段与 reasoning 显示/隐藏——预选当前阶段并标记两个当前值,Enter 应用高亮状态并关闭,Esc 或 Ctrl+C 取消;其宽度由配置键 `detailsDialogWidth` 决定,选择器打开时再次执行 `/details` 会替换它,与 `/model` 浮层一致。参数直接命名目标状态:`collapsed|expanded|hidden` 让工具卡片跳到该阶段,`reasoning on|off` 设置 reasoning 显示,裸 `reasoning` 切换它,且指令可在一次调用中组合。未知 token 返回携带用法行的命令错误。每个入口改动的都是与快捷键相同的闭包状态,重构后循环与切换成为 `setToolsVisibility`/`setReasoning` 之上的薄封装;快捷键及其通知保持不变。 组合调用先应用 reasoning 再应用可见性,因为 `setReasoning` 会从会话事件重建 transcript,而重建会丢弃非持久的通知组件;若最后才应用它,会抹掉刚追加的可见性通知。 @@ -18,7 +18,9 @@ reasoning 重建暴露了一个重放缺陷,本变更在 `renderEvent` 中修 ## Alternatives considered -**裸 `/details` 像 Ctrl+O 一样循环。** 否决:命令相对快捷键的价值在于命名绝对状态;循环命令只是按键更多的快捷键,裸调用作为状态报告更有用。 +**裸 `/details` 像 Ctrl+O 一样循环。** 否决:命令相对快捷键的价值在于命名绝对状态;循环命令只是按键更多的快捷键,裸调用作为选择器更有用——它在展示当前状态的同时提供所有目标。 + +**裸 `/details` 仅输出文本状态报告。** 首版如此实现,后被选择器取代:报告回答了“我在哪”,但改变任何东西仍需第二次、拼写参数的调用;选择器展示同样的状态并在一次交互中应用变更。文本语法保留给脚本、肌肉记忆和两维组合变更。 **拆分 `/tools` 与 `/reasoning` 两个命令。** 否决:两个维度同属一个展示关注点(“transcript 显示多少细节”),单一命令让注册表与 `/help` 列表更小,同时允许一次组合调用。 @@ -26,6 +28,7 @@ reasoning 重建暴露了一个重放缺陷,本变更在 `renderEvent` 中修 ## Consequences -- 用户可以跳到任意细节模式、一次设置两个维度并查询状态——包括在拦截 Ctrl+O/Ctrl+R 的终端上。 +- 用户可以跳到任意细节模式、一次设置两个维度,并在选择器中看到当前状态——包括在拦截 Ctrl+O/Ctrl+R 的终端上。 - 解析器接受无序 token,因此 `/details reasoning expanded` 会切换 reasoning 并展开卡片;每个维度以最后一个指令为准。这一宽松是刻意的,并记录在 README 中。 -- 当一个步骤携带多条 `assistant/message` 事件时,transcript 重建不再丢失 assistant 消息;`details-command` 快照同时固定命令表面与修复后的重放。 +- 选择器每次确认只应用一个维度;组合变更仍需参数形式。在已是当前值的行上按 Enter 会幂等地重新应用并重复其通知。 +- 当一个步骤携带多条 `assistant/message` 事件时,transcript 重建不再丢失 assistant 消息;`details-command` 快照固定参数表面与修复后的重放,`details-selector` 固定带当前值标记的打开选择器。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 2288d7ffbe..0f04e0a955 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2034,6 +2034,8 @@ export interface TuiConfig { questionDialogMaxHeight?: number /** Model-selector width in terminal columns. */ modelDialogWidth?: number + /** Transcript-details selector width in terminal columns. */ + detailsDialogWidth?: number /** Model-selector maximum height in terminal rows. */ modelDialogMaxHeight?: number /** Maximum fuzzy file candidates displayed for one `@` query. */ @@ -2067,7 +2069,7 @@ export interface TuiThemeConfig { } ``` -Source: [`packages/ui/tui/src/config.ts:117`](../packages/ui/tui/src/config.ts) +Source: [`packages/ui/tui/src/config.ts:121`](../packages/ui/tui/src/config.ts) ## `@deepseek-ai/dsh-typert-loader` diff --git a/packages/ui/tui/src/components/dialogs.ts b/packages/ui/tui/src/components/dialogs.ts index bad6a5458b..f59ff747be 100644 --- a/packages/ui/tui/src/components/dialogs.ts +++ b/packages/ui/tui/src/components/dialogs.ts @@ -34,6 +34,7 @@ import type { import type { AskUserQuestionItem } from '@deepseek-ai/dsh-user-interaction' import { BRACKETED_PASTE_END, BRACKETED_PASTE_START, displayText, sanitizePastedText } from './text.ts' import { dialogSelectTheme, type Palette } from './theme.ts' +import type { ToolCardVisibility } from './transcript.ts' import { renderTuiPromptTemplate, type TuiPromptTemplateToken, @@ -432,6 +433,63 @@ export class ModelDialog implements Component { } } +/** One transcript-detail state the details selector applies on confirm. */ +export type DetailsSelection = + | { readonly kind: 'tools'; readonly visibility: ToolCardVisibility } + | { readonly kind: 'reasoning'; readonly show: boolean } + +/** + * Keyboard selector over the transcript detail states: the three tool-card + * visibility phases and reasoning-block display. Enter applies the highlighted + * state and closes; Esc or Ctrl+C closes without changing anything. + */ +export class DetailsDialog implements Component { + private readonly list: SelectList + + constructor( + visibility: ToolCardVisibility, + showReasoning: boolean, + private readonly palette: Palette, + done: (selection: DetailsSelection) => void, + private readonly cancel: () => void, + ) { + const current = (isCurrent: boolean): string => isCurrent ? ' — current' : '' + const items: SelectItem[] = [ + { value: 'collapsed', label: 'Tool cards · collapsed', description: `head/tail preview${current(visibility === 'collapsed')}` }, + { value: 'expanded', label: 'Tool cards · expanded', description: `full bodies${current(visibility === 'expanded')}` }, + { value: 'hidden', label: 'Tool cards · hidden', description: `conversation only${current(visibility === 'hidden')}` }, + { value: 'reasoning-shown', label: 'Reasoning · shown', description: `show reasoning blocks${current(showReasoning)}` }, + { value: 'reasoning-hidden', label: 'Reasoning · hidden', description: `omit reasoning blocks${current(!showReasoning)}` }, + ] + this.list = new SelectList(items, items.length, dialogSelectTheme(palette)) + this.list.setSelectedIndex(items.findIndex(item => item.value === visibility)) + this.list.onSelect = (item) => { + done(item.value === 'reasoning-shown' || item.value === 'reasoning-hidden' + ? { kind: 'reasoning', show: item.value === 'reasoning-shown' } + : { kind: 'tools', visibility: item.value as ToolCardVisibility }) + } + } + + invalidate(): void { + this.list.invalidate() + } + + handleInput(data: string): void { + if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) this.cancel() + else this.list.handleInput(data) + this.invalidate() + } + + render(width: number): string[] { + const innerWidth = Math.max(1, width - 4) + return renderDialog('Transcript details', [ + ...this.list.render(innerWidth), + '', + this.palette.dim('↑/↓ move • Enter apply • Esc cancel'), + ], width, this.palette) + } +} + /** The provider/model route recovered from a resume candidate's log. */ export interface ResumeRoute { provider: string diff --git a/packages/ui/tui/src/config.ts b/packages/ui/tui/src/config.ts index def548861f..59a12c0b96 100644 --- a/packages/ui/tui/src/config.ts +++ b/packages/ui/tui/src/config.ts @@ -46,6 +46,8 @@ export interface TuiConfig { questionDialogMaxHeight?: number /** Model-selector width in terminal columns. */ modelDialogWidth?: number + /** Transcript-details selector width in terminal columns. */ + detailsDialogWidth?: number /** Model-selector maximum height in terminal rows. */ modelDialogMaxHeight?: number /** Maximum fuzzy file candidates displayed for one `@` query. */ @@ -70,6 +72,7 @@ const maxResumeOptionsSchema = z.number().step(1).min(1).default(8) const questionDialogWidthSchema = z.number().step(1).min(20).default(200) const questionDialogMaxHeightSchema = z.number().step(1).min(6).default(20) const modelDialogWidthSchema = z.number().step(1).min(20).default(76) +const detailsDialogWidthSchema = z.number().step(1).min(20).default(72) const modelDialogMaxHeightSchema = z.number().step(1).min(6).default(20) const fileSearchMaxResultsSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_RESULTS) const fileSearchMaxEntriesSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_ENTRIES) @@ -102,6 +105,7 @@ const tuiConfigSchemaFields = { questionDialogMaxHeight: questionDialogMaxHeightSchema, modelDialogWidth: modelDialogWidthSchema, modelDialogMaxHeight: modelDialogMaxHeightSchema, + detailsDialogWidth: detailsDialogWidthSchema, fileSearchMaxResults: fileSearchMaxResultsSchema, fileSearchMaxEntries: fileSearchMaxEntriesSchema, fileSearchExcludedDirectories: fileSearchExcludedDirectoriesSchema, @@ -142,6 +146,7 @@ export const Config: z = z.object({ questionDialogMaxHeight: tuiConfigSchemaFields.questionDialogMaxHeight, modelDialogWidth: tuiConfigSchemaFields.modelDialogWidth, modelDialogMaxHeight: tuiConfigSchemaFields.modelDialogMaxHeight, + detailsDialogWidth: tuiConfigSchemaFields.detailsDialogWidth, fileSearchMaxResults: tuiConfigSchemaFields.fileSearchMaxResults, fileSearchMaxEntries: tuiConfigSchemaFields.fileSearchMaxEntries, fileSearchExcludedDirectories: tuiConfigSchemaFields.fileSearchExcludedDirectories, @@ -171,6 +176,7 @@ export interface ResolvedTuiConfig { questionDialogMaxHeight: number modelDialogWidth: number modelDialogMaxHeight: number + detailsDialogWidth: number fileSearchMaxResults: number fileSearchMaxEntries: number fileSearchExcludedDirectories: string[] @@ -196,6 +202,7 @@ export function resolveTuiConfig(config: TuiConfig | undefined): ResolvedTuiConf questionDialogMaxHeight: config?.questionDialogMaxHeight ?? 20, modelDialogWidth: config?.modelDialogWidth ?? 76, modelDialogMaxHeight: config?.modelDialogMaxHeight ?? 20, + detailsDialogWidth: config?.detailsDialogWidth ?? 72, fileSearchMaxResults: config?.fileSearchMaxResults ?? DEFAULT_FILE_SEARCH_MAX_RESULTS, fileSearchMaxEntries: config?.fileSearchMaxEntries ?? DEFAULT_FILE_SEARCH_MAX_ENTRIES, fileSearchExcludedDirectories: [...(config?.fileSearchExcludedDirectories ?? DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES)], diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 6470e70114..7fd76a1a6f 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -104,6 +104,7 @@ import { } from './components/transcript.ts' import { compactTargetLabel, + DetailsDialog, diagnosticMeter, formatDiagnosticCount, formatDiagnosticNumber, @@ -112,6 +113,7 @@ import { StatusCardComponent, PromptContextComponent, targetLabel, + type DetailsSelection, type StatusCardRow, } from './components/dialogs.ts' import { @@ -1037,12 +1039,38 @@ export function createTuiChat( const toggleReasoning = (): void => { setReasoning(!showReasoning) } + // The selector and the argument grammar mutate the same closure state the + // Ctrl+O cycle and Ctrl+R toggle drive, so every entry converges. + let detailsOverlay: TuiOverlaySession | undefined + const showDetailsSelector = (): void => { + void detailsOverlay?.close() + const session = overlayManager.open({ + create: () => new DetailsDialog( + toolsVisibility, + showReasoning, + palette, + (selection: DetailsSelection) => { + void session.close() + if (selection.kind === 'reasoning') setReasoning(selection.show) + else setToolsVisibility(selection.visibility) + }, + () => { void session.close() }, + ), + options: { width: resolved.detailsDialogWidth, anchor: 'center', margin: 1 }, + }) + detailsOverlay = session + void session.closed.then(() => { + if (detailsOverlay === session) detailsOverlay = undefined + }) + requestRender() + } + // `/details` names the same transcript-detail state the Ctrl+O cycle and // Ctrl+R toggle mutate, so a user can jump to a mode without cycling. const runDetails = (rawInput: string): CommandResult => { const tokens = rawInput.split(/\s+/u).filter(token => token !== '') if (tokens.length === 0) { - appendNotice(`Tool and context cards ${toolsVisibility}; reasoning blocks ${showReasoning ? 'shown' : 'hidden'}.`) + showDetailsSelector() return { kind: 'success' } } let visibility: ToolCardVisibility | undefined @@ -1255,7 +1283,7 @@ export function createTuiChat( }) commandCtx.commands.register({ name: 'details', - description: 'Show or set tool-card visibility and reasoning display', + description: 'Select tool-card visibility and reasoning display', input: { hint: '[collapsed|expanded|hidden] [reasoning [on|off]]' }, handler: ({ rawInput }) => runDetails(rawInput), }) diff --git a/packages/ui/tui/tests/snapshots/details-command.expected.txt b/packages/ui/tui/tests/snapshots/details-command.expected.txt index 4e84695616..4d971c88df 100644 --- a/packages/ui/tui/tests/snapshots/details-command.expected.txt +++ b/packages/ui/tui/tests/snapshots/details-command.expected.txt @@ -1,7 +1,7 @@ terminal 100x40 buffer=normal length=40 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=7 viewportRow=19 bufferRow=19 +cursor hidden column=7 viewportRow=17 bufferRow=17 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-magenta bold @@ -29,17 +29,14 @@ buffer 14| "Tool cards hidden. " style 0-17 dim 15| -16| "Tool and context cards hidden; reasoning blocks hidden. " - style 0-54 dim -17| -18| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" +16| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" style 0-17 fg=bright-magenta bold style 18-31 dim style 34-50 dim style 53-57 dim style 60-69 dim -19| " dsh > " +17| " dsh > " style 1-3 fg=bright-magenta bold style 5-6 dim style 7-7 inverse -20-39| +18-39| diff --git a/packages/ui/tui/tests/snapshots/details-selector.expected.txt b/packages/ui/tui/tests/snapshots/details-selector.expected.txt new file mode 100644 index 0000000000..ad6514d4e9 --- /dev/null +++ b/packages/ui/tui/tests/snapshots/details-selector.expected.txt @@ -0,0 +1,66 @@ +terminal 100x40 buffer=normal length=40 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=0 viewportRow=39 bufferRow=39 +buffer +0| " DEEPSEEK HARNESS" + style 1-8 fg=bright-magenta bold + style 10-16 bold +1| " Snapshot agent ready." + style 1-21 dim +2| " main-session" + style 1-12 dim +3| +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| "Running the check now. " +6| "Model wait 0.0s " + style 0-14 dim +7| +8| "You " + style 0-2 fg=bright-magenta bold underline +9| "Inspect the renderer. " +10| "Model wait 0.0s · Completed 2026-07-30 18:00:00 " + style 0-46 dim +11| +12| "Reasoning blocks hidden. " + style 0-23 dim +13| +14| "Tool cards hidden. " + style 0-17 dim +15| " ╭ Transcript details ──────────────────────────────────────────────────╮ " + style 14-85 fg=bright-magenta +16| "/workspace/pro│ Tool cards · collapsed head/tail preview │ " + style 0-13 fg=bright-magenta bold + style 14-14 fg=bright-magenta + style 40-66 dim + style 85-85 fg=bright-magenta +17| " dsh > │ Tool cards · expanded full bodies │ " + style 1-3 fg=bright-magenta bold + style 5-6 dim + style 7-7 inverse + style 14-14 fg=bright-magenta + style 39-60 dim + style 85-85 fg=bright-magenta +18| " │ → Tool cards · hidden conversation only — current │ " + style 14-14 fg=bright-magenta + style 16-76 fg=bright-magenta inverse + style 85-85 fg=bright-magenta +19| " │ Reasoning · shown show reasoning blocks │ " + style 14-14 fg=bright-magenta + style 35-70 dim + style 85-85 fg=bright-magenta +20| " │ Reasoning · hidden omit reasoning blocks — current │ " + style 14-14 fg=bright-magenta + style 36-80 dim + style 85-85 fg=bright-magenta +21| " │ │ " + style 14-14 fg=bright-magenta + style 85-85 fg=bright-magenta +22| " │ ↑/↓ move • Enter apply • Esc cancel │ " + style 14-14 fg=bright-magenta + style 16-50 dim + style 85-85 fg=bright-magenta +23| " ╰──────────────────────────────────────────────────────────────────────╯ " + style 14-85 fg=bright-magenta +24-39| diff --git a/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt b/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt index aa41ca137d..1ade76a5aa 100644 --- a/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt +++ b/packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt @@ -29,10 +29,10 @@ buffer 12| " " 13| "/clear — Clear the transcript view (session history is unchanged) " style 0-64 dim -14| "/details [collapsed|expanded|hidden] [reasoning [on|off]] — Show or set tool-card visibility" +14| "/details [collapsed|expanded|hidden] [reasoning [on|off]] — Select tool-card visibility and " style 0-91 dim -15| "and reasoning display " - style 0-20 dim +15| "reasoning display " + style 0-16 dim 16| "/exit — Exit after the active turn reaches idle " style 0-46 dim 17| "/help — Show keyboard shortcuts and commands " diff --git a/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt b/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt index 48c25cf8fa..7da75336e3 100644 --- a/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt +++ b/packages/ui/tui/tests/snapshots/errors-and-help.expected.txt @@ -29,10 +29,10 @@ buffer 12| " " 13| "/clear — Clear the transcript view (session history is unchanged) " style 0-64 dim -14| "/details [collapsed|expanded|hidden] [reasoning [on|off]] — Show or set tool-card visibility" +14| "/details [collapsed|expanded|hidden] [reasoning [on|off]] — Select tool-card visibility and " style 0-91 dim -15| "and reasoning display " - style 0-20 dim +15| "reasoning display " + style 0-16 dim 16| "/exit — Exit after the active turn reaches idle " style 0-46 dim 17| "/help — Show keyboard shortcuts and commands " diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts index abea27e69e..c0b6d44f88 100644 --- a/packages/ui/tui/tests/tui.snapshot.ts +++ b/packages/ui/tui/tests/tui.snapshot.ts @@ -46,6 +46,7 @@ const CHECKPOINTS = [ 'advanced-cards-expanded', 'tool-cards-hidden-folded', 'details-command', + 'details-selector', 'untrusted-controls', 'question-dialog', 'question-dialog-single-option', @@ -661,11 +662,14 @@ describe('TUI terminal-state snapshots', () => { harness.terminal.send('/details hidden reasoning off') harness.terminal.send('\r') }) + await checkpoint('details-command', harness.terminal, { includeScrollback: true }) + // Bare /details opens the selector, preselecting and marking the current + // hidden/reasoning-off state. await renderAfter(harness, () => { harness.terminal.send('/details') harness.terminal.send('\r') }) - await checkpoint('details-command', harness.terminal, { includeScrollback: true }) + await checkpoint('details-selector', harness.terminal, { includeScrollback: true }) nowSpy.mockRestore() await disposeSnapshot(harness) }) diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index c532c5a67d..093d8ad285 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -186,6 +186,7 @@ describe('TUI config', () => { questionDialogMaxHeight: 20, modelDialogWidth: 76, modelDialogMaxHeight: 20, + detailsDialogWidth: 72, fileSearchMaxResults: 20, fileSearchMaxEntries: 10_000, fileSearchExcludedDirectories: ['.git', 'node_modules'], @@ -210,6 +211,7 @@ describe('TUI config', () => { questionDialogMaxHeight: 14, modelDialogWidth: 64, modelDialogMaxHeight: 16, + detailsDialogWidth: 44, fileSearchMaxResults: 7, fileSearchMaxEntries: 123, fileSearchExcludedDirectories: ['.git', 'generated'], @@ -226,6 +228,7 @@ describe('TUI config', () => { questionDialogMaxHeight: 14, modelDialogWidth: 64, modelDialogMaxHeight: 16, + detailsDialogWidth: 44, fileSearchMaxResults: 7, fileSearchMaxEntries: 123, fileSearchExcludedDirectories: ['.git', 'generated'], @@ -2510,7 +2513,7 @@ describe('pi-tui chat lifecycle and transcript', () => { await dispose(result) }) - it('/details reports and sets card visibility and reasoning display', async () => { + it('/details sets card visibility and reasoning display from arguments', async () => { const result = await setup() const run = async (line: string): Promise => { result.terminal.send(line) @@ -2518,9 +2521,6 @@ describe('pi-tui chat lifecycle and transcript', () => { await tick() } - await run('/details') - expect(result.terminal.output).toContain('Tool and context cards collapsed; reasoning blocks shown.') - await run('/details hidden') expect(result.terminal.output).toContain('Tool cards hidden.') @@ -2531,11 +2531,12 @@ describe('pi-tui chat lifecycle and transcript', () => { await run('/details reasoning on') expect(result.terminal.output).toContain('Reasoning blocks shown.') - // Bare `reasoning` toggles: shown -> hidden, confirmed by the status line. + // Bare `reasoning` toggles: shown -> hidden. + const toggleOutput = result.terminal.output.length await run('/details reasoning') + expect(result.terminal.output.slice(toggleOutput)).toContain('Reasoning blocks hidden.') await run('/details collapsed') - await run('/details') - expect(result.terminal.output).toContain('Tool and context cards collapsed; reasoning blocks hidden.') + expect(result.terminal.output.slice(toggleOutput)).toContain('Tool and context cards collapsed.') await run('/details bogus') expect(result.terminal.output).toContain('Unknown /details argument "bogus"') @@ -2543,6 +2544,59 @@ describe('pi-tui chat lifecycle and transcript', () => { await dispose(result) }) + it('bare /details opens the transcript-details selector and applies the confirmed state', async () => { + const result = await setup() + const open = async (): Promise => { + const from = result.terminal.output.length + result.terminal.send('/details') + result.terminal.send('\r') + await vi.waitFor(() => { expect(result.terminal.output.slice(from)).toContain('Transcript details') }) + return from + } + + await open() + expect(result.terminal.output).toContain('Tool cards · collapsed') + expect(result.terminal.output).toContain('head/tail preview — current') + expect(result.terminal.output).toContain('show reasoning blocks — current') + + // A second /details while the selector is open replaces the overlay + // instead of stacking a second one behind it. + await result.ctx.commands.execute(result.agent, '/details', new AbortController().signal) + await tick() + + // Esc cancels without touching the state. + const cancelOutput = result.terminal.output.length + result.terminal.send('\x1b') + await tick() + expect(result.terminal.output.slice(cancelOutput)).not.toContain('Tool and context cards') + + // Enter on the next visibility row applies it and closes. + await open() + result.terminal.send('\x1b[B') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('Tool and context cards expanded.') + + // The reopened selector preselects the current phase and marks it. + const reopened = await open() + expect(result.terminal.output.slice(reopened)).toContain('full bodies — current') + result.terminal.send('\x1b[B') + result.terminal.send('\x1b[B') + result.terminal.send('\x1b[B') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('Reasoning blocks hidden.') + + // Ctrl+C also cancels. + const ctrlCOutput = result.terminal.output.length + await open() + result.terminal.send('\x03') + await tick() + expect(result.terminal.output.slice(ctrlCOutput)).not.toContain('Reasoning blocks shown.') + + await dispose(result) + }) + it('sends, steers, handles commands, global keys, and disposed-agent input', async () => { const result = await setup() From f13003df757125176692740cac16898251457e84 Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 30 Jul 2026 23:45:50 +0800 Subject: [PATCH 020/129] feat(tui): two-entry /details selector with Tab cycling DetailsDialog now shows one entry per dimension (Tool cards, Reasoning) seeded with the current values; Tab cycles the highlighted entry's pending value (rendered as current -> pending), Enter applies every changed dimension in one confirm, Esc/Ctrl+C cancels. --- .../2026-07-30-tui-details-command.i18n.yaml | 4 +- .../feature/2026-07-30-tui-details-command.md | 6 +- .../2026-07-30-tui-details-command.zh.md | 6 +- packages/ui/tui/README.i18n.yaml | 4 +- packages/ui/tui/README.md | 6 +- packages/ui/tui/README.zh.md | 6 +- packages/ui/tui/src/components/dialogs.ts | 79 +++++++++++++------ packages/ui/tui/src/index.ts | 5 +- .../snapshots/details-selector.expected.txt | 58 ++++++-------- packages/ui/tui/tests/tui.snapshot.ts | 6 +- packages/ui/tui/tests/tui.spec.ts | 36 ++++++--- 11 files changed, 131 insertions(+), 85 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-30-tui-details-command.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.i18n.yaml index 33278b66d2..8e6c202a5e 100644 --- a/.agents/notes/implemented/feature/2026-07-30-tui-details-command.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.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 .agents/notes/implemented/feature/2026-07-30-tui-details-command.md -2026-07-30-tui-details-command.md: c14461aa08790033026eead38f53d4b8e686bbbb -2026-07-30-tui-details-command.zh.md: e9c9787a4911f29f49e7709d1ba673dc1a25e8a1 +2026-07-30-tui-details-command.md: 5de80ceb3ad78a949268c31e9c1d1a50c956b90f +2026-07-30-tui-details-command.zh.md: f74d0658cb776752bf3da1682291de882638310a diff --git a/.agents/notes/implemented/feature/2026-07-30-tui-details-command.md b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.md index c14461aa08..5de80ceb3a 100644 --- a/.agents/notes/implemented/feature/2026-07-30-tui-details-command.md +++ b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.md @@ -10,7 +10,7 @@ The TUI's transcript detail state — tool-card visibility (`collapsed`/`expande ## Decision -`dsh-tui` registers `/details` beside its other agent-scoped commands. Bare `/details` opens `DetailsDialog`, a centered keyboard selector over the five detail states — the three tool-card phases and reasoning shown/hidden — that preselects the current phase, marks both current values, applies the highlighted state on Enter, and cancels on Esc or Ctrl+C; its width is the `detailsDialogWidth` config key and a second `/details` replaces an open selector, mirroring the `/model` overlay. Arguments name target states directly: `collapsed|expanded|hidden` jumps tool cards to that phase, `reasoning on|off` sets reasoning display, bare `reasoning` toggles it, and directives combine in one invocation. An unknown token returns a command error carrying the usage line. Every entry mutates the same closure state as the shortcuts, refactored so the cycle and toggle are thin wrappers over `setToolsVisibility`/`setReasoning`; the shortcuts and their notices are unchanged. +`dsh-tui` registers `/details` beside its other agent-scoped commands. Bare `/details` opens `DetailsDialog`, a centered keyboard selector with one entry per dimension — `Tool cards` and `Reasoning` — seeded with the current values: Tab cycles the highlighted entry's pending value (rendered as `current → pending`), Enter applies every changed dimension in one confirm and closes, and Esc or Ctrl+C cancels; its width is the `detailsDialogWidth` config key and a second `/details` replaces an open selector, mirroring the `/model` overlay. Arguments name target states directly: `collapsed|expanded|hidden` jumps tool cards to that phase, `reasoning on|off` sets reasoning display, bare `reasoning` toggles it, and directives combine in one invocation. An unknown token returns a command error carrying the usage line. Every entry mutates the same closure state as the shortcuts, refactored so the cycle and toggle are thin wrappers over `setToolsVisibility`/`setReasoning`; the shortcuts and their notices are unchanged. A combined invocation applies reasoning before visibility because `setReasoning` rebuilds the transcript from session events, which drops non-durable notice components; applying it last would erase the just-appended visibility notice. @@ -30,5 +30,5 @@ The reasoning rebuild exposed a replay defect that this change fixes in `renderE - A user can jump to any detail mode, set both dimensions at once, and see the current state in the selector — including on terminals that intercept Ctrl+O/Ctrl+R. - The parser accepts order-free tokens, so `/details reasoning expanded` toggles reasoning and expands cards; last directive wins per dimension. This leniency is deliberate and documented in the README. -- The selector applies one dimension per confirm; a combined change still needs the argument form. Enter on the already-current row re-applies it idempotently and repeats its notice. -- Transcript rebuilds no longer lose assistant messages when a step carries more than one `assistant/message` event; the `details-command` snapshot pins the argument surface and the fixed replay, and `details-selector` pins the open selector with its current-state markers. +- The selector applies only changed dimensions on confirm, so Enter without a Tab closes silently; a two-dimension change is one open-Tab-Enter interaction. +- Transcript rebuilds no longer lose assistant messages when a step carries more than one `assistant/message` event; the `details-command` snapshot pins the argument surface and the fixed replay, and `details-selector` pins the open selector with a Tab-cycled `hidden → collapsed` pending value. diff --git a/.agents/notes/implemented/feature/2026-07-30-tui-details-command.zh.md b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.zh.md index e9c9787a49..f74d0658cb 100644 --- a/.agents/notes/implemented/feature/2026-07-30-tui-details-command.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.zh.md @@ -10,7 +10,7 @@ TUI 的 transcript(文本记录)细节状态——工具卡片可见性(`c ## Decision -`dsh-tui` 在其他 agent 作用域命令旁注册 `/details`。裸 `/details` 打开 `DetailsDialog`:一个居中的键盘选择器,列出五个细节状态——三个工具卡片阶段与 reasoning 显示/隐藏——预选当前阶段并标记两个当前值,Enter 应用高亮状态并关闭,Esc 或 Ctrl+C 取消;其宽度由配置键 `detailsDialogWidth` 决定,选择器打开时再次执行 `/details` 会替换它,与 `/model` 浮层一致。参数直接命名目标状态:`collapsed|expanded|hidden` 让工具卡片跳到该阶段,`reasoning on|off` 设置 reasoning 显示,裸 `reasoning` 切换它,且指令可在一次调用中组合。未知 token 返回携带用法行的命令错误。每个入口改动的都是与快捷键相同的闭包状态,重构后循环与切换成为 `setToolsVisibility`/`setReasoning` 之上的薄封装;快捷键及其通知保持不变。 +`dsh-tui` 在其他 agent 作用域命令旁注册 `/details`。裸 `/details` 打开 `DetailsDialog`:一个居中的键盘选择器,每个维度一个条目——`Tool cards` 与 `Reasoning`——以当前值为初始:Tab 循环高亮条目的待定值(渲染为 `current → pending`),Enter 一次确认应用所有已改变的维度并关闭,Esc 或 Ctrl+C 取消;其宽度由配置键 `detailsDialogWidth` 决定,选择器打开时再次执行 `/details` 会替换它,与 `/model` 浮层一致。参数直接命名目标状态:`collapsed|expanded|hidden` 让工具卡片跳到该阶段,`reasoning on|off` 设置 reasoning 显示,裸 `reasoning` 切换它,且指令可在一次调用中组合。未知 token 返回携带用法行的命令错误。每个入口改动的都是与快捷键相同的闭包状态,重构后循环与切换成为 `setToolsVisibility`/`setReasoning` 之上的薄封装;快捷键及其通知保持不变。 组合调用先应用 reasoning 再应用可见性,因为 `setReasoning` 会从会话事件重建 transcript,而重建会丢弃非持久的通知组件;若最后才应用它,会抹掉刚追加的可见性通知。 @@ -30,5 +30,5 @@ reasoning 重建暴露了一个重放缺陷,本变更在 `renderEvent` 中修 - 用户可以跳到任意细节模式、一次设置两个维度,并在选择器中看到当前状态——包括在拦截 Ctrl+O/Ctrl+R 的终端上。 - 解析器接受无序 token,因此 `/details reasoning expanded` 会切换 reasoning 并展开卡片;每个维度以最后一个指令为准。这一宽松是刻意的,并记录在 README 中。 -- 选择器每次确认只应用一个维度;组合变更仍需参数形式。在已是当前值的行上按 Enter 会幂等地重新应用并重复其通知。 -- 当一个步骤携带多条 `assistant/message` 事件时,transcript 重建不再丢失 assistant 消息;`details-command` 快照固定参数表面与修复后的重放,`details-selector` 固定带当前值标记的打开选择器。 +- 选择器确认时只应用已改变的维度,因此未按 Tab 直接 Enter 会静默关闭;两个维度的变更是一次打开-Tab-Enter 交互。 +- 当一个步骤携带多条 `assistant/message` 事件时,transcript 重建不再丢失 assistant 消息;`details-command` 快照固定参数表面与修复后的重放,`details-selector` 固定经 Tab 循环出 `hidden → collapsed` 待定值的打开选择器。 diff --git a/packages/ui/tui/README.i18n.yaml b/packages/ui/tui/README.i18n.yaml index 6587bc7624..af9ba4a67a 100644 --- a/packages/ui/tui/README.i18n.yaml +++ b/packages/ui/tui/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/ui/tui/README.md -README.md: a65d72b3b80b992fabcb33d4b4345942b58e9147 -README.zh.md: fd056da59335af5cb9fc63fd2fb681266fadeb62 +README.md: 7230e75535ce5710e12040e026bc77a1782ed6dd +README.zh.md: 3079b797b670de64cfdc1faa9d002ee04a6e28dd diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index 23ca8343ec..8ef5f20ca0 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -12,7 +12,7 @@ This package owns interactive terminal presentation and input only. It injects ` After terminal startup succeeds, the package provides the terminal-local `ctx.tui` extension service. A plugin that injects it can call `openOverlay()` with a component factory and constrained layout options; the host exposes the viewport, semantic theme, display-text escaping, redraw, close, and a lifetime signal, but not the pi-tui tree, terminal, focus controller, or overlay handle. Plugin overlays, the model selector, and user questions share one FIFO modal queue. Each request is an effect of the calling plugin fiber, so unload removes queued work or closes visible work before cleanup settles; terminal shutdown unloads dependents before stopping pi-tui. Overlay state is not logged or replayed. Component code is trusted and may render ANSI styling, but must pass untrusted text through `host.display()`. The [interactive-extension Agent Note](../../../.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md) owns the boundary and rejected alternatives. -The TUI rebuilds resumed history from the append-origin session events, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the standing `todo/write` plan above the editor (cleared on the next `turn/start`), and presents `ctx.userInteraction` questions in a wide bottom-left keyboard panel with progress, numbered options, and aligned descriptions. The latest logged session title becomes the header subtitle, with `welcome` before a title exists, and the terminal window title becomes ``. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Its idle view compares token-meter pressure with `ctx.llm.resolveModelInfo()` context for the current route, displays `context unknown` when the adapter has no capacity metadata, and also shows tool-card mode plus the current model and any explicitly selected reasoning effort; while the agent runs, an elapsed working indicator and `esc interrupt` replace that summary. A surface replacement never rewrites the rendered transcript: the conversation it shadows stays readable, and a landed compaction checkpoint adds one dim `… earlier context was compacted …` marker at its log position, so the terminal reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies — a pruned tool result, a regenerated assistant message — render nothing. +The TUI rebuilds resumed history from the active session surface, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the standing `todo/write` plan above the editor (cleared on the next `turn/start`), and presents `ctx.userInteraction` questions in a wide bottom-left keyboard panel with progress, numbered options, and aligned descriptions. The latest logged session title becomes the header subtitle, with `welcome` before a title exists, and the terminal window title becomes ``. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Its idle view compares token-meter pressure with `ctx.llm.resolveModelInfo()` context for the current route, displays `context unknown` when the adapter has no capacity metadata, and also shows tool-card mode plus the current model and any explicitly selected reasoning effort; while the agent runs, an elapsed working indicator and `esc interrupt` replace that summary. Surface replacement events rebuild the transcript so compacted history does not reappear. An embedding may provide `TuiRuntime.formatCwd` when its logical workspace label differs from the session's host directory. The override changes only the footer label; tools continue to use the session `cwd`. @@ -22,7 +22,7 @@ Typing `@` at a token boundary searches files and directories under the session When optional `ctx.sessionReferences` is mounted, the same `@` menu also offers metadata-only session candidates, inserts `@[label](dsh-session:)`, and prepares the selected snapshots before dispatch. Session references remain structured because the model has no filesystem-like tool for retrieving session snapshots later. Preparation disables duplicate submission and restores the editor input on failure. The TUI chooses `agent.steer()` or `agent.followup()` from the status after that asynchronous preparation, so idle follow-ups still dispatch `agent/prompt-submit` while in-turn steering joins at a checkpoint without that hook. -While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.followup()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path automatically reaches the model. A command producer may explicitly schedule agent work; [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) uses that contract for `/plan [message]`. The TUI registers `/help`, `/model`, `/clear`, `/details`, `/palette`, `/reload`, `/resume`, `/status`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically, as do `/skill:` completions. A status line above the editor reports the turn phase the TUI derives from session events — waiting for the first token, thinking, responding, or executing tools — with the elapsed time in that phase and the running step total, refreshed each second, and ends with the `Enter sends steering, Esc cancels` hint; while steering messages wait to reach the model it inserts a `N queued ·` badge before the hint that clears as each drains. Ctrl+C or Escape cancels a running turn. Tool and injected-context cards collapse long bodies into a configurable head/tail preview; Ctrl+O cycles tool cards through collapsed preview, full output, and hidden — the hidden phase drops tool cards from the transcript entirely while context cards stay at their preview, since injected instructions are not tool traffic. The hidden phase also folds each turn's assistant steps into one message: the first step with visible text or reasoning keeps the turn's single `Assistant` header, later steps render as headerless continuations, and a step without a visible body renders nothing; leaving the hidden phase restores the per-step headers. An injected-context card renders its message as prose with the producer's outer reminder frame stripped, so neither the fold nor the frame stripping depends on the payload's syntax. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle. `/details` names the same state those two shortcuts cycle: bare it opens a centered keyboard toggle with one entry per dimension — `Tool cards` and `Reasoning` — showing the live values, where Tab cycles the highlighted entry and applies the change immediately (the transcript behind the dialog is the preview), and Enter, Esc, or Ctrl+C closes; `/details collapsed|expanded|hidden` jumps tool cards to that phase directly, and `/details reasoning [on|off]` sets — or bare `reasoning` toggles — reasoning-block display; arguments combine in one invocation, an unknown argument fails with the usage line, and a combined invocation applies reasoning first so its transcript rebuild never drops the card notice. +While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.followup()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path automatically reaches the model. A command producer may explicitly schedule agent work; [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) uses that contract for `/plan [message]`. The TUI registers `/help`, `/model`, `/clear`, `/details`, `/palette`, `/reload`, `/resume`, `/status`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically, as do `/skill:` completions. A status line above the editor reports the turn phase the TUI derives from session events — waiting for the first token, thinking, responding, or executing tools — with the elapsed time in that phase and the running step total, refreshed each second, and ends with the `Enter sends steering, Esc cancels` hint; while steering messages wait to reach the model it inserts a `N queued ·` badge before the hint that clears as each drains. Ctrl+C or Escape cancels a running turn. Tool and injected-context cards collapse long bodies into a configurable head/tail preview; Ctrl+O cycles tool cards through collapsed preview, full output, and hidden — the hidden phase drops tool cards from the transcript entirely while context cards stay at their preview, since injected instructions are not tool traffic. The hidden phase also folds each turn's assistant steps into one message: the first step with visible text or reasoning keeps the turn's single `Assistant` header, later steps render as headerless continuations, and a step without a visible body renders nothing; leaving the hidden phase restores the per-step headers. An injected-context card renders its message as prose with the producer's outer reminder frame stripped, so neither the fold nor the frame stripping depends on the payload's syntax. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle. `/details` names the same state those two shortcuts cycle: bare it opens a centered keyboard selector with one entry per dimension — `Tool cards` and `Reasoning` — seeded with the current values, where Tab cycles the highlighted entry's pending value (rendered as `current → pending`), Enter applies every changed dimension and closes, and Esc or Ctrl+C cancels without changing anything; `/details collapsed|expanded|hidden` jumps tool cards to that phase directly, and `/details reasoning [on|off]` sets — or bare `reasoning` toggles — reasoning-block display; arguments combine in one invocation, an unknown argument fails with the usage line, and a combined invocation applies reasoning first so its transcript rebuild never drops the card notice. `/model` opens the advisory `ctx.llm` catalog as a keyboard selector: a filter box above the list narrows rows by a case-insensitive substring over each row's `provider/model` label, model name, and description, keeping the highlighted row selected when it survives the filter; Up/Down moves, Shift+Tab cycles the focused model's adapter-advertised reasoning efforts in display order, Enter selects the model and effort, and Escape clears a non-empty filter before a second Escape closes it. When an adapter does not advertise a default effort, the cycle also includes `Default`, which clears an explicit selection and preserves the provider default; models without selectable effort metadata ignore Shift+Tab. The selector renders the exact advertised effort list—including `off` when present—and does not synthesize, clamp, or transfer an effort between models. `/model ` still selects an unambiguous model id directly, while `/model /` selects an exact target and uses its adapter default when one exists. The configured target or latest logged request header initializes the selector, and an unlisted current model remains visible because catalogs are advisory. Selection is local to this TUI session. Prompt assembly snapshots the target for one step, replaces `{{provider}}` and `{{model}}`, and applies the same provider/model/reasoning-effort target through `agent/request`; a switch during assembly therefore starts with a later step. The request header durably records targets that reach the model, while an unused selection remains process-local. @@ -32,6 +32,7 @@ The footer sums the session's reported usage as `↑ `/status` adds a point-in-time diagnostics card to the transcript and remains available while the agent runs. It reports the session id, title, working directory, selected provider/model, selected reasoning effort or default behavior, reasoning-block visibility, agent state, event/turn/step/tool-call counts, exact input/output/cache token buckets, KV-cache hit rate, token-meter context use and capacity, creation time, and latest event time. Missing titles, models, cache input, or context capacity are labeled instead of inferred. The card is terminal-only and does not duplicate the compact footer. + `/resume` opens a full-viewport keyboard selector instead of a centered dialog. Two scopes cover the same candidate set: the current workspace, which it opens on, and all workspaces, which Tab toggles to. The scope line under the search field names the active scope and the count the other holds, and each row in the all-workspaces scope also reports its own workspace. Toggling clears the search and selection so the highlighted row always belongs to the visible list. Its focused search field starts immediately after the search glyph and emits pi-tui's cursor marker, so terminal IME composition remains anchored inside the field. Candidates are sorted by last logged activity and searchable by log-backed title or session id, and by workspace label in the all-workspaces scope; each row reports current/live/persisted state, last turn outcome, recent provider/model, and durable goal phase when present. Up/Down and Page Up/Page Down navigate, Enter resumes, Escape clears a non-empty search before a second Escape cancels, and Ctrl+C cancels directly. The current session, a session already live in this runtime, an unreadable log, a session with no recorded workspace to run in, or a session whose logged provider has no current adapter remains visible but disabled; a workspace other than the current one is a scope rather than a disabled reason, because resume enters that directory. @@ -57,6 +58,7 @@ A launcher can seed a fresh session's first turn by providing `INITIAL_SKILL_KEY | `questionDialogMaxHeight` | `20` | Question-panel maximum rows | | `modelDialogWidth` | `76` | Model-selector width in columns | | `modelDialogMaxHeight` | `20` | Model-selector maximum rows | +| `detailsDialogWidth` | `72` | Transcript-details selector width in columns | | `fileSearchMaxResults` | `20` | Maximum file and directory candidates shown for one `@` query | | `fileSearchMaxEntries` | `10000` | Maximum paths retained in the bounded workspace index used by bare fuzzy queries | | `fileSearchExcludedDirectories` | `['.git', 'node_modules']` | Directory basenames omitted from traversal and direct completion | diff --git a/packages/ui/tui/README.zh.md b/packages/ui/tui/README.zh.md index 40aa849838..48cb25fb94 100644 --- a/packages/ui/tui/README.zh.md +++ b/packages/ui/tui/README.zh.md @@ -12,7 +12,7 @@ DeepSeek Harness agent(智能体)的交互式终端入口,基于 [`@earend 终端成功启动后,本包会提供终端本地的 `ctx.tui` 扩展服务。注入该服务的插件可以使用组件工厂和受限布局选项调用 `openOverlay()`;宿主会公开 viewport、语义化主题、显示文本转义、重绘、关闭和生命周期信号,但不公开 pi-tui 树、终端、焦点控制器或 overlay 句柄。插件 overlay、模型选择器和用户问题共用一个 FIFO 模态队列。每个请求都是调用方插件 fiber 的 effect,因此卸载会移除排队工作,或在清理结算前关闭可见工作;终端关闭会先卸载依赖项,再停止 pi-tui。Overlay 状态不会记录或回放。组件代码受信任,可以渲染 ANSI 样式,但必须通过 `host.display()` 处理不受信任文本。[交互式扩展 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md)持有该边界和未采用的替代方案。 -TUI 从追加来源的会话事件重建已恢复历史,渲染 Markdown 响应与 reasoning,将每个工具的 `presentCall` / `presentResult` 意图应用到终端、diff 或通用卡片,把站立的 `todo/write` 计划保留在编辑器上方(下一个 `turn/start` 时清空),并在左下方宽键盘面板中展示 `ctx.userInteraction` 问题,包含进度、编号选项和对齐说明。最新记录的会话标题成为 header 副标题;标题不存在时使用 `welcome`,终端窗口标题则变为 ``。持久 `llm/retry` 事件会撤回失败步骤的实时 chunk,并在 transcript(文本记录)中渲染计划重试次数、延迟和失败;成功、耗尽与取消随后通过普通会话事件结算。Footer 会对每个已记录模型步骤的用量只计一次,包括失败尝试;对于没有用量 chunk 的日志,以已提交消息的用量回退。其空闲视图会将 token-meter 压力与 `ctx.llm.resolveModelInfo()` 为当前路由返回的上下文容量进行比较;适配器没有容量元数据时显示 `context unknown`,并显示工具卡片模式、当前模型,以及任何显式选择的推理强度。Agent 运行时,这些摘要会替换为已经过工作时间指示器和 `esc interrupt`。表层替换从不重写已渲染的 transcript:被它遮蔽的对话仍可阅读,而已落地的压缩(compaction)检查点会在其日志位置添加一行暗色 `… earlier context was compacted …` 标记,因此终端报告的是模型从何处起不再看到那段历史,而不是把它抹掉。仅供模型使用的替换副本——被裁剪的工具结果、重新生成的 assistant 消息——不渲染任何内容。 +TUI 从活跃会话表层重建已恢复历史,渲染 Markdown 响应与 reasoning,将每个工具的 `presentCall` / `presentResult` 意图应用到终端、diff 或通用卡片,把站立的 `todo/write` 计划保留在编辑器上方(下一个 `turn/start` 时清空),并在左下方宽键盘面板中展示 `ctx.userInteraction` 问题,包含进度、编号选项和对齐说明。最新记录的会话标题成为 header 副标题;标题不存在时使用 `welcome`,终端窗口标题则变为 ``。持久 `llm/retry` 事件会撤回失败步骤的实时 chunk,并在 transcript(文本记录)中渲染计划重试次数、延迟和失败;成功、耗尽与取消随后通过普通会话事件结算。Footer 会对每个已记录模型步骤的用量只计一次,包括失败尝试;对于没有用量 chunk 的日志,以已提交消息的用量回退。其空闲视图会将 token-meter 压力与 `ctx.llm.resolveModelInfo()` 为当前路由返回的上下文容量进行比较;适配器没有容量元数据时显示 `context unknown`,并显示工具卡片模式、当前模型,以及任何显式选择的推理强度。Agent 运行时,这些摘要会替换为已经过工作时间指示器和 `esc interrupt`。表层替换事件会重建 transcript,使经过压缩(compaction)的历史不会再次出现。 如果逻辑工作区标签与会话宿主目录不同,嵌入方可以提供 `TuiRuntime.formatCwd`。该覆盖只改变 footer 标签;工具仍使用会话 `cwd`。 @@ -22,7 +22,7 @@ TUI 从追加来源的会话事件重建已恢复历史,渲染 Markdown 响应 挂载可选的 `ctx.sessionReferences` 后,同一个 `@` 菜单还会提供仅含元数据的会话候选项,插入 `@[label](dsh-session:)`,并在分派前准备所选快照。会话引用保持结构化,因为模型没有类似文件系统的工具可在稍后检索会话快照。准备期间会禁止重复提交,并在失败时恢复编辑器输入。TUI 会在异步准备后根据状态选择 `agent.steer()` 或 `agent.followup()`,因此空闲 followup 仍会分派 `agent/prompt-submit`,而轮次中的 steering 会在检查点加入且不触发该 hook。 -Agent 运行时,普通编辑器提交会调用 `agent.steer()`;其他时候调用 `agent.followup()`。提交行以斜杠开头时会改为进入 `ctx.commands`:已知命令直接执行,未知命令产生警告,两条路径都不会自动到达模型。命令生产方可以显式调度 agent 工作;[`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) 使用该契约实现 `/plan [message]`。TUI 将 `/help`、`/model`、`/clear`、`/details`、`/palette`、`/reload`、`/resume`、`/status` 和 `/exit` 注册为 agent 作用域定义;其他所有有效命令都会动态加入自动补全与 `/help`,`/skill:` 补全也相同。编辑器上方的状态行会报告 TUI 从会话事件派生的轮次阶段,包括等待首个 token、思考、响应或执行工具;它显示该阶段已经过时间和运行中的步骤总数,每秒刷新,并以 `Enter sends steering, Esc cancels` 提示结尾。Steering 消息等待到达模型期间,会在提示前插入 `N queued ·` 徽标,每条消息排空后随即清除。Ctrl+C 或 Escape 会取消运行中的轮次。工具卡片与注入上下文卡片都把长主体折叠为可配置的头尾预览;Ctrl+O 让工具卡片在折叠预览、完整输出、隐藏三种状态间循环——隐藏阶段把工具卡片从 transcript 中完全去掉,而上下文卡片保持预览,因为注入的指令不属于工具流量。隐藏阶段还会把每个轮次的 assistant 步骤折叠为一条消息:第一个有可见文本或 reasoning 的步骤保留该轮次唯一的 `Assistant` 标题,之后的步骤渲染为无标题的续段,没有可见正文的步骤则不渲染任何内容;离开隐藏阶段会恢复每步各自的标题。注入上下文卡片把消息渲染为文本,并去掉生产方的外层提醒外框,因此折叠与去外框都不依赖载荷的语法。Ctrl+R 切换 reasoning,Ctrl+L 重绘,Ctrl+D 在空闲时退出。`/details` 命名的正是这两个快捷键循环的同一份状态:不带参数时打开一个居中的键盘开关,每个维度一个条目——`Tool cards` 与 `Reasoning`——显示实时值,Tab 循环高亮条目并立即应用变更(对话框背后的 transcript 即是预览),Enter、Esc 或 Ctrl+C 关闭;`/details collapsed|expanded|hidden` 让工具卡片直接跳到该阶段,`/details reasoning [on|off]` 设置——或裸 `reasoning` 切换——reasoning 块显示;参数可在一次调用中组合,未知参数会以用法行报错,组合调用先应用 reasoning,使其 transcript 重建不会丢掉卡片通知。 +Agent 运行时,普通编辑器提交会调用 `agent.steer()`;其他时候调用 `agent.followup()`。提交行以斜杠开头时会改为进入 `ctx.commands`:已知命令直接执行,未知命令产生警告,两条路径都不会自动到达模型。命令生产方可以显式调度 agent 工作;[`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) 使用该契约实现 `/plan [message]`。TUI 将 `/help`、`/model`、`/clear`、`/details`、`/palette`、`/reload`、`/resume`、`/status` 和 `/exit` 注册为 agent 作用域定义;其他所有有效命令都会动态加入自动补全与 `/help`,`/skill:` 补全也相同。编辑器上方的状态行会报告 TUI 从会话事件派生的轮次阶段,包括等待首个 token、思考、响应或执行工具;它显示该阶段已经过时间和运行中的步骤总数,每秒刷新,并以 `Enter sends steering, Esc cancels` 提示结尾。Steering 消息等待到达模型期间,会在提示前插入 `N queued ·` 徽标,每条消息排空后随即清除。Ctrl+C 或 Escape 会取消运行中的轮次。工具卡片与注入上下文卡片都把长主体折叠为可配置的头尾预览;Ctrl+O 让工具卡片在折叠预览、完整输出、隐藏三种状态间循环——隐藏阶段把工具卡片从 transcript 中完全去掉,而上下文卡片保持预览,因为注入的指令不属于工具流量。隐藏阶段还会把每个轮次的 assistant 步骤折叠为一条消息:第一个有可见文本或 reasoning 的步骤保留该轮次唯一的 `Assistant` 标题,之后的步骤渲染为无标题的续段,没有可见正文的步骤则不渲染任何内容;离开隐藏阶段会恢复每步各自的标题。注入上下文卡片把消息渲染为文本,并去掉生产方的外层提醒外框,因此折叠与去外框都不依赖载荷的语法。Ctrl+R 切换 reasoning,Ctrl+L 重绘,Ctrl+D 在空闲时退出。`/details` 命名的正是这两个快捷键循环的同一份状态:不带参数时打开一个居中的键盘选择器,每个维度一个条目——`Tool cards` 与 `Reasoning`——以当前值为初始,Tab 循环高亮条目的待定值(渲染为 `current → pending`),Enter 应用所有已改变的维度并关闭,Esc 或 Ctrl+C 取消且不改变任何东西;`/details collapsed|expanded|hidden` 让工具卡片直接跳到该阶段,`/details reasoning [on|off]` 设置——或裸 `reasoning` 切换——reasoning 块显示;参数可在一次调用中组合,未知参数会以用法行报错,组合调用先应用 reasoning,使其 transcript 重建不会丢掉卡片通知。 `/model` 将建议性的 `ctx.llm` catalog 打开为键盘选择器:列表上方设有一个过滤框,按对每行 `provider/model` 标签、模型名称和描述的大小写不敏感子串匹配来缩小行集,并在高亮行仍通过过滤时保持其选中状态;Up/Down 移动,Shift+Tab 按显示顺序循环切换适配器为焦点模型公布的推理强度,Enter 选择模型和推理强度,Escape 会先清除非空过滤内容,再次按下才关闭选择器。适配器未公布默认推理强度时,循环还会包含 `Default`,该项会清除显式选择并保留提供方默认行为;没有可选推理强度元数据的模型会忽略 Shift+Tab。选择器会原样呈现公布的推理强度列表(包括存在时的 `off`),不会合成、自动调整或在模型之间转移推理强度。`/model ` 仍可直接选择无歧义的模型 id,`/model /` 则选择精确目标,并在存在时使用其适配器默认值。已配置目标或最新记录的请求 header 会初始化选择器;由于 catalog 仅提供建议,未列出的当前模型仍会显示。选择仅对本 TUI 会话有效。提示词组装会为一个步骤建立目标快照,替换 `{{provider}}` 和 `{{model}}`,并通过 `agent/request` 应用同一个提供方/模型/推理强度目标;因此组装期间的切换会从后续步骤开始生效。请求 header 会持久记录真正到达模型的目标,未使用的选择则只存在于进程本地。 @@ -32,6 +32,7 @@ Footer 将会话报告的用量汇总为 `↑`;任 `/status` 会向 transcript 添加一张时间点诊断卡片,并在 agent 运行时保持可用。它报告会话 id、标题、工作目录、所选提供方/模型、所选推理强度或默认行为、reasoning 块可见性、agent 状态、事件/轮次/步骤/工具调用计数、精确输入/输出/缓存 token bucket、KV-cache 命中率、token-meter 上下文用量与容量、创建时间和最新事件时间。缺失标题、模型、缓存输入或上下文容量时会明确标记,而非推断。该卡片只存在于终端,不会重复紧凑 footer。 + `/resume` 会打开全 viewport 键盘选择器,而非居中对话框。两个作用域覆盖同一候选项集合:打开时所处的当前工作区,以及按 Tab 切换到的所有工作区。搜索字段下方的作用域行会给出当前作用域的名称以及另一个作用域包含的数量,且在所有工作区作用域中每行还会报告自身所属的工作区。切换会清除搜索与选择,使高亮行始终属于可见列表。 获得焦点的搜索字段紧跟搜索 glyph 开始,并发出 pi-tui 的 cursor marker,使终端 IME 组合保持锚定在字段内。候选项按最近记录的活动排序,可按日志支持的标题或会话 id 搜索,在所有工作区作用域中还可按工作区标签搜索;每行报告 current/live/persisted 状态、上一轮次结果、近期提供方/模型,以及存在时的持久目标阶段。Up/Down 与 Page Up/Page Down 导航,Enter 恢复,Escape 会先清除非空搜索,再次按下才取消,Ctrl+C 则直接取消。当前会话、已在本运行时中活跃的会话、不可读日志、没有可运行的已记录工作区的会话,或日志所记提供方没有当前适配器的会话仍会显示,但不可选择;不同于当前工作区的工作区属于作用域而非禁用原因,因为恢复会进入该目录。 @@ -57,6 +58,7 @@ Footer 将会话报告的用量汇总为 `↑`;任 | `questionDialogMaxHeight` | `20` | 问题面板最大行数 | | `modelDialogWidth` | `76` | 模型选择器宽度(列数) | | `modelDialogMaxHeight` | `20` | 模型选择器最大行数 | +| `detailsDialogWidth` | `72` | transcript 细节选择器宽度(列数) | | `fileSearchMaxResults` | `20` | 一次 `@` 查询显示的最大文件和目录候选数 | | `fileSearchMaxEntries` | `10000` | 无路径模糊查询使用的有界工作区索引最多保留的路径数 | | `fileSearchExcludedDirectories` | `['.git', 'node_modules']` | 遍历和直接补全时忽略的目录 basename | diff --git a/packages/ui/tui/src/components/dialogs.ts b/packages/ui/tui/src/components/dialogs.ts index f59ff747be..93c416b230 100644 --- a/packages/ui/tui/src/components/dialogs.ts +++ b/packages/ui/tui/src/components/dialogs.ts @@ -433,40 +433,70 @@ export class ModelDialog implements Component { } } -/** One transcript-detail state the details selector applies on confirm. */ -export type DetailsSelection = - | { readonly kind: 'tools'; readonly visibility: ToolCardVisibility } - | { readonly kind: 'reasoning'; readonly show: boolean } +/** Both transcript-detail dimensions the details selector applies on confirm. */ +export interface DetailsSelection { + readonly visibility: ToolCardVisibility + readonly showReasoning: boolean +} + +const TOOL_CARD_PHASES: readonly ToolCardVisibility[] = ['collapsed', 'expanded', 'hidden'] /** - * Keyboard selector over the transcript detail states: the three tool-card - * visibility phases and reasoning-block display. Enter applies the highlighted - * state and closes; Esc or Ctrl+C closes without changing anything. + * Keyboard selector over the two transcript-detail entries — tool-card + * visibility and reasoning display. Tab cycles the highlighted entry's pending + * value, Enter applies both pending values and closes, Esc or Ctrl+C closes + * without changing anything. A pending value renders as `current → pending`. */ export class DetailsDialog implements Component { private readonly list: SelectList + private readonly toolsItem: SelectItem + private readonly reasoningItem: SelectItem + private pendingVisibility: ToolCardVisibility + private pendingReasoning: boolean constructor( - visibility: ToolCardVisibility, - showReasoning: boolean, + private readonly visibility: ToolCardVisibility, + private readonly showReasoning: boolean, private readonly palette: Palette, done: (selection: DetailsSelection) => void, private readonly cancel: () => void, ) { - const current = (isCurrent: boolean): string => isCurrent ? ' — current' : '' - const items: SelectItem[] = [ - { value: 'collapsed', label: 'Tool cards · collapsed', description: `head/tail preview${current(visibility === 'collapsed')}` }, - { value: 'expanded', label: 'Tool cards · expanded', description: `full bodies${current(visibility === 'expanded')}` }, - { value: 'hidden', label: 'Tool cards · hidden', description: `conversation only${current(visibility === 'hidden')}` }, - { value: 'reasoning-shown', label: 'Reasoning · shown', description: `show reasoning blocks${current(showReasoning)}` }, - { value: 'reasoning-hidden', label: 'Reasoning · hidden', description: `omit reasoning blocks${current(!showReasoning)}` }, - ] - this.list = new SelectList(items, items.length, dialogSelectTheme(palette)) - this.list.setSelectedIndex(items.findIndex(item => item.value === visibility)) - this.list.onSelect = (item) => { - done(item.value === 'reasoning-shown' || item.value === 'reasoning-hidden' - ? { kind: 'reasoning', show: item.value === 'reasoning-shown' } - : { kind: 'tools', visibility: item.value as ToolCardVisibility }) + this.pendingVisibility = visibility + this.pendingReasoning = showReasoning + this.toolsItem = { value: 'tools', label: 'Tool cards', description: this.describeTools() } + this.reasoningItem = { value: 'reasoning', label: 'Reasoning', description: this.describeReasoning() } + this.list = new SelectList([this.toolsItem, this.reasoningItem], 2, dialogSelectTheme(palette)) + this.list.onSelect = () => { + done({ visibility: this.pendingVisibility, showReasoning: this.pendingReasoning }) + } + } + + /** `current → pending` when Tab moved the value, otherwise the current value. */ + private static pendingLabel(current: string, pending: string): string { + return pending === current ? current : `${current} → ${pending}` + } + + private describeTools(): string { + return DetailsDialog.pendingLabel(this.visibility, this.pendingVisibility) + } + + private describeReasoning(): string { + const label = (show: boolean): string => show ? 'shown' : 'hidden' + return DetailsDialog.pendingLabel(label(this.showReasoning), label(this.pendingReasoning)) + } + + /** Cycle the highlighted entry's pending value one step. */ + private cyclePending(): void { + const selected = this.list.getSelectedItem() + /* v8 ignore next -- the two-entry list always has a selection. */ + if (selected === null) return + if (selected.value === 'tools') { + const index = TOOL_CARD_PHASES.indexOf(this.pendingVisibility) + this.pendingVisibility = TOOL_CARD_PHASES[(index + 1) % TOOL_CARD_PHASES.length] as ToolCardVisibility + this.toolsItem.description = this.describeTools() + } else { + this.pendingReasoning = !this.pendingReasoning + this.reasoningItem.description = this.describeReasoning() } } @@ -476,6 +506,7 @@ export class DetailsDialog implements Component { handleInput(data: string): void { if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) this.cancel() + else if (matchesKey(data, Key.tab)) this.cyclePending() else this.list.handleInput(data) this.invalidate() } @@ -485,7 +516,7 @@ export class DetailsDialog implements Component { return renderDialog('Transcript details', [ ...this.list.render(innerWidth), '', - this.palette.dim('↑/↓ move • Enter apply • Esc cancel'), + this.palette.dim('↑/↓ move • Tab cycle • Enter apply • Esc cancel'), ], width, this.palette) } } diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 7fd76a1a6f..9a07b1f7c4 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -1051,8 +1051,9 @@ export function createTuiChat( palette, (selection: DetailsSelection) => { void session.close() - if (selection.kind === 'reasoning') setReasoning(selection.show) - else setToolsVisibility(selection.visibility) + // Reasoning first: its transcript rebuild would drop the card notice. + if (selection.showReasoning !== showReasoning) setReasoning(selection.showReasoning) + if (selection.visibility !== toolsVisibility) setToolsVisibility(selection.visibility) }, () => { void session.close() }, ), diff --git a/packages/ui/tui/tests/snapshots/details-selector.expected.txt b/packages/ui/tui/tests/snapshots/details-selector.expected.txt index ad6514d4e9..d6cacf8cef 100644 --- a/packages/ui/tui/tests/snapshots/details-selector.expected.txt +++ b/packages/ui/tui/tests/snapshots/details-selector.expected.txt @@ -28,39 +28,33 @@ buffer 13| 14| "Tool cards hidden. " style 0-17 dim -15| " ╭ Transcript details ──────────────────────────────────────────────────╮ " - style 14-85 fg=bright-magenta -16| "/workspace/pro│ Tool cards · collapsed head/tail preview │ " - style 0-13 fg=bright-magenta bold - style 14-14 fg=bright-magenta - style 40-66 dim - style 85-85 fg=bright-magenta -17| " dsh > │ Tool cards · expanded full bodies │ " +15| +16| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-magenta bold + style 18-31 dim + style 34-50 dim + style 53-57 dim + style 60-69 dim +17| " dsh > ╭ Transcript details ──────────────────────────────────────────────────╮ " style 1-3 fg=bright-magenta bold style 5-6 dim style 7-7 inverse - style 14-14 fg=bright-magenta - style 39-60 dim - style 85-85 fg=bright-magenta -18| " │ → Tool cards · hidden conversation only — current │ " - style 14-14 fg=bright-magenta - style 16-76 fg=bright-magenta inverse - style 85-85 fg=bright-magenta -19| " │ Reasoning · shown show reasoning blocks │ " - style 14-14 fg=bright-magenta - style 35-70 dim - style 85-85 fg=bright-magenta -20| " │ Reasoning · hidden omit reasoning blocks — current │ " - style 14-14 fg=bright-magenta - style 36-80 dim - style 85-85 fg=bright-magenta -21| " │ │ " - style 14-14 fg=bright-magenta - style 85-85 fg=bright-magenta -22| " │ ↑/↓ move • Enter apply • Esc cancel │ " - style 14-14 fg=bright-magenta - style 16-50 dim - style 85-85 fg=bright-magenta -23| " ╰──────────────────────────────────────────────────────────────────────╯ " style 14-85 fg=bright-magenta -24-39| +18| " │ → Tool cards hidden → collapsed │ " + style 14-14 fg=bright-magenta + style 16-67 fg=bright-magenta inverse + style 85-85 fg=bright-magenta +19| " │ Reasoning hidden │ " + style 14-14 fg=bright-magenta + style 27-55 dim + style 85-85 fg=bright-magenta +20| " │ │ " + style 14-14 fg=bright-magenta + style 85-85 fg=bright-magenta +21| " │ ↑/↓ move • Tab cycle • Enter apply • Esc cancel │ " + style 14-14 fg=bright-magenta + style 16-62 dim + style 85-85 fg=bright-magenta +22| " ╰──────────────────────────────────────────────────────────────────────╯ " + style 14-85 fg=bright-magenta +23-39| diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts index c0b6d44f88..6931df723f 100644 --- a/packages/ui/tui/tests/tui.snapshot.ts +++ b/packages/ui/tui/tests/tui.snapshot.ts @@ -663,11 +663,13 @@ describe('TUI terminal-state snapshots', () => { harness.terminal.send('\r') }) await checkpoint('details-command', harness.terminal, { includeScrollback: true }) - // Bare /details opens the selector, preselecting and marking the current - // hidden/reasoning-off state. + // Bare /details opens the two-entry selector seeded with the current + // hidden/reasoning-off state; one Tab renders the tool-card entry's + // pending cycle as `hidden → collapsed`. await renderAfter(harness, () => { harness.terminal.send('/details') harness.terminal.send('\r') + harness.terminal.send('\t') }) await checkpoint('details-selector', harness.terminal, { includeScrollback: true }) nowSpy.mockRestore() diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 093d8ad285..1f4272bb6b 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -2554,10 +2554,9 @@ describe('pi-tui chat lifecycle and transcript', () => { return from } - await open() - expect(result.terminal.output).toContain('Tool cards · collapsed') - expect(result.terminal.output).toContain('head/tail preview — current') - expect(result.terminal.output).toContain('show reasoning blocks — current') + const opened = await open() + expect(result.terminal.output.slice(opened)).toContain('Tool cards') + expect(result.terminal.output.slice(opened)).toContain('Reasoning') // A second /details while the selector is open replaces the overlay // instead of stacking a second one behind it. @@ -2570,22 +2569,37 @@ describe('pi-tui chat lifecycle and transcript', () => { await tick() expect(result.terminal.output.slice(cancelOutput)).not.toContain('Tool and context cards') - // Enter on the next visibility row applies it and closes. - await open() - result.terminal.send('\x1b[B') + // Tab cycles the highlighted entry's pending value; Enter applies it. + const cycled = await open() + result.terminal.send('\t') + await tick() + expect(result.terminal.output.slice(cycled)).toContain('collapsed → expanded') result.terminal.send('\r') await tick() expect(result.terminal.output).toContain('Tool and context cards expanded.') - // The reopened selector preselects the current phase and marks it. + // Both entries apply in one confirm: cycle tool cards through the + // wraparound back to collapsed and toggle reasoning off. const reopened = await open() - expect(result.terminal.output.slice(reopened)).toContain('full bodies — current') - result.terminal.send('\x1b[B') - result.terminal.send('\x1b[B') + result.terminal.send('\t') + result.terminal.send('\t') + await tick() + expect(result.terminal.output.slice(reopened)).toContain('expanded → collapsed') result.terminal.send('\x1b[B') + result.terminal.send('\t') + await tick() + expect(result.terminal.output.slice(reopened)).toContain('shown → hidden') result.terminal.send('\r') await tick() expect(result.terminal.output).toContain('Reasoning blocks hidden.') + expect(result.terminal.output).toContain('Tool and context cards collapsed.') + + // Enter with no pending change closes without a notice. + const unchanged = await open() + result.terminal.send('\r') + await tick() + expect(result.terminal.output.slice(unchanged)).not.toContain('Tool and context cards') + expect(result.terminal.output.slice(unchanged)).not.toContain('Reasoning blocks') // Ctrl+C also cancels. const ctrlCOutput = result.terminal.output.length From 5b144238a28352d204926d444bd8f7cbbb20cf9e Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 30 Jul 2026 23:55:33 +0800 Subject: [PATCH 021/129] feat(tui): /details Tab toggles apply immediately Drop the pending state and confirm step: Tab cycles the highlighted entry and applies at once, so the transcript behind the dialog is the live preview; Enter/Esc/Ctrl+C just close. --- .../2026-07-30-tui-details-command.i18n.yaml | 4 +- .../feature/2026-07-30-tui-details-command.md | 6 +- .../2026-07-30-tui-details-command.zh.md | 6 +- packages/ui/tui/README.i18n.yaml | 4 +- packages/ui/tui/README.md | 2 +- packages/ui/tui/README.zh.md | 2 +- packages/ui/tui/src/components/dialogs.ts | 65 +++++++------------ packages/ui/tui/src/index.ts | 3 +- .../snapshots/details-selector.expected.txt | 60 ++++++++++------- packages/ui/tui/tests/tui.snapshot.ts | 7 +- packages/ui/tui/tests/tui.spec.ts | 50 ++++++-------- 11 files changed, 98 insertions(+), 111 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-30-tui-details-command.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.i18n.yaml index 8e6c202a5e..5a1b64f6b3 100644 --- a/.agents/notes/implemented/feature/2026-07-30-tui-details-command.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.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 .agents/notes/implemented/feature/2026-07-30-tui-details-command.md -2026-07-30-tui-details-command.md: 5de80ceb3ad78a949268c31e9c1d1a50c956b90f -2026-07-30-tui-details-command.zh.md: f74d0658cb776752bf3da1682291de882638310a +2026-07-30-tui-details-command.md: fb7c4dfaedeff27c9cafd0ba82daf4739665f19c +2026-07-30-tui-details-command.zh.md: 5f9e033311d1999998ef08b8f340d71f751b11f6 diff --git a/.agents/notes/implemented/feature/2026-07-30-tui-details-command.md b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.md index 5de80ceb3a..fb7c4dfaed 100644 --- a/.agents/notes/implemented/feature/2026-07-30-tui-details-command.md +++ b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.md @@ -10,7 +10,7 @@ The TUI's transcript detail state — tool-card visibility (`collapsed`/`expande ## Decision -`dsh-tui` registers `/details` beside its other agent-scoped commands. Bare `/details` opens `DetailsDialog`, a centered keyboard selector with one entry per dimension — `Tool cards` and `Reasoning` — seeded with the current values: Tab cycles the highlighted entry's pending value (rendered as `current → pending`), Enter applies every changed dimension in one confirm and closes, and Esc or Ctrl+C cancels; its width is the `detailsDialogWidth` config key and a second `/details` replaces an open selector, mirroring the `/model` overlay. Arguments name target states directly: `collapsed|expanded|hidden` jumps tool cards to that phase, `reasoning on|off` sets reasoning display, bare `reasoning` toggles it, and directives combine in one invocation. An unknown token returns a command error carrying the usage line. Every entry mutates the same closure state as the shortcuts, refactored so the cycle and toggle are thin wrappers over `setToolsVisibility`/`setReasoning`; the shortcuts and their notices are unchanged. +`dsh-tui` registers `/details` beside its other agent-scoped commands. Bare `/details` opens `DetailsDialog`, a centered keyboard toggle with one entry per dimension — `Tool cards` and `Reasoning` — showing the live values: Tab cycles the highlighted entry and applies the change immediately, so the transcript behind the dialog is the preview, and Enter, Esc, or Ctrl+C closes; its width is the `detailsDialogWidth` config key and a second `/details` replaces an open selector, mirroring the `/model` overlay. Arguments name target states directly: `collapsed|expanded|hidden` jumps tool cards to that phase, `reasoning on|off` sets reasoning display, bare `reasoning` toggles it, and directives combine in one invocation. An unknown token returns a command error carrying the usage line. Every entry mutates the same closure state as the shortcuts, refactored so the cycle and toggle are thin wrappers over `setToolsVisibility`/`setReasoning`; the shortcuts and their notices are unchanged. A combined invocation applies reasoning before visibility because `setReasoning` rebuilds the transcript from session events, which drops non-durable notice components; applying it last would erase the just-appended visibility notice. @@ -30,5 +30,5 @@ The reasoning rebuild exposed a replay defect that this change fixes in `renderE - A user can jump to any detail mode, set both dimensions at once, and see the current state in the selector — including on terminals that intercept Ctrl+O/Ctrl+R. - The parser accepts order-free tokens, so `/details reasoning expanded` toggles reasoning and expands cards; last directive wins per dimension. This leniency is deliberate and documented in the README. -- The selector applies only changed dimensions on confirm, so Enter without a Tab closes silently; a two-dimension change is one open-Tab-Enter interaction. -- Transcript rebuilds no longer lose assistant messages when a step carries more than one `assistant/message` event; the `details-command` snapshot pins the argument surface and the fixed replay, and `details-selector` pins the open selector with a Tab-cycled `hidden → collapsed` pending value. +- The selector has no pending state or cancel: every Tab is a real, already-notified change, and closing never reverts. A user who over-cycles simply Tabs on to the wanted value. +- Transcript rebuilds no longer lose assistant messages when a step carries more than one `assistant/message` event; the `details-command` snapshot pins the argument surface and the fixed replay, and `details-selector` pins the open toggle right after a Tab applied `hidden` -> `collapsed`, including the restored tool card behind it. diff --git a/.agents/notes/implemented/feature/2026-07-30-tui-details-command.zh.md b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.zh.md index f74d0658cb..5f9e033311 100644 --- a/.agents/notes/implemented/feature/2026-07-30-tui-details-command.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-tui-details-command.zh.md @@ -10,7 +10,7 @@ TUI 的 transcript(文本记录)细节状态——工具卡片可见性(`c ## Decision -`dsh-tui` 在其他 agent 作用域命令旁注册 `/details`。裸 `/details` 打开 `DetailsDialog`:一个居中的键盘选择器,每个维度一个条目——`Tool cards` 与 `Reasoning`——以当前值为初始:Tab 循环高亮条目的待定值(渲染为 `current → pending`),Enter 一次确认应用所有已改变的维度并关闭,Esc 或 Ctrl+C 取消;其宽度由配置键 `detailsDialogWidth` 决定,选择器打开时再次执行 `/details` 会替换它,与 `/model` 浮层一致。参数直接命名目标状态:`collapsed|expanded|hidden` 让工具卡片跳到该阶段,`reasoning on|off` 设置 reasoning 显示,裸 `reasoning` 切换它,且指令可在一次调用中组合。未知 token 返回携带用法行的命令错误。每个入口改动的都是与快捷键相同的闭包状态,重构后循环与切换成为 `setToolsVisibility`/`setReasoning` 之上的薄封装;快捷键及其通知保持不变。 +`dsh-tui` 在其他 agent 作用域命令旁注册 `/details`。裸 `/details` 打开 `DetailsDialog`:一个居中的键盘开关,每个维度一个条目——`Tool cards` 与 `Reasoning`——显示实时值:Tab 循环高亮条目并立即应用变更,对话框背后的 transcript 即是预览,Enter、Esc 或 Ctrl+C 关闭;其宽度由配置键 `detailsDialogWidth` 决定,选择器打开时再次执行 `/details` 会替换它,与 `/model` 浮层一致。参数直接命名目标状态:`collapsed|expanded|hidden` 让工具卡片跳到该阶段,`reasoning on|off` 设置 reasoning 显示,裸 `reasoning` 切换它,且指令可在一次调用中组合。未知 token 返回携带用法行的命令错误。每个入口改动的都是与快捷键相同的闭包状态,重构后循环与切换成为 `setToolsVisibility`/`setReasoning` 之上的薄封装;快捷键及其通知保持不变。 组合调用先应用 reasoning 再应用可见性,因为 `setReasoning` 会从会话事件重建 transcript,而重建会丢弃非持久的通知组件;若最后才应用它,会抹掉刚追加的可见性通知。 @@ -30,5 +30,5 @@ reasoning 重建暴露了一个重放缺陷,本变更在 `renderEvent` 中修 - 用户可以跳到任意细节模式、一次设置两个维度,并在选择器中看到当前状态——包括在拦截 Ctrl+O/Ctrl+R 的终端上。 - 解析器接受无序 token,因此 `/details reasoning expanded` 会切换 reasoning 并展开卡片;每个维度以最后一个指令为准。这一宽松是刻意的,并记录在 README 中。 -- 选择器确认时只应用已改变的维度,因此未按 Tab 直接 Enter 会静默关闭;两个维度的变更是一次打开-Tab-Enter 交互。 -- 当一个步骤携带多条 `assistant/message` 事件时,transcript 重建不再丢失 assistant 消息;`details-command` 快照固定参数表面与修复后的重放,`details-selector` 固定经 Tab 循环出 `hidden → collapsed` 待定值的打开选择器。 +- 选择器没有待定状态与取消:每次 Tab 都是已生效、已通知的真实变更,关闭从不回退。循环过头的用户继续 Tab 到想要的值即可。 +- 当一个步骤携带多条 `assistant/message` 事件时,transcript 重建不再丢失 assistant 消息;`details-command` 快照固定参数表面与修复后的重放,`details-selector` 固定 Tab 将 `hidden` 应用为 `collapsed` 后仍打开的开关,包括其背后恢复显示的工具卡片。 diff --git a/packages/ui/tui/README.i18n.yaml b/packages/ui/tui/README.i18n.yaml index af9ba4a67a..1bef78a96e 100644 --- a/packages/ui/tui/README.i18n.yaml +++ b/packages/ui/tui/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/ui/tui/README.md -README.md: 7230e75535ce5710e12040e026bc77a1782ed6dd -README.zh.md: 3079b797b670de64cfdc1faa9d002ee04a6e28dd +README.md: b6d0ab8e143f14ea2826054f72cf5a778ffa9bbb +README.zh.md: addce9f3b55c081208b0849bc00522fa0a22e295 diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index 8ef5f20ca0..66d7141994 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -22,7 +22,7 @@ Typing `@` at a token boundary searches files and directories under the session When optional `ctx.sessionReferences` is mounted, the same `@` menu also offers metadata-only session candidates, inserts `@[label](dsh-session:)`, and prepares the selected snapshots before dispatch. Session references remain structured because the model has no filesystem-like tool for retrieving session snapshots later. Preparation disables duplicate submission and restores the editor input on failure. The TUI chooses `agent.steer()` or `agent.followup()` from the status after that asynchronous preparation, so idle follow-ups still dispatch `agent/prompt-submit` while in-turn steering joins at a checkpoint without that hook. -While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.followup()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path automatically reaches the model. A command producer may explicitly schedule agent work; [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) uses that contract for `/plan [message]`. The TUI registers `/help`, `/model`, `/clear`, `/details`, `/palette`, `/reload`, `/resume`, `/status`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically, as do `/skill:` completions. A status line above the editor reports the turn phase the TUI derives from session events — waiting for the first token, thinking, responding, or executing tools — with the elapsed time in that phase and the running step total, refreshed each second, and ends with the `Enter sends steering, Esc cancels` hint; while steering messages wait to reach the model it inserts a `N queued ·` badge before the hint that clears as each drains. Ctrl+C or Escape cancels a running turn. Tool and injected-context cards collapse long bodies into a configurable head/tail preview; Ctrl+O cycles tool cards through collapsed preview, full output, and hidden — the hidden phase drops tool cards from the transcript entirely while context cards stay at their preview, since injected instructions are not tool traffic. The hidden phase also folds each turn's assistant steps into one message: the first step with visible text or reasoning keeps the turn's single `Assistant` header, later steps render as headerless continuations, and a step without a visible body renders nothing; leaving the hidden phase restores the per-step headers. An injected-context card renders its message as prose with the producer's outer reminder frame stripped, so neither the fold nor the frame stripping depends on the payload's syntax. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle. `/details` names the same state those two shortcuts cycle: bare it opens a centered keyboard selector with one entry per dimension — `Tool cards` and `Reasoning` — seeded with the current values, where Tab cycles the highlighted entry's pending value (rendered as `current → pending`), Enter applies every changed dimension and closes, and Esc or Ctrl+C cancels without changing anything; `/details collapsed|expanded|hidden` jumps tool cards to that phase directly, and `/details reasoning [on|off]` sets — or bare `reasoning` toggles — reasoning-block display; arguments combine in one invocation, an unknown argument fails with the usage line, and a combined invocation applies reasoning first so its transcript rebuild never drops the card notice. +While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.followup()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path automatically reaches the model. A command producer may explicitly schedule agent work; [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) uses that contract for `/plan [message]`. The TUI registers `/help`, `/model`, `/clear`, `/details`, `/palette`, `/reload`, `/resume`, `/status`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically, as do `/skill:` completions. A status line above the editor reports the turn phase the TUI derives from session events — waiting for the first token, thinking, responding, or executing tools — with the elapsed time in that phase and the running step total, refreshed each second, and ends with the `Enter sends steering, Esc cancels` hint; while steering messages wait to reach the model it inserts a `N queued ·` badge before the hint that clears as each drains. Ctrl+C or Escape cancels a running turn. Tool and injected-context cards collapse long bodies into a configurable head/tail preview; Ctrl+O cycles tool cards through collapsed preview, full output, and hidden — the hidden phase drops tool cards from the transcript entirely while context cards stay at their preview, since injected instructions are not tool traffic. The hidden phase also folds each turn's assistant steps into one message: the first step with visible text or reasoning keeps the turn's single `Assistant` header, later steps render as headerless continuations, and a step without a visible body renders nothing; leaving the hidden phase restores the per-step headers. An injected-context card renders its message as prose with the producer's outer reminder frame stripped, so neither the fold nor the frame stripping depends on the payload's syntax. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle. `/details` names the same state those two shortcuts cycle: bare it opens a centered keyboard toggle with one entry per dimension — `Tool cards` and `Reasoning` — showing the live values, where Tab cycles the highlighted entry and applies the change immediately (the transcript behind the dialog is the preview), and Enter, Esc, or Ctrl+C closes; `/details collapsed|expanded|hidden` jumps tool cards to that phase directly, and `/details reasoning [on|off]` sets — or bare `reasoning` toggles — reasoning-block display; arguments combine in one invocation, an unknown argument fails with the usage line, and a combined invocation applies reasoning first so its transcript rebuild never drops the card notice. `/model` opens the advisory `ctx.llm` catalog as a keyboard selector: a filter box above the list narrows rows by a case-insensitive substring over each row's `provider/model` label, model name, and description, keeping the highlighted row selected when it survives the filter; Up/Down moves, Shift+Tab cycles the focused model's adapter-advertised reasoning efforts in display order, Enter selects the model and effort, and Escape clears a non-empty filter before a second Escape closes it. When an adapter does not advertise a default effort, the cycle also includes `Default`, which clears an explicit selection and preserves the provider default; models without selectable effort metadata ignore Shift+Tab. The selector renders the exact advertised effort list—including `off` when present—and does not synthesize, clamp, or transfer an effort between models. `/model ` still selects an unambiguous model id directly, while `/model /` selects an exact target and uses its adapter default when one exists. The configured target or latest logged request header initializes the selector, and an unlisted current model remains visible because catalogs are advisory. Selection is local to this TUI session. Prompt assembly snapshots the target for one step, replaces `{{provider}}` and `{{model}}`, and applies the same provider/model/reasoning-effort target through `agent/request`; a switch during assembly therefore starts with a later step. The request header durably records targets that reach the model, while an unused selection remains process-local. diff --git a/packages/ui/tui/README.zh.md b/packages/ui/tui/README.zh.md index 48cb25fb94..9b67d1a4a2 100644 --- a/packages/ui/tui/README.zh.md +++ b/packages/ui/tui/README.zh.md @@ -22,7 +22,7 @@ TUI 从活跃会话表层重建已恢复历史,渲染 Markdown 响应与 reaso 挂载可选的 `ctx.sessionReferences` 后,同一个 `@` 菜单还会提供仅含元数据的会话候选项,插入 `@[label](dsh-session:)`,并在分派前准备所选快照。会话引用保持结构化,因为模型没有类似文件系统的工具可在稍后检索会话快照。准备期间会禁止重复提交,并在失败时恢复编辑器输入。TUI 会在异步准备后根据状态选择 `agent.steer()` 或 `agent.followup()`,因此空闲 followup 仍会分派 `agent/prompt-submit`,而轮次中的 steering 会在检查点加入且不触发该 hook。 -Agent 运行时,普通编辑器提交会调用 `agent.steer()`;其他时候调用 `agent.followup()`。提交行以斜杠开头时会改为进入 `ctx.commands`:已知命令直接执行,未知命令产生警告,两条路径都不会自动到达模型。命令生产方可以显式调度 agent 工作;[`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) 使用该契约实现 `/plan [message]`。TUI 将 `/help`、`/model`、`/clear`、`/details`、`/palette`、`/reload`、`/resume`、`/status` 和 `/exit` 注册为 agent 作用域定义;其他所有有效命令都会动态加入自动补全与 `/help`,`/skill:` 补全也相同。编辑器上方的状态行会报告 TUI 从会话事件派生的轮次阶段,包括等待首个 token、思考、响应或执行工具;它显示该阶段已经过时间和运行中的步骤总数,每秒刷新,并以 `Enter sends steering, Esc cancels` 提示结尾。Steering 消息等待到达模型期间,会在提示前插入 `N queued ·` 徽标,每条消息排空后随即清除。Ctrl+C 或 Escape 会取消运行中的轮次。工具卡片与注入上下文卡片都把长主体折叠为可配置的头尾预览;Ctrl+O 让工具卡片在折叠预览、完整输出、隐藏三种状态间循环——隐藏阶段把工具卡片从 transcript 中完全去掉,而上下文卡片保持预览,因为注入的指令不属于工具流量。隐藏阶段还会把每个轮次的 assistant 步骤折叠为一条消息:第一个有可见文本或 reasoning 的步骤保留该轮次唯一的 `Assistant` 标题,之后的步骤渲染为无标题的续段,没有可见正文的步骤则不渲染任何内容;离开隐藏阶段会恢复每步各自的标题。注入上下文卡片把消息渲染为文本,并去掉生产方的外层提醒外框,因此折叠与去外框都不依赖载荷的语法。Ctrl+R 切换 reasoning,Ctrl+L 重绘,Ctrl+D 在空闲时退出。`/details` 命名的正是这两个快捷键循环的同一份状态:不带参数时打开一个居中的键盘选择器,每个维度一个条目——`Tool cards` 与 `Reasoning`——以当前值为初始,Tab 循环高亮条目的待定值(渲染为 `current → pending`),Enter 应用所有已改变的维度并关闭,Esc 或 Ctrl+C 取消且不改变任何东西;`/details collapsed|expanded|hidden` 让工具卡片直接跳到该阶段,`/details reasoning [on|off]` 设置——或裸 `reasoning` 切换——reasoning 块显示;参数可在一次调用中组合,未知参数会以用法行报错,组合调用先应用 reasoning,使其 transcript 重建不会丢掉卡片通知。 +Agent 运行时,普通编辑器提交会调用 `agent.steer()`;其他时候调用 `agent.followup()`。提交行以斜杠开头时会改为进入 `ctx.commands`:已知命令直接执行,未知命令产生警告,两条路径都不会自动到达模型。命令生产方可以显式调度 agent 工作;[`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) 使用该契约实现 `/plan [message]`。TUI 将 `/help`、`/model`、`/clear`、`/details`、`/palette`、`/reload`、`/resume`、`/status` 和 `/exit` 注册为 agent 作用域定义;其他所有有效命令都会动态加入自动补全与 `/help`,`/skill:` 补全也相同。编辑器上方的状态行会报告 TUI 从会话事件派生的轮次阶段,包括等待首个 token、思考、响应或执行工具;它显示该阶段已经过时间和运行中的步骤总数,每秒刷新,并以 `Enter sends steering, Esc cancels` 提示结尾。Steering 消息等待到达模型期间,会在提示前插入 `N queued ·` 徽标,每条消息排空后随即清除。Ctrl+C 或 Escape 会取消运行中的轮次。工具卡片与注入上下文卡片都把长主体折叠为可配置的头尾预览;Ctrl+O 让工具卡片在折叠预览、完整输出、隐藏三种状态间循环——隐藏阶段把工具卡片从 transcript 中完全去掉,而上下文卡片保持预览,因为注入的指令不属于工具流量。隐藏阶段还会把每个轮次的 assistant 步骤折叠为一条消息:第一个有可见文本或 reasoning 的步骤保留该轮次唯一的 `Assistant` 标题,之后的步骤渲染为无标题的续段,没有可见正文的步骤则不渲染任何内容;离开隐藏阶段会恢复每步各自的标题。注入上下文卡片把消息渲染为文本,并去掉生产方的外层提醒外框,因此折叠与去外框都不依赖载荷的语法。Ctrl+R 切换 reasoning,Ctrl+L 重绘,Ctrl+D 在空闲时退出。`/details` 命名的正是这两个快捷键循环的同一份状态:不带参数时打开一个居中的键盘开关,每个维度一个条目——`Tool cards` 与 `Reasoning`——显示实时值,Tab 循环高亮条目并立即应用变更(对话框背后的 transcript 即是预览),Enter、Esc 或 Ctrl+C 关闭;`/details collapsed|expanded|hidden` 让工具卡片直接跳到该阶段,`/details reasoning [on|off]` 设置——或裸 `reasoning` 切换——reasoning 块显示;参数可在一次调用中组合,未知参数会以用法行报错,组合调用先应用 reasoning,使其 transcript 重建不会丢掉卡片通知。 `/model` 将建议性的 `ctx.llm` catalog 打开为键盘选择器:列表上方设有一个过滤框,按对每行 `provider/model` 标签、模型名称和描述的大小写不敏感子串匹配来缩小行集,并在高亮行仍通过过滤时保持其选中状态;Up/Down 移动,Shift+Tab 按显示顺序循环切换适配器为焦点模型公布的推理强度,Enter 选择模型和推理强度,Escape 会先清除非空过滤内容,再次按下才关闭选择器。适配器未公布默认推理强度时,循环还会包含 `Default`,该项会清除显式选择并保留提供方默认行为;没有可选推理强度元数据的模型会忽略 Shift+Tab。选择器会原样呈现公布的推理强度列表(包括存在时的 `off`),不会合成、自动调整或在模型之间转移推理强度。`/model ` 仍可直接选择无歧义的模型 id,`/model /` 则选择精确目标,并在存在时使用其适配器默认值。已配置目标或最新记录的请求 header 会初始化选择器;由于 catalog 仅提供建议,未列出的当前模型仍会显示。选择仅对本 TUI 会话有效。提示词组装会为一个步骤建立目标快照,替换 `{{provider}}` 和 `{{model}}`,并通过 `agent/request` 应用同一个提供方/模型/推理强度目标;因此组装期间的切换会从后续步骤开始生效。请求 header 会持久记录真正到达模型的目标,未使用的选择则只存在于进程本地。 diff --git a/packages/ui/tui/src/components/dialogs.ts b/packages/ui/tui/src/components/dialogs.ts index 93c416b230..946055f1d8 100644 --- a/packages/ui/tui/src/components/dialogs.ts +++ b/packages/ui/tui/src/components/dialogs.ts @@ -433,7 +433,7 @@ export class ModelDialog implements Component { } } -/** Both transcript-detail dimensions the details selector applies on confirm. */ +/** Both transcript-detail dimensions, applied immediately on each Tab. */ export interface DetailsSelection { readonly visibility: ToolCardVisibility readonly showReasoning: boolean @@ -442,62 +442,47 @@ export interface DetailsSelection { const TOOL_CARD_PHASES: readonly ToolCardVisibility[] = ['collapsed', 'expanded', 'hidden'] /** - * Keyboard selector over the two transcript-detail entries — tool-card - * visibility and reasoning display. Tab cycles the highlighted entry's pending - * value, Enter applies both pending values and closes, Esc or Ctrl+C closes - * without changing anything. A pending value renders as `current → pending`. + * Keyboard toggle over the two transcript-detail entries — tool-card + * visibility and reasoning display. Tab cycles the highlighted entry's value + * and applies it immediately, so the transcript behind the dialog is the live + * preview; Enter, Esc, or Ctrl+C closes. */ export class DetailsDialog implements Component { private readonly list: SelectList private readonly toolsItem: SelectItem private readonly reasoningItem: SelectItem - private pendingVisibility: ToolCardVisibility - private pendingReasoning: boolean constructor( - private readonly visibility: ToolCardVisibility, - private readonly showReasoning: boolean, + private visibility: ToolCardVisibility, + private showReasoning: boolean, private readonly palette: Palette, - done: (selection: DetailsSelection) => void, - private readonly cancel: () => void, + private readonly apply: (selection: DetailsSelection) => void, + private readonly close: () => void, ) { - this.pendingVisibility = visibility - this.pendingReasoning = showReasoning - this.toolsItem = { value: 'tools', label: 'Tool cards', description: this.describeTools() } - this.reasoningItem = { value: 'reasoning', label: 'Reasoning', description: this.describeReasoning() } + this.toolsItem = { value: 'tools', label: 'Tool cards', description: visibility } + this.reasoningItem = { value: 'reasoning', label: 'Reasoning', description: this.reasoningLabel() } this.list = new SelectList([this.toolsItem, this.reasoningItem], 2, dialogSelectTheme(palette)) - this.list.onSelect = () => { - done({ visibility: this.pendingVisibility, showReasoning: this.pendingReasoning }) - } + this.list.onSelect = close } - /** `current → pending` when Tab moved the value, otherwise the current value. */ - private static pendingLabel(current: string, pending: string): string { - return pending === current ? current : `${current} → ${pending}` + private reasoningLabel(): string { + return this.showReasoning ? 'shown' : 'hidden' } - private describeTools(): string { - return DetailsDialog.pendingLabel(this.visibility, this.pendingVisibility) - } - - private describeReasoning(): string { - const label = (show: boolean): string => show ? 'shown' : 'hidden' - return DetailsDialog.pendingLabel(label(this.showReasoning), label(this.pendingReasoning)) - } - - /** Cycle the highlighted entry's pending value one step. */ - private cyclePending(): void { + /** Cycle the highlighted entry one step and apply the new state. */ + private cycle(): void { const selected = this.list.getSelectedItem() /* v8 ignore next -- the two-entry list always has a selection. */ if (selected === null) return if (selected.value === 'tools') { - const index = TOOL_CARD_PHASES.indexOf(this.pendingVisibility) - this.pendingVisibility = TOOL_CARD_PHASES[(index + 1) % TOOL_CARD_PHASES.length] as ToolCardVisibility - this.toolsItem.description = this.describeTools() + const index = TOOL_CARD_PHASES.indexOf(this.visibility) + this.visibility = TOOL_CARD_PHASES[(index + 1) % TOOL_CARD_PHASES.length] as ToolCardVisibility + this.toolsItem.description = this.visibility } else { - this.pendingReasoning = !this.pendingReasoning - this.reasoningItem.description = this.describeReasoning() + this.showReasoning = !this.showReasoning + this.reasoningItem.description = this.reasoningLabel() } + this.apply({ visibility: this.visibility, showReasoning: this.showReasoning }) } invalidate(): void { @@ -505,8 +490,8 @@ export class DetailsDialog implements Component { } handleInput(data: string): void { - if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) this.cancel() - else if (matchesKey(data, Key.tab)) this.cyclePending() + if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) this.close() + else if (matchesKey(data, Key.tab)) this.cycle() else this.list.handleInput(data) this.invalidate() } @@ -516,7 +501,7 @@ export class DetailsDialog implements Component { return renderDialog('Transcript details', [ ...this.list.render(innerWidth), '', - this.palette.dim('↑/↓ move • Tab cycle • Enter apply • Esc cancel'), + this.palette.dim('↑/↓ move • Tab toggle • Enter/Esc close'), ], width, this.palette) } } diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 9a07b1f7c4..0e3e42c6a1 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -1049,9 +1049,8 @@ export function createTuiChat( toolsVisibility, showReasoning, palette, + // Each Tab applies immediately; one dimension changes per call. (selection: DetailsSelection) => { - void session.close() - // Reasoning first: its transcript rebuild would drop the card notice. if (selection.showReasoning !== showReasoning) setReasoning(selection.showReasoning) if (selection.visibility !== toolsVisibility) setToolsVisibility(selection.visibility) }, diff --git a/packages/ui/tui/tests/snapshots/details-selector.expected.txt b/packages/ui/tui/tests/snapshots/details-selector.expected.txt index d6cacf8cef..69ab18bddd 100644 --- a/packages/ui/tui/tests/snapshots/details-selector.expected.txt +++ b/packages/ui/tui/tests/snapshots/details-selector.expected.txt @@ -20,41 +20,53 @@ buffer 8| "You " style 0-2 fg=bright-magenta bold underline 9| "Inspect the renderer. " -10| "Model wait 0.0s · Completed 2026-07-30 18:00:00 " - style 0-46 dim -11| -12| "Reasoning blocks hidden. " +10| +11| "Assistant " + style 0-8 fg=bright-magenta bold underline +12| +13| "● Tool / bash / Run the coverage gate" + style 0-36 fg=green +14| "$ pnpm run test:coverage " style 0-23 dim -13| -14| "Tool cards hidden. " +15| "/workspace/project " style 0-17 dim -15| -16| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" - style 0-17 fg=bright-magenta bold - style 18-31 dim - style 34-50 dim - style 53-57 dim - style 60-69 dim -17| " dsh > ╭ Transcript details ──────────────────────────────────────────────────╮ " - style 1-3 fg=bright-magenta bold - style 5-6 dim - style 7-7 inverse +16| "… +4 lines (Ctrl+O to expand) " + style 0-28 dim +17| "[exit 0] ╭ Transcript details ──────────────────────────────────────────────────╮ " + style 0-7 dim style 14-85 fg=bright-magenta -18| " │ → Tool cards hidden → collapsed │ " +18| "Model wait 0.0│ → Tool cards collapsed │ " + style 0-13 dim style 14-14 fg=bright-magenta - style 16-67 fg=bright-magenta inverse + style 16-58 fg=bright-magenta inverse style 85-85 fg=bright-magenta 19| " │ Reasoning hidden │ " style 14-14 fg=bright-magenta style 27-55 dim style 85-85 fg=bright-magenta -20| " │ │ " +20| "Reasoning bloc│ │ " + style 0-13 dim style 14-14 fg=bright-magenta style 85-85 fg=bright-magenta -21| " │ ↑/↓ move • Tab cycle • Enter apply • Esc cancel │ " +21| " │ ↑/↓ move • Tab toggle • Enter/Esc close │ " style 14-14 fg=bright-magenta - style 16-62 dim + style 16-54 dim style 85-85 fg=bright-magenta -22| " ╰──────────────────────────────────────────────────────────────────────╯ " +22| "Tool cards hid╰──────────────────────────────────────────────────────────────────────╯ " + style 0-13 dim style 14-85 fg=bright-magenta -23-39| +23| +24| "Tool and context cards collapsed. " + style 0-32 dim +25| +26| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-magenta bold + style 18-31 dim + style 34-50 dim + style 53-57 dim + style 60-69 dim +27| " dsh > " + style 1-3 fg=bright-magenta bold + style 5-6 dim + style 7-7 inverse +28-39| diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts index 6931df723f..706ed7dce5 100644 --- a/packages/ui/tui/tests/tui.snapshot.ts +++ b/packages/ui/tui/tests/tui.snapshot.ts @@ -663,9 +663,10 @@ describe('TUI terminal-state snapshots', () => { harness.terminal.send('\r') }) await checkpoint('details-command', harness.terminal, { includeScrollback: true }) - // Bare /details opens the two-entry selector seeded with the current - // hidden/reasoning-off state; one Tab renders the tool-card entry's - // pending cycle as `hidden → collapsed`. + // Bare /details opens the two-entry toggle seeded with the current + // hidden/reasoning-off state; one Tab immediately cycles tool cards + // hidden -> collapsed, so the frame pins the applied notice, the restored + // tool card behind the dialog, and the updated entry value together. await renderAfter(harness, () => { harness.terminal.send('/details') harness.terminal.send('\r') diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 1f4272bb6b..8fe7981cb7 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -2544,7 +2544,7 @@ describe('pi-tui chat lifecycle and transcript', () => { await dispose(result) }) - it('bare /details opens the transcript-details selector and applies the confirmed state', async () => { + it('bare /details opens the transcript-details toggle and Tab applies immediately', async () => { const result = await setup() const open = async (): Promise => { const from = result.terminal.output.length @@ -2563,50 +2563,40 @@ describe('pi-tui chat lifecycle and transcript', () => { await result.ctx.commands.execute(result.agent, '/details', new AbortController().signal) await tick() - // Esc cancels without touching the state. - const cancelOutput = result.terminal.output.length - result.terminal.send('\x1b') - await tick() - expect(result.terminal.output.slice(cancelOutput)).not.toContain('Tool and context cards') - - // Tab cycles the highlighted entry's pending value; Enter applies it. - const cycled = await open() + // Each Tab applies one step immediately while the dialog stays open: + // collapsed -> expanded -> hidden -> collapsed (wraparound). result.terminal.send('\t') await tick() - expect(result.terminal.output.slice(cycled)).toContain('collapsed → expanded') - result.terminal.send('\r') - await tick() expect(result.terminal.output).toContain('Tool and context cards expanded.') - - // Both entries apply in one confirm: cycle tool cards through the - // wraparound back to collapsed and toggle reasoning off. - const reopened = await open() - result.terminal.send('\t') result.terminal.send('\t') await tick() - expect(result.terminal.output.slice(reopened)).toContain('expanded → collapsed') + expect(result.terminal.output).toContain('Tool cards hidden.') + result.terminal.send('\t') + await tick() + expect(result.terminal.output).toContain('Tool and context cards collapsed.') + + // The reasoning entry toggles the same way. result.terminal.send('\x1b[B') result.terminal.send('\t') await tick() - expect(result.terminal.output.slice(reopened)).toContain('shown → hidden') - result.terminal.send('\r') - await tick() expect(result.terminal.output).toContain('Reasoning blocks hidden.') - expect(result.terminal.output).toContain('Tool and context cards collapsed.') - // Enter with no pending change closes without a notice. - const unchanged = await open() + // Enter closes without further changes. + const entered = result.terminal.output.length result.terminal.send('\r') await tick() - expect(result.terminal.output.slice(unchanged)).not.toContain('Tool and context cards') - expect(result.terminal.output.slice(unchanged)).not.toContain('Reasoning blocks') + expect(result.terminal.output.slice(entered)).not.toContain('Reasoning blocks') - // Ctrl+C also cancels. - const ctrlCOutput = result.terminal.output.length - await open() + // Esc and Ctrl+C also close; the reopened dialog shows the live values. + const reopened = await open() + expect(result.terminal.output.slice(reopened)).toContain('collapsed') + expect(result.terminal.output.slice(reopened)).toContain('hidden') + result.terminal.send('\x1b') + await tick() + const ctrlCOutput = await open() result.terminal.send('\x03') await tick() - expect(result.terminal.output.slice(ctrlCOutput)).not.toContain('Reasoning blocks shown.') + expect(result.terminal.output.slice(ctrlCOutput)).not.toContain('Reasoning blocks') await dispose(result) }) From 27ab6ee05ec36dad17f5d47e389457109f70046d Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 31 Jul 2026 16:37:46 +0800 Subject: [PATCH 022/129] docs: regenerate cordis catalog and re-record TUI README pairing --- docs/cordis-catalog/services.md | 2 +- packages/ui/tui/README.i18n.yaml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index d92f467469..4101fcb9e6 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2336,7 +2336,7 @@ The concrete provider retains pi-tui, focus, and terminal lifecycle state. Plugi abstract openOverlay(request: TuiOverlayRequest): TuiOverlaySession ``` -Source: [`packages/ui/tui/src/index.ts:241`](../../packages/ui/tui/src/index.ts) +Source: [`packages/ui/tui/src/index.ts:243`](../../packages/ui/tui/src/index.ts) ## `ctx.typert` — `TypertRegistry` diff --git a/packages/ui/tui/README.i18n.yaml b/packages/ui/tui/README.i18n.yaml index 1bef78a96e..df5af8083c 100644 --- a/packages/ui/tui/README.i18n.yaml +++ b/packages/ui/tui/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/ui/tui/README.md -README.md: b6d0ab8e143f14ea2826054f72cf5a778ffa9bbb -README.zh.md: addce9f3b55c081208b0849bc00522fa0a22e295 +README.md: 66d71419945edc9d9f5fe10bf9bcd810d05ba20c +README.zh.md: 9b67d1a4a2e4a3d27d263421cc909188ff2e34cc From 9146c97d13628200006701da513bfb9c48787c79 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 31 Jul 2026 17:35:37 +0800 Subject: [PATCH 023/129] review: order detailsDialogWidth consistently across the parallel config lists --- docs/config-catalog.md | 4 ++-- packages/ui/tui/src/config.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 0f04e0a955..2cd6ca2bed 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2034,10 +2034,10 @@ export interface TuiConfig { questionDialogMaxHeight?: number /** Model-selector width in terminal columns. */ modelDialogWidth?: number - /** Transcript-details selector width in terminal columns. */ - detailsDialogWidth?: number /** Model-selector maximum height in terminal rows. */ modelDialogMaxHeight?: number + /** Transcript-details selector width in terminal columns. */ + detailsDialogWidth?: number /** Maximum fuzzy file candidates displayed for one `@` query. */ fileSearchMaxResults?: number /** Maximum paths retained in one `@` workspace index. */ diff --git a/packages/ui/tui/src/config.ts b/packages/ui/tui/src/config.ts index 59a12c0b96..e82aa46e08 100644 --- a/packages/ui/tui/src/config.ts +++ b/packages/ui/tui/src/config.ts @@ -46,10 +46,10 @@ export interface TuiConfig { questionDialogMaxHeight?: number /** Model-selector width in terminal columns. */ modelDialogWidth?: number - /** Transcript-details selector width in terminal columns. */ - detailsDialogWidth?: number /** Model-selector maximum height in terminal rows. */ modelDialogMaxHeight?: number + /** Transcript-details selector width in terminal columns. */ + detailsDialogWidth?: number /** Maximum fuzzy file candidates displayed for one `@` query. */ fileSearchMaxResults?: number /** Maximum paths retained in one `@` workspace index. */ @@ -72,8 +72,8 @@ const maxResumeOptionsSchema = z.number().step(1).min(1).default(8) const questionDialogWidthSchema = z.number().step(1).min(20).default(200) const questionDialogMaxHeightSchema = z.number().step(1).min(6).default(20) const modelDialogWidthSchema = z.number().step(1).min(20).default(76) -const detailsDialogWidthSchema = z.number().step(1).min(20).default(72) const modelDialogMaxHeightSchema = z.number().step(1).min(6).default(20) +const detailsDialogWidthSchema = z.number().step(1).min(20).default(72) const fileSearchMaxResultsSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_RESULTS) const fileSearchMaxEntriesSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_ENTRIES) const fileSearchExcludedDirectoriesSchema = z.array(z.string()).default([...DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES]) From 70f37206d2ead01a36217f7fe81ffc1baa451f2e Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 31 Jul 2026 19:57:45 +0800 Subject: [PATCH 024/129] fix(app-boot): release the terminal before a fatal load exit A dsh launch whose config failed validation returned the user to a broken shell: typing was invisible and the next command was mangled by a stray Device Attributes reply (1;2;4cecho ...). The Loader mounts entries concurrently, so ui-tui can already hold the terminal (raw mode, bracketed paste, keyboard protocol, plus an in-flight DA query) when a sibling entry rejects on its own config. installFailLoud wrote its diagnostic and exited immediately, so nothing disposed the tree and ProcessTerminal.stop() never ran. Give installFailLoud an optional release teardown, awaited between the diagnostic and the exit and bounded by FAIL_LOUD_RELEASE_TIMEOUT_MS. The TUI launcher passes one that disposes the root context, reaching the same shutdown() the /exit path already uses (drainInput() + ui.stop()). The context is captured in boot()'s prepare hook because the rejection arrives while boot() is still in flight. Bins that pass no release keep the previous behavior exactly. --- ...-fail-loud-releases-the-terminal.i18n.yaml | 6 ++ ...6-07-31-fail-loud-releases-the-terminal.md | 57 +++++++++++++++++++ ...7-31-fail-loud-releases-the-terminal.zh.md | 57 +++++++++++++++++++ apps/cli/src/tui.ts | 16 +++++- packages/ui/app-boot/README.i18n.yaml | 4 +- packages/ui/app-boot/README.md | 5 +- packages/ui/app-boot/README.zh.md | 5 +- packages/ui/app-boot/src/index.ts | 51 ++++++++++++++++- packages/ui/app-boot/tests/app-boot.spec.ts | 53 ++++++++++++++++- 9 files changed, 245 insertions(+), 9 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.md create mode 100644 .agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.i18n.yaml new file mode 100644 index 0000000000..13949d3b73 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.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/bug-fix/2026-07-31-fail-loud-releases-the-terminal.md +2026-07-31-fail-loud-releases-the-terminal.md: 410e89a1f172f2c7a37016aa6ac023e9cb80d153 +2026-07-31-fail-loud-releases-the-terminal.zh.md: 678834d8705eb6ce7ad52560a0ec255b4ea518a1 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.md b/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.md new file mode 100644 index 0000000000..410e89a1f1 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.md @@ -0,0 +1,57 @@ +# Agent Note: fail-loud releases the terminal before exiting + +Status: implemented + +English | [中文](2026-07-31-fail-loud-releases-the-terminal.zh.md) + +## Problem + +A `dsh` launch whose config failed validation printed its diagnostic and returned the user to a broken shell. Typing was invisible, and the next command was mangled by stray text: + +``` +dsh: fatal load failure: ValidationError: invalid config: + - $.providers expected object but got [object Object] (at providers) +$ 1;2;4cecho hello +zsh: command not found: 4cecho +``` + +The Loader mounts entries concurrently, so entry failure order is not startup order. `ui-tui` activates and calls pi-tui's `ProcessTerminal.start()`, which puts stdin in raw mode, enables bracketed paste, and writes the Kitty keyboard-protocol probe — a sequence ending in a Device Attributes query (`ESC [ c`). A sibling entry (here `llm-pi-ai`) then rejects on its own config. That rejection surfaces as an unhandled rejection, and `installFailLoud` wrote one stderr line and called `process.exit(1)` immediately. + +Nothing disposed the tree, so `ProcessTerminal.stop()` never ran: raw mode, bracketed paste, and the keyboard protocol stayed set on the shell that outlived the process. The terminal's answer to the Device Attributes query (`1;2;4c`) arrived after exit and was read by the shell as typed input — the literal text above. + +The `/exit` path was never affected, because it disposes the tree and reaches the TUI's own `shutdown()`, which calls `drainInput()` (absorbing the pending reply) and then `ui.stop()`. The defect was that a *failed boot* had no path to that same teardown. + +## Decision + +`installFailLoud` takes an optional `release` teardown, awaited between the diagnostic and the exit: + +- The diagnostic is written **before** the release, so the reason survives a disposer that repaints or clears the screen. +- The handler uninstalls itself before releasing. Teardown runs plugin disposers that may themselves reject, and a re-entered handler would report a cleanup failure as a second fatal load failure, burying the real one. +- The release is bounded by `FAIL_LOUD_RELEASE_TIMEOUT_MS` (2s) and its rejection is swallowed. A wedged or failing disposer delays the fatal exit; it never cancels it. +- Omitting `release` keeps the previous behavior exactly, so the ACP, JSON-RPC, and demo bins are unchanged. + +`dsh`'s TUI launcher passes a release that disposes the root context, which runs the TUI's existing `shutdown()` and hands the terminal back. + +The launcher captures the root context in `boot()`'s `prepare` hook rather than from its return value. The rejection arrives while `boot()` is still in flight, so `app.current` assigned after the `await` would still be `undefined` at exactly the moment the hook needs it. `prepare` runs after the Loader installs and before any config-tree entry mounts, which covers the whole window in which an entry can reject. + +## Alternatives considered + +**Reset the terminal from the fail-loud handler** (write `ESC [ ? 2004 l`, pop the keyboard protocol, clear raw mode). This duplicates pi-tui's teardown in a package that owns no terminal, and would drift as pi-tui's startup sequence changes. It also cannot absorb the in-flight Device Attributes reply, which is what corrupts the next prompt — only draining stdin while it is still raw does that. + +**Register a `process.on('exit')` terminal reset in the TUI.** Exit handlers are synchronous, so they cannot await `drainInput()`; the stray reply would still land. It also puts teardown on a global hook rather than the disposal path that already exists. + +**Have the TUI refuse to start until the tree settles.** This serializes a deliberately concurrent Loader and delays first paint for every healthy launch to fix a failure path. + +**Reorder config entries so `llm-pi-ai` mounts before `ui-tui`.** Ordering is not a guarantee the Loader makes, and any future entry could fail after the TUI mounts. + +## Consequences + +A failed boot now costs one tree disposal (bounded at 2s) before exit, and the exit code stays 1. In exchange, a misconfigured `dsh` returns a usable shell instead of one needing `stty sane` or `reset`. + +The guarantee belongs to whichever bin owns the terminal: a surface that grabs terminal state and does not pass `release` reintroduces this defect. `installFailLoud` cannot detect that on its own, since it has no view of what a mounted plugin did to the process. + +## Testing + +`packages/ui/app-boot/tests/app-boot.spec.ts` covers the release contract: the hook is awaited before the exit commits, a rejecting hook still exits 1, a never-settling hook exits after `FAIL_LOUD_RELEASE_TIMEOUT_MS` under fake timers, and the handler is uninstalled before releasing so teardown cannot re-enter it. + +The end-to-end symptom is terminal state after process exit — what the *shell* sees once `dsh` is gone — which no in-process assertion observes. It was verified manually in tmux against a config with a list-shaped `providers` value: before the change the next command was mangled (`zsh: command not found: 4cecho`); after it, the diagnostic is intact, the exit code is 1, and the next command runs normally. The `/exit` path was re-checked to confirm the goodbye line and exit code 0 are unchanged. diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.zh.md b/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.zh.md new file mode 100644 index 0000000000..678834d870 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.zh.md @@ -0,0 +1,57 @@ +# Agent Note:fail-loud 在退出前释放终端 + +Status: implemented + +[English](2026-07-31-fail-loud-releases-the-terminal.md) | 中文 + +## Problem + +配置校验失败的 `dsh` 启动会打印诊断信息,然后把用户丢回一个损坏的 shell:输入不可见,下一条命令还会被残留文本弄乱: + +``` +dsh: fatal load failure: ValidationError: invalid config: + - $.providers expected object but got [object Object] (at providers) +$ 1;2;4cecho hello +zsh: command not found: 4cecho +``` + +Loader 并发挂载各个条目,因此条目失败的顺序并不等于启动顺序。`ui-tui` 会先激活并调用 pi-tui 的 `ProcessTerminal.start()`,它把 stdin 置为 raw 模式、启用 bracketed paste,并写出 Kitty 键盘协议探测序列——该序列以一个 Device Attributes 查询(`ESC [ c`)结尾。随后某个同级条目(这里是 `llm-pi-ai`)因自身配置而 rejection。 + +该 rejection 以未处理 rejection 的形式浮现,而 `installFailLoud` 只写一行 stderr 就立即调用 `process.exit(1)`。没有任何环节释放这棵树,因此 `ProcessTerminal.stop()` 从未执行:raw 模式、bracketed paste 和键盘协议都残留在比进程活得更久的 shell 上。终端对 Device Attributes 查询的回应(`1;2;4c`)在进程退出之后才到达,被 shell 当作用户输入读入——也就是上面那段字面文本。 + +`/exit` 路径从不受影响,因为它会释放整棵树,从而进入 TUI 自身的 `shutdown()`:先 `drainInput()`(吸收尚未返回的响应),再 `ui.stop()`。缺陷在于**启动失败**没有通往这同一套拆卸流程的路径。 + +## Decision + +`installFailLoud` 新增可选的 `release` 拆卸回调,在诊断信息与退出之间被等待: + +- 诊断信息在 release **之前**写出,因此即使 disposer 重绘或清屏,失败原因也不会丢失。 +- 处理函数在 release 之前先卸载自己。拆卸会执行插件 disposer,其自身可能 rejection;若处理函数被重入,就会把清理失败报告成第二次致命加载失败,从而掩盖真正的原因。 +- release 以 `FAIL_LOUD_RELEASE_TIMEOUT_MS`(2 秒)为上限,且其 rejection 被吞掉。卡住或失败的 disposer 只会延迟致命退出,绝不会取消它。 +- 不传 `release` 时行为与此前完全一致,因此 ACP、JSON-RPC 和各 demo bin 均无变化。 + +`dsh` 的 TUI 启动器传入的 release 会释放根上下文,从而执行 TUI 已有的 `shutdown()` 并把终端交还。 + +启动器在 `boot()` 的 `prepare` 回调中捕获根上下文,而不是取其返回值。rejection 到达时 `boot()` 尚未结算,因此在 `await` 之后赋值的 `app.current` 恰好在回调需要它的那一刻仍是 `undefined`。`prepare` 在 Loader 安装之后、任何配置树条目挂载之前运行,覆盖了条目可能 rejection 的整个窗口。 + +## Alternatives considered + +**在 fail-loud 处理函数里直接重置终端**(写 `ESC [ ? 2004 l`、弹出键盘协议、清除 raw 模式)。这会在一个并不拥有终端的包里重复 pi-tui 的拆卸逻辑,并随 pi-tui 启动序列的变化而漂移。它同样无法吸收尚未返回的 Device Attributes 响应——而这正是弄乱下一个提示符的原因,只有在 stdin 仍处于 raw 模式时排空它才能解决。 + +**在 TUI 中注册 `process.on('exit')` 终端重置。** exit 处理函数是同步的,无法等待 `drainInput()`,残留响应依旧会落到 shell;而且这把拆卸挂到全局钩子上,而非已经存在的释放路径。 + +**让 TUI 等整棵树结算后再启动。** 这会把刻意并发的 Loader 串行化,并为修复一条失败路径而拖慢每一次正常启动的首次绘制。 + +**调整配置顺序,让 `llm-pi-ai` 先于 `ui-tui` 挂载。** 顺序并不是 Loader 提供的保证,而且未来任何条目都可能在 TUI 挂载之后失败。 + +## Consequences + +启动失败现在会在退出前多付出一次树释放的代价(上限 2 秒),退出码仍为 1。作为交换,配置错误的 `dsh` 会交还一个可用的 shell,而不是需要 `stty sane` 或 `reset` 才能恢复的终端。 + +这项保证属于**拥有终端的那个 bin**:任何抢占终端状态却不传 `release` 的界面都会重新引入该缺陷。`installFailLoud` 自身无法察觉这一点,因为它看不到已挂载的插件对进程做了什么。 + +## Testing + +`packages/ui/app-boot/tests/app-boot.spec.ts` 覆盖 release 契约:退出提交前会等待该回调;回调 rejection 时仍退出 1;在 fake timers 下,永不结算的回调会在 `FAIL_LOUD_RELEASE_TIMEOUT_MS` 后退出;以及处理函数在 release 之前已卸载,使拆卸无法重入它。 + +端到端症状是**进程退出之后**的终端状态——即 `dsh` 消失后 shell 所看到的东西——没有任何进程内断言能观测到它。该症状在 tmux 中针对 `providers` 为列表形状的配置手工验证:修复前下一条命令会被弄乱(`zsh: command not found: 4cecho`),修复后诊断信息完整、退出码为 1、下一条命令正常执行。同时复查了 `/exit` 路径,确认告别行与退出码 0 均未改变。 diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts index 15e7d6f77b..5e19dc9c6d 100644 --- a/apps/cli/src/tui.ts +++ b/apps/cli/src/tui.ts @@ -113,7 +113,6 @@ export async function runTui( ) process.exit(1) } - installFailLoud(NAME) // The bin already loaded the invoking directory's .env, and that is the // whole environment: $DSH_HOME/.env is credentials-local's writable store, // and hoisting it would make every stored key read as a read-only ambient @@ -140,6 +139,17 @@ export async function runTui( const entry = process.argv[1] const execve = process.execve?.bind(process) const app: { current?: Context } = {} + // The Loader mounts entries concurrently, so `ui-tui` can already hold the + // terminal (raw mode, bracketed paste, keyboard protocol) when a sibling + // entry rejects — and that rejection arrives while `boot` is still in + // flight. Disposing the tree runs the TUI's own shutdown, which stops the + // terminal and hands the shell back; without it a failed boot returns to a + // corrupted prompt. `app.current` is captured from boot's `prepare` hook, so + // it holds the root context for the whole mounting window rather than only + // after boot resolves. + installFailLoud(NAME, process, async () => { + await app.current?.fiber.dispose() + }) // Resume always enters the default surface because experimental-meta rejects // parent options, including `--resume`. The resumed session already persists // its cwd. @@ -216,6 +226,10 @@ export async function runTui( bootConfig, patches, (hostCtx) => { + // Runs after the Loader installs and before any config-tree entry mounts, + // so the fail-loud release hook can reach the tree for the whole window in + // which an entry may reject. + app.current = hostCtx // The launcher owns session identity and the exit line: a config-mounted // app bundle reads both from these slots, so no cordis.yml key can drop // resume. diff --git a/packages/ui/app-boot/README.i18n.yaml b/packages/ui/app-boot/README.i18n.yaml index 75b1ec16b2..c0e13b0cf2 100644 --- a/packages/ui/app-boot/README.i18n.yaml +++ b/packages/ui/app-boot/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/ui/app-boot/README.md -README.md: ebd8e0842b934f6887e3c122e781c1d0f13bb5d3 -README.zh.md: ccd897d48178482aa74d0eb73505e26ec3a08d6c +README.md: ba5cf9a05b456e2d72abe1e2a65b64825ceef1a5 +README.zh.md: d2f2b2d2c93b1ecb9fb4fad085d4abd663440108 diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md index ebd8e0842b..ba5cf9a05b 100644 --- a/packages/ui/app-boot/README.md +++ b/packages/ui/app-boot/README.md @@ -8,7 +8,8 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-c |---|---| | `resolveConfigPath(path, snapshotMode, cwd?)` | Absolute config path; `snapshotMode === 'replay'` swaps a `cordis.yml`/`.yaml` basename for its sibling `cordis.snapshot.yml` | | `loadEnv(binName, dir?, warn?)` | Load the gitignored `.env` (Node `process.loadEnvFile`); absent file is fine, an unloadable one warns a single labelled line (default: stderr) | -| `installFailLoud(binName, proc?)` | Turn a post-`boot()` unhandled Loader rejection into one labelled stderr line + `exit(1)`; returns the uninstaller (for tests) | +| `installFailLoud(binName, proc?, release?)` | Turn a post-`boot()` unhandled Loader rejection into one labelled stderr line + `exit(1)`; the optional `release` teardown is awaited between the two (bounded by `FAIL_LOUD_RELEASE_TIMEOUT_MS`) so a terminal-owning surface restores the terminal before exit; returns the uninstaller (for tests) | +| `FAIL_LOUD_RELEASE_TIMEOUT_MS` | How long `installFailLoud` waits for its `release` hook; a wedged disposer delays the fatal exit, never cancels it | | `assertEntriesLoaded(ctx, binName)` | Throw when a settled tree holds an enabled entry with no fiber, reporting every unresolved plugin name as a Cordis startup failure | | `assertEntriesActivated(ctx, binName)` | Include the `assertEntriesLoaded` check, then await every enabled entry after the Loader settles; throw with each failed plugin's original stack or each pending plugin's unresolved services | | `loadPersonalPatches(binName, dir?)` | Parse the optional `config.yaml` in the Harness home (default [`resolveDshHome()`](../../util/paths/README.md): `$DSH_HOME`, else `~/.dsh`) — a top-level YAML array of include `PatchOptions` (id-targeted config overrides, `insert` lists, `!!js` allowed); absent file → `undefined`, an unreadable/unparsable/non-array file throws | @@ -20,6 +21,8 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-c Two Loader failure classes require separate guards because tree settlement propagates neither to its caller. A failed plugin import leaves a fiber-less entry that `assertEntriesLoaded` turns into a `boot()` rejection naming every unresolved plugin. A plugin callback or config failure leaves a failed fiber because `loader.await()` settles lifecycle tasks without propagating that error; `assertEntriesActivated` awaits the fiber explicitly and includes its original stack in the startup rejection. Before throwing, the audit marks those exact rejection reasons through one process checkpoint so `installFailLoud` coalesces Loader's duplicate notification while every unrelated unhandled rejection remains fatal. +The Loader mounts entries concurrently, so a surface can already own the terminal when a sibling entry rejects: exiting straight from the handler would leave raw mode, bracketed paste, and the keyboard protocol set on the user's shell, and an in-flight terminal query's reply would land as literal text at the next prompt. A terminal-owning bin therefore passes `release` to dispose the tree — running that surface's own shutdown — before the exit commits. `dsh` captures the root context in `boot()`'s `prepare` hook rather than from its return value, because the rejection arrives while `boot()` is still in flight. + Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve through the Cordis Loader's internal module loader. Repository bins install Loader's optional `node-addon-require-builtin` peer; external callers must supply it or install plugins where plain Node import resolution can find them. Relative specifiers resolve against the config directory without the native helper. The built `dsh-app-boot` artifact embeds the statically mounted Include implementation while leaving Loader external, so the include tree and host bind to one Loader peer. The `dsh` source launcher additionally maps manifest-declared workspace packages to their TypeScript source; its configuration gate requires every TUI/Web bare plugin to appear in the resolver manifest's `dependencies`. The bins' subprocess smokes exercise the internal-loader path, while this package's unit suite drives `boot()` in-process against configs with relative specifiers. This package carries no loader hooks and no dev-mode surface. The [`dsh` app](../../../apps/cli/README.md) owns its Node source-launch hook and consumes these helpers for the boot sequence; built consumers continue to use plain Node package resolution. diff --git a/packages/ui/app-boot/README.zh.md b/packages/ui/app-boot/README.zh.md index ccd897d481..d2f2b2d2c9 100644 --- a/packages/ui/app-boot/README.zh.md +++ b/packages/ui/app-boot/README.zh.md @@ -8,7 +8,8 @@ |---|---| | `resolveConfigPath(path, snapshotMode, cwd?)` | 生成绝对配置路径;当 `snapshotMode === 'replay'` 时,把 basename 为 `cordis.yml`/`.yaml` 的文件替换为同级 `cordis.snapshot.yml` | | `loadEnv(binName, dir?, warn?)` | 加载已被 git 忽略的 `.env`(Node `process.loadEnvFile`);文件不存在不影响启动,文件无法加载时输出一行带标签的警告(默认写入 stderr) | -| `installFailLoud(binName, proc?)` | 将 `boot()` 之后未处理的 Loader rejection 转换为一行带标签的 stderr 消息并执行 `exit(1)`;返回卸载函数(供测试使用) | +| `installFailLoud(binName, proc?, release?)` | 将 `boot()` 之后未处理的 Loader rejection 转换为一行带标签的 stderr 消息并执行 `exit(1)`;两者之间会等待可选的 `release` 拆卸回调(以 `FAIL_LOUD_RELEASE_TIMEOUT_MS` 为上限),使持有终端的界面能在退出前恢复终端;返回卸载函数(供测试使用) | +| `FAIL_LOUD_RELEASE_TIMEOUT_MS` | `installFailLoud` 等待其 `release` 回调的时长;卡住的 disposer 只会延迟致命退出,而不会取消它 | | `assertEntriesLoaded(ctx, binName)` | 树结算后,如果其中存在已启用但没有 fiber 的条目,则抛出异常,并以 Cordis 启动故障的形式报告每个未解析插件的名称 | | `assertEntriesActivated(ctx, binName)` | 先执行 `assertEntriesLoaded` 检查,再在 Loader 结算后等待每个已启用配置项;抛出的错误包含每个失败插件的原始错误堆栈,或每个等待中插件尚未解析的服务 | | `loadPersonalPatches(binName, dir?)` | 解析 Harness home 中可选的 `config.yaml`(默认使用 [`resolveDshHome()`](../../util/paths/README.md):先取 `$DSH_HOME`,否则取 `~/.dsh`):其顶层是一个 YAML 数组,内容为 include 的 `PatchOptions`(按 id 定位的配置覆盖、`insert` 列表,允许 `!!js`);文件不存在时返回 `undefined`,文件不可读、不可解析或内容不是数组时抛出异常 | @@ -20,6 +21,8 @@ Loader 树结算不会向调用方传播两类故障,因此需要分别保护。插件导入失败会留下没有 fiber 的配置项,`assertEntriesLoaded` 将其转换为 `boot()` rejection,并列出每个未解析插件。插件回调或配置失败则会留下失败的 fiber,因为 `loader.await()` 只结算生命周期任务,不传播该错误;`assertEntriesActivated` 会显式等待该 fiber,并把原始错误堆栈写入启动 rejection。抛出错误前,审计会通过一个进程级检查点标记这些 rejection 的确切原因,从而让 `installFailLoud` 将 Loader 的重复通知合并为一次,而所有无关的未处理 rejection 仍然致命。 +Loader 并发挂载各个条目,因此当某个同级条目 rejection 时,某个界面可能已经持有终端:此时直接从处理函数退出,会把 raw 模式、bracketed paste 和键盘协议残留在用户的 shell 上,而尚未返回的终端查询响应会在下一个提示符处显示为字面文本。因此,持有终端的 bin 会传入 `release` 来释放整棵树——执行该界面自身的 shutdown——然后才提交退出。`dsh` 在 `boot()` 的 `prepare` 回调中捕获根上下文,而不是取其返回值,因为 rejection 到达时 `boot()` 尚未结算。 + 配置中的裸插件 specifier(`@deepseek-ai/dsh-*`、npm 包(package))通过 Cordis Loader 的内部模块 loader 解析。仓库 bin 会安装 Loader 的可选 peer `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。相对 specifier 无需原生 helper,并以配置目录为基准解析。构建后的 `dsh-app-boot` 产物内嵌静态挂载的 Include 实现,但仍将 Loader 保持为外部依赖,因此 include 树与 host 会绑定到同一个 Loader peer。`dsh` 源码启动器还会将 manifest(元数据清单)声明的 workspace 包映射到其 TypeScript 源码;其配置门禁要求每个 TUI/Web 裸插件都出现在解析所用 manifest 的 `dependencies` 中。bin 的子进程冒烟测试覆盖内部 loader 路径,而本包的单元测试套件会在进程内使用相对 specifier 配置驱动 `boot()`。 此包不包含 loader 钩子,也不提供开发模式接口。[`dsh` 应用](../../../apps/cli/README.md)持有自己的 Node 源码启动钩子,并在启动序列中使用这些 helper;构建后的消费方仍使用普通 Node 包解析。 diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index 982bcc59ed..ec8f717f45 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -325,24 +325,69 @@ async function observeLoaderRejectionCheckpoint(reasons: readonly unknown[]): Pr } } +/** + * How long {@link installFailLoud} waits for its `release` hook before exiting + * anyway. A wedged disposer must delay the fatal exit, never cancel it. + */ +export const FAIL_LOUD_RELEASE_TIMEOUT_MS = 2_000 + /** * Install before boot to turn a late unhandled plugin-init rejection into one * labelled stderr diagnostic and `exit(1)`. A rejection already included by * {@link assertEntriesActivated} is ignored during its process checkpoint; * every other rejection remains fatal. Stdout remains untouched for ACP; the * returned function removes the handler. + * + * The Loader mounts entries concurrently, so a surface that owns the terminal + * can already hold it when a sibling entry rejects. Exiting straight from the + * handler would strand raw mode, bracketed paste, and the keyboard protocol on + * the user's shell, and leave an in-flight terminal query's reply to land as + * literal text at the next prompt. `release` is the terminal owner's chance to + * hand it back; it is awaited under {@link FAIL_LOUD_RELEASE_TIMEOUT_MS}. The + * diagnostic is written before the release so the reason survives a disposer + * that repaints or clears the screen, and the handler uninstalls itself before + * releasing so a rejection from teardown cannot re-enter it. * @param binName - the diagnostic prefix on the fatal-failure line. * @param proc - the process slice to register on; tests inject a fake. + * @param release - optional teardown awaited before exit, used by a + * terminal-owning surface to restore the terminal. Its own failure is + * swallowed because the pending fatal exit already owns the outcome. * @returns the uninstaller that removes the rejection handler. */ -export function installFailLoud(binName: string, proc: FailLoudProcess = process): () => void { +export function installFailLoud( + binName: string, + proc: FailLoudProcess = process, + release?: () => Promise | void, +): () => void { const handler = (err: unknown): void => { if (assembledActivationRejections.has(err)) return proc.stderr.write(`${binName}: fatal load failure: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`) - proc.exit(1) + if (release === undefined) { + proc.exit(1) + return + } + // The release runs plugin disposers, which may themselves reject. Without + // this the handler would re-enter and report a teardown failure as a second + // fatal load failure, hiding the real one. + uninstall() + void (async () => { + try { + await Promise.race([ + (async () => release())(), + new Promise((resolve) => { + setTimeout(resolve, FAIL_LOUD_RELEASE_TIMEOUT_MS).unref() + }), + ]) + } catch { + // The terminal release failed; the fatal exit below is the outcome that + // matters, and no reporter runs after it. + } + proc.exit(1) + })() } + const uninstall = (): void => void proc.off('unhandledRejection', handler) proc.on('unhandledRejection', handler) - return () => void proc.off('unhandledRejection', handler) + return uninstall } /** diff --git a/packages/ui/app-boot/tests/app-boot.spec.ts b/packages/ui/app-boot/tests/app-boot.spec.ts index 7f06016267..c47b16cc78 100644 --- a/packages/ui/app-boot/tests/app-boot.spec.ts +++ b/packages/ui/app-boot/tests/app-boot.spec.ts @@ -5,7 +5,8 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import { - addHarnessSourceSection, assertEntriesActivated, assertEntriesLoaded, boot, HARNESS_SOURCE_SECTION, + addHarnessSourceSection, assertEntriesActivated, assertEntriesLoaded, boot, + FAIL_LOUD_RELEASE_TIMEOUT_MS, HARNESS_SOURCE_SECTION, installFailLoud, loadEnv, loadOverlayPatches, resolveConfigPath, type FailLoudProcess, } from '../src/index.ts' @@ -162,6 +163,56 @@ describe('installFailLoud', () => { proc.handlers[0]!(error) expect(proc.exits).toEqual([1]) }) + + // The Loader mounts entries concurrently, so a terminal-owning surface can + // already hold raw mode when a sibling entry rejects. Exiting without running + // its teardown strands the terminal on the user's shell. + it('awaits the release hook before exiting so the terminal owner can restore it', async () => { + const proc = fakeProc() + const order: string[] = [] + installFailLoud(NAME, proc, async () => { + await Promise.resolve() + order.push('released') + }) + proc.handlers[0]!(new Error('sibling entry rejected')) + expect(proc.written[0]).toContain(`${NAME}: fatal load failure: `) + // The release is in flight, so the exit has not committed yet. + expect(proc.exits).toEqual([]) + await vi.waitFor(() => { expect(proc.exits).toEqual([1]) }) + expect(order).toEqual(['released']) + }) + + it('still exits when the release hook rejects', async () => { + const proc = fakeProc() + installFailLoud(NAME, proc, () => Promise.reject(new Error('terminal stop failed'))) + proc.handlers[0]!(new Error('boom')) + await vi.waitFor(() => { expect(proc.exits).toEqual([1]) }) + }) + + it('exits without waiting when a release hook never settles', async () => { + vi.useFakeTimers() + try { + const proc = fakeProc() + installFailLoud(NAME, proc, () => new Promise(() => {})) + proc.handlers[0]!(new Error('boom')) + expect(proc.exits).toEqual([]) + await vi.advanceTimersByTimeAsync(FAIL_LOUD_RELEASE_TIMEOUT_MS) + expect(proc.exits).toEqual([1]) + } finally { + vi.useRealTimers() + } + }) + + // Teardown runs plugin disposers, whose own rejection must not be reported as + // a second fatal load failure over the real one. + it('uninstalls the handler before releasing, so teardown cannot re-enter it', async () => { + const proc = fakeProc() + installFailLoud(NAME, proc, () => {}) + proc.handlers[0]!(new Error('boom')) + expect(proc.handlers).toHaveLength(0) + await vi.waitFor(() => { expect(proc.exits).toEqual([1]) }) + expect(proc.written).toHaveLength(1) + }) }) describe('assertEntriesLoaded', () => { From b35b06396def4a4a1e5770c1b389cc3f3f9cd4d1 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 31 Jul 2026 20:19:58 +0800 Subject: [PATCH 025/129] fix(app-boot): keep the fail-loud exit fatal while the terminal is released Review of the previous commit found two defects in the release path, both reproduced against the implementation: - The timeout guarding a never-settling release was unref'ed. An unhandledRejection listener suppresses Node's default fatal exit, so with nothing else referenced the process reached an empty event loop and exited 0 on the very failure it was reporting. Keep the timer referenced and clear it once the race settles. - The handler uninstalled itself before awaiting the release. A second concurrent rejection then became uncaught and killed the process mid-teardown, stranding exactly the terminal state this restores. Replace the uninstall with a latch: the first rejection is the reported one, and later rejections (teardown's own included) fall through to the pending exit. Add the PTY regression the fake-process tests cannot express: boot the shipped tree over a fixture whose llm-pi-ai providers value is list-shaped, expect exit 1, and assert the captured bytes carry both the diagnostic and ESC[?2004l. Against the pre-fix source the stream ends at ESC[?2004h ESC[>7u ESC[?u ESC[c with no reset and the case fails, so it pins the actual bug. Split the two-shape formatting test into one install per case; a latched handler reports once by design. --- ...-fail-loud-releases-the-terminal.i18n.yaml | 4 +- ...6-07-31-fail-loud-releases-the-terminal.md | 12 +++--- ...7-31-fail-loud-releases-the-terminal.zh.md | 12 +++--- .../fixtures/tui-invalid-provider.cordis.yml | 10 +++++ apps/cli/tests/tui-keyless-smoke.e2e.ts | 22 ++++++++++ packages/ui/app-boot/README.i18n.yaml | 4 +- packages/ui/app-boot/README.md | 2 +- packages/ui/app-boot/README.zh.md | 2 +- packages/ui/app-boot/src/index.ts | 28 +++++++++---- packages/ui/app-boot/tests/app-boot.spec.ts | 42 ++++++++++++------- 10 files changed, 99 insertions(+), 39 deletions(-) create mode 100644 apps/cli/tests/fixtures/tui-invalid-provider.cordis.yml diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.i18n.yaml index 13949d3b73..97dc84403b 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.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 .agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.md -2026-07-31-fail-loud-releases-the-terminal.md: 410e89a1f172f2c7a37016aa6ac023e9cb80d153 -2026-07-31-fail-loud-releases-the-terminal.zh.md: 678834d8705eb6ce7ad52560a0ec255b4ea518a1 +2026-07-31-fail-loud-releases-the-terminal.md: ccac625171ef5523a4ed27843b543c838bf43ce8 +2026-07-31-fail-loud-releases-the-terminal.zh.md: fe8a3271b26a94b9d986b8d17744893e5f448593 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.md b/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.md index 410e89a1f1..ccac625171 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.md @@ -25,9 +25,9 @@ The `/exit` path was never affected, because it disposes the tree and reaches th `installFailLoud` takes an optional `release` teardown, awaited between the diagnostic and the exit: -- The diagnostic is written **before** the release, so the reason survives a disposer that repaints or clears the screen. -- The handler uninstalls itself before releasing. Teardown runs plugin disposers that may themselves reject, and a re-entered handler would report a cleanup failure as a second fatal load failure, burying the real one. -- The release is bounded by `FAIL_LOUD_RELEASE_TIMEOUT_MS` (2s) and its rejection is swallowed. A wedged or failing disposer delays the fatal exit; it never cancels it. +- The diagnostic is written **before** the release, so a hanging or failing disposer cannot swallow the reason. +- A latch, not an uninstall, keeps the first rejection the reported one. Removing the listener during teardown would let a second concurrent rejection become uncaught, and Node would kill the process mid-teardown — stranding exactly the terminal state this restores. Later rejections, including the release's own, fall through to the pending exit. +- The release is bounded by `FAIL_LOUD_RELEASE_TIMEOUT_MS` (2s) and its rejection is swallowed. A wedged or failing disposer delays the fatal exit; it never cancels it. That timer stays **referenced**: an `unref()`ed one lets Node reach an empty event loop and exit 0 on the very failure being reported, because an `unhandledRejection` listener suppresses the default fatal exit. - Omitting `release` keeps the previous behavior exactly, so the ACP, JSON-RPC, and demo bins are unchanged. `dsh`'s TUI launcher passes a release that disposes the root context, which runs the TUI's existing `shutdown()` and hands the terminal back. @@ -52,6 +52,8 @@ The guarantee belongs to whichever bin owns the terminal: a surface that grabs t ## Testing -`packages/ui/app-boot/tests/app-boot.spec.ts` covers the release contract: the hook is awaited before the exit commits, a rejecting hook still exits 1, a never-settling hook exits after `FAIL_LOUD_RELEASE_TIMEOUT_MS` under fake timers, and the handler is uninstalled before releasing so teardown cannot re-enter it. +`packages/ui/app-boot/tests/app-boot.spec.ts` covers the release contract: the hook is awaited before the exit commits, a rejecting hook still exits 1, a never-settling hook exits after `FAIL_LOUD_RELEASE_TIMEOUT_MS`, and a burst of rejections reports only the first while the release still completes. -The end-to-end symptom is terminal state after process exit — what the *shell* sees once `dsh` is gone — which no in-process assertion observes. It was verified manually in tmux against a config with a list-shaped `providers` value: before the change the next command was mangled (`zsh: command not found: 4cecho`); after it, the diagnostic is intact, the exit code is 1, and the next command runs normally. The `/exit` path was re-checked to confirm the goodbye line and exit code 0 are unchanged. +Those fake-process tests cannot observe the two failure modes that matter most — process exit code with a real event loop, and terminal state after exit — so the regression lives in `apps/cli/tests/tui-keyless-smoke.e2e.ts`. It boots the shipped tree in a real PTY over `fixtures/tui-invalid-provider.cordis.yml` (a list-shaped `providers`, the mistake users actually make), expects exit 1, and asserts the captured bytes contain both the diagnostic and `ESC[?2004l`. Against the pre-fix source the captured stream ends at `ESC[?2004h ESC[>7u ESC[?u ESC[c` with no reset, and the case fails on that assertion. + +Testing policy requires a PTY case whenever terminal teardown changes, and this is it. The `/exit` path keeps its existing assertion that the same reset appears on a clean exit. diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.zh.md b/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.zh.md index 678834d870..fe8a3271b2 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.zh.md @@ -25,9 +25,9 @@ Loader 并发挂载各个条目,因此条目失败的顺序并不等于启动 `installFailLoud` 新增可选的 `release` 拆卸回调,在诊断信息与退出之间被等待: -- 诊断信息在 release **之前**写出,因此即使 disposer 重绘或清屏,失败原因也不会丢失。 -- 处理函数在 release 之前先卸载自己。拆卸会执行插件 disposer,其自身可能 rejection;若处理函数被重入,就会把清理失败报告成第二次致命加载失败,从而掩盖真正的原因。 -- release 以 `FAIL_LOUD_RELEASE_TIMEOUT_MS`(2 秒)为上限,且其 rejection 被吞掉。卡住或失败的 disposer 只会延迟致命退出,绝不会取消它。 +- 诊断信息在 release **之前**写出,因此卡住或失败的 disposer 无法吞掉失败原因。 +- 使用闩锁(latch)而非卸载监听器,来保证被报告的始终是第一个 rejection。若在拆卸期间移除监听器,第二个并发 rejection 就会变成未捕获错误,Node 会在拆卸中途杀死进程——恰好残留下本次要恢复的终端状态。后续 rejection(包括 release 自身的)都会落入已挂起的退出流程。 +- release 以 `FAIL_LOUD_RELEASE_TIMEOUT_MS`(2 秒)为上限,且其 rejection 被吞掉。卡住或失败的 disposer 只会延迟致命退出,绝不会取消它。该定时器保持 **referenced**:一旦 `unref()`,Node 就会在事件循环清空后、恰恰在报告这次失败时以 0 退出,因为 `unhandledRejection` 监听器抑制了默认的致命退出。 - 不传 `release` 时行为与此前完全一致,因此 ACP、JSON-RPC 和各 demo bin 均无变化。 `dsh` 的 TUI 启动器传入的 release 会释放根上下文,从而执行 TUI 已有的 `shutdown()` 并把终端交还。 @@ -52,6 +52,8 @@ Loader 并发挂载各个条目,因此条目失败的顺序并不等于启动 ## Testing -`packages/ui/app-boot/tests/app-boot.spec.ts` 覆盖 release 契约:退出提交前会等待该回调;回调 rejection 时仍退出 1;在 fake timers 下,永不结算的回调会在 `FAIL_LOUD_RELEASE_TIMEOUT_MS` 后退出;以及处理函数在 release 之前已卸载,使拆卸无法重入它。 +`packages/ui/app-boot/tests/app-boot.spec.ts` 覆盖 release 契约:退出提交前会等待该回调;回调 rejection 时仍退出 1;永不结算的回调会在 `FAIL_LOUD_RELEASE_TIMEOUT_MS` 后退出;以及一连串 rejection 只报告第一个,同时 release 仍能跑完。 -端到端症状是**进程退出之后**的终端状态——即 `dsh` 消失后 shell 所看到的东西——没有任何进程内断言能观测到它。该症状在 tmux 中针对 `providers` 为列表形状的配置手工验证:修复前下一条命令会被弄乱(`zsh: command not found: 4cecho`),修复后诊断信息完整、退出码为 1、下一条命令正常执行。同时复查了 `/exit` 路径,确认告别行与退出码 0 均未改变。 +这些基于假进程的测试无法观测到最关键的两种失败形态——真实事件循环下的进程退出码,以及退出之后的终端状态——因此回归用例放在 `apps/cli/tests/tui-keyless-smoke.e2e.ts`。它在真实 PTY 中以 `fixtures/tui-invalid-provider.cordis.yml`(`providers` 为列表形状,正是用户真实会犯的错误)启动出厂配置树,期望退出码为 1,并断言捕获到的字节流同时包含诊断信息与 `ESC[?2004l`。在修复前的源码上,捕获流止于 `ESC[?2004h ESC[>7u ESC[?u ESC[c` 而没有任何重置,该用例正是在这条断言上失败。 + +测试规范要求:只要改动终端拆卸,就必须有 PTY 用例——这就是它。`/exit` 路径保留其原有断言,确认正常退出时同样会出现该重置序列。 diff --git a/apps/cli/tests/fixtures/tui-invalid-provider.cordis.yml b/apps/cli/tests/fixtures/tui-invalid-provider.cordis.yml new file mode 100644 index 0000000000..f03a58d5d7 --- /dev/null +++ b/apps/cli/tests/fixtures/tui-invalid-provider.cordis.yml @@ -0,0 +1,10 @@ +# An overlay whose `llm-pi-ai` config fails schema validation: `providers` is a +# dict keyed by provider name, and a list is the shape users reach for. The +# entry rejects while `ui-tui` — mounted concurrently by the Loader — already +# holds the terminal, which is the boot failure the fail-loud release hook +# exists for. +- id: llm-pi-ai + config: + providers: + - provider: openai + apiKey: keyless-invalid-shape diff --git a/apps/cli/tests/tui-keyless-smoke.e2e.ts b/apps/cli/tests/tui-keyless-smoke.e2e.ts index 7c7ad10141..8a917517a2 100644 --- a/apps/cli/tests/tui-keyless-smoke.e2e.ts +++ b/apps/cli/tests/tui-keyless-smoke.e2e.ts @@ -24,6 +24,9 @@ const dshBinScript = fileURLToPath(new URL('../src/bin.ts', import.meta.url)) // `--config` layers an overlay over the shared base, so the default surface // needs no config argument at all; these are the overlays under test. const scriptedConfigPath = fileURLToPath(new URL('./fixtures/tui-scripted.cordis.yml', import.meta.url)) +// An overlay whose `llm-pi-ai` config fails validation, so an entry rejects +// while the TUI already holds the terminal. +const invalidProviderConfigPath = fileURLToPath(new URL('./fixtures/tui-invalid-provider.cordis.yml', import.meta.url)) const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) const firstRunSnapshots = fileURLToPath(new URL('./tui-first-run-snapshots/', import.meta.url)) const synchronizedFrameEnd = '\x1b[?2026l' @@ -380,6 +383,25 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { expect(output).toContain('\u001B[?2004l') }, PTY_SMOKE_TEST_TIMEOUT_MS) + // The Loader mounts entries concurrently, so `ui-tui` can already own the + // terminal when a sibling entry rejects on its config. Exiting straight from + // the fail-loud handler left raw mode and bracketed paste set on the user's + // shell, and the pending Device Attributes reply landed there as literal + // text. The launcher's release hook must reach the TUI's own teardown. + it('restores the terminal when a sibling entry fails to validate during boot', async () => { + const output = await smoke({ + label: 'dsh invalid provider config', + tempDirPrefix: 'dsh-tui-invalid-config-', + configPath: invalidProviderConfigPath, + expectedExitCode: 1, + }) + expect(output).toContain('dsh: fatal load failure:') + expect(output).toContain('$.providers') + // Bracketed paste is disabled again, which only `ProcessTerminal.stop()` + // writes — proof the tree was disposed rather than exited out from under. + expect(output).toContain('\u001B[?2004l') + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('switches models, streams a response, answers a user-question dialog, and exits cleanly', async () => { const output = await smoke({ label: 'dsh conversation', diff --git a/packages/ui/app-boot/README.i18n.yaml b/packages/ui/app-boot/README.i18n.yaml index c0e13b0cf2..2e88921efc 100644 --- a/packages/ui/app-boot/README.i18n.yaml +++ b/packages/ui/app-boot/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/ui/app-boot/README.md -README.md: ba5cf9a05b456e2d72abe1e2a65b64825ceef1a5 -README.zh.md: d2f2b2d2c93b1ecb9fb4fad085d4abd663440108 +README.md: 7107ea20e72a6117f957090e753c126106b26663 +README.zh.md: 1e5b0850d7c92cd365adf441c31ee3432f13f7fa diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md index ba5cf9a05b..7107ea20e7 100644 --- a/packages/ui/app-boot/README.md +++ b/packages/ui/app-boot/README.md @@ -21,7 +21,7 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-c Two Loader failure classes require separate guards because tree settlement propagates neither to its caller. A failed plugin import leaves a fiber-less entry that `assertEntriesLoaded` turns into a `boot()` rejection naming every unresolved plugin. A plugin callback or config failure leaves a failed fiber because `loader.await()` settles lifecycle tasks without propagating that error; `assertEntriesActivated` awaits the fiber explicitly and includes its original stack in the startup rejection. Before throwing, the audit marks those exact rejection reasons through one process checkpoint so `installFailLoud` coalesces Loader's duplicate notification while every unrelated unhandled rejection remains fatal. -The Loader mounts entries concurrently, so a surface can already own the terminal when a sibling entry rejects: exiting straight from the handler would leave raw mode, bracketed paste, and the keyboard protocol set on the user's shell, and an in-flight terminal query's reply would land as literal text at the next prompt. A terminal-owning bin therefore passes `release` to dispose the tree — running that surface's own shutdown — before the exit commits. `dsh` captures the root context in `boot()`'s `prepare` hook rather than from its return value, because the rejection arrives while `boot()` is still in flight. +The Loader mounts entries concurrently, so a surface can already own the terminal when a sibling entry rejects: exiting straight from the handler would leave raw mode, bracketed paste, and the keyboard protocol set on the user's shell, and an in-flight terminal query's reply would land as literal text at the next prompt. A terminal-owning bin therefore passes `release` to dispose the tree — running that surface's own shutdown — before the exit commits. `dsh` captures the root context in `boot()`'s `prepare` hook rather than from its return value, because the rejection arrives while `boot()` is still in flight. While a release is in flight the handler stays installed and latched: the first rejection is the reported one, and later rejections (teardown's own included) are swallowed rather than becoming uncaught and killing the process mid-teardown. Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve through the Cordis Loader's internal module loader. Repository bins install Loader's optional `node-addon-require-builtin` peer; external callers must supply it or install plugins where plain Node import resolution can find them. Relative specifiers resolve against the config directory without the native helper. The built `dsh-app-boot` artifact embeds the statically mounted Include implementation while leaving Loader external, so the include tree and host bind to one Loader peer. The `dsh` source launcher additionally maps manifest-declared workspace packages to their TypeScript source; its configuration gate requires every TUI/Web bare plugin to appear in the resolver manifest's `dependencies`. The bins' subprocess smokes exercise the internal-loader path, while this package's unit suite drives `boot()` in-process against configs with relative specifiers. diff --git a/packages/ui/app-boot/README.zh.md b/packages/ui/app-boot/README.zh.md index d2f2b2d2c9..1e5b0850d7 100644 --- a/packages/ui/app-boot/README.zh.md +++ b/packages/ui/app-boot/README.zh.md @@ -21,7 +21,7 @@ Loader 树结算不会向调用方传播两类故障,因此需要分别保护。插件导入失败会留下没有 fiber 的配置项,`assertEntriesLoaded` 将其转换为 `boot()` rejection,并列出每个未解析插件。插件回调或配置失败则会留下失败的 fiber,因为 `loader.await()` 只结算生命周期任务,不传播该错误;`assertEntriesActivated` 会显式等待该 fiber,并把原始错误堆栈写入启动 rejection。抛出错误前,审计会通过一个进程级检查点标记这些 rejection 的确切原因,从而让 `installFailLoud` 将 Loader 的重复通知合并为一次,而所有无关的未处理 rejection 仍然致命。 -Loader 并发挂载各个条目,因此当某个同级条目 rejection 时,某个界面可能已经持有终端:此时直接从处理函数退出,会把 raw 模式、bracketed paste 和键盘协议残留在用户的 shell 上,而尚未返回的终端查询响应会在下一个提示符处显示为字面文本。因此,持有终端的 bin 会传入 `release` 来释放整棵树——执行该界面自身的 shutdown——然后才提交退出。`dsh` 在 `boot()` 的 `prepare` 回调中捕获根上下文,而不是取其返回值,因为 rejection 到达时 `boot()` 尚未结算。 +Loader 并发挂载各个条目,因此当某个同级条目 rejection 时,某个界面可能已经持有终端:此时直接从处理函数退出,会把 raw 模式、bracketed paste 和键盘协议残留在用户的 shell 上,而尚未返回的终端查询响应会在下一个提示符处显示为字面文本。因此,持有终端的 bin 会传入 `release` 来释放整棵树——执行该界面自身的 shutdown——然后才提交退出。`dsh` 在 `boot()` 的 `prepare` 回调中捕获根上下文,而不是取其返回值,因为 rejection 到达时 `boot()` 尚未结算。release 执行期间处理函数保持注册并加闩:被报告的始终是第一个 rejection,后续 rejection(包括拆卸自身的)会被吞掉,而不会变成未捕获错误、在拆卸中途杀死进程。 配置中的裸插件 specifier(`@deepseek-ai/dsh-*`、npm 包(package))通过 Cordis Loader 的内部模块 loader 解析。仓库 bin 会安装 Loader 的可选 peer `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。相对 specifier 无需原生 helper,并以配置目录为基准解析。构建后的 `dsh-app-boot` 产物内嵌静态挂载的 Include 实现,但仍将 Loader 保持为外部依赖,因此 include 树与 host 会绑定到同一个 Loader peer。`dsh` 源码启动器还会将 manifest(元数据清单)声明的 workspace 包映射到其 TypeScript 源码;其配置门禁要求每个 TUI/Web 裸插件都出现在解析所用 manifest 的 `dependencies` 中。bin 的子进程冒烟测试覆盖内部 loader 路径,而本包的单元测试套件会在进程内使用相对 specifier 配置驱动 `boot()`。 diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index ec8f717f45..da824851cc 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -343,10 +343,16 @@ export const FAIL_LOUD_RELEASE_TIMEOUT_MS = 2_000 * handler would strand raw mode, bracketed paste, and the keyboard protocol on * the user's shell, and leave an in-flight terminal query's reply to land as * literal text at the next prompt. `release` is the terminal owner's chance to - * hand it back; it is awaited under {@link FAIL_LOUD_RELEASE_TIMEOUT_MS}. The - * diagnostic is written before the release so the reason survives a disposer - * that repaints or clears the screen, and the handler uninstalls itself before - * releasing so a rejection from teardown cannot re-enter it. + * hand it back; it is awaited under {@link FAIL_LOUD_RELEASE_TIMEOUT_MS}, whose + * timer stays referenced so a never-settling disposer cannot let Node reach an + * empty event loop and exit 0 instead of failing. + * + * The diagnostic is written before the release so a hanging or failing disposer + * cannot swallow the reason. The handler stays installed while the release runs + * — removing it would let a second concurrent rejection become uncaught and kill + * the process mid-teardown, stranding exactly the terminal state this restores — + * so a latch keeps the first rejection the reported one and lets later + * rejections (including the release's own) fall through to the pending exit. * @param binName - the diagnostic prefix on the fatal-failure line. * @param proc - the process slice to register on; tests inject a fake. * @param release - optional teardown awaited before exit, used by a @@ -359,29 +365,33 @@ export function installFailLoud( proc: FailLoudProcess = process, release?: () => Promise | void, ): () => void { + let exiting = false const handler = (err: unknown): void => { if (assembledActivationRejections.has(err)) return + // A release in flight already owns the exit. Swallow later rejections + // (teardown's own included) rather than reporting a second failure over the + // real one or letting Node kill the process before the terminal is back. + if (exiting) return + exiting = true proc.stderr.write(`${binName}: fatal load failure: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`) if (release === undefined) { proc.exit(1) return } - // The release runs plugin disposers, which may themselves reject. Without - // this the handler would re-enter and report a teardown failure as a second - // fatal load failure, hiding the real one. - uninstall() void (async () => { + let timer: ReturnType | undefined try { await Promise.race([ (async () => release())(), new Promise((resolve) => { - setTimeout(resolve, FAIL_LOUD_RELEASE_TIMEOUT_MS).unref() + timer = setTimeout(resolve, FAIL_LOUD_RELEASE_TIMEOUT_MS) }), ]) } catch { // The terminal release failed; the fatal exit below is the outcome that // matters, and no reporter runs after it. } + if (timer !== undefined) clearTimeout(timer) proc.exit(1) })() } diff --git a/packages/ui/app-boot/tests/app-boot.spec.ts b/packages/ui/app-boot/tests/app-boot.spec.ts index c47b16cc78..f4b2e2a902 100644 --- a/packages/ui/app-boot/tests/app-boot.spec.ts +++ b/packages/ui/app-boot/tests/app-boot.spec.ts @@ -110,16 +110,22 @@ describe('installFailLoud', () => { expect(proc.exits).toEqual([1]) }) + // One rejection is reported per install: the first is the diagnosis, so each + // formatting case needs its own handler rather than reusing a latched one. it('stringifies a non-Error rejection and an Error without a stack falls back to its message', () => { - const proc = fakeProc() - installFailLoud(NAME, proc) - proc.handlers[0]!('plain failure') - expect(proc.written[0]).toContain('plain failure') + const plain = fakeProc() + installFailLoud(NAME, plain) + plain.handlers[0]!('plain failure') + expect(plain.written[0]).toContain('plain failure') + expect(plain.exits).toEqual([1]) + const stackless = new Error('no stack') delete (stackless as { stack?: string }).stack - proc.handlers[0]!(stackless) - expect(proc.written[1]).toContain('no stack') - expect(proc.exits).toEqual([1, 1]) + const bare = fakeProc() + installFailLoud(NAME, bare) + bare.handlers[0]!(stackless) + expect(bare.written[0]).toContain('no stack') + expect(bare.exits).toEqual([1]) }) it('returns an uninstaller that removes the handler (and defaults to the real process)', () => { @@ -203,15 +209,23 @@ describe('installFailLoud', () => { } }) - // Teardown runs plugin disposers, whose own rejection must not be reported as - // a second fatal load failure over the real one. - it('uninstalls the handler before releasing, so teardown cannot re-enter it', async () => { + // Loader failures arrive in bursts, and teardown's own disposers may reject. + // Only the first rejection is the diagnosis; the handler must stay installed + // so a later one cannot become uncaught and kill the process mid-teardown. + it('reports only the first rejection and keeps handling later ones during the release', async () => { const proc = fakeProc() - installFailLoud(NAME, proc, () => {}) - proc.handlers[0]!(new Error('boom')) - expect(proc.handlers).toHaveLength(0) - await vi.waitFor(() => { expect(proc.exits).toEqual([1]) }) + let released = false + installFailLoud(NAME, proc, async () => { + await Promise.resolve() + released = true + }) + proc.handlers[0]!(new Error('first rejection')) + proc.handlers[0]!(new Error('second rejection')) + expect(proc.handlers).toHaveLength(1) expect(proc.written).toHaveLength(1) + expect(proc.written[0]).toContain('first rejection') + await vi.waitFor(() => { expect(proc.exits).toEqual([1]) }) + expect(released).toBe(true) }) }) From b4f1675360f1b36c712eca1a14395707a72bd3dc Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 31 Jul 2026 20:29:06 +0800 Subject: [PATCH 026/129] docs(app-boot): correct the pre-fix capture claim and pin the exit seam contract The PTY capture does continue past the terminal-takeover bytes with the fatal diagnostic; only the reset never follows. State that precisely in both notes. Document on FailLoudProcess.exit that callers treat it as the end of the run, matching how the release path already relies on it. --- .../2026-07-31-fail-loud-releases-the-terminal.i18n.yaml | 4 ++-- .../bug-fix/2026-07-31-fail-loud-releases-the-terminal.md | 2 +- .../bug-fix/2026-07-31-fail-loud-releases-the-terminal.zh.md | 2 +- packages/ui/app-boot/src/index.ts | 5 +++++ 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.i18n.yaml index 97dc84403b..df444bc96d 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.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 .agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.md -2026-07-31-fail-loud-releases-the-terminal.md: ccac625171ef5523a4ed27843b543c838bf43ce8 -2026-07-31-fail-loud-releases-the-terminal.zh.md: fe8a3271b26a94b9d986b8d17744893e5f448593 +2026-07-31-fail-loud-releases-the-terminal.md: 8659c8a72dbb25cceaccbb0fb99b8b0251e1d506 +2026-07-31-fail-loud-releases-the-terminal.zh.md: 19ced1f685c8719a652ebabd27ac199519b09369 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.md b/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.md index ccac625171..8659c8a72d 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.md @@ -54,6 +54,6 @@ The guarantee belongs to whichever bin owns the terminal: a surface that grabs t `packages/ui/app-boot/tests/app-boot.spec.ts` covers the release contract: the hook is awaited before the exit commits, a rejecting hook still exits 1, a never-settling hook exits after `FAIL_LOUD_RELEASE_TIMEOUT_MS`, and a burst of rejections reports only the first while the release still completes. -Those fake-process tests cannot observe the two failure modes that matter most — process exit code with a real event loop, and terminal state after exit — so the regression lives in `apps/cli/tests/tui-keyless-smoke.e2e.ts`. It boots the shipped tree in a real PTY over `fixtures/tui-invalid-provider.cordis.yml` (a list-shaped `providers`, the mistake users actually make), expects exit 1, and asserts the captured bytes contain both the diagnostic and `ESC[?2004l`. Against the pre-fix source the captured stream ends at `ESC[?2004h ESC[>7u ESC[?u ESC[c` with no reset, and the case fails on that assertion. +Those fake-process tests cannot observe the two failure modes that matter most — process exit code with a real event loop, and terminal state after exit — so the regression lives in `apps/cli/tests/tui-keyless-smoke.e2e.ts`. It boots the shipped tree in a real PTY over `fixtures/tui-invalid-provider.cordis.yml` (a list-shaped `providers`, the mistake users actually make), expects exit 1, and asserts the captured bytes contain both the diagnostic and `ESC[?2004l`. Against the pre-fix source the capture still shows the terminal being taken (`ESC[?2004h ESC[>7u ESC[?u ESC[c`) and the diagnostic printed, but no reset ever follows, and the case fails on the `ESC[?2004l` assertion alone. Testing policy requires a PTY case whenever terminal teardown changes, and this is it. The `/exit` path keeps its existing assertion that the same reset appears on a clean exit. diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.zh.md b/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.zh.md index fe8a3271b2..19ced1f685 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.zh.md @@ -54,6 +54,6 @@ Loader 并发挂载各个条目,因此条目失败的顺序并不等于启动 `packages/ui/app-boot/tests/app-boot.spec.ts` 覆盖 release 契约:退出提交前会等待该回调;回调 rejection 时仍退出 1;永不结算的回调会在 `FAIL_LOUD_RELEASE_TIMEOUT_MS` 后退出;以及一连串 rejection 只报告第一个,同时 release 仍能跑完。 -这些基于假进程的测试无法观测到最关键的两种失败形态——真实事件循环下的进程退出码,以及退出之后的终端状态——因此回归用例放在 `apps/cli/tests/tui-keyless-smoke.e2e.ts`。它在真实 PTY 中以 `fixtures/tui-invalid-provider.cordis.yml`(`providers` 为列表形状,正是用户真实会犯的错误)启动出厂配置树,期望退出码为 1,并断言捕获到的字节流同时包含诊断信息与 `ESC[?2004l`。在修复前的源码上,捕获流止于 `ESC[?2004h ESC[>7u ESC[?u ESC[c` 而没有任何重置,该用例正是在这条断言上失败。 +这些基于假进程的测试无法观测到最关键的两种失败形态——真实事件循环下的进程退出码,以及退出之后的终端状态——因此回归用例放在 `apps/cli/tests/tui-keyless-smoke.e2e.ts`。它在真实 PTY 中以 `fixtures/tui-invalid-provider.cordis.yml`(`providers` 为列表形状,正是用户真实会犯的错误)启动出厂配置树,期望退出码为 1,并断言捕获到的字节流同时包含诊断信息与 `ESC[?2004l`。在修复前的源码上,捕获内容仍能看到终端被接管(`ESC[?2004h ESC[>7u ESC[?u ESC[c`)以及诊断信息被打印,但其后始终没有任何重置序列,该用例仅在 `ESC[?2004l` 这条断言上失败。 测试规范要求:只要改动终端拆卸,就必须有 PTY 用例——这就是它。`/exit` 路径保留其原有断言,确认正常退出时同样会出现该重置序列。 diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index da824851cc..727c551187 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -295,6 +295,11 @@ export interface FailLoudProcess { on(event: 'unhandledRejection', handler: (err: unknown) => void): unknown off(event: 'unhandledRejection', handler: (err: unknown) => void): unknown stderr: { write(chunk: string): unknown } + /** + * Terminate the process. Callers treat this as the end of the run, as + * `process.exit` is; a fake that returns lets the caller continue, which only + * a test observes. + */ exit(code: number): void } From 54e541d33f76b1fcbfb922d29d80052dd808b0f9 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 31 Jul 2026 22:14:43 +0800 Subject: [PATCH 027/129] fix(app-boot): drop the unreachable timer guard on the fail-loud release path The timeout promise's executor runs synchronously while the race is constructed, so the timer is always assigned; the undefined check was a dead branch the per-file coverage gate rejected. --- packages/ui/app-boot/src/index.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index 727c551187..88a1f82736 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -384,7 +384,9 @@ export function installFailLoud( return } void (async () => { - let timer: ReturnType | undefined + // Definitely assigned: the timeout promise's executor runs synchronously + // while the race is being constructed, before the first await. + let timer!: ReturnType try { await Promise.race([ (async () => release())(), @@ -396,7 +398,7 @@ export function installFailLoud( // The terminal release failed; the fatal exit below is the outcome that // matters, and no reporter runs after it. } - if (timer !== undefined) clearTimeout(timer) + clearTimeout(timer) proc.exit(1) })() } From b270b3ef9f816c97facc5025bc535667ccf7e396 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:18:34 +0800 Subject: [PATCH 028/129] fix(web): run a trailing catalog refresh for coalesced membership changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `refreshSubagents` single-flights per catalog owner: a request arriving while a pull is in flight returns the in-flight promise and is silently coalesced into it. The in-flight response was requested before the triggering change, so it can never contain that change — a debounced membership refresh (50ms after `host/session-added`) firing during a slow pull therefore lost the new child, and the catalog stayed stale until an unrelated trigger (reselection, menu reopen, reconnect). Mark the owner stale on coalescing and re-arm one trailing pull in the settlement `finally`, so every membership change observed during a pull is carried by a follow-up refresh exactly once. Bounded: the trailing pull only runs when a refresh request was actually coalesced, and a new coalescing during the trailing pull re-marks the same set. Adds a fake-timer regression test: a `host/session-added` debounce firing mid-pull yields exactly two `subagent.list` calls and the catalog eventually contains the new child. --- .../runtime/src/client/sessions/manager.ts | 17 +++++- packages/client/runtime/tests/manager.spec.ts | 58 +++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index 1019327df5..3b841ae0b2 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -101,6 +101,8 @@ export class SessionManager { private readonly addresses = new Map() private readonly catalogs = new Map() private readonly catalogInflight = new Map() + /** Catalog owners whose membership changed while a pull was in flight: one trailing refresh after it settles. */ + private readonly catalogStale = new Set() private readonly openCatalogs = new Set() private readonly catalogDebounce = new Map>() @@ -286,7 +288,16 @@ export class SessionManager { */ refreshSubagents(parentSessionId: SessionId): Promise { const existing = this.catalogInflight.get(parentSessionId) - if (existing !== undefined) return existing.promise + if (existing !== undefined) { + // A refresh requested while a pull is in flight must not be silently + // coalesced into it: the in-flight response was requested before the + // triggering change (a membership frame or an opened menu), so it can + // never contain that change. Queue one trailing refresh that runs after + // the pull settles; without it the change stays invisible until an + // unrelated later trigger (reselection, menu reopen, reconnect). + this.catalogStale.add(parentSessionId) + return existing.promise + } const previous = this.catalogs.get(parentSessionId) const expandableRows = new Set() const activityRows = new Map() @@ -333,6 +344,10 @@ export class SessionManager { }) } finally { this.catalogInflight.delete(parentSessionId) + // Re-arm the trailing pull before the dirty notify: the response the + // caller observed predates the stale-marking change, so the follow-up + // refresh is the only carrier of that change. + if (this.catalogStale.delete(parentSessionId)) void this.refreshSubagents(parentSessionId) this.notifier.markDirty() } })() diff --git a/packages/client/runtime/tests/manager.spec.ts b/packages/client/runtime/tests/manager.spec.ts index 6ada34de7f..2c1b8c259d 100644 --- a/packages/client/runtime/tests/manager.spec.ts +++ b/packages/client/runtime/tests/manager.spec.ts @@ -529,6 +529,64 @@ describe('subagent catalogs', () => { { kind: 'child', id: S2, activity: 'inactive' }, ]) }) + + it('runs one trailing catalog refresh for a membership change coalesced into an in-flight pull', async () => { + vi.useFakeTimers() + try { + const api = new FakeApiClient() + const root = 'fk-root' as SessionId + const first = deferred>>() + const second = deferred>>() + api.onSubagentList = () => first.promise + const manager = new SessionManager(api) + manager.setSubagentCatalogOpen(root, true) + const refresh = manager.refreshSubagents(root) + + // A membership frame arrives while the pull is in flight; the debounced + // refresh it schedules fires 50ms later and is coalesced into the pull — + // which was requested before the new child existed. The stale mark must + // queue one trailing pull carrying the change. + manager.handleHostEnvelope({ + rpcId: 'child-added' as never, + payload: { + type: 'host/session-added', sessionId: S2, parentSessionId: root, blank: false, + }, + }) + await vi.advanceTimersByTimeAsync(50) + api.onSubagentList = () => second.promise + first.resolve(ok({ + entries: [{ + kind: 'child', id: S1, mode: 'continuable', label: 'older', + activity: 'inactive', hasChildren: false, + }] as never[], + parentAvailable: true, + })) + await refresh + // The trailing pull is already in flight (kicked synchronously in finally). + second.resolve(ok({ + entries: [ + { + kind: 'child', id: S1, mode: 'continuable', label: 'older', + activity: 'inactive', hasChildren: false, + }, + { + kind: 'child', id: S2, mode: 'continuable', label: 'new child', + activity: 'inactive', hasChildren: false, + }, + ] as never[], + parentAvailable: true, + })) + await second.promise + + expect(api.callsOf('subagent.list')).toHaveLength(2) + expect(manager.getListSnapshot().subagentsByParent[root]?.entries).toMatchObject([ + { kind: 'child', id: S1, label: 'older' }, + { kind: 'child', id: S2, label: 'new child' }, + ]) + } finally { + vi.useRealTimers() + } + }) }) describe('remaining branches', () => { From 8431dbead35b934148df806665e1d9af923ed727 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:19:08 +0800 Subject: [PATCH 029/129] fix(web): invalidate catalog availability when the owning parent is removed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A removed session can no longer be the delivery owner of its continuable children, but the `host/session-removed` handler only reconciled the removed row's own activity. `parentAvailable` was updated exclusively from `refreshSubagents` success, and removal schedules no catalog refresh — so after the parent's Activation detaches, an addressed child kept a writable editor against a dead continuation owner until an unrelated refresh (or forever, for a closed menu). Flip `parentAvailable` to false on the owned catalog and push `handleSubagentParentAvailable(false)` to every addressed child Session at removal time, matching the refresh path's notification. New Session instances already read `parentAvailable` from the catalog, so they inherit the invalidated state. Adds a regression test: removing the catalog's owning parent flips the snapshot's `parentAvailable` and notifies the addressed child instance. --- .../runtime/src/client/sessions/manager.ts | 13 ++++++++++ packages/client/runtime/tests/manager.spec.ts | 24 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index 3b841ae0b2..1c186059de 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -688,6 +688,19 @@ export class SessionManager { this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation this.waitingApprovals.delete(frame.sessionId) // a removed session cannot wait on anyone if (!durableSubagent) this.projectionStores.delete(frame.sessionId) + // The removed session can no longer be the delivery owner of its + // catalog: invalidate availability immediately. Removal schedules no + // catalog refresh, and without this an addressed child keeps a + // writable editor against a dead continuation owner until an + // unrelated refresh (or forever, for a closed menu). + const ownedCatalog = this.catalogs.get(frame.sessionId) + if (ownedCatalog !== undefined && ownedCatalog.parentAvailable) { + this.catalogs.set(frame.sessionId, { ...ownedCatalog, parentAvailable: false }) + } + for (const [childId, address] of this.addresses) { + if (address.parentSessionId !== frame.sessionId) continue + this.sessions.get(childId)?.handleSubagentParentAvailable(false) + } return } case 'host/session-status': { diff --git a/packages/client/runtime/tests/manager.spec.ts b/packages/client/runtime/tests/manager.spec.ts index 2c1b8c259d..5d12498edd 100644 --- a/packages/client/runtime/tests/manager.spec.ts +++ b/packages/client/runtime/tests/manager.spec.ts @@ -587,6 +587,30 @@ describe('subagent catalogs', () => { vi.useRealTimers() } }) + + it('invalidates catalog availability when the owning parent is removed', async () => { + const api = new FakeApiClient() + const root = 'fk-root' as SessionId + api.onSubagentList = () => Promise.resolve(ok({ + entries: [{ + kind: 'child', id: S2, mode: 'continuable', label: 'worker', + activity: 'inactive', hasChildren: false, + }] as never[], + parentAvailable: true, + })) + const manager = new SessionManager(api) + await manager.refreshSubagents(root) + manager.selectSubagent({ parentSessionId: root, childSessionId: S2, mode: 'continuable' }) + expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: true }) + + manager.handleHostEnvelope({ + rpcId: 'parent-removed' as never, + payload: { type: 'host/session-removed', sessionId: root }, + }) + + expect(manager.getListSnapshot().subagentsByParent[root]?.parentAvailable).toBe(false) + expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: false }) + }) }) describe('remaining branches', () => { From b2187cabf6b29a77ad2178b12abab45a93d6fd94 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:19:15 +0800 Subject: [PATCH 030/129] fix(cli): keep the core-web overlay at its documented two-tool surface The opt-in `core-web.cordis.yml` profile promises "exactly persistent `bash` plus `str_replace_editor`" (its header comment and `apps/cli/ README.md`), but the base registration of `tool-subagent-list-agents` (added with the durable child catalog) was not disabled by the overlay, so the profile actually exposed `bash`, `str_replace_editor`, and `list_agents`. The assembled snapshot was updated to accept the third tool, which ratified the contract break instead of fixing it. Disable `tool-subagent-list-agents` in the overlay and restore the snapshot's expected tool registry to the documented two tools. --- apps/cli/config/core-web.cordis.yml | 3 +++ apps/web/tests/core-web-profile.snapshot.ts | 1 - 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/cli/config/core-web.cordis.yml b/apps/cli/config/core-web.cordis.yml index 2a5205cd0d..6b31c8f424 100644 --- a/apps/cli/config/core-web.cordis.yml +++ b/apps/cli/config/core-web.cordis.yml @@ -28,6 +28,9 @@ - id: tool-subagent-control disabled: true +- id: tool-subagent-list-agents + disabled: true + - id: tool-subagent disabled: true diff --git a/apps/web/tests/core-web-profile.snapshot.ts b/apps/web/tests/core-web-profile.snapshot.ts index 5f3334c0e7..58f2a34858 100644 --- a/apps/web/tests/core-web-profile.snapshot.ts +++ b/apps/web/tests/core-web-profile.snapshot.ts @@ -72,7 +72,6 @@ describe('core Web profile', () => { "tools": [ "bash", "str_replace_editor", - "list_agents", ], } `) From 4b2fa3317ed557a4c5edb3dc47a6f490ce746d5b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:22:14 +0800 Subject: [PATCH 031/129] perf(host): scan the own-suffix for a subagent descriptor without copying MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `hasSubagentDescriptor` sliced the whole own-suffix events array on every Agent-bound RPC — including each `session.prompt` and `sessions.models` call on long transcripts — and `ensureSession` rescans the same suffix after creation. Replace the slice-then-some with an indexed loop from the seed boundary, so the classification is a plain O(suffix) read with no allocation. --- packages/host/apiproxy/src/api-proxy.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index fd4626e573..70661bb027 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -1017,8 +1017,14 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro /** Whether the session's own suffix carries the durable subagent discriminator. */ function hasSubagentDescriptor(session: Pick): boolean { - const ownStart = session.header.seedLength ?? 0 - return session.events.slice(ownStart).some(event => event.type === 'subagent/descriptor') + const events = session.events + // Indexed scan from the own-suffix start: slicing copies the whole suffix + // on every Agent-bound RPC, including each `session.prompt` on long + // transcripts. + for (let index = session.header.seedLength ?? 0; index < events.length; index += 1) { + if (events[index]?.type === 'subagent/descriptor') return true + } + return false } /** From 56e252bed35cec9b304af92f389ecd01ac357e20 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:22:20 +0800 Subject: [PATCH 032/129] fix(host): fence the agentFor live fast path on the agent's own session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `agentFor` fenced subagent ownership through the attached session store (`ctx.sessions.get`) and only then returned a live registered agent. A registered agent whose session is ever absent from the attached store — an invariant nothing in this package guarantees — would therefore be handed out through generic Host routing unfenced, bypassing subagent delivery entirely. Fence `live.session` directly whenever a live agent exists, and keep the attached-store check only for the not-live durable classification. `ensureSession`'s race `.catch` already fences `live.session`; this makes the fast path the same check instead of an asymmetric weaker one. --- packages/host/apiproxy/src/api-proxy.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 70661bb027..96e040d2a2 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -1106,6 +1106,19 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro if (error instanceof SubagentSessionOwnership) { return { error: subagentOwnershipError(error.sessionId) } } + // A concurrent parent `enter()` can win the identity between the + // pre-resume published re-check and `ctx.agents.resume` publication; + // the ID-collision rejection falls through here. Re-classify that + // raced published winner into the stable ownership error, mirroring + // ensureSession's `.catch`. + const live = ctx.agents.get(sessionId) + if (live !== undefined && hasSubagentOwner(live.session, live)) { + return { error: subagentOwnershipError(sessionId) } + } + const attached = ctx.sessions.get(sessionId) + if (attached !== undefined && hasSubagentOwner(attached, undefined)) { + return { error: subagentOwnershipError(sessionId) } + } // The internal details slot is contractually {}; the reason rides the message. return { error: { code: 'internal', message: `resume failed for session "${sessionId}": ${String(error)}`, details: {} } } } From 468fd29e51f160135d6e2b0f30c335d5a8fbb3cb Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:22:28 +0800 Subject: [PATCH 033/129] fix(host): classify a raced cold-resume ID collision as agent-busy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a generic `agentFor` cold resume loses the identity to a parent's concurrent `enter()` — the collision rejection arrives from `ctx.agents.resume` publication after the pre-resume re-check — the error fell through to the `internal` mapping. Clients retrying then see a transient-looking internal failure instead of the stable ownership error that `ensureSession`'s `.catch` already produces for the exact same published-winner case. Mirror that re-classification in `agentFor`'s resume error path: after the typed errors, re-check the registry and attached store and answer `agent-busy` when the raced winner is subagent-owned. Adds a regression test whose resume mock publishes the subagent winner before throwing the ID-collision error. --- packages/host/apiproxy/src/api-proxy.ts | 8 ++-- .../apiproxy/tests/api-proxy-cold.spec.ts | 39 +++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 96e040d2a2..517688de34 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -2253,9 +2253,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }, commands: { - // Both methods address one session's agent (agentFor keeps its - // resume-on-miss: clients only send a sessionId for a published - // session, and resume restores an existing entity). + // Both methods address one session's agent. agentFor resumes on miss + // and fences every subagent-owned identity with `agent-busy`; the + // api/commands.ts module contract owns that fence's wording, so this + // comment only notes the routing shape: clients send a sessionId for a + // published session, and resume restores an existing entity. async list(request) { // Missing service = the deployment omitted dsh-commands from its // composition, not an empty catalog: fail loud instead of serving []. diff --git a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts index 1e5b095778..7eeeba3af6 100644 --- a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts @@ -338,4 +338,43 @@ describe('sessions.prompt synchronous rejection', () => { } } }) + + it('classifies a raced cold-resume ID collision as agent-busy', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(UserInteractionService) + const sessionId = sid('race-resume') + const meta: SessionHeader = header('race-resume', 1000) + ctx.provide('sessionPersistence', { + list: () => Promise.resolve([meta]), + inspect: () => Promise.resolve({ meta, events: [] as SessionEvent[] }), + locate: () => undefined, + } as never) + // The raced winner: a live parent-owned subagent publishes the identity + // while the generic cold resume is in flight, so the resume collides. + const parentSession = ctx.sessions.create(sid('race-parent'), { meta: { cwd: '/proj' } }) + const parent = { id: parentSession.id, session: parentSession, status: 'idle', ctx } as Agent + ctx.agents.register(parent) + const childSession = ctx.sessions.create(sessionId, { + meta: { cwd: '/proj', parentSession: parent.id, origin: 'subagent' }, + }) + const child = { id: sessionId, session: childSession, status: 'idle', ctx } as unknown as Agent + vi.spyOn(ctx.agents, 'resume').mockImplementationOnce(async () => { + // The parent's `enter()` wins the identity between the pre-resume + // re-check and publication; the generic resume then collides. + ctx.agents.register(child) + throw new Error('session id already published') + }) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + + const models = await api.sessions.models(request({ sessionId })) + expect(models.result.ok).toBe(false) + if (!models.result.ok) { + expect(models.result.error).toMatchObject({ + code: 'agent-busy', + details: { reason: 'use subagent delivery for this child session' }, + }) + } + }) }) From c68c3dbb43e2b9137a37a3f0d8b9daab154123a6 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:22:41 +0800 Subject: [PATCH 034/129] fix(host): check subagent ownership before cwd conflict in ensureSession MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Explicit-id adoption of a cold session-backed subagent under a *different* cwd answered `session-conflict` because the cwd check ran before the persistence inspection classified the identity. The api/commands.ts contract states explicit-id `session.create` adoption rejects session-backed subagents with `agent-busy` — ownership is an identity property, so it must win regardless of the requested workspace. Reorder the stored-session branch to inspect and classify ownership first, then enforce the cwd match, making the response match the documented contract. --- packages/host/apiproxy/src/api-proxy.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 517688de34..501390e488 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -1066,12 +1066,20 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } async function agentFor(sessionId: SessionId): Promise<{ agent: Agent } | { error: RpcError }> { - const attached = ctx.sessions.get(sessionId) const live = ctx.agents.get(sessionId) - if (attached !== undefined && hasSubagentOwner(attached, live)) { + if (live !== undefined) { + // Fence the live agent's own session rather than trusting a + // "registered ⇒ attached-store" invariant: a registered subagent whose + // session is ever absent from the attached store must still not be + // handed out through generic Host routing (ensureSession's `.catch` + // already fences `live.session`; this is the same check on the fast path). + if (hasSubagentOwner(live.session, live)) return { error: subagentOwnershipError(sessionId) } + return { agent: live } + } + const attached = ctx.sessions.get(sessionId) + if (attached !== undefined && hasSubagentOwner(attached, undefined)) { return { error: subagentOwnershipError(sessionId) } } - if (live !== undefined) return { agent: live } let resume = resumes.get(sessionId) if (resume === undefined) { resume = (async () => { From e81267945abc04887f3ea68f6525c36d97b0a4e9 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:22:54 +0800 Subject: [PATCH 035/129] docs(host): refresh the stale agentFor resume-on-miss comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The commands entry's inline comment described the old routing shape ("clients only send a sessionId for a published session") without the ownership fence that agentFor now applies on every path — the fence's contract home is the api/commands.ts module JSDoc, so trim the duplicate and point at the routing shape only, keeping one home per fact. --- packages/host/apiproxy/src/api-proxy.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 501390e488..0827762a8a 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -1202,13 +1202,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro ? undefined : (await persistence.list()).find(header => header.id === sessionId) if (persistence !== undefined && stored !== undefined) { - if (stored.cwd !== cwd) { - throw new SessionCwdConflict(sessionId, cwd, stored.cwd) - } const inspected = await persistence.inspect(sessionId) + // Ownership first: explicit-id adoption of a session-backed + // subagent must answer `agent-busy` regardless of the requested + // cwd (the api/commands.ts contract), not a cwd conflict. if (hasSubagentOwner({ header: inspected.meta, events: inspected.events }, undefined)) { throw new SubagentSessionOwnership(sessionId) } + if (inspected.meta.cwd !== cwd) { + throw new SessionCwdConflict(sessionId, cwd, inspected.meta.cwd) + } return (await ctx.agents.resume({ resumeSessionId: sessionId, agentOptions, From cb835c7ea98345d51508b944f57c252c7c55e503 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:23:29 +0800 Subject: [PATCH 036/129] fix(acp): keep per-session teardown failure reasons in the aggregate log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The connection-close teardown path threw a bare `AggregateError` whose message counts the failed sessions, and its only production consumer logs through `String(error)` — which renders the message alone. Compared with the previous `Promise.all` behavior, every actual disposal failure reason disappeared from operational logs. Join the per-session reasons into the aggregate message, matching the subagent seam's own aggregate disposal messages, and pin the reason in the dispose spec's warning assertion. --- packages/acp/acp/src/index.ts | 10 +++++++++- packages/acp/acp/tests/dispose.spec.ts | 3 ++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/acp/acp/src/index.ts b/packages/acp/acp/src/index.ts index 0a2f7f7f68..7af26b594a 100644 --- a/packages/acp/acp/src/index.ts +++ b/packages/acp/acp/src/index.ts @@ -368,7 +368,15 @@ export function apply(ctx: Context, config: AcpConfig): void { if (result.status === 'rejected') failures.push(result.reason as unknown) } if (failures.length > 0) { - throw new AggregateError(failures, `ACP agent teardown failed for ${failures.length} session(s)`) + // The only production consumer logs this error through `String`, which + // renders the message alone — without the joined reasons, per-session + // disposal failures would vanish from operational logs. Join them like + // the subagent seam's own aggregate disposal messages. + const detail = failures.map(failure => String(failure)).join('; ') + throw new AggregateError( + failures, + `ACP agent teardown failed for ${failures.length} session(s): ${detail}`, + ) } })() return quiescing diff --git a/packages/acp/acp/tests/dispose.spec.ts b/packages/acp/acp/tests/dispose.spec.ts index 5f6b5babd5..0eea014eeb 100644 --- a/packages/acp/acp/tests/dispose.spec.ts +++ b/packages/acp/acp/tests/dispose.spec.ts @@ -131,7 +131,8 @@ describe('ACP connection ownership', () => { releaseSecond.resolve(undefined) await vi.waitFor(() => { - expect(warnings.some(warning => warning.includes('ACP agent teardown failed for 1 session(s)'))).toBe(true) + expect(warnings.some(warning => + warning.includes('ACP agent teardown failed for 1 session(s): Error: first session cleanup failed'))).toBe(true) expect(harness!.ctx.agents.get(SessionId(first.sessionId))).toBeUndefined() expect(harness!.ctx.agents.get(SessionId(second.sessionId))).toBeUndefined() }) From 2a3a8ff66d294eb442a4163b13fd3b4cd57b29cf Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:24:08 +0800 Subject: [PATCH 037/129] docs(subagent): mark the superseded flush-required clause in the intent-named note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2026-07-27 intent-named operations note still declared that a continuable provider requires `flush()` to resolve `true` at its final result boundary and maps `false`/rejection to `DURABILITY_FAILED`. The activation-based record (2026-07-28-continuable-subagent-conversations) superseded that contract: the manager awaits the final flush as a best-effort barrier and deliberately ignores the boolean, because listener participation cannot identify a persistence backend. Active notes are the current source of truth — sync both sides of the bilingual pair by marking the old clause superseded with a link to the record that replaced it. --- .../2026-07-27-intent-named-subagent-continuation-operations.md | 2 +- ...26-07-27-intent-named-subagent-continuation-operations.zh.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md index 5029d8335f..00340ac534 100644 --- a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md +++ b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md @@ -18,7 +18,7 @@ The durability boundary also exposed both `SessionStore.flush()` and `flushRequi Caller and provider requests are distinct. `SubagentStartRequest` contains only caller-supplied start data; `SubagentProviderStartRequest` adds service-resolved continuation state. Ordinary `start()` clears that state before provider dispatch. `SubagentProviderResumeRequest` remains part of the provider seam, but `SubagentService.resume()` is absent: the continuation manager loads the descriptor, authorizes the parent, and invokes private provider start/resume closures owned by the service. Provider dispatch still receives the same capability checks and run lifecycle observation without becoming a caller operation. -`SessionStore.flush(session)` returns `Promise`. It resolves `true` after at least one scoped durability listener participates successfully, resolves `false` for an empty listener snapshot, and rejects with the first registered listener failure after all listeners settle. Ordinary checkpoints may ignore the boolean. A continuable provider requires `true` at its final result boundary and maps `false` or rejection to `DURABILITY_FAILED`. +`SessionStore.flush(session)` returns `Promise`. It resolves `true` after at least one scoped durability listener participates successfully, resolves `false` for an empty listener snapshot, and rejects with the first registered listener failure after all listeners settle. Ordinary checkpoints may ignore the boolean. A continuable provider requires `true` at its final result boundary and maps `false` or rejection to `DURABILITY_FAILED`. **Superseded** by the activation-based record [2026-07-28-continuable-subagent-conversations](../feature/2026-07-28-continuable-subagent-conversations.md): the continuation manager awaits the final `flush()` as a best-effort barrier and deliberately ignores the boolean, because listener participation cannot identify a persistence backend; a rejection is logged without changing the lifecycle result or host-drain outcome. ## Alternatives considered diff --git a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.zh.md b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.zh.md index 0785730c19..58af4f2dc9 100644 --- a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.zh.md @@ -18,7 +18,7 @@ Status: implemented 调用方请求与提供方请求相互分离。`SubagentStartRequest` 只包含调用方提供的启动数据;`SubagentProviderStartRequest` 则加入由服务解析的继续执行状态。普通 `start()` 在分发给提供方之前会清除该状态。`SubagentProviderResumeRequest` 仍属于提供方 seam,但 `SubagentService.resume()` 不对外公开:继续执行管理器加载描述符、对 parent 进行鉴权,并调用由服务持有的私有提供方启动与恢复闭包。提供方分发仍会经过相同的功能检查和 run 生命周期观测,而无需将其变成调用方操作。 -`SessionStore.flush(session)` 返回 `Promise`。至少一个作用域内的持久性监听器成功参与后,它解析为 `true`;监听器快照为空时解析为 `false`;所有监听器结算后,如有失败,则以注册顺序最靠前的监听器错误拒绝。普通检查点可以忽略该布尔值。可继续提供方在最终结果边界要求该值为 `true`,并将 `false` 或拒绝映射为 `DURABILITY_FAILED`。 +`SessionStore.flush(session)` 返回 `Promise`。至少一个作用域内的持久性监听器成功参与后,它解析为 `true`;监听器快照为空时解析为 `false`;所有监听器结算后,如有失败,则以注册顺序最靠前的监听器错误拒绝。普通检查点可以忽略该布尔值。可继续提供方在最终结果边界要求该值为 `true`,并将 `false` 或拒绝映射为 `DURABILITY_FAILED`。**已被取代**:激活化记录 [2026-07-28-continuable-subagent-conversations](../feature/2026-07-28-continuable-subagent-conversations.zh.md) 规定延续管理器把最终 `flush()` 作为 best-effort 屏障并有意忽略布尔值——监听器参与度无法识别持久化后端;拒绝只记日志,不改变生命周期结果或宿主 drain 结果。 ## 已考虑的替代方案 From 42ee4e22debcbfcc1234c89b5c710ecece8d9ae5 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:27:55 +0800 Subject: [PATCH 038/129] fix(subagent): validate setup transactions before agent publication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `materialize` ran `setupTransaction.assertIntact()` only after `ctx.agents.create()/resume()` resolved — but the factory publishes `session/created` (and the persistence backend writes the descriptor seed) inside that call, and `rollbackUnpublished()` only disposes the live handle; the persistence seam has no delete. A setup contribution revoked during construction therefore left a durable ghost: `startContinuable()` rejected with `ACTIVATION_SETUP_REVOKED` and returned no child id, yet `list_agents` surfaced a persisted `continuable` child whose log carries a valid descriptor — so a later `send_message` could cold-resume a child the deployment had explicitly refused to establish. Move the validation into the creation callback, before the factory can publish: `assertIntact()` then rejects the create/resume call itself, so no session is ever persisted for a rejected child. Commit the batch in the same callback so a later contribution removal releases the installation instead of invalidating a child already being established (live revocation, matching the resident semantics). Pins the rollback regression test to assert that no `session/created` is ever announced for the rejected child (the parent is created before the listener registers), in addition to the existing registry assertion. --- packages/subagent/subagent/src/continuation.ts | 16 +++++++++++++--- .../tests/tool-subagent-report.spec.ts | 11 +++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index d21f235589..bc4833bbd0 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -804,6 +804,16 @@ export class SubagentContinuationManager { const setup = (childCtx: Context): void => { applyChildComposition(childCtx, inputs.composition) setupTransaction = this.setupRegistry.apply(childCtx) + // Validate and freeze the batch inside the creation callback, before the + // factory can publish the session: a revoked contribution must reject + // the create/resume call pre-publication, so no persisted session is + // ever left behind for a child the manager rejects — rollback only + // disposes the live handle, and the persistence seam has no delete, so + // a post-publication rejection would leave a resumable ghost child. + // Committing here also means a later contribution removal releases the + // installation instead of invalidating a child already being established. + setupTransaction.assertIntact() + setupTransaction.commit() } const observer = this.host.observeActivation(provider, childId, parent) const { create } = inputs @@ -842,7 +852,6 @@ export class SubagentContinuationManager { try { inputs.signal.throwIfAborted() this.assertAdmitting(parent) - setupTransaction.assertIntact() this.acquireOwnership(parent, childId) // Every accepted id leaves the inbox exactly once, through dequeue or // discard. Clearing it there is what lets `stateOf()` distinguish a truly @@ -860,8 +869,9 @@ export class SubagentContinuationManager { for (const item of items) activation.accepted.delete(item.message.id) this.wake(activation) }) - // Resident setup revokes live from here instead of invalidating creation. - setupTransaction.commit() + // Setup already validated and committed inside the creation callback; + // revocations from here on are immediate live revocation, never + // creation invalidation. // Publish the start edge before any turn can run, so observers see this // epoch before its first request. observer.start(handle.agent) diff --git a/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts b/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts index 31837afa9d..20e751530f 100644 --- a/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts +++ b/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts @@ -327,6 +327,15 @@ describe('dsh-tool-subagent-report', () => { return dispose }) + // No session may be announced for the rejected child: the setup + // validation must reject inside the creation callback, before the factory + // publishes — a post-publication rejection would persist a resumable + // ghost that `list_agents` surfaces and `send_message` can resurrect. + // The parent was created inside setup(), so any later announcement is the + // rejected child's. + const announced: SessionId[] = [] + const listener = (session: { id: SessionId }): void => { announced.push(session.id) } + const removeListener = ctx.on('session/created', listener) await expect(ctx.subagents.startContinuable({ provider: 'spawn', label: 'racing child', @@ -336,6 +345,8 @@ describe('dsh-tool-subagent-report', () => { }, signal: testSignal, })).rejects.toMatchObject({ code: 'ACTIVATION_SETUP_REVOKED' }) + removeListener() + expect(announced).toEqual([]) expect(ctx.agents.list().map(agent => agent.id)).toEqual([parent.id]) }) From 879a623095345df5e117bf9512cb33bf32c4a0aa Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:28:48 +0800 Subject: [PATCH 039/129] fix(subagent): cover the scope-disposal effect registration with setup rollback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `childCtx.effect()` that routes scope disposal into `releaseChild` was registered after the install loop's try/catch, so a hypothetical throw from the registration itself (effect() rejects only on an inactive fiber, which a live unpublished scope cannot be) would leak the just-installed batch — neither the setup-rollback catch nor `releaseChild` would release it. Move the registration inside the try so the existing rollback path covers it; no observable behavior change. --- packages/subagent/subagent/src/activation-setup-registry.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/subagent/subagent/src/activation-setup-registry.ts b/packages/subagent/subagent/src/activation-setup-registry.ts index c0fc84552c..dca194f113 100644 --- a/packages/subagent/subagent/src/activation-setup-registry.ts +++ b/packages/subagent/subagent/src/activation-setup-registry.ts @@ -123,6 +123,10 @@ export class SubagentActivationSetupRegistry { // Dispose that escaped record and invalidate the provisioning batch. if (isRemoved(registration)) this.release(installation) } + // Register the scope-disposal release inside the same try so the + // setup-rollback catch also covers a hypothetical effect-registration + // throw; today effect() cannot reject on a live unpublished scope. + childCtx.effect(() => () => { this.releaseChild(childCtx) }, 'subagents.activationSetup()') } catch (error: unknown) { // Keep the installer failure authoritative, but attempt every rollback. try { @@ -133,7 +137,6 @@ export class SubagentActivationSetupRegistry { } throw error } - childCtx.effect(() => () => { this.releaseChild(childCtx) }, 'subagents.activationSetup()') return { assertIntact: () => { if (!state.invalidated) return From 98ccbade7edefa3c4e19d7a1ba30319a055df044 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:28:50 +0800 Subject: [PATCH 040/129] fix(subagent): drop the dead reportDelivery destructure default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `apply()` resolved the deployment config through schemastery's `Config()`, which always fills the schema default (`quiet`, pinned by the config test), so the `= 'quiet'` destructure fallback was dead at runtime on every path — and as a defaulted parameter it formed a branch no test could ever exercise against the per-file coverage gate. Remove the fallback and let the schema be the single home of the default. --- packages/subagent/tool-subagent-report/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/subagent/tool-subagent-report/src/index.ts b/packages/subagent/tool-subagent-report/src/index.ts index e83d65f830..6f6160dc85 100644 --- a/packages/subagent/tool-subagent-report/src/index.ts +++ b/packages/subagent/tool-subagent-report/src/index.ts @@ -88,7 +88,7 @@ export function installReportTool( * @param config - deployment scheduling policy. */ export function apply(ctx: Context, config: Config = {}): void { - const { reportDelivery = 'quiet' } = Config(config) + const { reportDelivery } = Config(config) ctx.subagents.registerContinuableSetup(childCtx => installReportTool(childCtx, ctx, reportDelivery)) } From 5da2ac58357bfdb6164e79af3f907a54889f0514 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:29:02 +0800 Subject: [PATCH 041/129] docs(subagent): state that per-activation knobs are not restored on cold resume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The descriptor deliberately snapshots a curated composition field set rather than the merge-extensible `AgentOptions`, and it already names the per-activation exclusions (`outputSchema`). `maxTokens` is the same class of property — it budgets one activation, and on cold resume there is no parent to inherit a limit from, so the resumed activation runs under the deployment defaults. Spell that out in the module contract so the fallback is a documented decision instead of a silent surprise for deployments that set explicit child token limits. --- packages/subagent/subagent/src/descriptor.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/subagent/subagent/src/descriptor.ts b/packages/subagent/subagent/src/descriptor.ts index 22acbecbdd..4cec72e658 100644 --- a/packages/subagent/subagent/src/descriptor.ts +++ b/packages/subagent/subagent/src/descriptor.ts @@ -12,6 +12,10 @@ * omits `subagentDepth` — cold resume trusts the persisted header's * `delegationDepth` as the monotone floor — and `outputSchema`, which belongs * to one activation's result contract rather than durable child composition. + * Per-activation knobs such as `maxTokens` are omitted for the same reason as + * `outputSchema`: they budget one activation and, on cold resume, no parent + * exists to inherit them from, so the resumed activation runs under the + * deployment defaults rather than restoring a stale budget. * * @module @deepseek-ai/dsh-subagent/descriptor */ From 31149473240ee2a92b68ae55347a1756e17b5941 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:31:54 +0800 Subject: [PATCH 042/129] docs(subagent): correct report acceptance semantics for closing parents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tool README claimed a "missing, disposed, or closing parent" fails the call — but acceptance is governed by the parent's registry presence: `resolveReportParent` only rejects when the durable parent id is absent from the registry, so a host-owned parent already in disposal but still registered still accepts (the pinned host-disposing-parent behavior). The claim misled callers into treating disposal state as a delivery signal. Restate the contract in both languages: absence from the registry is the only `PARENT_UNAVAILABLE` case, and a failed tool call does not prove non-delivery — a later `tools/post-execute` veto can fail a call whose report was already accepted, so the durable child transcript remains the recovery source. Adds a regression test pinning acceptance into a host-disposing but still-registered parent, and rejection after disposal settles. --- .../subagent/tool-subagent-report/README.md | 2 +- .../subagent/tool-subagent-report/README.zh.md | 2 +- .../tests/tool-subagent-report.spec.ts | 17 +++++++++++++++++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/subagent/tool-subagent-report/README.md b/packages/subagent/tool-subagent-report/README.md index e15b8b5d58..5e3947c6a6 100644 --- a/packages/subagent/tool-subagent-report/README.md +++ b/packages/subagent/tool-subagent-report/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) The optional child-scoped `report` tool is a thin adapter over `ctx.subagents.reportFrom()`. It gives every continuable in-process child a return channel to the Agent that started it. The package registers a continuable-child setup contribution instead of a global tool, so `report` exists only inside those children. Roots, one-shot subagents, remote subagent providers, sibling scopes, and agentless tool execution never present or execute it. Installing this package grants only that child-scoped capability; the parent-to-child direction remains the independent [`@deepseek-ai/dsh-tool-subagent-control`](../tool-subagent-control/README.md), and continuable mode depends on neither package. -A child may call `report` zero or many times in one turn. A successful call neither concludes the turn, settles the Activation, nor prevents later parent follow-ups, and finishing a turn never reports automatically. The tool accepts no recipient: `exec.agent` is the sender's exact live Agent and the authority credential, and the service derives the sole recipient from that child's durable `parentSession`. Success returns the stable `MessageId` of the parent-accepted message, not a read receipt, an inbox-occurrence id, a parent-log acknowledgement, a turn-completion receipt, or a persistence flush. A missing, disposed, or closing parent fails the call with `direct parent is not live; report was not delivered`; the service performs no injection, parent cold resume, or offline mailbox write, so the durable child transcript remains the recovery source. +A child may call `report` zero or many times in one turn. A successful call neither concludes the turn, settles the Activation, nor prevents later parent follow-ups, and finishing a turn never reports automatically. The tool accepts no recipient: `exec.agent` is the sender's exact live Agent and the authority credential, and the service derives the sole recipient from that child's durable `parentSession`. Success returns the stable `MessageId` of the parent-accepted message, not a read receipt, an inbox-occurrence id, a parent-log acknowledgement, a turn-completion receipt, or a persistence flush. A parent absent from the registry fails the call with `direct parent is not live; report was not delivered` — acceptance is governed by registry presence, so a parent already in host-owned disposal but still registered still accepts. The service performs no injection, parent cold resume, or offline mailbox write; the durable child transcript remains the recovery source, and a failed tool call does not prove non-delivery (a later `tools/post-execute` veto can fail a call whose report was already accepted). `reportDelivery` selects parent scheduling for every accepted report. `quiet` (the default) uses `parent.inject()`, adding model-facing context without starting a parent model request: an idle parent's append completes before the call returns, while a report reaching an admitting or running parent stages for the next safe log position. `wakeup` uses `parent.followup()`, creating exactly one ordinary later parent turn and waking a parked parent driver; it never steers an open turn. This is deployment scheduling policy, so the model-facing schema cannot select or override it per call. diff --git a/packages/subagent/tool-subagent-report/README.zh.md b/packages/subagent/tool-subagent-report/README.zh.md index 0c41bc9c1e..bb008f5b0b 100644 --- a/packages/subagent/tool-subagent-report/README.zh.md +++ b/packages/subagent/tool-subagent-report/README.zh.md @@ -4,7 +4,7 @@ 可选的子级作用域 `report` 工具是 `ctx.subagents.reportFrom()` 之上的轻量适配器。它为每个可继续的进程内子级提供一条返回通道,指向启动该子级的 Agent(智能体)。本包(package)注册的是可继续子级设置贡献,而不是全局工具,因此 `report` 只存在于这些子级内部。根 Agent、一次性 subagent、远程 subagent 提供方、同级作用域以及不关联 Agent 的工具执行都不会提供或执行它。安装本包只授予这项子级作用域功能;父到子方向仍由独立的 [`@deepseek-ai/dsh-tool-subagent-control`](../tool-subagent-control/README.md) 负责,可继续模式不依赖这两个包中的任一个。 -子级可以在一个轮次中调用 `report` 零次或多次。调用成功既不会结束轮次或结算 Activation,也不会阻止父级后续消息;轮次结束也绝不会自动上报。该工具不接受接收方参数:`exec.agent` 是发送方准确的实时 Agent,也是权限凭据;服务根据该子级持久化的 `parentSession` 推导唯一接收方。成功时返回父级已接受消息的稳定 `MessageId`,不表示已读回执、inbox 中该次出现的 id、父级日志确认、轮次完成回执或持久化刷盘。父级不存在、已 dispose(资源释放)或正在关闭时,本次调用会失败并返回 `direct parent is not live; report was not delivered`;服务不会执行注入、父级冷恢复或离线 mailbox 写入,因此持久化子级 transcript(文本记录)仍是恢复真源。 +子级可以在一个轮次中调用 `report` 零次或多次。调用成功既不会结束轮次或结算 Activation,也不会阻止父级后续消息;轮次结束也绝不会自动上报。该工具不接受接收方参数:`exec.agent` 是发送方准确的实时 Agent,也是权限凭据;服务根据该子级持久化的 `parentSession` 推导唯一接收方。成功时返回父级已接受消息的稳定 `MessageId`,不表示已读回执、inbox 中该次出现的 id、父级日志确认、轮次完成回执或持久化刷盘。接受与否由父级在注册表中的存在性决定:父级不在注册表时,调用失败并返回 `direct parent is not live; report was not delivered`;已开始宿主 dispose 但仍在注册表中的父级仍会接受。服务不会执行注入、父级冷恢复或离线 mailbox 写入;持久化子级 transcript(文本记录)仍是恢复真源,且工具调用失败不能证明未送达(后续 `tools/post-execute` 否决可能让报告已被接受的调用以失败结束)。 `reportDelivery` 为每条已接受的报告选择父级调度方式。`quiet`(默认值)使用 `parent.inject()`,在不启动父级模型请求的情况下添加面向模型的上下文:父级空闲时,追加操作会在调用返回前完成;报告到达正在准入或运行的父级时,则会暂存到下一个安全日志位置。`wakeup` 使用 `parent.followup()`,准确创建一个普通的后续父级轮次,并唤醒停驻的父级驱动;它绝不会对正在运行的轮次进行 steering(中途引导)。这是部署调度策略,因此面向模型的 schema 不能在单次调用中选择或覆盖该策略。 diff --git a/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts b/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts index 20e751530f..4d44204fd6 100644 --- a/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts +++ b/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts @@ -350,6 +350,23 @@ describe('dsh-tool-subagent-report', () => { expect(ctx.agents.list().map(agent => agent.id)).toEqual([parent.id]) }) + it('accepts a report into a host-disposing but still-registered parent', async () => { + const { ctx } = await setup() + const parentHandle = await ctx.agents.create({ + sessionId: SessionId('disposing-parent'), + agentOptions: { provider: 'mock', model: 'mock' }, + }) + const { child } = await startChild(ctx, parentHandle.agent) + // Host-owned disposal starts asynchronously; the parent stays registered + // until quiescence, and registry presence — not disposal state — is the + // acceptance gate (pins the README contract). + const disposing = parentHandle.dispose() + const accepted = await callReport(ctx, child, 'during-close') + expect(accepted.isError).toBe(false) + await disposing + expect((await callReport(ctx, child, 'after-close')).isError).toBe(true) + }) + it('keeps the namespace plugin shape and validates its default', () => { expect('default' in tool).toBe(false) expect(tool.name).toBe('tool-subagent-report') From 902b46b86bc2402df644474b4dffec7ccd5cac62 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:37:54 +0800 Subject: [PATCH 043/129] feat(web): localize the subagent catalog and read-only composer copy The catalog action (diagnostics, relative times, loading/error/retry, mode and activity labels, branch toggles, descendant counts, tree aria) and the read-only composer were hardcoded to Simplified Chinese, so an English-locale session rendered mixed-language UI. Register a `subagent` locale namespace (zh source of truth + en dictionary), declare it on both slot registrations, thread the locale `t` seat through the components, and mount the locale service in the plugin specs. The UI spec's zh assertions now run against the real dictionary through a `t` stub that interpolates `{name}` params exactly like the locale service. --- .../src/client/SubagentCatalogAction.tsx | 72 +++++++++++-------- .../src/client/SubagentReadOnlyComposer.tsx | 15 ++-- .../client/ui-subagent/src/client/index.ts | 14 +++- .../client/ui-subagent/src/client/locales.ts | 67 +++++++++++++++++ .../ui-subagent/tests/browser-plugin.spec.ts | 5 +- .../tests/conversation-ui.spec.tsx | 22 +++++- 6 files changed, 153 insertions(+), 42 deletions(-) create mode 100644 packages/client/ui-subagent/src/client/locales.ts diff --git a/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx b/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx index c23c82d77b..359827780f 100644 --- a/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx +++ b/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx @@ -7,7 +7,8 @@ import type { import { IconChevronDownOutline14, IconChevronRightOutline14, IconRefreshOutline14, StateDot, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { PropsLocale, PropsRuntime, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' +import { NS } from './locales.ts' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import css from './SubagentCatalogAction.module.css' @@ -23,7 +24,7 @@ export interface SubagentCatalogInjected { /** Full props for the session-header catalog action. */ export type SubagentCatalogActionProps = - PropsRuntime<'conversation.session.header.actions'> & SubagentCatalogInjected + PropsRuntime<'conversation.session.header.actions'> & SubagentCatalogInjected & PropsLocale interface CatalogRowsProps { parentSessionId: SessionId @@ -39,11 +40,14 @@ interface CatalogRowsProps { closeCatalog: () => void } -function diagnosticReason(entry: Extract): string { +function diagnosticReason( + entry: Extract, + t: TranslateNS, +): string { switch (entry.reason) { - case 'corrupt': return '会话记录损坏' - case 'unsupported': return '子代理记录版本不受支持' - case 'unavailable': return '会话记录暂不可用' + case 'corrupt': return t('diagnostic.corrupt') + case 'unsupported': return t('diagnostic.unsupported') + case 'unavailable': return t('diagnostic.unavailable') } } @@ -54,18 +58,22 @@ function treeItems(root: HTMLDivElement | null): HTMLElement[] { } /** Compact trailing activity time for a catalog row. */ -function relativeTime(updatedAt: number | undefined, now: number): string | undefined { +function relativeTime( + updatedAt: number | undefined, + now: number, + t: TranslateNS, +): string | undefined { if (updatedAt === undefined) return undefined const minute = 60_000 const hour = 60 * minute const day = 24 * hour const diff = Math.max(0, now - updatedAt) - if (diff < minute) return '刚刚' - if (diff < hour) return `${Math.floor(diff / minute)}分钟` - if (diff < day) return `${Math.floor(diff / hour)}小时` - if (diff < 30 * day) return `${Math.floor(diff / day)}天` - if (diff < 365 * day) return `${Math.floor(diff / (30 * day))}个月` - return `${Math.floor(diff / (365 * day))}年` + if (diff < minute) return t('time.justNow') + if (diff < hour) return t('time.minutes', { n: Math.floor(diff / minute) }) + if (diff < day) return t('time.hours', { n: Math.floor(diff / hour) }) + if (diff < 30 * day) return t('time.days', { n: Math.floor(diff / day) }) + if (diff < 365 * day) return t('time.months', { n: Math.floor(diff / (30 * day)) }) + return t('time.years', { n: Math.floor(diff / (365 * day)) }) } /** Aggregate the complete subagent-only descendant subtree from flat summaries. */ @@ -98,28 +106,30 @@ function CatalogLoadingRows({ parentSessionId, summaries, level, + t, }: { parentSessionId: SessionId summaries: Readonly> level: number + t: TranslateNS }) { const children = Object.values(summaries).filter(summary => ( summary.origin === 'subagent' && summary.parentId === parentSessionId )) - if (children.length === 0) return
正在加载子代理…
+ if (children.length === 0) return
{t('loading.label')}
return children.map(summary => (
- 正在加载子代理… + {t('loading.label')}
@@ -129,8 +139,8 @@ function CatalogLoadingRows({ /** Render one catalog level and recurse only through explicitly expanded rows. */ function CatalogRows({ parentSessionId, catalog, catalogs, summaries, expanded, level, now, - openChild, refresh, toggleBranch, closeCatalog, -}: CatalogRowsProps) { + openChild, refresh, toggleBranch, closeCatalog, t, +}: CatalogRowsProps & { t: TranslateNS }) { const emptyLoading = catalog.state === 'loading' && catalog.entries.length === 0 return ( <> @@ -139,24 +149,25 @@ function CatalogRows({ parentSessionId={parentSessionId} summaries={summaries} level={level} + t={t} /> )} {catalog.state === 'error' && (
- {catalog.error?.message ?? '无法加载子代理'} + {catalog.error?.message ?? t('load.error')}
)} {catalog.entries.map((entry) => { if (entry.kind === 'diagnostic') { - const reason = diagnosticReason(entry) + const reason = diagnosticReason(entry, t) return (
value !== undefined) .join(' · ') - const time = relativeTime(summary?.updatedAt, now) + const time = relativeTime(summary?.updatedAt, now, t) const open = (): void => { openChild({ parentSessionId, childSessionId: entry.id, mode: entry.mode }) @@ -235,7 +246,7 @@ function CatalogRows({ type="button" tabIndex={-1} className={`${css.disclosure} ${isExpanded ? css.disclosureOpen : ''}`} - aria-label={`${isExpanded ? '收起' : '展开'} ${label} 的下级子代理`} + aria-label={t(isExpanded ? 'branch.collapse' : 'branch.expand', { label })} onClick={toggle} > @@ -262,6 +273,7 @@ function CatalogRows({ parentSessionId={entry.id} summaries={summaries} level={level + 1} + t={t} /> ) : ( @@ -277,6 +289,7 @@ function CatalogRows({ refresh={refresh} toggleBranch={toggleBranch} closeCatalog={closeCatalog} + t={t} /> )}
@@ -294,7 +307,7 @@ function CatalogRows({ * @returns The action only after a non-empty catalog arrives. */ export function SubagentCatalogAction({ - sessionId, useSessions, openChild, refresh, setCatalogOpen, + sessionId, useSessions, openChild, refresh, setCatalogOpen, t, }: SubagentCatalogActionProps) { const catalogs = useSessions(state => state.subagentsByParent) const summaries = useSessions(state => state.byId) @@ -419,7 +432,7 @@ export function SubagentCatalogAction({ className={css.trigger} aria-haspopup="tree" aria-expanded={open} - aria-label={`${descendantCount} 个子代理${descendants.running ? ',正在运行' : ''}`} + aria-label={t(descendants.running ? 'count.running' : 'count.total', { count: descendantCount })} onClick={() => { changeOpen(!open) }} onKeyDown={(event) => { if (event.key !== 'ArrowDown') return @@ -431,11 +444,11 @@ export function SubagentCatalogAction({ {descendants.running && } - {descendantCount} 个子代理 + {t('count.total', { count: descendantCount })} {open && ( -
+
{ changeOpen(false) }} + t={t} />
)} diff --git a/packages/client/ui-subagent/src/client/SubagentReadOnlyComposer.tsx b/packages/client/ui-subagent/src/client/SubagentReadOnlyComposer.tsx index 0cc2699bd8..158e8cb77b 100644 --- a/packages/client/ui-subagent/src/client/SubagentReadOnlyComposer.tsx +++ b/packages/client/ui-subagent/src/client/SubagentReadOnlyComposer.tsx @@ -1,4 +1,5 @@ -import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import { NS } from './locales.ts' import css from './SubagentReadOnlyComposer.module.css' /** Why a catalog-addressed conversation cannot accept human input. */ @@ -8,7 +9,7 @@ export interface SubagentReadOnlyMatch { /** Full chain props after the read-only subagent selector accepts the owner currency. */ export type SubagentReadOnlyComposerProps = - PropsRuntime<'conversation.composer'> & { matched: SubagentReadOnlyMatch } + PropsRuntime<'conversation.composer'> & { matched: SubagentReadOnlyMatch } & PropsLocale /** * Explain why the normal composer is unavailable for an addressed child. @@ -16,16 +17,14 @@ export type SubagentReadOnlyComposerProps = * @returns A read-only composer replacement. */ export function SubagentReadOnlyComposer({ - matched, -}: Pick) { + matched, t, +}: Pick) { const oneShot = matched.reason === 'one-shot' return (
- {oneShot ? '一次性子代理记录' : '此子代理暂时只读'} + {t(oneShot ? 'readonly.oneShot.title' : 'readonly.title')} - {oneShot - ? '一次性任务不支持后续消息,可在这里查看完整执行记录。' - : '父会话当前不在线,重新打开父会话后即可继续发送消息。'} + {t(oneShot ? 'readonly.oneShot.body' : 'readonly.body')}
) diff --git a/packages/client/ui-subagent/src/client/index.ts b/packages/client/ui-subagent/src/client/index.ts index 239ffd4335..31579dc258 100644 --- a/packages/client/ui-subagent/src/client/index.ts +++ b/packages/client/ui-subagent/src/client/index.ts @@ -18,6 +18,15 @@ import { SubagentCatalogAction, type SubagentCatalogInjected } from './SubagentC import { SubagentReadOnlyComposer, type SubagentReadOnlyMatch, } from './SubagentReadOnlyComposer.tsx' +import type {} from '@deepseek-ai/dsh-client-locale/client' +import { en, NS, zh, type SubagentKey } from './locales.ts' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Subagent catalog and read-only composer copy. */ + 'subagent': SubagentKey + } +} export type { SubagentCatalogActionProps, SubagentCatalogInjected, @@ -27,7 +36,7 @@ export type { } from './SubagentReadOnlyComposer.tsx' /** Required services for references, conversation slots, and session navigation. */ -export const inject = ['slash', 'sessions', 'conversation', 'slots'] +export const inject = ['slash', 'sessions', 'conversation', 'slots', 'locale'] /** Claim the composer for one-shot history or an unavailable continuation owner. */ function selectReadOnlySubagent(owner: ComposerChainProps): SubagentReadOnlyMatch | null { @@ -42,6 +51,7 @@ function selectReadOnlySubagent(owner: ComposerChainProps): SubagentReadOnlyMatc * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-subagent: dictionaries') const sessions = ctx.sessions // Child labels live on the session list (parentId lineage + displayTitle), // not the conversation snapshot — the list store is the zero-RPC candidate feed. @@ -98,6 +108,7 @@ export function apply(ctx: ClientContext): void { name: 'conversation.session.header.actions', id: 'subagent-catalog', order: 10, + locale: NS, inject: catalogActions, }, SubagentCatalogAction), 'ui-subagent: lazy descendant catalog action', @@ -106,6 +117,7 @@ export function apply(ctx: ClientContext): void { () => ctx.slots.register({ name: 'conversation.composer', priority: -10, + locale: NS, select: selectReadOnlySubagent, }, SubagentReadOnlyComposer), 'ui-subagent: read-only addressed composer', diff --git a/packages/client/ui-subagent/src/client/locales.ts b/packages/client/ui-subagent/src/client/locales.ts new file mode 100644 index 0000000000..2ecf1be4f5 --- /dev/null +++ b/packages/client/ui-subagent/src/client/locales.ts @@ -0,0 +1,67 @@ +/** `subagent` namespace dictionaries. */ + +/** Dictionary namespace owned by this plugin. */ +export const NS = 'subagent' + +/** Simplified Chinese dictionary (the key-set source of truth). */ +export const zh = { + 'diagnostic.corrupt': '会话记录损坏', + 'diagnostic.unsupported': '子代理记录版本不受支持', + 'diagnostic.unavailable': '会话记录暂不可用', + 'time.justNow': '刚刚', + 'time.minutes': '{n}分钟', + 'time.hours': '{n}小时', + 'time.days': '{n}天', + 'time.months': '{n}个月', + 'time.years': '{n}年', + 'loading.label': '正在加载子代理…', + 'loading.aria': '正在加载子代理', + 'load.error': '无法加载子代理', + 'retry': '重试', + 'mode.oneShot': '一次性', + 'mode.continuable': '可继续', + 'activity.running': '正在运行', + 'activity.inactive': '当前未运行', + 'branch.collapse': '收起 {label} 的下级子代理', + 'branch.expand': '展开 {label} 的下级子代理', + 'count.total': '{count} 个子代理', + 'count.running': '{count} 个子代理,正在运行', + 'tree.aria': '子代理会话', + 'readonly.oneShot.title': '一次性子代理记录', + 'readonly.title': '此子代理暂时只读', + 'readonly.oneShot.body': '一次性任务不支持后续消息,可在这里查看完整执行记录。', + 'readonly.body': '父会话当前不在线,重新打开父会话后即可继续发送消息。', +} as const + +/** English dictionary, key-identical to the Chinese source of truth. */ +export const en: Record = { + 'diagnostic.corrupt': 'corrupted session record', + 'diagnostic.unsupported': 'unsupported subagent record version', + 'diagnostic.unavailable': 'session record temporarily unavailable', + 'time.justNow': 'just now', + 'time.minutes': '{n}m', + 'time.hours': '{n}h', + 'time.days': '{n}d', + 'time.months': '{n}mo', + 'time.years': '{n}y', + 'loading.label': 'Loading subagents…', + 'loading.aria': 'Loading subagents', + 'load.error': 'Unable to load subagents', + 'retry': 'Retry', + 'mode.oneShot': 'one-shot', + 'mode.continuable': 'continuable', + 'activity.running': 'running', + 'activity.inactive': 'not running', + 'branch.collapse': 'Collapse {label} descendants', + 'branch.expand': 'Expand {label} descendants', + 'count.total': '{count} subagents', + 'count.running': '{count} subagents running', + 'tree.aria': 'Subagent sessions', + 'readonly.oneShot.title': 'One-shot subagent record', + 'readonly.title': 'This subagent is read-only for now', + 'readonly.oneShot.body': 'One-shot tasks do not accept follow-ups; review the full execution record here.', + 'readonly.body': 'The parent session is offline; reopen it to continue sending messages.', +} + +/** Key domain of the `subagent` namespace (zh is the source of truth). */ +export type SubagentKey = keyof typeof zh diff --git a/packages/client/ui-subagent/tests/browser-plugin.spec.ts b/packages/client/ui-subagent/tests/browser-plugin.spec.ts index 0db157a5a7..d2332cb3af 100644 --- a/packages/client/ui-subagent/tests/browser-plugin.spec.ts +++ b/packages/client/ui-subagent/tests/browser-plugin.spec.ts @@ -18,6 +18,7 @@ import { import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client' import { SlashService } from '@deepseek-ai/dsh-client-ui-slash/client' import type { ClientSessionContext, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client' +import { apply as applyLocale } from '@deepseek-ai/dsh-client-locale/client' import { SubagentCatalogAction, type SubagentCatalogInjected, } from '../src/client/SubagentCatalogAction.tsx' @@ -85,6 +86,7 @@ async function fullBench(sessions: SessionSummary[]) { ctx.provide('slash', { registerSource: (src: SlashSource) => { captured = src; return () => {} } }) ctx.provide('sessions', face) await provideSlotFaces(ctx) + await ctx.plugin({ inject: ['slots'], apply: applyLocale }).await() await ctx.plugin({ inject: [...inject], apply }).await() return { source: captured!, face, ctx } } @@ -111,7 +113,7 @@ const req = (query: string) => describe('apply', () => { it('declares the services it binds', () => { - expect(inject).toEqual(['slash', 'sessions', 'conversation', 'slots']) + expect(inject).toEqual(['slash', 'sessions', 'conversation', 'slots', 'locale']) }) it('registers the "@" subagent source; disposal frees the name (HMR safety)', async () => { @@ -119,6 +121,7 @@ describe('apply', () => { await ctx.plugin(SlashService).await() ctx.provide('sessions', sessionsWith(FAMILY)) await provideSlotFaces(ctx) + await ctx.plugin({ inject: ['slots'], apply: applyLocale }).await() const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() const slash = ctx.get('slash') as SlashService diff --git a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx b/packages/client/ui-subagent/tests/conversation-ui.spec.tsx index 6eb9f881c0..2e90893244 100644 --- a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx +++ b/packages/client/ui-subagent/tests/conversation-ui.spec.tsx @@ -7,7 +7,10 @@ import type { import { SubagentCatalogAction, type SubagentCatalogActionProps, } from '../src/client/SubagentCatalogAction.tsx' -import { SubagentReadOnlyComposer } from '../src/client/SubagentReadOnlyComposer.tsx' +import { + SubagentReadOnlyComposer, type SubagentReadOnlyComposerProps, +} from '../src/client/SubagentReadOnlyComposer.tsx' +import { zh, type SubagentKey } from '../src/client/locales.ts' afterEach(() => { cleanup() @@ -63,12 +66,22 @@ function props( function useSessions(select: (snapshot: SessionListState) => T): T { return select(state) } + // The zh dictionary is the source of truth for this spec's assertions: + // the stub interpolates `{name}` params like the locale service does. + const t = ((key: SubagentKey, params?: Record): string => { + let text = zh[key] + for (const [name, value] of Object.entries(params ?? {})) { + text = text.replaceAll(`{${name}}`, String(value)) + } + return text + }) as SubagentCatalogActionProps['t'] return { sessionId: PARENT, useSessions, openChild: vi.fn(), refresh: vi.fn(), setCatalogOpen: vi.fn(), + t, } as unknown as SubagentCatalogActionProps } @@ -453,13 +466,16 @@ describe('SubagentCatalogAction', () => { }) describe('SubagentReadOnlyComposer', () => { + // The zh dictionary is the source of truth for this spec's assertions. + const t = ((key: SubagentKey): string => zh[key]) as SubagentReadOnlyComposerProps['t'] + it('explains the exact missing-parent recovery path', () => { - render() + render() expect(screen.getByRole('status').textContent).toContain('父会话当前不在线') }) it('explains that one-shot histories never accept follow-ups', () => { - render() + render() expect(screen.getByRole('status').textContent).toContain('一次性任务不支持后续消息') }) }) From df01ed926a7b8198ee009c3543a3f3afc0551341 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:39:35 +0800 Subject: [PATCH 044/129] fix(subagent): type the schema-resolved reportDelivery shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Config() applies the schemastery default at runtime, but its return type keeps the input's optional field, so assert the resolved shape at the seam — keeping the dead fallback branch gone. --- packages/client/ui-subagent/tests/conversation-ui.spec.tsx | 2 +- packages/subagent/tool-subagent-report/src/index.ts | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx b/packages/client/ui-subagent/tests/conversation-ui.spec.tsx index 2e90893244..1687b1ffc1 100644 --- a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx +++ b/packages/client/ui-subagent/tests/conversation-ui.spec.tsx @@ -69,7 +69,7 @@ function props( // The zh dictionary is the source of truth for this spec's assertions: // the stub interpolates `{name}` params like the locale service does. const t = ((key: SubagentKey, params?: Record): string => { - let text = zh[key] + let text: string = zh[key] for (const [name, value] of Object.entries(params ?? {})) { text = text.replaceAll(`{${name}}`, String(value)) } diff --git a/packages/subagent/tool-subagent-report/src/index.ts b/packages/subagent/tool-subagent-report/src/index.ts index 6f6160dc85..962d8cf382 100644 --- a/packages/subagent/tool-subagent-report/src/index.ts +++ b/packages/subagent/tool-subagent-report/src/index.ts @@ -88,7 +88,10 @@ export function installReportTool( * @param config - deployment scheduling policy. */ export function apply(ctx: Context, config: Config = {}): void { - const { reportDelivery } = Config(config) + // Config() applies the schema default ('quiet') at runtime; the schemastery + // return type keeps the input's optional shape, so assert the resolved + // shape here — no runtime fallback exists or is wanted. + const { reportDelivery } = Config(config) as { reportDelivery: SubagentReportDelivery } ctx.subagents.registerContinuableSetup(childCtx => installReportTool(childCtx, ctx, reportDelivery)) } From 8bba72639ad48931c5c2c2ccf14b4f09bdcd078d Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:48:45 +0800 Subject: [PATCH 045/129] chore(docs): refresh the persistence catalog after the descriptor doc edit The maxTokens contract sentences added lines above the `subagent/descriptor` declaration, shifting its source anchor from line 32 to 36; regenerate the catalog so the source link stays accurate. --- docs/persistence-catalog.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 0aa81a7939..53502cc905 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -531,7 +531,7 @@ Source: [`packages/core/session/src/types.ts:222`](../packages/core/session/src/ 'subagent/descriptor': SubagentDescriptorData ``` -Source: [`packages/subagent/subagent/src/descriptor.ts:32`](../packages/subagent/subagent/src/descriptor.ts) +Source: [`packages/subagent/subagent/src/descriptor.ts:36`](../packages/subagent/subagent/src/descriptor.ts) ### `todo/*` From e55d3e96d971b31143636c754603521b6780da84 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:50:24 +0800 Subject: [PATCH 046/129] docs(subagent): re-record bilingual pairs after the stack end-result doc edits Two pairs needed their confirmed-consistent state refreshed: the intent-named note's supersession clause (zh link normalized to the shared `.md` target, since the pairing contract requires identical link targets) and the report README's acceptance-semantics rewrite (both sides edited). Re-record both pairs so the translation-pairing gate passes. --- ...27-intent-named-subagent-continuation-operations.i18n.yaml | 4 ++-- ...-07-27-intent-named-subagent-continuation-operations.zh.md | 2 +- packages/subagent/tool-subagent-report/README.i18n.yaml | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.i18n.yaml index ad2e2c40bd..659cebef8f 100644 --- a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.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 .agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md -2026-07-27-intent-named-subagent-continuation-operations.md: 5029d8335f699e99e67c6027b7d1666880db4724 -2026-07-27-intent-named-subagent-continuation-operations.zh.md: 0785730c1934a192380af41f3ad88f95a2747cf7 +2026-07-27-intent-named-subagent-continuation-operations.md: 00340ac53443741e9857cb238ddf95bced504c7f +2026-07-27-intent-named-subagent-continuation-operations.zh.md: 3184a066de98fb442cb5e2d305191419c27f278c diff --git a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.zh.md b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.zh.md index 58af4f2dc9..3184a066de 100644 --- a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.zh.md @@ -18,7 +18,7 @@ Status: implemented 调用方请求与提供方请求相互分离。`SubagentStartRequest` 只包含调用方提供的启动数据;`SubagentProviderStartRequest` 则加入由服务解析的继续执行状态。普通 `start()` 在分发给提供方之前会清除该状态。`SubagentProviderResumeRequest` 仍属于提供方 seam,但 `SubagentService.resume()` 不对外公开:继续执行管理器加载描述符、对 parent 进行鉴权,并调用由服务持有的私有提供方启动与恢复闭包。提供方分发仍会经过相同的功能检查和 run 生命周期观测,而无需将其变成调用方操作。 -`SessionStore.flush(session)` 返回 `Promise`。至少一个作用域内的持久性监听器成功参与后,它解析为 `true`;监听器快照为空时解析为 `false`;所有监听器结算后,如有失败,则以注册顺序最靠前的监听器错误拒绝。普通检查点可以忽略该布尔值。可继续提供方在最终结果边界要求该值为 `true`,并将 `false` 或拒绝映射为 `DURABILITY_FAILED`。**已被取代**:激活化记录 [2026-07-28-continuable-subagent-conversations](../feature/2026-07-28-continuable-subagent-conversations.zh.md) 规定延续管理器把最终 `flush()` 作为 best-effort 屏障并有意忽略布尔值——监听器参与度无法识别持久化后端;拒绝只记日志,不改变生命周期结果或宿主 drain 结果。 +`SessionStore.flush(session)` 返回 `Promise`。至少一个作用域内的持久性监听器成功参与后,它解析为 `true`;监听器快照为空时解析为 `false`;所有监听器结算后,如有失败,则以注册顺序最靠前的监听器错误拒绝。普通检查点可以忽略该布尔值。可继续提供方在最终结果边界要求该值为 `true`,并将 `false` 或拒绝映射为 `DURABILITY_FAILED`。**已被取代**:激活化记录 [2026-07-28-continuable-subagent-conversations](../feature/2026-07-28-continuable-subagent-conversations.md) 规定延续管理器把最终 `flush()` 作为 best-effort 屏障并有意忽略布尔值——监听器参与度无法识别持久化后端;拒绝只记日志,不改变生命周期结果或宿主 drain 结果。 ## 已考虑的替代方案 diff --git a/packages/subagent/tool-subagent-report/README.i18n.yaml b/packages/subagent/tool-subagent-report/README.i18n.yaml index 849f6de67c..50bdb0ae5f 100644 --- a/packages/subagent/tool-subagent-report/README.i18n.yaml +++ b/packages/subagent/tool-subagent-report/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/subagent/tool-subagent-report/README.md -README.md: e15b8b5d5881fd7b6868995fec22048a605f4c7e -README.zh.md: 0c41bc9c1e5aa4d728789b064f2d00c8da8ca6c8 +README.md: 5e3947c6a6e65b15040ab8e0852db347130d5fa0 +README.zh.md: bb008f5b0b5ebf0c117d9a4c49a769210726d458 From daf955480416b5c4667ea7b3ff5461d6be162bff Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:55:10 +0800 Subject: [PATCH 047/129] refactor(subagent): scope the setup transaction to the creation callback The setup validation and commit moved into the callback, so the outer definite-assignment slot and its type import are no longer needed; declare the transaction as a callback-local const. --- packages/subagent/subagent/src/continuation.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index bc4833bbd0..82f66d08cb 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -42,7 +42,6 @@ import type { ContinuableCreateRequest, ContinuableCreateSpec, SubagentStartRequ import type { ActivationObserver } from './lifecycle.ts' import { SubagentError } from './error.ts' import type SubagentActivationSetupRegistry from './activation-setup-registry.ts' -import type { ActivationSetupTransaction } from './activation-setup-registry.ts' /** Attribution for a model coordinator's follow-up to one of its children. */ export interface CoordinatorMessageSource { @@ -800,10 +799,9 @@ export class SubagentContinuationManager { // `AgentRegistry.enter()` is the authoritative collision boundary for an id // some other owner holds — a duplicate would reject there with rollback. inputs.signal.throwIfAborted() - let setupTransaction!: ActivationSetupTransaction const setup = (childCtx: Context): void => { applyChildComposition(childCtx, inputs.composition) - setupTransaction = this.setupRegistry.apply(childCtx) + const setupTransaction = this.setupRegistry.apply(childCtx) // Validate and freeze the batch inside the creation callback, before the // factory can publish the session: a revoked contribution must reject // the create/resume call pre-publication, so no persisted session is From 5c98cbd8f62c7c865f833a1d9608e2e954443b10 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 13:36:54 +0800 Subject: [PATCH 048/129] test(web): run the subagent-conversation e2e against the locale-aware copy The ui-subagent catalog and read-only composer copy moved from hardcoded Chinese to the locale-aware `subagent` namespace, so an en-US headless browser now renders English. The e2e's selectors and goldens still asserted the old hardcoded Chinese strings, leaving the scenario unable to find the catalog trigger. Convert the selectors to the default (en-US) render and re-record the catalog goldens (ui, tree, nested) in English. The locale-aware parts of the remaining goldens were already English (recorded under the en-US default), so sidebar and fork are untouched. --- .../subagent-conversation/nested.expected.md | 4 +- .../subagent-conversation/tree.expected.md | 12 +++--- .../subagent-conversation/ui.expected.md | 4 +- apps/web/tests/subagent-conversation.e2e.ts | 40 +++++++++---------- 4 files changed, 30 insertions(+), 30 deletions(-) diff --git a/apps/web/tests/snapshots/subagent-conversation/nested.expected.md b/apps/web/tests/snapshots/subagent-conversation/nested.expected.md index 7d7595c7a4..9f7c0f23f7 100644 --- a/apps/web/tests/snapshots/subagent-conversation/nested.expected.md +++ b/apps/web/tests/snapshots/subagent-conversation/nested.expected.md @@ -14,5 +14,5 @@ - button "Branch into a new conversation": - img - status: - - strong: 此子代理暂时只读 - - text: 父会话当前不在线,重新打开父会话后即可继续发送消息。 + - strong: This subagent is read-only for now + - text: The parent session is offline; reopen it to continue sending messages. diff --git a/apps/web/tests/snapshots/subagent-conversation/tree.expected.md b/apps/web/tests/snapshots/subagent-conversation/tree.expected.md index 12520a65c4..88dd7ec974 100644 --- a/apps/web/tests/snapshots/subagent-conversation/tree.expected.md +++ b/apps/web/tests/snapshots/subagent-conversation/tree.expected.md @@ -1,8 +1,8 @@ -- tree "子代理会话": - - treeitem "event-sourcing researcher Explain event sourcing in one · 可继续 · 当前未运行 刚刚" [expanded] [level=1]: - - button "收起 event-sourcing researcher 的下级子代理": +- tree "Subagent sessions": + - treeitem "event-sourcing researcher Explain event sourcing in one · continuable · not running just now" [expanded] [level=1]: + - button "Collapse event-sourcing researcher descendants": - img - - text: event-sourcing researcher Explain event sourcing in one · 可继续 · 当前未运行 刚刚 + - text: event-sourcing researcher Explain event sourcing in one · continuable · not running just now - group: - - treeitem "example editor 可继续 · 当前未运行 刚刚" [level=2] - - treeitem "event-sourcing reviewer 一次性 · 当前未运行 刚刚" [level=1] + - treeitem "example editor continuable · not running just now" [level=2] + - treeitem "event-sourcing reviewer one-shot · not running just now" [level=1] diff --git a/apps/web/tests/snapshots/subagent-conversation/ui.expected.md b/apps/web/tests/snapshots/subagent-conversation/ui.expected.md index 6513bb60e3..3a9b03fffe 100644 --- a/apps/web/tests/snapshots/subagent-conversation/ui.expected.md +++ b/apps/web/tests/snapshots/subagent-conversation/ui.expected.md @@ -3,8 +3,8 @@ - button "Ask a research subagent to" - text: / - button "event-sourcing researcher" [disabled] - - button "1 个子代理": - - text: 1 个子代理 + - button "1 subagents": + - text: 1 subagents - img - tablist: - tab "Chat" [selected] diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index e71b54e0d9..83ce78f3e4 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -217,13 +217,13 @@ describe('web e2e: persisted subagent conversation and human continuation', () = const warningStart = tripwire.warnings.length await page.reload({ waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) - const catalogButton = page.getByRole('button', { name: /个子代理/ }) + const catalogButton = page.getByRole('button', { name: /subagents/ }) await catalogButton.waitFor({ timeout: 15_000 }) await catalogButton.click() - const catalogTree = page.getByRole('tree', { name: '子代理会话' }) + const catalogTree = page.getByRole('tree', { name: 'Subagent sessions' }) await catalogTree.getByRole('treeitem').nth(1).waitFor({ timeout: 15_000 }) await catalogTree.press('Escape') - await page.getByRole('button', { name: '3 个子代理' }).waitFor({ timeout: 15_000 }) + await page.getByRole('button', { name: '3 subagents' }).waitFor({ timeout: 15_000 }) acknowledgeReloadConnectionLoss(tripwire, warningStart) }, 120_000) @@ -241,26 +241,26 @@ describe('web e2e: persisted subagent conversation and human continuation', () = it('expands a persisted grandchild progressively without activating either level', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-tree')) - await page.getByRole('button', { name: '3 个子代理' }).click() + await page.getByRole('button', { name: '3 subagents' }).click() expect(await page.getByRole('button', { - name: `展开 ${ONE_SHOT_LABEL} 的下级子代理`, + name: `Expand ${ONE_SHOT_LABEL} descendants`, }).count()).toBe(0) - await page.getByRole('button', { name: `展开 ${LABEL} 的下级子代理` }).click() + await page.getByRole('button', { name: `Expand ${LABEL} descendants` }).click() await page.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }).waitFor({ timeout: 15_000 }) expect(scaffold.ctx.agents.get(childId)).toBeUndefined() expect(scaffold.ctx.agents.get(grandchildId)).toBeUndefined() const snapshot = await captureStableAria( page, - '[role="tree"][aria-label="子代理会话"]', + '[role="tree"][aria-label="Subagent sessions"]', scaffold.workspaceCwd, ) await compareOrRefreshGolden(TREE_EXPECTED, snapshot, MODE) - await page.getByRole('tree', { name: '子代理会话' }).press('Escape') + await page.getByRole('tree', { name: 'Subagent sessions' }).press('Escape') }) it('opens the completed child from persistence without activating it', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-open')) - await page.getByRole('button', { name: '3 个子代理' }).click() + await page.getByRole('button', { name: '3 subagents' }).click() await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click() await expect.poll( () => page.getByText(INITIAL_PROMPT, { exact: true }).count(), @@ -302,18 +302,18 @@ describe('web e2e: persisted subagent conversation and human continuation', () = ).toBe('running') const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' }) await hierarchy.getByRole('button').first().click() - const runningTrigger = page.getByRole('button', { name: '3 个子代理,正在运行' }) + const runningTrigger = page.getByRole('button', { name: '3 subagents running' }) await runningTrigger.waitFor({ timeout: 10_000 }) expect(await runningTrigger.locator('[data-state="ongoing"]').count()).toBe(1) await runningTrigger.click() await page.getByRole('treeitem', { - name: new RegExp(`${LABEL}.*正在运行`), + name: new RegExp(`${LABEL}.*running`), }).waitFor({ timeout: 10_000 }) await ended await page.getByRole('treeitem', { - name: new RegExp(`${LABEL}.*当前未运行`), + name: new RegExp(`${LABEL}.*not running`), }).waitFor({ timeout: 10_000 }) - expect(await page.getByRole('button', { name: '3 个子代理' }) + expect(await page.getByRole('button', { name: '3 subagents' }) .locator('[data-state="ongoing"]').count()).toBe(0) await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click() await expect.poll(() => page.getByText(FOLLOWUP, { exact: true }).count(), { timeout: 10_000 }).toBe(1) @@ -331,9 +331,9 @@ describe('web e2e: persisted subagent conversation and human continuation', () = it('opens an unavailable persisted grandchild after recording the available child', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-grandchild')) - await page.getByRole('button', { name: '1 个子代理' }).click() + await page.getByRole('button', { name: '1 subagents' }).click() await page.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }).click() - await page.getByText('父会话当前不在线,重新打开父会话后即可继续发送消息。').waitFor() + await page.getByText('The parent session is offline; reopen it to continue sending messages.').waitFor() const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' }) const crumbs = await hierarchy.getByRole('button').allTextContents() expect(crumbs.slice(-2)).toEqual([LABEL, NESTED_LABEL]) @@ -352,9 +352,9 @@ describe('web e2e: persisted subagent conversation and human continuation', () = .getByRole('treeitem') .last() await parentSession.click() - await page.getByRole('button', { name: '3 个子代理' }).click() + await page.getByRole('button', { name: '3 subagents' }).click() await page.getByRole('treeitem', { name: new RegExp(ONE_SHOT_LABEL) }).click() - await page.getByText('一次性任务不支持后续消息,可在这里查看完整执行记录。').waitFor() + await page.getByText('One-shot tasks do not accept follow-ups; review the full execution record here.').waitFor() expect(scaffold.ctx.agents.get(oneShotId)).toBeUndefined() }) @@ -363,7 +363,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = await page.getByRole('tree', { name: 'Sessions' }) .getByRole('treeitem', { name: /Ask a research subagent to/ }) .click() - await page.getByRole('button', { name: '3 个子代理' }).click() + await page.getByRole('button', { name: '3 subagents' }).click() await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click() await page.getByRole('textbox', { name: 'Message the agent' }).waitFor() const forkResponse = page.waitForResponse(response => @@ -389,7 +389,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-post-fork-followup')) const sessions = page.getByRole('tree', { name: 'Sessions' }) await sessions.getByRole('treeitem', { name: /Ask a research subagent to/ }).click() - await page.getByRole('button', { name: '3 个子代理' }).click() + await page.getByRole('button', { name: '3 subagents' }).click() await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click() await page.locator('textarea:enabled').first().waitFor() expect(scaffold.ctx.agents.get(childId)).toBeUndefined() @@ -406,7 +406,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = await expect.poll(() => scaffold.ctx.agents.get(forkId)).not.toBeUndefined() await sessions.getByRole('treeitem', { name: /Ask a research subagent to/ }).click() - await page.getByRole('button', { name: '3 个子代理' }).click() + await page.getByRole('button', { name: '3 subagents' }).click() await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click() const input = page.locator('textarea:enabled').first() await input.waitFor() From 295e56b61ec64ea366a215869085e0e580e6efd6 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 13:46:51 +0800 Subject: [PATCH 049/129] fix(web): keep removal-time availability invalidation across an in-flight pull MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `host/session-removed` invalidation flipped the owned catalog and addressed children to `parentAvailable:false`, but a `subagent.list` pull already in flight was requested before the removal and its ok-response carries the pre-removal `parentAvailable:true` — the response then overwrote both the catalog and every addressed child, resurrecting the writable-editor-against-a-dead-continuation-owner bug the invalidation closes, with no refresh scheduled to converge afterwards. Mark the owner stale when a pull is in flight at removal time, so one trailing refresh runs after the in-flight response settles and the post-removal host truth lands. Adds a regression test: removal mid-pull, stale ok response, trailing pull, final state stays unavailable on the catalog and the addressed child. --- .../runtime/src/client/sessions/manager.ts | 5 +++ packages/client/runtime/tests/manager.spec.ts | 38 +++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index 1c186059de..2d20875adb 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -688,6 +688,11 @@ export class SessionManager { this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation this.waitingApprovals.delete(frame.sessionId) // a removed session cannot wait on anyone if (!durableSubagent) this.projectionStores.delete(frame.sessionId) + // A pull already in flight was requested before this removal and can + // carry the pre-removal parentAvailable:true, which would resurrect + // the writable editor this invalidation just closed. Queue one + // trailing refresh so the post-removal host truth converges. + if (this.catalogInflight.has(frame.sessionId)) this.catalogStale.add(frame.sessionId) // The removed session can no longer be the delivery owner of its // catalog: invalidate availability immediately. Removal schedules no // catalog refresh, and without this an addressed child keeps a diff --git a/packages/client/runtime/tests/manager.spec.ts b/packages/client/runtime/tests/manager.spec.ts index 5d12498edd..2d456a9c34 100644 --- a/packages/client/runtime/tests/manager.spec.ts +++ b/packages/client/runtime/tests/manager.spec.ts @@ -588,6 +588,44 @@ describe('subagent catalogs', () => { } }) + it('does not let a stale in-flight pull resurrect a removed parent\'s availability', async () => { + const api = new FakeApiClient() + const root = 'fk-root' as SessionId + const child = () => ({ + kind: 'child' as const, id: S2, mode: 'continuable' as const, label: 'worker', + activity: 'inactive' as const, hasChildren: false, + }) + const first = deferred>>() + api.onSubagentList = () => first.promise + const manager = new SessionManager(api) + const refresh = manager.refreshSubagents(root) + first.resolve(ok({ entries: [child()] as never[], parentAvailable: true })) + await refresh + manager.selectSubagent({ parentSessionId: root, childSessionId: S2, mode: 'continuable' }) + + // The removal lands while a second pull is in flight: the invalidation + // must survive the pre-removal ok response, so one trailing pull runs. + const mid = deferred>>() + api.onSubagentList = () => mid.promise + const midRefresh = manager.refreshSubagents(root) + manager.handleHostEnvelope({ + rpcId: 'parent-removed-mid-pull' as never, + payload: { type: 'host/session-removed', sessionId: root }, + }) + const trailing = deferred>>() + api.onSubagentList = () => trailing.promise + mid.resolve(ok({ entries: [child()] as never[], parentAvailable: true })) + await midRefresh + trailing.resolve(ok({ entries: [child()] as never[], parentAvailable: false })) + await trailing.promise + + const rootCalls = api.callsOf('subagent.list') + .filter((call: { parentSessionId: SessionId }) => call.parentSessionId === root) + expect(rootCalls).toHaveLength(3) + expect(manager.getListSnapshot().subagentsByParent[root]?.parentAvailable).toBe(false) + expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: false }) + }) + it('invalidates catalog availability when the owning parent is removed', async () => { const api = new FakeApiClient() const root = 'fk-root' as SessionId From fb6ccdff04cfd39bc8ad2fbb6cc7035e39ee1930 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 13:47:12 +0800 Subject: [PATCH 050/129] docs(subagent): scope report acceptance to parent resolution, not delivery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README claimed "acceptance is governed by registry presence" as a universal statement, but `sendReport` translates a registered parent's send rejection into the same PARENT_UNAVAILABLE code — registry presence governs parent *resolution*, while acceptance additionally depends on the parent's log still admitting appends. Soften both languages to the precise contract and re-record the pair. --- packages/subagent/tool-subagent-report/README.i18n.yaml | 4 ++-- packages/subagent/tool-subagent-report/README.md | 2 +- packages/subagent/tool-subagent-report/README.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/subagent/tool-subagent-report/README.i18n.yaml b/packages/subagent/tool-subagent-report/README.i18n.yaml index 50bdb0ae5f..389a16e620 100644 --- a/packages/subagent/tool-subagent-report/README.i18n.yaml +++ b/packages/subagent/tool-subagent-report/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/subagent/tool-subagent-report/README.md -README.md: 5e3947c6a6e65b15040ab8e0852db347130d5fa0 -README.zh.md: bb008f5b0b5ebf0c117d9a4c49a769210726d458 +README.md: c1cff4d023e35ff246e592f58b0c85c8a10f327a +README.zh.md: 167a6338e8db9fbb5efce7037392f7c75e48f116 diff --git a/packages/subagent/tool-subagent-report/README.md b/packages/subagent/tool-subagent-report/README.md index 5e3947c6a6..c1cff4d023 100644 --- a/packages/subagent/tool-subagent-report/README.md +++ b/packages/subagent/tool-subagent-report/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) The optional child-scoped `report` tool is a thin adapter over `ctx.subagents.reportFrom()`. It gives every continuable in-process child a return channel to the Agent that started it. The package registers a continuable-child setup contribution instead of a global tool, so `report` exists only inside those children. Roots, one-shot subagents, remote subagent providers, sibling scopes, and agentless tool execution never present or execute it. Installing this package grants only that child-scoped capability; the parent-to-child direction remains the independent [`@deepseek-ai/dsh-tool-subagent-control`](../tool-subagent-control/README.md), and continuable mode depends on neither package. -A child may call `report` zero or many times in one turn. A successful call neither concludes the turn, settles the Activation, nor prevents later parent follow-ups, and finishing a turn never reports automatically. The tool accepts no recipient: `exec.agent` is the sender's exact live Agent and the authority credential, and the service derives the sole recipient from that child's durable `parentSession`. Success returns the stable `MessageId` of the parent-accepted message, not a read receipt, an inbox-occurrence id, a parent-log acknowledgement, a turn-completion receipt, or a persistence flush. A parent absent from the registry fails the call with `direct parent is not live; report was not delivered` — acceptance is governed by registry presence, so a parent already in host-owned disposal but still registered still accepts. The service performs no injection, parent cold resume, or offline mailbox write; the durable child transcript remains the recovery source, and a failed tool call does not prove non-delivery (a later `tools/post-execute` veto can fail a call whose report was already accepted). +A child may call `report` zero or many times in one turn. A successful call neither concludes the turn, settles the Activation, nor prevents later parent follow-ups, and finishing a turn never reports automatically. The tool accepts no recipient: `exec.agent` is the sender's exact live Agent and the authority credential, and the service derives the sole recipient from that child's durable `parentSession`. Success returns the stable `MessageId` of the parent-accepted message, not a read receipt, an inbox-occurrence id, a parent-log acknowledgement, a turn-completion receipt, or a persistence flush. A parent absent from the registry fails the call with `direct parent is not live; report was not delivered` — registry presence governs parent resolution, and a registered parent already in host-owned disposal still accepts while its log admits appends. The service performs no injection, parent cold resume, or offline mailbox write; the durable child transcript remains the recovery source, and a failed tool call does not prove non-delivery (a later `tools/post-execute` veto can fail a call whose report was already accepted). `reportDelivery` selects parent scheduling for every accepted report. `quiet` (the default) uses `parent.inject()`, adding model-facing context without starting a parent model request: an idle parent's append completes before the call returns, while a report reaching an admitting or running parent stages for the next safe log position. `wakeup` uses `parent.followup()`, creating exactly one ordinary later parent turn and waking a parked parent driver; it never steers an open turn. This is deployment scheduling policy, so the model-facing schema cannot select or override it per call. diff --git a/packages/subagent/tool-subagent-report/README.zh.md b/packages/subagent/tool-subagent-report/README.zh.md index bb008f5b0b..167a6338e8 100644 --- a/packages/subagent/tool-subagent-report/README.zh.md +++ b/packages/subagent/tool-subagent-report/README.zh.md @@ -4,7 +4,7 @@ 可选的子级作用域 `report` 工具是 `ctx.subagents.reportFrom()` 之上的轻量适配器。它为每个可继续的进程内子级提供一条返回通道,指向启动该子级的 Agent(智能体)。本包(package)注册的是可继续子级设置贡献,而不是全局工具,因此 `report` 只存在于这些子级内部。根 Agent、一次性 subagent、远程 subagent 提供方、同级作用域以及不关联 Agent 的工具执行都不会提供或执行它。安装本包只授予这项子级作用域功能;父到子方向仍由独立的 [`@deepseek-ai/dsh-tool-subagent-control`](../tool-subagent-control/README.md) 负责,可继续模式不依赖这两个包中的任一个。 -子级可以在一个轮次中调用 `report` 零次或多次。调用成功既不会结束轮次或结算 Activation,也不会阻止父级后续消息;轮次结束也绝不会自动上报。该工具不接受接收方参数:`exec.agent` 是发送方准确的实时 Agent,也是权限凭据;服务根据该子级持久化的 `parentSession` 推导唯一接收方。成功时返回父级已接受消息的稳定 `MessageId`,不表示已读回执、inbox 中该次出现的 id、父级日志确认、轮次完成回执或持久化刷盘。接受与否由父级在注册表中的存在性决定:父级不在注册表时,调用失败并返回 `direct parent is not live; report was not delivered`;已开始宿主 dispose 但仍在注册表中的父级仍会接受。服务不会执行注入、父级冷恢复或离线 mailbox 写入;持久化子级 transcript(文本记录)仍是恢复真源,且工具调用失败不能证明未送达(后续 `tools/post-execute` 否决可能让报告已被接受的调用以失败结束)。 +子级可以在一个轮次中调用 `report` 零次或多次。调用成功既不会结束轮次或结算 Activation,也不会阻止父级后续消息;轮次结束也绝不会自动上报。该工具不接受接收方参数:`exec.agent` 是发送方准确的实时 Agent,也是权限凭据;服务根据该子级持久化的 `parentSession` 推导唯一接收方。成功时返回父级已接受消息的稳定 `MessageId`,不表示已读回执、inbox 中该次出现的 id、父级日志确认、轮次完成回执或持久化刷盘。父级解析由注册表中的存在性决定:父级不在注册表时,调用失败并返回 `direct parent is not live; report was not delivered`;已开始宿主 dispose 但仍在注册表中的父级在其日志仍接受追加时仍会接受。服务不会执行注入、父级冷恢复或离线 mailbox 写入;持久化子级 transcript(文本记录)仍是恢复真源,且工具调用失败不能证明未送达(后续 `tools/post-execute` 否决可能让报告已被接受的调用以失败结束)。 `reportDelivery` 为每条已接受的报告选择父级调度方式。`quiet`(默认值)使用 `parent.inject()`,在不启动父级模型请求的情况下添加面向模型的上下文:父级空闲时,追加操作会在调用返回前完成;报告到达正在准入或运行的父级时,则会暂存到下一个安全日志位置。`wakeup` 使用 `parent.followup()`,准确创建一个普通的后续父级轮次,并唤醒停驻的父级驱动;它绝不会对正在运行的轮次进行 steering(中途引导)。这是部署调度策略,因此面向模型的 schema 不能在单次调用中选择或覆盖该策略。 From d7a70f6efa376ee4adac97ea717bc7994d79a038 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 13:47:24 +0800 Subject: [PATCH 051/129] fix(host): hand a raced plain-agent winner back from agentFor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The raced-collision catch mirrored only the subagent-owned half of ensureSession's `.catch`: a concurrent plain-agent publish winning the identity still fell through to `internal`, where ensureSession returns the winner. Mirror in full — classify a subagent-owned winner as `agent-busy`, return a clean plain-agent winner directly. --- packages/host/apiproxy/src/api-proxy.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 0827762a8a..14f03b3773 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -1114,14 +1114,15 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro if (error instanceof SubagentSessionOwnership) { return { error: subagentOwnershipError(error.sessionId) } } - // A concurrent parent `enter()` can win the identity between the - // pre-resume published re-check and `ctx.agents.resume` publication; - // the ID-collision rejection falls through here. Re-classify that - // raced published winner into the stable ownership error, mirroring - // ensureSession's `.catch`. + // A concurrent publish can win the identity between the pre-resume + // re-check and `ctx.agents.resume` publication; the ID-collision + // rejection falls through here. Mirror ensureSession's `.catch` in + // full: classify a subagent-owned winner into the stable ownership + // error, and hand a clean plain-agent winner straight back. const live = ctx.agents.get(sessionId) - if (live !== undefined && hasSubagentOwner(live.session, live)) { - return { error: subagentOwnershipError(sessionId) } + if (live !== undefined) { + if (hasSubagentOwner(live.session, live)) return { error: subagentOwnershipError(sessionId) } + return { agent: live } } const attached = ctx.sessions.get(sessionId) if (attached !== undefined && hasSubagentOwner(attached, undefined)) { From c8b2e709886cdb1d30b096aa95ac5ecb39915f3b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 13:47:54 +0800 Subject: [PATCH 052/129] build(web): declare the locale dependency for ui-subagent The client plugin now consumes `ctx.locale` (dictionary registration plus the slot `t` seat), but the package graph did not know it: no `dshClient.inject` entry, no peer/devDependency, no tsconfig project reference. Mirror the ui-conversation convention so the dependency graph, HMR/preflight metadata, and standalone packaging all recognize the `@deepseek-ai/dsh-client-locale` seam. --- packages/client/ui-subagent/package.json | 3 +++ packages/client/ui-subagent/tsconfig.json | 3 +++ 2 files changed, 6 insertions(+) diff --git a/packages/client/ui-subagent/package.json b/packages/client/ui-subagent/package.json index efec3bc047..a3e753d91b 100644 --- a/packages/client/ui-subagent/package.json +++ b/packages/client/ui-subagent/package.json @@ -24,6 +24,7 @@ }, "dshClient": { "inject": [ + "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-ui-conversation", "@deepseek-ai/dsh-client-ui-primitives", @@ -40,6 +41,7 @@ "react": "^18.2.0" }, "peerDependencies": { + "@deepseek-ai/dsh-client-locale": "^0.0.1", "@deepseek-ai/dsh-client-runtime": "^0.0.1", "@deepseek-ai/dsh-client-ui-conversation": "^0.0.1", "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", @@ -49,6 +51,7 @@ "cordis": "^4.0.0-rc.7" }, "devDependencies": { + "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", diff --git a/packages/client/ui-subagent/tsconfig.json b/packages/client/ui-subagent/tsconfig.json index 395281ce6d..84c310fc56 100644 --- a/packages/client/ui-subagent/tsconfig.json +++ b/packages/client/ui-subagent/tsconfig.json @@ -11,6 +11,9 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../locale" + }, { "path": "../runtime" }, From d004c694f1cef0e972985a3a0852790a4ed9a4c3 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 13:48:50 +0800 Subject: [PATCH 053/129] test(web): narrow the stale-pull assertion to the root catalog's calls --- packages/client/runtime/tests/manager.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/client/runtime/tests/manager.spec.ts b/packages/client/runtime/tests/manager.spec.ts index 2d456a9c34..c37f0b88a1 100644 --- a/packages/client/runtime/tests/manager.spec.ts +++ b/packages/client/runtime/tests/manager.spec.ts @@ -620,7 +620,7 @@ describe('subagent catalogs', () => { await trailing.promise const rootCalls = api.callsOf('subagent.list') - .filter((call: { parentSessionId: SessionId }) => call.parentSessionId === root) + .filter(call => (call as { parentSessionId: SessionId }).parentSessionId === root) expect(rootCalls).toHaveLength(3) expect(manager.getListSnapshot().subagentsByParent[root]?.parentAvailable).toBe(false) expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: false }) From e2982e0fcc38b305b937848171ffabed334e8fc4 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 13:50:33 +0800 Subject: [PATCH 054/129] chore(deps): record the ui-subagent locale devDependency in the lockfile --- pnpm-lock.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e1d8d15c03..d81b237b7c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1879,6 +1879,9 @@ importers: specifier: ^18.2.0 version: 18.3.1 devDependencies: + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../runtime From dabb710ab3ba03415d90e1bbf4378b1079dd7795 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 13:52:39 +0800 Subject: [PATCH 055/129] chore(docs): refresh the module graph for the ui-subagent locale edge --- docs/module-graph.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index 2af5dc62ea..6687a583c1 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -832,6 +832,7 @@ flowchart TD pkg_client_ui_goal --> pkg_client_ui_slots pkg_client_ui_goal --> pkg_goal pkg_client_ui_goal --> pkg_invariants + pkg_client_ui_subagent --> pkg_client_locale pkg_client_ui_subagent --> pkg_client_runtime pkg_client_ui_subagent --> pkg_client_ui_conversation pkg_client_ui_subagent --> pkg_client_ui_primitives @@ -1217,7 +1218,7 @@ flowchart TD | [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`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-goal`](../packages/client/ui-goal) | `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), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | -| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`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-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) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | | [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | From dbe053fe082f799102a6f73c72011b9937099eb4 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 13:58:22 +0800 Subject: [PATCH 056/129] refactor(host): share the fenced-live-agent resolution between agentFor paths The live fast-path fence and the raced-collision catch duplicated the same subagent-ownership classification, tripping the duplication gate. Extract `fencedLiveAgent` so both paths resolve one live identity through the fence identically. --- packages/host/apiproxy/src/api-proxy.ts | 34 ++++++++++++++----------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 14f03b3773..a84e1d5c47 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -1065,17 +1065,24 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro return inspected } - async function agentFor(sessionId: SessionId): Promise<{ agent: Agent } | { error: RpcError }> { + /** + * Resolve one live registered identity through the subagent-ownership + * fence: subagent-owned agents answer `agent-busy`, plain agents pass. + * Fences the live agent's own session rather than trusting a + * "registered ⇒ attached-store" invariant — a registered subagent whose + * session is ever absent from the attached store must still not be handed + * out through generic Host routing. `undefined` means no live agent. + */ + function fencedLiveAgent(sessionId: SessionId): { agent: Agent } | { error: RpcError } | undefined { const live = ctx.agents.get(sessionId) - if (live !== undefined) { - // Fence the live agent's own session rather than trusting a - // "registered ⇒ attached-store" invariant: a registered subagent whose - // session is ever absent from the attached store must still not be - // handed out through generic Host routing (ensureSession's `.catch` - // already fences `live.session`; this is the same check on the fast path). - if (hasSubagentOwner(live.session, live)) return { error: subagentOwnershipError(sessionId) } - return { agent: live } - } + if (live === undefined) return undefined + if (hasSubagentOwner(live.session, live)) return { error: subagentOwnershipError(sessionId) } + return { agent: live } + } + + async function agentFor(sessionId: SessionId): Promise<{ agent: Agent } | { error: RpcError }> { + const fenced = fencedLiveAgent(sessionId) + if (fenced !== undefined) return fenced const attached = ctx.sessions.get(sessionId) if (attached !== undefined && hasSubagentOwner(attached, undefined)) { return { error: subagentOwnershipError(sessionId) } @@ -1119,11 +1126,8 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro // rejection falls through here. Mirror ensureSession's `.catch` in // full: classify a subagent-owned winner into the stable ownership // error, and hand a clean plain-agent winner straight back. - const live = ctx.agents.get(sessionId) - if (live !== undefined) { - if (hasSubagentOwner(live.session, live)) return { error: subagentOwnershipError(sessionId) } - return { agent: live } - } + const fenced = fencedLiveAgent(sessionId) + if (fenced !== undefined) return fenced const attached = ctx.sessions.get(sessionId) if (attached !== undefined && hasSubagentOwner(attached, undefined)) { return { error: subagentOwnershipError(sessionId) } From 76547dfe0cc7b1db2b6cead44a7f7f2f78b53550 Mon Sep 17 00:00:00 2001 From: kingwl Date: Sun, 2 Aug 2026 16:25:02 +0800 Subject: [PATCH 057/129] fix(web): restrict message forks to completed turn tails --- ...ions-require-completed-turn-tail.i18n.yaml | 6 ++ ...ork-actions-require-completed-turn-tail.md | 27 ++++++++ ...-actions-require-completed-turn-tail.zh.md | 27 ++++++++ ...6-07-27-web-session-fork-actions.i18n.yaml | 4 +- .../2026-07-27-web-session-fork-actions.md | 8 ++- .../2026-07-27-web-session-fork-actions.zh.md | 8 ++- apps/web/tests/message-actions.e2e.ts | 68 +++++++++++++++---- .../snapshots/message-actions/ui.expected.md | 11 ++- packages/client/runtime/README.i18n.yaml | 4 +- packages/client/runtime/README.md | 2 +- packages/client/runtime/README.zh.md | 2 +- .../src/client/sessions/conversation.ts | 2 + .../runtime/src/client/sessions/session.ts | 13 ++++ packages/client/runtime/tests/session.spec.ts | 2 + packages/client/test-runtime/src/fixtures.ts | 1 + .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 4 +- packages/client/ui-conversation/README.zh.md | 4 +- .../src/client/chat/AssistantMarkdown.tsx | 9 +-- .../src/client/chat/ChatView.tsx | 8 ++- .../src/client/chat/MessageIconActions.tsx | 14 ++-- .../src/client/chat/MessageItem.tsx | 2 +- .../src/client/chat/chat-flow.ts | 36 +++++++++- .../src/client/contract/slots.ts | 2 +- .../tests/chat-branch-tails.spec.tsx | 9 +++ .../tests/chat-code-subcalls.spec.tsx | 2 +- .../tests/chat-stats-bash-sample.spec.tsx | 2 +- .../ui-conversation/tests/chat-view.spec.tsx | 53 ++++++++++++--- .../ui-conversation/tests/diff-card.spec.tsx | 2 +- .../tests/gate-branch-tails.spec.tsx | 2 +- .../ui-conversation/tests/input-bar.spec.tsx | 2 +- .../tests/input-matrix.spec.tsx | 2 +- .../tests/input-scenarios.spec.tsx | 2 +- .../ui-conversation/tests/queue-dock.spec.tsx | 2 +- .../ui-conversation/tests/read-card.spec.tsx | 2 +- .../tests/search-card.spec.tsx | 2 +- .../ui-conversation/tests/skeleton.spec.tsx | 2 +- .../tests/terminal-card.spec.tsx | 2 +- .../ui-conversation/tests/web-card.spec.tsx | 2 +- 39 files changed, 283 insertions(+), 73 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.i18n.yaml new file mode 100644 index 0000000000..15b7acbeba --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.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/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md +2026-08-02-message-fork-actions-require-completed-turn-tail.md: 1f5ee6b7ff37709e07544f3e7c73ba1eee951f8d +2026-08-02-message-fork-actions-require-completed-turn-tail.zh.md: ed897e9af56a601c8fc42379e3907e3551c6389e diff --git a/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md b/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md new file mode 100644 index 0000000000..1f5ee6b7ff --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md @@ -0,0 +1,27 @@ +# Agent Note: Message fork actions require a completed turn tail + +Status: implemented + +English | [中文](2026-08-02-message-fork-actions-require-completed-turn-tail.zh.md) + +## Problem + +The Web conversation attached branch to the last assistant node with nonempty text in each turn. A later tool result, interrupted reasoning node, or terminal error did not take ownership because those rows have no content-text IconActions. The branch icon could therefore appear beneath an assistant response while more rows from the same turn remained below it. The Host correctly expanded that message anchor through the containing `turn/end`, but the placement made the action look like a message-level cut and the child visibly inherited the same-turn suffix. + +## Decision + +`ConversationSnapshot.turnEnds` retains the completed turn boundaries present in the raw event window. The conversation view walks transcript nodes through each boundary and exposes branch only when the boundary's last node is a user message or a content-bearing assistant message. Open turns have no eligible message, and a later tool result, reasoning-only interruption, turn error, or other transcript node suppresses branch on earlier messages. Copy and clock remain available under their existing message chrome, and the Host's completed-turn fork semantics remain unchanged. + +This narrows the message eligibility established by the earlier [Web session fork action decision](../feature/2026-07-27-web-session-fork-actions.md). Session-row forking still selects the latest completed turn, and eligible message actions still pass their event seq through the shared client runtime operation. + +## Alternatives considered + +**Cut the event log at the clicked assistant message.** Rejected because an assistant message can sit inside an open step and can contain tool calls whose results occur later. A raw prefix at that seq is not a balanced turn and may not be a valid provider transcript. + +**Infer completion from `running` or the next user message.** Rejected because retry and steering turns need not align with the next visible user bubble, and a paged window may omit that later bubble. The durable `turn/end` event is the authoritative completion fact. + +**Hide branch from every interrupted turn.** Rejected because an aborted turn is durably closed and its final interrupted text can be the true transcript tail. Eligibility depends on the completed boundary and node order, not the outcome kind. + +## Consequences + +A branch icon now denotes the same completed-turn boundary that the Host will copy. In the reported response → tool → interrupted Think shape, the response keeps copy and clock but no longer advertises branch. This change deliberately does not provide same-turn transcript editing or a retry-before-turn operation; the Session-row action remains available when a reader wants to copy the latest completed turn in full. Runtime tests pin boundary projection and reference stability, while conversation tests cover normal assistant tails, user-only tails, and suppression by later tool and interrupted reasoning rows. diff --git a/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.zh.md b/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.zh.md new file mode 100644 index 0000000000..ed897e9af5 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.zh.md @@ -0,0 +1,27 @@ +# Agent Note: 消息 fork 操作要求消息位于已完成轮次尾部 + +Status: implemented + +[English](2026-08-02-message-fork-actions-require-completed-turn-tail.md) | 中文 + +## 问题 + +Web 会话把分支操作挂到每个轮次中最后一个文本非空的 assistant 节点上。如果后面还有工具结果、被中断的推理(reasoning)节点或终态错误,这些行也不会接管操作,因为它们没有内容文本 IconActions。因此,分支图标可能出现在 assistant 响应下方,而同一轮次的更多行仍位于其后。Host 会正确地把该消息锚点扩展到其所在的 `turn/end`,但图标位置使操作看起来像在消息级截断,子会话又会明显继承同轮次的后缀。 + +## 决策 + +`ConversationSnapshot.turnEnds` 保留原始事件窗口中的已完成轮次边界。会话视图按各边界遍历 transcript(文本记录)节点,仅当边界的最后一个节点是用户消息或含内容的 assistant 消息时才暴露分支操作。开放轮次没有符合条件的消息;如果后面还有工具结果、只有推理内容的中断、轮次错误或其他 transcript 节点,较早消息上的分支操作就会被抑制。复制和时钟仍可在既有消息 chrome 下使用,Host 按已完成轮次 fork 的语义保持不变。 + +本决策收紧了较早的 [Web 会话 fork 操作决策](../feature/2026-07-27-web-session-fork-actions.md)所定义的消息资格。Session 行 fork 仍选择最新的已完成轮次;符合条件的消息操作仍通过共享 client 运行时操作传递其事件 seq。 + +## 考虑过的替代方案 + +**在点击的 assistant 消息处截断事件日志。** 不予采纳:assistant 消息可能位于尚未结束的步骤内,也可能包含结果随后才出现的工具调用。以该 seq 截取的原始前缀并不是结构完整的轮次,也可能不是有效的提供方 transcript。 + +**从 `running` 或下一条用户消息推断完成状态。** 不予采纳:重试轮次与 steering(中途引导)轮次不一定和下一个可见用户气泡对齐,分页窗口也可能省略该气泡。持久 `turn/end` 事件才是权威的完成事实。 + +**对每个被中断轮次隐藏分支。** 不予采纳:已中止的轮次会持久关闭,其最终的中断文本可能正是真正的 transcript 尾部。资格取决于已完成边界与节点顺序,而非结果类别。 + +## 后果 + +分支图标现在表示的已完成轮次边界与 Host 实际复制的边界一致。在所报告的「响应 → 工具 → 被中断的 Think」形态中,响应仍保留复制和时钟,但不再显示分支。本变更刻意不提供同轮次 transcript 编辑,也不提供轮次前重试操作;当读者希望完整复制最新的已完成轮次时,仍可使用 Session 行操作。运行时测试固定边界投影和引用稳定性,会话测试则覆盖普通 assistant 尾部、纯用户消息尾部,以及后续工具行和被中断推理行对分支操作的抑制。 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.i18n.yaml index 21eea20254..5268ea62f9 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.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 .agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.md -2026-07-27-web-session-fork-actions.md: 58960169a2e499d953840e5769e7689b5cd48047 -2026-07-27-web-session-fork-actions.zh.md: ea2f9030f672f00fb91bce3546836689a7d41004 +2026-07-27-web-session-fork-actions.md: 578e59ec92e003fe5c8cdfe951a595f3a7371ecf +2026-07-27-web-session-fork-actions.zh.md: d90124f6e6e164b0a1e0fce734f52976630cd848 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.md b/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.md index 58960169a2..578e59ec92 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.md @@ -10,7 +10,9 @@ The Session store already provides a fork primitive that creates a child session ## Decision -The Web Session-row menu and message IconActions share the client runtime's `sessions.fork` action. A Session row passes `{ sessionId, increaseTitle: true }`, so it forks at the source session's last completed turn; a user message or settled assistant content message passes `{ sessionId, atSeq: node.seq, increaseTitle: true }`, so it forks at the turn containing that event. Only the client consumes `increaseTitle`: after adding the child session to its local list, the client increments a trailing `(N)` or `(N)` in the source session's persisted title without changing bracket style, appends ` (1)` to an unnumbered title, and skips the rename when no persisted title exists; the Host fork request still contains only `sessionId` and the optional `atSeq`. The caller opens the child only after the rename succeeds; a fork or rename failure leaves the source session and current selection unchanged, while a child created before a rename failure remains in the list. +The message-eligibility portion of this decision is narrowed by the [completed-turn-tail decision](../bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md); the shared runtime action, injection ownership, title handling, and peer-list decisions remain current. + +The Web Session-row menu and message IconActions share the client runtime's `sessions.fork` action. A Session row passes `{ sessionId, increaseTitle: true }`, so it forks at the source session's last completed turn; an eligible completed-turn-tail message passes `{ sessionId, atSeq: node.seq, increaseTitle: true }`, so it forks at the turn ending at that message. Only the client consumes `increaseTitle`: after adding the child session to its local list, the client increments a trailing `(N)` or `(N)` in the source session's persisted title without changing bracket style, appends ` (1)` to an unnumbered title, and skips the rename when no persisted title exists; the Host fork request still contains only `sessionId` and the optional `atSeq`. The caller opens the child only after the rename succeeds; a fork or rename failure leaves the source session and current selection unchanged, while a child created before a rename failure remains in the list. `forkAt(seq)` touches the session service only in ui-conversation's apply injection layer; message components report only the event `seq`. Session rows likewise initiate the operation only through ui-workspace's injected callback. Neither presentation package owns session mutation state or duplicates the host's boundary evaluation. @@ -28,6 +30,6 @@ Session lineage is not projected into a list hierarchy. WorkSpace mode displays ## Consequences -Users can create forks from Session rows, user messages, or settled assistant content messages; all three entry points ultimately use the same runtime/host operation. Message entry points preserve the exact event boundary, while the list entry point preserves the “latest completed turn” shortcut. Successive fork titles increment through `(1)`, `(2)`, and so on instead of repeatedly appending `(1)`; titles with fullwidth parentheses retain that style. Every fork child immediately appears as an ordinary peer row, so the list no longer needs session expansion state, recursive nodes, or twist controls. +Users can create forks from Session rows or eligible completed-turn-tail messages; both entry points ultimately use the same runtime/host operation. Message entry points preserve the exact event boundary, while the list entry point preserves the “latest completed turn” shortcut. Successive fork titles increment through `(1)`, `(2)`, and so on instead of repeatedly appending `(1)`; titles with fullwidth parentheses retain that style. Every fork child immediately appears as an ordinary peer row, so the list no longer needs session expansion state, recursive nodes, or twist controls. -Fork and child-rename failures stay silent and preserve the source selection, preventing a derivation action from disrupting the current reading position; this tradeoff also means the UI does not yet expose a failure reason or retry entry point. Package tests separately pin the two message `seq` paths, title increments, and the peer-list derivation; `apps/web/tests/message-actions.e2e.ts` exercises assistant-message branching and Session-row menu branching through the assembled application. +Fork and child-rename failures stay silent and preserve the source selection, preventing a derivation action from disrupting the current reading position; this tradeoff also means the UI does not yet expose a failure reason or retry entry point. Package tests pin eligible message `seq` forwarding, title increments, and the peer-list derivation; `apps/web/tests/message-actions.e2e.ts` exercises assistant-message branching and Session-row menu branching through the assembled application. diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.zh.md index ea2f9030f6..d90124f6e6 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.zh.md @@ -10,7 +10,9 @@ Session store 已提供按完成轮前缀创建子会话的 fork 原语,但 We ## Decision -Web 的 session 行菜单与消息 IconActions 共用 client runtime 的 `sessions.fork` 操作。Session 行传 `{ sessionId, increaseTitle: true }`,因此在源会话最后一个已完成轮次处分支;用户消息与已定稿 assistant 内容消息传 `{ sessionId, atSeq: node.seq, increaseTitle: true }`,因此在包含该事件的轮次处分支。`increaseTitle` 只由 client 消费:子会话进入本地列表后,client 把源会话持久化标题尾部的 `(N)` 或 `(N)` 递增并保留括号样式,无编号时追加 ` (1)`,没有持久化标题时不改名;Host fork 请求仍只有 `sessionId` 与可选的 `atSeq`。改名成功后调用方才打开子会话;fork 或改名失败时保持源会话与当前选择不变,改名失败时已创建的子会话仍留在列表中。 +本决策中的消息资格部分由[已完成轮次尾部决策](../bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md)收紧;共享运行时操作、注入归属、标题处理和同级列表决策仍然有效。 + +Web 的 session 行菜单与消息 IconActions 共用 client runtime 的 `sessions.fork` 操作。Session 行传 `{ sessionId, increaseTitle: true }`,因此在源会话最后一个已完成轮次处分支;符合条件且位于已完成轮次尾部的消息传 `{ sessionId, atSeq: node.seq, increaseTitle: true }`,因此在以该消息结束的轮次处分支。`increaseTitle` 只由 client 消费:子会话进入本地列表后,client 把源会话持久化标题尾部的 `(N)` 或 `(N)` 递增并保留括号样式,无编号时追加 ` (1)`,没有持久化标题时不改名;Host fork 请求仍只有 `sessionId` 与可选的 `atSeq`。改名成功后调用方才打开子会话;fork 或改名失败时保持源会话与当前选择不变,改名失败时已创建的子会话仍留在列表中。 `forkAt(seq)` 只在 ui-conversation 的 apply 注入层接触 session 服务,消息组件只回传事件 `seq`。Session 行同理只通过 ui-workspace 的注入回调发起操作;两个呈现包都不持有 session mutation 状态,也不复制 host 的边界求值。 @@ -28,6 +30,6 @@ Session lineage 不投影成列表层级。WorkSpace 模式按 `WorkspaceView.se ## Consequences -用户可从 session 行、用户消息或已定稿 assistant 内容消息创建分支,三处最终走同一个 runtime/host 操作;消息点位保留精确事件边界,列表点位保留「最新完成轮」快捷语义。连续 fork 的标题按 `(1)`、`(2)` 递增,而不是重复追加 `(1)`;全角括号标题保持全角样式。所有 fork 子会话立即作为普通同级行出现,列表不再需要 session 展开状态、递归节点或 twist 控件。 +用户可从 session 行或符合条件的已完成轮次尾部消息创建分支,两处最终走同一个 runtime/host 操作;消息点位保留精确事件边界,列表点位保留「最新完成轮」快捷语义。连续 fork 的标题按 `(1)`、`(2)` 递增,而不是重复追加 `(1)`;全角括号标题保持全角样式。所有 fork 子会话立即作为普通同级行出现,列表不再需要 session 展开状态、递归节点或 twist 控件。 -Fork 与子会话改名失败都保持静默并保留源选择,避免一个派生操作破坏当前阅读位置;该取舍也意味着 UI 暂不提供失败原因或重试入口。Package tests 分别钉住两种消息 `seq`、标题递增与同级列表派生,`apps/web/tests/message-actions.e2e.ts` 通过装配后的应用执行 assistant 消息分支与 session 行菜单分支。 +Fork 与子会话改名失败都保持静默并保留源选择,避免一个派生操作破坏当前阅读位置;该取舍也意味着 UI 暂不提供失败原因或重试入口。包级测试固定符合条件的消息 `seq` 转发、标题递增与同级列表派生,`apps/web/tests/message-actions.e2e.ts` 通过装配后的应用执行 assistant 消息分支与 session 行菜单分支。 diff --git a/apps/web/tests/message-actions.e2e.ts b/apps/web/tests/message-actions.e2e.ts index bb58ccefc4..96dd27f393 100644 --- a/apps/web/tests/message-actions.e2e.ts +++ b/apps/web/tests/message-actions.e2e.ts @@ -1,7 +1,7 @@ -// Web e2e scenario: message IconActions + clocks. Cold-seeds the seeded-history -// fixture (zero model calls) and pins the settled conversation aria after the -// user/assistant footers are focus-revealed — the surface package jsdom tests -// cannot substitute for (docs/testing.md snapshot rule). +// Web e2e scenario: message IconActions + clocks. Cold-seeds a deterministic +// completed-turn-tail fork case (zero model calls) and pins the settled +// conversation aria after the footers are focus-revealed — the surface package +// jsdom tests cannot substitute for (docs/testing.md snapshot rule). import { mkdir, readFile, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -25,6 +25,48 @@ const MODE = webSnapshotMode() const SEED_ID = 'message-actions-web-e2e' const PROMPT = 'Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop.' +const MID_TURN_TEXT = 'I will read both files before answering.' +const SECOND_PROMPT = 'Now give the final answer.' + +/** + * Adapt the borrowed recording into response -> tools -> interrupted Think, + * followed by one ordinary completed response. The first response keeps + * copy/clock but is not a legal branch point; the second is the real turn tail. + * @param raw - Recorded seeded-history JSONL. + * @returns A contiguous, closed two-turn fixture. + */ +function completedTailFixture(raw: string): string { + const kept: string[] = [] + for (const line of raw.trimEnd().split('\n')) { + const row = JSON.parse(line) as { + type: string + seq?: number + seq0?: number + data?: { content?: unknown[] } + } + const firstSeq = row.seq ?? row.seq0 + if (firstSeq !== undefined && firstSeq >= 101) break + if (row.type === 'assistant/message' && row.seq === 64) { + const content = row.data?.content + if (!Array.isArray(content)) throw new Error('borrowed step-one assistant message has no content') + content.splice(1, 0, { type: 'text', text: MID_TURN_TEXT }) + kept.push(JSON.stringify(row)) + } else { + kept.push(line) + } + } + const tail = [ + { type: 'step/end', seq: 101, time: 1784974102749, data: { turn: 1, step: 2 } }, + { type: 'turn/end', seq: 102, time: 1784974102750, data: { turn: 1, reason: { kind: 'aborted' } } }, + { type: 'turn/start', seq: 103, time: 1784974103000, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user', rpcId: '{{rpcId}}' } } } }, + { type: 'user/message', seq: 104, time: 1784974103001, data: { content: [{ type: 'text', text: SECOND_PROMPT }], source: { kind: 'user', rpcId: '{{rpcId}}' } }, surfaceOp: 'append' }, + { type: 'step/start', seq: 105, time: 1784974103002, data: { turn: 2, step: 1 } }, + { type: 'assistant/message', seq: 106, time: 1784974103003, data: { turn: 2, step: 1, content: [{ type: 'text', text: 'DONE' }], provenance: { provider: 'deepseek-official', model: 'deepseek-v4-flash' } }, sourceEventSeqs: [], surfaceOp: 'append' }, + { type: 'step/end', seq: 107, time: 1784974103004, data: { turn: 2, step: 1 } }, + { type: 'turn/end', seq: 108, time: 1784974103005, data: { turn: 2, reason: { kind: 'completed' } } }, + ] + return `${[...kept, ...tail.map(row => JSON.stringify(row))].join('\n')}\n` +} describe('web e2e: message IconActions and clocks on settled history', () => { let scaffold: WebScaffold @@ -38,8 +80,8 @@ describe('web e2e: message IconActions and clocks on settled history', () => { await mkdir(sessionCwd, { recursive: true }) await writeFile(join(sessionCwd, 'a.txt'), 'alpha\n') await writeFile(join(sessionCwd, 'b.txt'), 'beta\n') - const raw = await readFile(SEED, 'utf8') - expect(fixtureUserPrompts(raw), 'borrowed seed must carry the drive prompt').toEqual([PROMPT]) + const raw = completedTailFixture(await readFile(SEED, 'utf8')) + expect(fixtureUserPrompts(raw), 'adapted seed must carry both prompts').toEqual([PROMPT, SECOND_PROMPT]) await seedSession(scaffold, raw, SEED_ID) browser = await chromium.launch() page = await newEnglishPage(browser) @@ -53,7 +95,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => { await scaffold?.close() }) - it.skipIf(MODE === 'record')('lists the seeded session and reveals user/assistant IconActions', async () => { + it.skipIf(MODE === 'record')('shows branch only on the completed transcript tail', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-message-actions')) const groupRow = page.locator('[role="treeitem"]').first() await groupRow.waitFor({ timeout: 15_000 }) @@ -61,16 +103,17 @@ describe('web e2e: message IconActions and clocks on settled history', () => { const sessionRow = page.locator('[role="treeitem"]').nth(1) await sessionRow.waitFor({ timeout: 10_000 }) await sessionRow.click() + await expect.poll(() => page.getByText(MID_TURN_TEXT, { exact: true }).count(), { timeout: 15_000 }).toBe(1) await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBe(1) // Focus-reveal the footers (hover:hover keeps them opacity-hidden until - // hover/focus-within). User and each turn's last content assistant both - // have copy + branch. + // hover/focus-within). All message rows keep copy, but only the final + // assistant at a completed transcript tail has branch. const copyButtons = page.getByRole('button', { name: 'Copy' }) - await expect.poll(() => copyButtons.count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2) + await expect.poll(() => copyButtons.count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(4) await copyButtons.first().focus() await expect.poll(() => page.getByRole('button', { name: 'Branch into a new conversation' }).count(), { timeout: 5_000 }) - .toBeGreaterThanOrEqual(2) + .toBe(1) await expect.poll(() => page.getByRole('button', { name: 'Edit' }).count(), { timeout: 5_000 }).toBe(0) }, 60_000) @@ -89,8 +132,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => { it.skipIf(MODE === 'record')('forks through the settled-message and session-row actions', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-message-fork')) - // Exercise the assistant action specifically; package coverage pins the - // user action separately at its own event seq. + // The sole message action belongs to the completed second-turn assistant. await page.getByRole('button', { name: 'Branch into a new conversation' }).last().click() await expect.poll( () => scaffold.ctx.agents.list().find(agent => agent.session.header.parentSession === SessionId(SEED_ID)), diff --git a/apps/web/tests/snapshots/message-actions/ui.expected.md b/apps/web/tests/snapshots/message-actions/ui.expected.md index bf67498178..65152bb4de 100644 --- a/apps/web/tests/snapshots/message-actions/ui.expected.md +++ b/apps/web/tests/snapshots/message-actions/ui.expected.md @@ -8,12 +8,14 @@ - button "Copy": - img - tooltip "Copy" -- button "Branch into a new conversation": - - img - button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel.": - img - img - text: Think The user wants me to read a.txt and b.txt, then reply with "DONE". Let me do both reads in parallel. +- paragraph: I will read both files before answering. +- button "Copy": + - img +- text: 7/25 {{clock}} - button "Read a.txt": - img - img @@ -28,6 +30,9 @@ - img - img - text: Think Both files have been read. a.txt contains "alpha" and b.txt contains "beta". I'll now reply with DONE as instructed. +- text: Stopped Now give the final answer. 7/25 {{clock}} +- button "Copy": + - img - paragraph: DONE - button "Copy": - img @@ -42,4 +47,4 @@ - text: deepseek-v4-flash - img - button "Send message" [disabled] -- text: 1 turns · 2 steps Tool call {{duration}} Cache hit 98% Input 15.8K tok · Output 135 tok +- text: 2 turns · 3 steps Tool call {{duration}} Cache hit 98% Input 7.8K tok · Output 103 tok diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index f20967ee2f..dd36762aeb 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: f956be22384a42e9ed30e8aa5f25fe8173cc9f9c -README.zh.md: 49449c51d89d957b5bd39798c9167607f72a5c3a +README.md: 91844016ce58e172161343022a1824be91b7ff2f +README.zh.md: 0ea77774c6181858748a8adbc737de7e213be31b diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index f956be2238..91844016ce 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -26,7 +26,7 @@ SlotsService gives the renderer separate bare observables for `useSessions` and ## The human transcript -`ConversationSnapshot.nodes` is the human transcript, not the model surface. `TranscriptAdapter` projects the raw window in log order — every append-origin surface event (`isAppendSurfaceEvent`) at its own log position, plus one `CompactionSummaryNode` marker per landed compaction checkpoint — and never consults surface order. A landed compaction therefore keeps the conversation it shadowed on the model side: the marker reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies stay out: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary. A checkpoint is a `user/message` carrying the compaction seam's plugin source that **replaced** a surface range; an appending plugin-sourced `user/message` is injected context, not a compaction. The adapter's plugin literal is pinned to the seam's own declaration by a type-only import of the cordis-free [`dsh-compact/checkpoint`](../../compact/compact/README.md) leaf, so renaming it there fails `tsc` here; a **value** import of the package would fail the client purity gate, and the package **root** is unreachable even as a type (it reaches `dsh-session`'s root, whose `Context` merge collides the host `sessions` with this program's). `tests/compact-checkpoint-pin.spec.ts` covers the same drift behaviorally. +`ConversationSnapshot.nodes` is the human transcript, not the model surface. `TranscriptAdapter` projects the raw window in log order — every append-origin surface event (`isAppendSurfaceEvent`) at its own log position, plus one `CompactionSummaryNode` marker per landed compaction checkpoint — and never consults surface order. `ConversationSnapshot.turnEnds` maps each completed turn in that window to its `turn/end` seq, retaining turn completion independently from the transcript so presentation can require a real boundary before exposing an action. A landed compaction therefore keeps the conversation it shadowed on the model side: the marker reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies stay out: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary. A checkpoint is a `user/message` carrying the compaction seam's plugin source that **replaced** a surface range; an appending plugin-sourced `user/message` is injected context, not a compaction. The adapter's plugin literal is pinned to the seam's own declaration by a type-only import of the cordis-free [`dsh-compact/checkpoint`](../../compact/compact/README.md) leaf, so renaming it there fails `tsc` here; a **value** import of the package would fail the client purity gate, and the package **root** is unreachable even as a type (it reaches `dsh-session`'s root, whose `Context` merge collides the host `sessions` with this program's). `tests/compact-checkpoint-pin.spec.ts` covers the same drift behaviorally. Because the projection is log-ordered, the node array is seq-monotonic by construction: log-only `command/run` / `command/done` nodes splice in by seq, `Session` merges interrupted frozen nodes by their fractional seqs, and a window whose checkpoint cites a shadowed range outside it renders the marker with nothing logged. The marker's summary text comes from the checkpoint's `compact/summary` provenance; a window cut that left the provenance outside makes the row non-expandable rather than empty, and a later page that supplies it resolves the text. Performance contract: one append materializes at most one node and copies the projection only when it adds that node; an event that changes no node keeps the previous array reference (a chunk storm costs nothing), and unchanged nodes keep their object identity. diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 49449c51d8..0ea77774c6 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -26,7 +26,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 ## 面向人的 transcript(文本记录) -`ConversationSnapshot.nodes` 是面向人的 transcript,不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口——每个 append 来源的 surface 事件(`isAppendSurfaceEvent`)落在它自己的日志位置上,外加每次落地的压缩(compaction)检查点贡献一个 `CompactionSummaryNode` 标记——且从不查询 surface 顺序。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话:标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不标记任何边界。检查点是携带压缩 seam 插件来源、且**替换**了一段 surface 范围的 `user/message`;一条 append 的插件来源 `user/message` 是注入上下文,不是压缩。适配器的插件字面量通过对无 cordis 的 [`dsh-compact/checkpoint`](../../compact/compact/README.md) 叶子做仅类型导入,钉在压缩 seam 自己的声明上:在那里改名会让此处 `tsc` 失败;而对该包(package)做**值**导入会被客户端纯度门禁拒绝,包的**根**即便作为类型也无法到达(它会到达 `dsh-session` 的根,其 `Context` 合并会让 host 的 `sessions` 与本程序的冲突)。`tests/compact-checkpoint-pin.spec.ts` 从行为侧覆盖同一漂移。 +`ConversationSnapshot.nodes` 是面向人的 transcript,不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口——每个 append 来源的 surface 事件(`isAppendSurfaceEvent`)落在它自己的日志位置上,外加每次落地的压缩(compaction)检查点贡献一个 `CompactionSummaryNode` 标记——且从不查询 surface 顺序。`ConversationSnapshot.turnEnds` 把该窗口中的每个已完成轮次映射到其 `turn/end` seq;它独立于 transcript 保留轮次完成状态,使呈现层能够在暴露操作前要求存在真实边界。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话:标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不标记任何边界。检查点是携带压缩 seam 插件来源、且**替换**了一段 surface 范围的 `user/message`;一条 append 的插件来源 `user/message` 是注入上下文,不是压缩。适配器的插件字面量通过对无 cordis 的 [`dsh-compact/checkpoint`](../../compact/compact/README.md) 叶子做仅类型导入,钉在压缩 seam 自己的声明上:在那里改名会让此处 `tsc` 失败;而对该包(package)做**值**导入会被客户端纯度门禁拒绝,包的**根**即便作为类型也无法到达(它会到达 `dsh-session` 的根,其 `Context` 合并会让 host 的 `sessions` 与本程序的冲突)。`tests/compact-checkpoint-pin.spec.ts` 从行为侧覆盖同一漂移。 由于投影按日志顺序,节点数组天然按 seq 单调:仅日志的 `command/run` / `command/done` 节点按 seq 插入,`Session` 按分数 seq 归并被打断的冻结节点,而检查点所引范围落在窗口之外的窗口会渲染出标记且不打印任何日志。标记的摘要文本来自检查点的 `compact/summary` 溯源;窗口切分把溯源留在窗口外时该行不可展开而非空白,后续补上溯源的分页会解析出文本。性能契约:一次追加最多物化一个节点,并且仅在加入该节点时复制投影;不改变任何节点的事件保持上一次的数组引用(分片风暴零成本),未变化的节点保持其对象标识。 diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index a4c260a068..7752762eae 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -322,6 +322,8 @@ export interface ConversationSnapshot { sessionId: SessionId /** Human transcript plus retry notices and interrupted-turn terminal nodes in event order. */ nodes: readonly ConversationNode[] + /** In-window completed turn number -> its `turn/end` event seq. */ + turnEnds: ReadonlyMap partial: PartialAssistant | null runningCalls: readonly RunningToolCall[] /** diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 2bcb2ac5fc..dee41b2fe0 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -113,6 +113,11 @@ export class Session implements SessionFace { private pendingCache: { rev: number; value: PendingInteraction[] } | null = null private derivedRev = 0 private nodesCache: { projected: readonly ConversationNode[]; derivedRev: number; value: readonly ConversationNode[] } | null = null + /** Completed turn boundaries retained from the raw window so presentation + * actions never infer a safe fork point from transcript content alone. */ + private turnEnds = new Map() + private turnEndsRev = 0 + private turnEndsCache: { rev: number; value: ReadonlyMap } | null = null /** Authoritative stream-only inbox snapshot; pending work never hits history. */ private queued: QueuedMessage[] = [] private queueRev = 0 @@ -807,6 +812,8 @@ export class Session implements SessionFace { return } case 'turn/end': { + this.turnEnds.set(event.data.turn, event.seq) + this.turnEndsRev++ if (event.data.reason.kind === 'aborted' || event.data.reason.kind === 'disposed') { this.settleScheduledRetry('cancelled', event.data.turn) } @@ -897,6 +904,8 @@ export class Session implements SessionFace { this.callsRev++ this.derivedNodes = [] this.derivedRev++ + this.turnEnds = new Map() + this.turnEndsRev++ this.codeDispatches = new Map() this.dispatchesRev++ for (let i = 0; i < this.events.length; i++) { @@ -928,6 +937,9 @@ export class Session implements SessionFace { if (this.callsCache === null || this.callsCache.rev !== this.callsRev) { this.callsCache = { rev: this.callsRev, value: [...this.openCalls.values()] } } + if (this.turnEndsCache === null || this.turnEndsCache.rev !== this.turnEndsRev) { + this.turnEndsCache = { rev: this.turnEndsRev, value: new Map(this.turnEnds) } + } if (this.pendingCache === null || this.pendingCache.rev !== this.pendingRev) { this.pendingCache = { rev: this.pendingRev, value: [...this.pending.values()] } } @@ -941,6 +953,7 @@ export class Session implements SessionFace { return { sessionId: this.sessionId, nodes, + turnEnds: this.turnEndsCache.value, partial, runningCalls: this.callsCache.value, pending: this.pendingCache.value, diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index 7f5f793948..6e75593e3a 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -426,6 +426,7 @@ describe('live event path', () => { feed(ev.turnEnd(10, 1, 'aborted')) // no assistant/message ever arrives const snapshot = session.getSnapshot() expect(snapshot.partial).toBeNull() + expect(snapshot.turnEnds.get(1)).toBe(10) const frozen = snapshot.nodes.at(-1) expect(frozen).toMatchObject({ kind: 'assistant', interrupted: true, blocks: [{ kind: 'text', text: '说到一半' }] }) // Ordered inside the flow: after the user message (seq 7), before any later turn. @@ -1215,6 +1216,7 @@ describe('reference stability (the memo contract)', () => { expect(after).not.toBe(before) expect(after.runningCalls).toBe(before.runningCalls) expect(after.pending).toBe(before.pending) + expect(after.turnEnds).toBe(before.turnEnds) // And a mutation on the tracked domain swaps that array. feed(ev.toolResult(11, 1, 'c1', 'ECHO')) const resolved = session.getSnapshot() diff --git a/packages/client/test-runtime/src/fixtures.ts b/packages/client/test-runtime/src/fixtures.ts index 2544870ace..d033669215 100644 --- a/packages/client/test-runtime/src/fixtures.ts +++ b/packages/client/test-runtime/src/fixtures.ts @@ -46,6 +46,7 @@ export function conversationSnapshot(sessionId: SessionId): ConversationSnapshot return { sessionId, nodes: [], + turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index aec4bc46bc..597f99f7e3 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/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-conversation/README.md -README.md: 65e4542e622713e2cd120906926fc0601ef280bd -README.zh.md: 4828ad214bb5ec0a9921307dd01dcc282910b330 +README.md: e351880066ac00677ab9d22c96ea75e2244a2ecf +README.zh.md: f7c7e3ab6d341f9b3d0866951ab80a18d5968044 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 65e4542e62..e351880066 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -57,8 +57,8 @@ None; this package neither assembles nor sends a provider request. - **Compaction markers show no scale** — the row does not yet report how many messages or which range the checkpoint replaced. - **Stats-line durations cover the in-window flow only** — LLM and tool wall times fold the snapshot's assistant `timing` and tool call/result pairs, so nodes outside the loaded event window (older history) are not counted. - **Details panel is the minimal form and currently has no entry point** — selected call args/result raw display; the Input/Output/Metadata switch, Prev/Next stepping, and See-in-trajectory deep link are deferred. Tool rows stopped being details-panel click targets and nothing replaced that gesture, so `ChatViewInjected.openDetails` is implemented but uncalled and the panel (including its terminal card) is unreachable in the assembled application; its rendering stays covered by mounting it with a selection directly. -- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / branch / clock) ships under the last content-text assistant of each turn only; mid-turn narration and Think-only nodes stay chrome-free. Branch forks through the turn containing that message, increments the inherited title on the client, and then opens the child, while a fork or rename failure leaves the source selected. -- **Sent user messages cannot be edited** — the user bubble's IconActions row carries clock / copy / branch only, and branching from the message is the nearest gesture. The control returns with the capability behind it: a client mutation over a settled user message, plus the host behavior for the turn that already consumed it ([decision](../../../.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md)). +- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / clock, plus branch when eligible) ships under the last content-text assistant of each turn only; mid-turn narration and Think-only nodes stay chrome-free. Branch appears only when that message is also the last transcript node of a completed turn, then forks through that turn, increments the inherited title on the client, and opens the child; a fork or rename failure leaves the source selected ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md)). +- **Sent user messages cannot be edited** — user bubbles retain clock and copy, while branch appears only for a completed turn whose transcript ends at that user message. Editing returns with the capability behind it: a client mutation over a settled user message, plus the host behavior for the turn that already consumed it ([decision](../../../.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md)). - **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export. - **The approval panel's "Always allow this type" is deferred** — durable grants need a grant-storage design; only allow-once/reject answer today. - **TodoPanel truncates long item text to one ellipsized line** — the figma strip has no wrap or expand affordance; full text is not readable inline. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 4828ad214b..f7c7e3ab6d 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -57,8 +57,8 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插 - **压缩标记不显示规模**:该行尚不报告检查点替换了多少条消息或哪段范围。 - **统计行的耗时只覆盖窗口内消息流**:LLM 与工具墙钟时间由快照的 assistant `timing` 与工具 call/result 配对折算,落在已加载事件窗口之外的节点(更早的历史)不计入。 - **详情面板是最小形态,且当前没有入口**:以原始形式显示已选择调用的参数/结果;Input/Output/Metadata 切换、Prev/Next 步进与 See-in-trajectory 深链接暂缓实现。工具行已不再是详情面板的点击目标,且没有任何手势接替它,因此 `ChatViewInjected.openDetails` 虽已实现却无人调用,该面板(含其终端卡片)在组装后的应用中不可达;其渲染仍由直接以选中态挂载它来覆盖。 -- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/分支/时钟)只挂在每个轮次中最后一条带 text 内容的 assistant 下;轮次中间的叙述与纯 Think 节点不带 chrome。分支会 fork 到包含该消息的轮次末尾,在 client 端递增继承标题后打开子会话,而 fork 或改名失败时源会话保持选中。 -- **已发送的 user 消息无法编辑**:user 气泡的 IconActions 行只有时钟/复制/分支,从该消息分支是最接近的手势。该控件要与其背后的能力一起回归:既需要针对已定稿 user 消息的 client 变更,也需要 host 侧对已经消费过它的轮次给出行为([决策](../../../.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md))。 +- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/时钟,符合条件时再显示分支)只挂在每个轮次中最后一条带 text 内容的 assistant 下;轮次中间的叙述与纯 Think 节点不带 chrome。只有当该消息同时也是已完成轮次的最后一个 transcript 节点时才显示分支;随后 fork 到该轮次末尾,在 client 端递增继承标题并打开子会话;fork 或改名失败时源会话保持选中([决策](../../../.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md))。 +- **已发送的 user 消息无法编辑**:user 气泡保留时钟和复制;仅当已完成轮次的 transcript 结束于该 user 消息时才显示分支。编辑功能要与其背后的能力一起回归:既需要针对已定稿 user 消息的 client 变更,也需要 host 侧对已经消费过它的轮次给出行为([决策](../../../.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md))。 - **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。 - **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。 - **TodoPanel 将过长条目截成单行省略号**:figma 条没有换行或展开入口,完整文本无法在行内读完。 diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index 387a7fd82a..8569051d30 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -4,9 +4,10 @@ // view groups them into tool rows through its keyed toolview slot (figma // step-summary flow). Shared by finalized nodes and the streaming partial; // the turn-level loading dots live in the chat view's tail, not here. -// Finalized turn-tail content (text) nodes append IconActions once streaming -// ends (`time` is omitted for mid-turn narration); Think / tool-head-only -// nodes stay chrome-free. +// Finalized content (text) nodes append IconActions once streaming ends +// (`time` is omitted for mid-turn narration); their branch action is present +// only when the node is also the completed turn's transcript tail. Think / +// tool-head-only nodes stay chrome-free. import { memo, useMemo } from 'react' import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client' @@ -28,7 +29,7 @@ export interface AssistantMarkdownProps { time?: number | undefined /** Event sequence used as the fork boundary; omitted while streaming. */ seq?: number | undefined - /** Fork the session through the turn containing this finalized message. */ + /** Fork the session through this finalized message's completed turn. */ onFork?: ((seq: number) => void) | undefined /** The owning view's locale seat, passed down as a plain prop. */ t: ChatViewSlotProps['t'] diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index 4a8e715bcf..af0e3a1c39 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -30,7 +30,7 @@ import type { import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps } from '../contract/slots.ts' -import { assistantActionsSeqs, deriveChatFlow, type ChatFlowItem } from './chat-flow.ts' +import { assistantActionsSeqs, deriveChatFlow, messageBranchSeqs, type ChatFlowItem } from './chat-flow.ts' import { AssistantMarkdown } from './AssistantMarkdown.tsx' import { GenericCommandCard } from './GenericCommandCard.tsx' import { GenericToolCard } from './GenericToolCard.tsx' @@ -236,6 +236,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, inspectCall, chatScroll, forkAt, t, }: ChatViewSlotProps) { const nodes = useSession(s => s.nodes) + const turnEnds = useSession(s => s.turnEnds) // Workspace root off the session list row: path summaries display relative to it. const cwd = useSessions(s => s.byId[sessionId]?.cwd) const running = useSession(s => s.running) @@ -252,6 +253,7 @@ export function ChatView({ // Only the last content assistant of each turn owns IconActions; mid-turn // text (before tools) omits `time` so AssistantMarkdown stays chrome-free. const actionSeqs = useMemo(() => assistantActionsSeqs(nodes), [nodes]) + const branchSeqs = useMemo(() => messageBranchSeqs(nodes, turnEnds), [nodes, turnEnds]) const listRef = useRef(null) const atBottomRef = useRef(true) @@ -401,7 +403,7 @@ export function ChatView({ interrupted={node.interrupted} time={actionSeqs.has(node.seq) ? node.time : undefined} seq={node.seq} - onFork={forkAt} + onFork={branchSeqs.has(node.seq) ? forkAt : undefined} t={t} /> ) @@ -416,7 +418,7 @@ export function ChatView({ key={item.key} node={node} retryActive={node.kind === 'model-retry' && node.seq === activeRetry} - onFork={forkAt} + {...branchSeqs.has(node.seq) ? { onFork: forkAt } : {}} t={t} /> ) diff --git a/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx b/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx index cd6b0a36ce..81129b94ac 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx @@ -17,7 +17,7 @@ export interface MessageIconActionsProps { time: number /** Clock before icons (user) or after (assistant). */ clock: 'start' | 'end' - /** Fork the session at this message. */ + /** Fork the session at this message; omission hides the branch action. */ onBranch?: (() => void) | undefined /** Parent layout class composed onto the actions row. */ className?: string | undefined @@ -50,11 +50,13 @@ export function MessageIconActions({ - - - + {onBranch !== undefined && ( + + + + )} {clock === 'end' ? clockEl : null}
) diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index b54604060a..3a27feaef3 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -26,7 +26,7 @@ export interface MessageItemProps { | TurnErrorNode | UnknownSurfaceNode retryActive?: boolean - /** Fork the session through the turn containing this message (user-bubble branch action). */ + /** Fork through this message's completed turn when it is the transcript tail. */ onFork?: (seq: number) => void /** The owning view's locale seat, passed down as a plain prop. */ t: ChatViewSlotProps['t'] diff --git a/packages/client/ui-conversation/src/client/chat/chat-flow.ts b/packages/client/ui-conversation/src/client/chat/chat-flow.ts index 4958894154..9bd98fd5a5 100644 --- a/packages/client/ui-conversation/src/client/chat/chat-flow.ts +++ b/packages/client/ui-conversation/src/client/chat/chat-flow.ts @@ -5,8 +5,8 @@ * reuse the first notice's row while projecting the latest retry turn. * Item identity keys are stable across snapshots so the list parent can * subscribe to keys only while rows subscribe to content. IconActions ownership - * (last content assistant per turn) is derived here too so ChatView and the - * flow share one gate. + * and completed-turn branch points are derived here too so ChatView and the + * flow share their gates. */ import type { AssistantBlock, ConversationNode, ToolResultNode, @@ -47,6 +47,38 @@ export function assistantActionsSeqs(nodes: readonly ConversationNode[]): Readon return new Set(lastByTurn.values()) } +/** + * Seq set of message rows that may fork: the last transcript node of a + * completed turn, when that node owns message chrome. A later tool, reasoning, + * error, or other transcript node suppresses the earlier message's branch + * action even though the Host would include the whole turn. + * @param nodes - snapshot nodes in event order. + * @param turnEnds - completed turn boundaries retained from the event window. + * @returns Message seq values whose visible position matches the fork boundary. + */ +export function messageBranchSeqs( + nodes: readonly ConversationNode[], + turnEnds: ReadonlyMap, +): ReadonlySet { + const result = new Set() + const boundaries = [...turnEnds].sort((a, b) => a[1] - b[1]) + let nodeIndex = 0 + for (const [turn, endSeq] of boundaries) { + let tail: ConversationNode | undefined + while (nodeIndex < nodes.length) { + const candidate = nodes[nodeIndex] + if (candidate === undefined || candidate.seq > endSeq) break + tail = candidate + nodeIndex++ + } + if (tail?.kind === 'user' + || (tail?.kind === 'assistant' && tail.turn === turn && hasContentText(tail.blocks))) { + result.add(tail.seq) + } + } + return result +} + /** * Group finalized nodes into the step-summary flow. * @param nodes - snapshot nodes in human-transcript and durable-notice order. diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index a6c9d16e61..390a9aeb76 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -454,7 +454,7 @@ export interface ChatViewInjected { /** Last recorded offset, or null when pinned or never recorded. */ read: () => number | null } - /** Fork the session through the turn containing the message at `seq`, then open the child. */ + /** Fork through the completed turn ending at the eligible message `seq`, then open the child. */ forkAt: (seq: number) => void } diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index a829f6f04a..964b261b78 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -36,12 +36,14 @@ describe('MessageItem arms', () => { // Same-day clock: construct "today at 14:24" so the label stays `HH:mm`. const now = new Date() const time = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 14, 24).getTime() + const onFork = vi.fn() render( , ) expect(screen.getByText('14:24')).toBeTruthy() @@ -50,6 +52,8 @@ describe('MessageItem arms', () => { expect(screen.queryByRole('button', { name: '编辑' })).toBeNull() fireEvent.click(screen.getByRole('button', { name: '复制' })) expect(writeText).toHaveBeenCalledWith('hello bubble') + fireEvent.click(screen.getByRole('button', { name: '在新对话中分支' })) + expect(onFork).toHaveBeenCalledWith(1) }) it('user copy falls back to execCommand when clipboard.writeText is unavailable', () => { @@ -410,12 +414,15 @@ describe('small branch tails', () => { }) const now = new Date() const time = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 14, 24).getTime() + const onFork = vi.fn() const settled = render( , ) expect(settled.getByText('14:24')).toBeTruthy() @@ -423,6 +430,8 @@ describe('small branch tails', () => { expect(settled.getByRole('button', { name: '在新对话中分支' })).toBeTruthy() fireEvent.click(settled.getByRole('button', { name: '复制' })) expect(writeText).toHaveBeenCalledWith('answer body') + fireEvent.click(settled.getByRole('button', { name: '在新对话中分支' })) + expect(onFork).toHaveBeenCalledWith(3) settled.unmount() const thinkOnly = render( diff --git a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx index e1366721c8..6708cdb759 100644 --- a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx @@ -67,7 +67,7 @@ function snapshotWith( runningCalls: RunningToolCall[] = [], ): ConversationSnapshot { return { - sessionId: SID, nodes, partial: null, runningCalls, codeDispatches, + sessionId: SID, nodes, turnEnds: new Map(), partial: null, runningCalls, codeDispatches, pending: [], queue: [], running: runningCalls.length > 0, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, diff --git a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx b/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx index a02f8ccb6f..95da35f267 100644 --- a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx @@ -32,7 +32,7 @@ const assistant = (seq: number, turn: number, usage?: unknown): AssistantMessage function snapshotBase(): ConversationSnapshot { return { - sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(), + sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, } diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 04f412fb25..86333eb6ec 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -20,7 +20,7 @@ import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts import { createChatStore } from '../src/client/stores.ts' import { ChatView } from '../src/client/chat/ChatView.tsx' import { zh } from '../src/client/locales.ts' -import { assistantActionsSeqs, deriveChatFlow, flowKeys } from '../src/client/chat/chat-flow.ts' +import { assistantActionsSeqs, deriveChatFlow, flowKeys, messageBranchSeqs } from '../src/client/chat/chat-flow.ts' afterEach(cleanup) // Keyless create() persists under the bare declared key; clear between cases @@ -33,7 +33,7 @@ const SID = 's1' as SessionId function snapshotBase(): ConversationSnapshot { return { - sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(), + sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, } @@ -211,6 +211,24 @@ describe('chat-flow derivation', () => { ]) expect([...seqs].sort((a, b) => a - b)).toEqual([5, 7]) }) + + it('messageBranchSeqs keeps only message rows at completed transcript tails', () => { + const interruptedThink: AssistantMessageNode = { + kind: 'assistant', seq: 4.1, time: 4_100, turn: 1, step: 2, + blocks: [{ kind: 'reasoning', text: 'bad path' }], interrupted: true, + } + const nodes = [ + user(1, 'first'), + assistant(2, 'answer before tools'), + toolResult(3, 'a'), + interruptedThink, + user(6, 'second'), + assistant(7, 'clean tail', 2), + user(10, 'user-only tail'), + ] + const seqs = messageBranchSeqs(nodes, new Map([[1, 5], [2, 8], [3, 11]])) + expect([...seqs]).toEqual([7, 10]) + }) }) describe('ChatView', () => { @@ -323,21 +341,38 @@ describe('ChatView', () => { user(5, 'next'), assistant(6, 'second turn', 2), ], + turnEnds: new Map([[1, 4], [2, 6]]), }) const view = render() - // 2 user + 2 turn-tail assistants; mid-turn text at seq 2 stays chrome-free. + // User rows keep copy/clock, while only the two completed assistant tails may branch. expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(4) - expect(view.getAllByRole('button', { name: '在新对话中分支' })).toHaveLength(4) + expect(view.getAllByRole('button', { name: '在新对话中分支' })).toHaveLength(2) }) - it('forks from both user and finalized assistant message actions at their event seq', () => { - const h = makeHarness({ nodes: [user(1, 'question'), assistant(2, 'answer')] }) + it('forks only from a finalized assistant at the completed transcript tail', () => { + const h = makeHarness({ + nodes: [user(1, 'question'), assistant(2, 'answer')], + turnEnds: new Map([[1, 3]]), + }) const view = render() const buttons = view.getAllByRole('button', { name: '在新对话中分支' }) - expect(buttons).toHaveLength(2) + expect(buttons).toHaveLength(1) fireEvent.click(buttons[0]!) - fireEvent.click(buttons[1]!) - expect(h.forkAt.mock.calls).toEqual([[1], [2]]) + expect(h.forkAt.mock.calls).toEqual([[2]]) + }) + + it('keeps copy chrome but hides branch when tool and interrupted Think follow the response', () => { + const interruptedThink: AssistantMessageNode = { + kind: 'assistant', seq: 4.1, time: 4_100, turn: 1, step: 2, + blocks: [{ kind: 'reasoning', text: 'bad path' }], interrupted: true, + } + const h = makeHarness({ + nodes: [user(1, 'question'), assistant(2, 'answer'), toolResult(3, 'a'), interruptedThink], + turnEnds: new Map([[1, 5]]), + }) + const view = render() + expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(2) + expect(view.queryByRole('button', { name: '在新对话中分支' })).toBeNull() }) it('renders assistant Markdown across history, streaming, final, and interrupted states while user text stays literal', () => { diff --git a/packages/client/ui-conversation/tests/diff-card.spec.tsx b/packages/client/ui-conversation/tests/diff-card.spec.tsx index 1722b9628e..f7de4e45f9 100644 --- a/packages/client/ui-conversation/tests/diff-card.spec.tsx +++ b/packages/client/ui-conversation/tests/diff-card.spec.tsx @@ -335,7 +335,7 @@ describe('DetailsPanel diff Output section', () => { function snapshot(over: Partial = {}): ConversationSnapshot { return { - sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(), + sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, ...over, diff --git a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx index 4496423efa..8350b318e9 100644 --- a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx @@ -24,7 +24,7 @@ const SID = 's1' as SessionId function snapshotBase(): ConversationSnapshot { return { - sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(), + sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, } diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index 2072fd2c90..5b2884c01e 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -23,7 +23,7 @@ const SID = 's1' as SessionId function snapshotOf(overrides: Partial = {}): ConversationSnapshot { return { - sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(), + sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, diff --git a/packages/client/ui-conversation/tests/input-matrix.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.spec.tsx index 45ce41c3b8..c91631e374 100644 --- a/packages/client/ui-conversation/tests/input-matrix.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.spec.tsx @@ -26,7 +26,7 @@ const SID = 's1' as SessionId /** Standard-props InputBar mount over a real shell (the composer-bar entry shape). */ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled?: boolean }) { const session = createSnapshotStore({ - sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(), + sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), pending: [], queue: [], running: over?.running ?? false, composerPhase: 'active', removed: over?.disabled ?? false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, diff --git a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx index a4eb2e668f..157419fd79 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx @@ -112,7 +112,7 @@ async function scopedBench(register?: (slash: SlashService) => void) { actx.on('slash/input-consume-token', req => shell.consumeToken(req.guard) ? true : undefined) const wiring = shell const sessionStore = createSnapshotStore({ - sessionId, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(), + sessionId, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, diff --git a/packages/client/ui-conversation/tests/queue-dock.spec.tsx b/packages/client/ui-conversation/tests/queue-dock.spec.tsx index 6c11119c0a..321d7250fa 100644 --- a/packages/client/ui-conversation/tests/queue-dock.spec.tsx +++ b/packages/client/ui-conversation/tests/queue-dock.spec.tsx @@ -28,7 +28,7 @@ function row(id: string, text: string | null, preview = text ?? '[image]'): Queu function snapshotWith(queue: QueuedMessage[]): ConversationSnapshot { return { - sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(), + sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), pending: [], queue, running: true, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, } diff --git a/packages/client/ui-conversation/tests/read-card.spec.tsx b/packages/client/ui-conversation/tests/read-card.spec.tsx index 700c8c3d75..bf9898cff9 100644 --- a/packages/client/ui-conversation/tests/read-card.spec.tsx +++ b/packages/client/ui-conversation/tests/read-card.spec.tsx @@ -283,7 +283,7 @@ describe('DetailsPanel Output section (read)', () => { function snapshot(over: Partial = {}): ConversationSnapshot { return { - sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(), + sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, ...over, diff --git a/packages/client/ui-conversation/tests/search-card.spec.tsx b/packages/client/ui-conversation/tests/search-card.spec.tsx index b4306d064d..c50c308edf 100644 --- a/packages/client/ui-conversation/tests/search-card.spec.tsx +++ b/packages/client/ui-conversation/tests/search-card.spec.tsx @@ -397,7 +397,7 @@ describe('DetailsPanel Output section (search)', () => { function snapshot(over: Partial = {}): ConversationSnapshot { return { - sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(), + sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, ...over, diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index eff655d229..413adf7a86 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -68,7 +68,7 @@ const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState => function conversationSnapshot(overrides: Partial = {}): ConversationSnapshot { return { - sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(), + sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, diff --git a/packages/client/ui-conversation/tests/terminal-card.spec.tsx b/packages/client/ui-conversation/tests/terminal-card.spec.tsx index 2ae268debd..babefe16f1 100644 --- a/packages/client/ui-conversation/tests/terminal-card.spec.tsx +++ b/packages/client/ui-conversation/tests/terminal-card.spec.tsx @@ -455,7 +455,7 @@ describe('DetailsPanel Output section', () => { function snapshot(over: Partial = {}): ConversationSnapshot { return { - sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(), + sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, ...over, diff --git a/packages/client/ui-conversation/tests/web-card.spec.tsx b/packages/client/ui-conversation/tests/web-card.spec.tsx index a60c16f77c..974df2264a 100644 --- a/packages/client/ui-conversation/tests/web-card.spec.tsx +++ b/packages/client/ui-conversation/tests/web-card.spec.tsx @@ -232,7 +232,7 @@ describe('DetailsPanel web Output section', () => { function snapshot(over: Partial = {}): ConversationSnapshot { return { - sessionId: SID, nodes: [], partial: null, runningCalls: [], codeDispatches: new Map(), + sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(), pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, ...over, From d9a370e185d52291ae57959fb2ff2e9a373a5ed2 Mon Sep 17 00:00:00 2001 From: kingwl Date: Sun, 2 Aug 2026 17:53:38 +0800 Subject: [PATCH 058/129] test(web): refresh completed-turn-tail snapshots --- apps/web/tests/snapshots/code-mode-round/ui.expected.md | 2 -- apps/web/tests/snapshots/cordis-tool-round/ui.expected.md | 2 -- apps/web/tests/snapshots/fresh-round-trip/ui.expected.md | 2 -- .../web/tests/snapshots/lifecycle-chrome/reloaded.expected.md | 2 -- apps/web/tests/snapshots/live-interactions/cancel.expected.md | 2 -- .../tests/snapshots/live-interactions/error-auth.expected.md | 2 -- .../web/tests/snapshots/live-interactions/loading.expected.md | 2 -- apps/web/tests/snapshots/live-interactions/retry.expected.md | 2 -- apps/web/tests/snapshots/plan-review/approved.expected.md | 2 -- .../tests/snapshots/question-composer/answered.expected.md | 2 -- apps/web/tests/snapshots/queue-actions/collapsed.expected.md | 2 -- apps/web/tests/snapshots/queue-actions/editing.expected.md | 2 -- apps/web/tests/snapshots/queue-actions/preserved.expected.md | 4 ---- apps/web/tests/snapshots/queue-actions/ui.expected.md | 2 -- .../tests/snapshots/seeded-history/command-row.expected.md | 2 -- apps/web/tests/snapshots/seeded-history/ui.expected.md | 2 -- apps/web/tests/snapshots/steering/mid-steer.expected.md | 2 -- apps/web/tests/snapshots/steering/settled.expected.md | 2 -- apps/web/tests/snapshots/subagent-conversation/ui.expected.md | 4 ---- apps/web/tests/snapshots/web-search-round/ui.expected.md | 2 -- 20 files changed, 44 deletions(-) diff --git a/apps/web/tests/snapshots/code-mode-round/ui.expected.md b/apps/web/tests/snapshots/code-mode-round/ui.expected.md index 8f4c7e5bf2..239c4d4548 100644 --- a/apps/web/tests/snapshots/code-mode-round/ui.expected.md +++ b/apps/web/tests/snapshots/code-mode-round/ui.expected.md @@ -7,8 +7,6 @@ - text: "Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt catching its error in the program. Return an object with both outcomes. Then reply DONE and stop. {{clock}}" - button "Copy": - img -- button "Branch into a new conversation": - - img - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md index e4d5ac8426..cc502f2b5f 100644 --- a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md +++ b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md @@ -7,8 +7,6 @@ - text: "Use only Cordis tools. First call cordis_inspect with what \"temporary\". Then call cordis_mount with this exact code: \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\". Read its returned id and call cordis_unmount with that exact id. After all three calls succeed, reply exactly CORDIS_UI_DONE and stop. {{clock}}" - button "Copy": - img -- button "Branch into a new conversation": - - img - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md index facb7b58cc..73817d6c7e 100644 --- a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md +++ b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md @@ -7,8 +7,6 @@ - text: "Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop. {{clock}}" - button "Copy": - img -- button "Branch into a new conversation": - - img - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md index 5965797c69..d961d20c4f 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md @@ -7,8 +7,6 @@ - text: Reply with the single word LIGHTHOUSE and stop. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": - - img - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/live-interactions/cancel.expected.md b/apps/web/tests/snapshots/live-interactions/cancel.expected.md index d1e4d2bbef..57d2ea37dc 100644 --- a/apps/web/tests/snapshots/live-interactions/cancel.expected.md +++ b/apps/web/tests/snapshots/live-interactions/cancel.expected.md @@ -7,8 +7,6 @@ - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": - - img - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md index fb9337e978..9539eb180f 100644 --- a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md +++ b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md @@ -7,8 +7,6 @@ - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": - - img - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/live-interactions/loading.expected.md b/apps/web/tests/snapshots/live-interactions/loading.expected.md index a5dfd08fb5..caee0ad6aa 100644 --- a/apps/web/tests/snapshots/live-interactions/loading.expected.md +++ b/apps/web/tests/snapshots/live-interactions/loading.expected.md @@ -7,8 +7,6 @@ - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": - - img - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/live-interactions/retry.expected.md b/apps/web/tests/snapshots/live-interactions/retry.expected.md index 6380eaf5c6..a46b0966a9 100644 --- a/apps/web/tests/snapshots/live-interactions/retry.expected.md +++ b/apps/web/tests/snapshots/live-interactions/retry.expected.md @@ -7,8 +7,6 @@ - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": - - img - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/plan-review/approved.expected.md b/apps/web/tests/snapshots/plan-review/approved.expected.md index 5be3f83247..cd0d50c095 100644 --- a/apps/web/tests/snapshots/plan-review/approved.expected.md +++ b/apps/web/tests/snapshots/plan-review/approved.expected.md @@ -8,8 +8,6 @@ - text: "plan Plan mode on. Use /plan off to leave. Plan a small change: add a --greeting flag to a CLI. Do not read or write any files. Call exit_plan_mode with a short plan of at most five bullet points. Once the plan is approved, reply with the single word DONE and stop. {{clock}}" - button "Copy": - img -- button "Branch into a new conversation": - - img - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/question-composer/answered.expected.md b/apps/web/tests/snapshots/question-composer/answered.expected.md index 28297569ab..e02232a945 100644 --- a/apps/web/tests/snapshots/question-composer/answered.expected.md +++ b/apps/web/tests/snapshots/question-composer/answered.expected.md @@ -7,8 +7,6 @@ - text: "Use the ask_user_question tool to ask me exactly one question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" After I answer, reply with the single word DONE and stop. {{clock}}" - button "Copy": - img -- button "Branch into a new conversation": - - img - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md index b9dee060ac..f51e7b3a1b 100644 --- a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md +++ b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md @@ -7,8 +7,6 @@ - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": - - img - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/queue-actions/editing.expected.md b/apps/web/tests/snapshots/queue-actions/editing.expected.md index 3a70840713..c9e6229194 100644 --- a/apps/web/tests/snapshots/queue-actions/editing.expected.md +++ b/apps/web/tests/snapshots/queue-actions/editing.expected.md @@ -7,8 +7,6 @@ - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": - - img - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/queue-actions/preserved.expected.md b/apps/web/tests/snapshots/queue-actions/preserved.expected.md index 0a43cee68b..4da58812dd 100644 --- a/apps/web/tests/snapshots/queue-actions/preserved.expected.md +++ b/apps/web/tests/snapshots/queue-actions/preserved.expected.md @@ -7,8 +7,6 @@ - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": - - img - button "Context injection": - img - img @@ -22,8 +20,6 @@ - text: {{clock}} Edited queue item {{clock}} - button "Copy": - img -- button "Branch into a new conversation": - - img - paragraph: partial - status: Deep diving... - list: diff --git a/apps/web/tests/snapshots/queue-actions/ui.expected.md b/apps/web/tests/snapshots/queue-actions/ui.expected.md index 24edb57417..0b70af3f64 100644 --- a/apps/web/tests/snapshots/queue-actions/ui.expected.md +++ b/apps/web/tests/snapshots/queue-actions/ui.expected.md @@ -7,8 +7,6 @@ - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": - - img - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/seeded-history/command-row.expected.md b/apps/web/tests/snapshots/seeded-history/command-row.expected.md index b916a3add2..e3f8cdc925 100644 --- a/apps/web/tests/snapshots/seeded-history/command-row.expected.md +++ b/apps/web/tests/snapshots/seeded-history/command-row.expected.md @@ -7,8 +7,6 @@ - text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. 7/25 {{clock}}" - button "Copy": - img -- button "Branch into a new conversation": - - img - button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel.": - img - img diff --git a/apps/web/tests/snapshots/seeded-history/ui.expected.md b/apps/web/tests/snapshots/seeded-history/ui.expected.md index a168d5e2a3..fee3747f32 100644 --- a/apps/web/tests/snapshots/seeded-history/ui.expected.md +++ b/apps/web/tests/snapshots/seeded-history/ui.expected.md @@ -7,8 +7,6 @@ - text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. 7/25 {{clock}}" - button "Copy": - img -- button "Branch into a new conversation": - - img - button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel.": - img - img diff --git a/apps/web/tests/snapshots/steering/mid-steer.expected.md b/apps/web/tests/snapshots/steering/mid-steer.expected.md index 27b40ef442..857e80e19c 100644 --- a/apps/web/tests/snapshots/steering/mid-steer.expected.md +++ b/apps/web/tests/snapshots/steering/mid-steer.expected.md @@ -7,8 +7,6 @@ - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": - - img - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/steering/settled.expected.md b/apps/web/tests/snapshots/steering/settled.expected.md index 72e28598e5..6eb6600ccd 100644 --- a/apps/web/tests/snapshots/steering/settled.expected.md +++ b/apps/web/tests/snapshots/steering/settled.expected.md @@ -7,8 +7,6 @@ - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": - - img - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/subagent-conversation/ui.expected.md b/apps/web/tests/snapshots/subagent-conversation/ui.expected.md index 6513bb60e3..37588b7756 100644 --- a/apps/web/tests/snapshots/subagent-conversation/ui.expected.md +++ b/apps/web/tests/snapshots/subagent-conversation/ui.expected.md @@ -12,8 +12,6 @@ - text: Explain event sourcing in one sentence. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": - - img - button "Context injection": - img - img @@ -30,8 +28,6 @@ - text: {{clock}} Now give the same explanation to a human reader. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": - - img - button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.": - img - img diff --git a/apps/web/tests/snapshots/web-search-round/ui.expected.md b/apps/web/tests/snapshots/web-search-round/ui.expected.md index 4ff674462e..c0e55a5051 100644 --- a/apps/web/tests/snapshots/web-search-round/ui.expected.md +++ b/apps/web/tests/snapshots/web-search-round/ui.expected.md @@ -7,8 +7,6 @@ - text: Use web_search to search exactly "DeepSeek Harness snapshot search". Then reply exactly SEARCH_DONE and stop. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": - - img - button "Context injection": - img - img From b54381f3e744453d975d131654e28b9fdade8f4c Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 20:09:05 +0800 Subject: [PATCH 059/129] fix(agent): commit mutable setup at publication Agent setup may await while a mutable contribution registry changes. The previous subagent path validated and committed its provisioning batch inside the setup callback. A revocation queued after that callback returned therefore treated the installation as resident and released it, even though AgentLoop had not published the child yet. AgentLoop could then admit and announce a child whose required capability had already disappeared. Introduce AgentSetupCommit as the optional synchronous result of create and resume setup. AgentLoop now awaits setup, invokes that commit with no intervening asynchronous boundary, and only then enters the Session and Agent registries. A commit failure follows the existing private-transaction rollback, so neither identity is published and the caller can reuse the id. Keep continuable-subagent installations provisional until this publication commit. Contribution removal still releases every installation immediately, but now marks an unpublished batch invalid so its commit rejects with ACTIVATION_SETUP_REVOKED. Once the commit succeeds, later removal remains ordinary live revocation. Cover create and resume ordering, resume commit rejection and identity reuse, and an assembled microtask revocation that leaves only the parent Agent and Session. Update the public JSDoc, architecture flow, package contracts, current Agent Notes, Chinese counterparts, pairing records, and generated Cordis API to describe the new boundary. Validated with the four focused Agent/subagent test files (91 tests), the isolated assembled regression, targeted TypeScript project builds, generated Cordis API freshness, export JSDoc verification, scoped translation pairing, Markdown wrapping, and Mermaid parsing. --- .../2026-07-08-agent-scope-contexts.i18n.yaml | 6 +- .../2026-07-08-agent-scope-contexts.md | 10 ++-- .../2026-07-08-agent-scope-contexts.zh.md | 10 ++-- ...continuable-subagent-report-tool.i18n.yaml | 4 +- ...-07-30-continuable-subagent-report-tool.md | 8 ++- ...-30-continuable-subagent-report-tool.zh.md | 8 ++- docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 4 +- docs/architecture.zh.md | 4 +- docs/cordis-catalog/services.md | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 12 +++- packages/core/agent-loop/README.i18n.yaml | 4 +- packages/core/agent-loop/README.md | 6 +- packages/core/agent-loop/README.zh.md | 6 +- packages/core/agent-loop/src/index.ts | 8 ++- packages/core/agent-loop/tests/resume.spec.ts | 35 ++++++++++++ .../agent-loop/tests/scope-lifecycle.spec.ts | 8 +++ packages/core/agent/README.i18n.yaml | 4 +- packages/core/agent/README.md | 6 +- packages/core/agent/README.zh.md | 6 +- packages/core/agent/src/index.ts | 57 +++++++++++++------ .../subagent/src/activation-setup-registry.ts | 31 ++++------ .../subagent/subagent/src/continuation.ts | 20 ++----- .../tests/activation-setup-registry.spec.ts | 7 +-- .../tool-subagent-report/README.i18n.yaml | 4 +- .../subagent/tool-subagent-report/README.md | 1 - .../tool-subagent-report/README.zh.md | 1 - .../tests/tool-subagent-report.spec.ts | 28 +++++++++ 28 files changed, 198 insertions(+), 106 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.i18n.yaml index 67955d377d..cdee6ab259 100644 --- a/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.i18n.yaml @@ -1,6 +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 -2026-07-08-agent-scope-contexts.md: e4c076189a8e8a438b561232d3779ad1f6ab0d08 -2026-07-08-agent-scope-contexts.zh.md: 35e725e43d402b048daf12c3b4be384b3fd2d2ce +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md +2026-07-08-agent-scope-contexts.md: 5e09bdbcae1e57e6b65eb7d1720a6e7a7f758a9f +2026-07-08-agent-scope-contexts.zh.md: 4714045f28e0386a3a53b53437d063462e75a9f1 diff --git a/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md b/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md index e4c076189a..5e09bdbcae 100644 --- a/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md +++ b/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md @@ -108,11 +108,11 @@ A listener registered with `{ global: true }` deliberately bypasses contextual a ### Creation publishes last and disposal revokes last -`ctx.agents.create()` and `resume()` build an unpublished session, scope, agent, and driver. They await `setup`, admit the final session and agent entries, announce them in order, start the loop, and only then return a handle. +`ctx.agents.create()` and `resume()` build an unpublished session, scope, agent, and driver. They await `setup`, synchronously invoke its optional `AgentSetupCommit`, admit the final session and agent entries, announce them in order, start the loop, and only then return a handle. The commit lets mutable provisioning revalidate at the exact publication boundary after every setup await; a throw rolls the private transaction back before either identity is announced, while revocation after a successful commit is ordinary live teardown. An optional creation signal cancels work only while create or resume is pending. After the promise resolves, the returned `AgentHandle` owns explicit disposal. -If loading, setup, admission, or publication fails, the private transaction rolls back everything it prepared. Concurrent operations using the same caller-supplied live ID may both reach setup, but final registry entry admits only one; every loser rejects and cleans its private resources. Sequential reuse after awaited disposal remains valid. +If loading, setup, the optional setup commit, admission, or publication fails, the private transaction rolls back everything it prepared. Concurrent operations using the same caller-supplied live ID may both reach setup, but final registry entry admits only one; every loser rejects and cleans its private resources. Sequential reuse after awaited disposal remains valid. `AgentHandle.dispose()` reverses the boundary. It deactivates creation or driving, waits for synchronous publication to unwind, stops and drains the driver and final session flushes, detaches the agent and session, and finally disposes the scope. Repeated or racing disposal requests join one completion promise. @@ -122,12 +122,14 @@ The calling Cordis context and the concrete AgentLoop factory are structural co- flowchart TB request["Create or resume"] --> privateWorld["Build private session, scope, agent, and driver"] privateWorld --> setup["Await composition through agent.ctx"] - setup --> admission["Admit final session and agent entries"] + setup --> setupCommit["Commit optional mutable provisioning"] + setupCommit --> admission["Admit final session and agent entries"] admission --> publish["Announce lifecycle and start the driver"] publish --> live["Return AgentHandle"] privateWorld -->|"failure, cancellation, or owner loss"| rollback["Rollback private work"] setup -->|"failure, cancellation, or owner loss"| rollback + setupCommit -->|"revalidation failure or owner loss"| rollback admission -->|"duplicate or owner loss"| rollback publish -->|"listener failure or owner loss"| rollback live -->|"handle or owner disposal"| quiesce["Stop and drain work"] @@ -166,6 +168,6 @@ Parentage describes lifetime and conversation lineage, not a universal merge pol ## Consequences -Contributors use one familiar pattern: register shared behavior through a plugin context, register local behavior through `agent.ctx`, select the real agent on operations, and dispose the returned handle. Setup is atomic from an observer's perspective, and teardown preserves local behavior until work stops. +Contributors use one familiar pattern: register shared behavior through a plugin context, register local behavior through `agent.ctx`, select the real agent on operations, and dispose the returned handle. Setup and its optional publication commit are atomic from an observer's perspective, and teardown preserves local behavior until work stops. The cost is explicit subject selection, asynchronous programmatic creation, and service-specific scope adoption. Flat registration scope is intentionally not authority, and subagent composition controls remain a separate feature rather than hidden scope semantics. diff --git a/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md b/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md index 35e725e43d..4714045f28 100644 --- a/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md @@ -108,11 +108,11 @@ setup 接收一个完整的受信 Cordis 上下文,因此可以组合普通插 ### 创建最后发布,dispose 最后撤销 -`ctx.agents.create()` 和 `resume()` 构建未发布的会话、作用域、agent 和驱动器。它们等待 `setup`,准入最终的会话和 agent 条目,按序公告,启动循环,然后才返回 handle。 +`ctx.agents.create()` 和 `resume()` 构建未发布的会话、作用域、agent 和驱动器。它们等待 `setup`,同步调用其可选的 `AgentSetupCommit`,准入最终的会话和 agent 条目,按序公告,启动循环,然后才返回 handle。该提交操作让可变的配置状态在所有 setup 的 await 均结算后,于确切的发布边界重新校验;若其抛出异常,则会在公告任何一个身份前回滚私有事务,而成功提交后的撤销属于普通的实时拆卸。 可选的创建信号仅在创建或恢复挂起期间取消工作。promise resolve 后,返回的 `AgentHandle` 拥有显式 dispose 权。 -如果加载、setup、准入或发布失败,私有事务回滚其准备的一切。使用同一个调用方提供的存活 ID 的并发操作可能都到达 setup,但最终注册表条目只准入一个;每个失败者拒绝并清理其私有资源。在等待 dispose 完成后的顺序复用仍然有效。 +如果加载、setup、可选的 setup 提交、准入或发布失败,私有事务回滚其准备的一切。使用同一个调用方提供的存活 ID 的并发操作可能都到达 setup,但最终注册表条目只准入一个;每个失败者拒绝并清理其私有资源。在等待 dispose 完成后的顺序复用仍然有效。 `AgentHandle.dispose()` 反转边界。它停用创建或驱动,等待同步发布解除,停止并排空驱动器和最终会话刷写,分离 agent 和会话,最后 dispose 作用域。重复或竞争的 dispose 请求合并为一个完成 promise。 @@ -122,12 +122,14 @@ setup 接收一个完整的受信 Cordis 上下文,因此可以组合普通插 flowchart TB request["Create or resume"] --> privateWorld["Build private session, scope, agent, and driver"] privateWorld --> setup["Await composition through agent.ctx"] - setup --> admission["Admit final session and agent entries"] + setup --> setupCommit["Commit optional mutable provisioning"] + setupCommit --> admission["Admit final session and agent entries"] admission --> publish["Announce lifecycle and start the driver"] publish --> live["Return AgentHandle"] privateWorld -->|"failure, cancellation, or owner loss"| rollback["Rollback private work"] setup -->|"failure, cancellation, or owner loss"| rollback + setupCommit -->|"revalidation failure or owner loss"| rollback admission -->|"duplicate or owner loss"| rollback publish -->|"listener failure or owner loss"| rollback live -->|"handle or owner disposal"| quiesce["Stop and drain work"] @@ -166,6 +168,6 @@ agent 作用域组合的是受信的同进程注册。它不沙箱化插件、 ## 后果 -贡献者使用一种熟悉的模式:通过插件上下文注册共享行为,通过 `agent.ctx` 注册本地行为,在操作中选择真实 agent,dispose 返回的 handle。从观察者角度看 setup 是原子的,拆除则保留本地行为直到工作停止。 +贡献者使用一种熟悉的模式:通过插件上下文注册共享行为,通过 `agent.ctx` 注册本地行为,在操作中选择真实 agent,dispose 返回的 handle。从观察者角度看,setup 及其可选的发布提交是原子的,拆除则保留本地行为直到工作停止。 代价是显式的主体选择、异步的编程式创建,以及服务需要逐个采纳作用域。扁平注册作用域有意不等同于权限,subagent 组合控制作为独立功能存在,而非隐藏的作用域语义。 diff --git a/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.i18n.yaml index 53d40cf299..54966549ba 100644 --- a/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.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 .agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md -2026-07-30-continuable-subagent-report-tool.md: 24922cfe88084bb0f9fea8c9363980a224b875da -2026-07-30-continuable-subagent-report-tool.zh.md: bb0b1847f157dba6116526851194cf1228e52e49 +2026-07-30-continuable-subagent-report-tool.md: 8324f1fa08f7dace6153712575e7e70a07ee9344 +2026-07-30-continuable-subagent-report-tool.zh.md: e7599c1d85328e83c7773718101b59e763a1e37f diff --git a/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md b/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md index 24922cfe88..8324f1fa08 100644 --- a/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md +++ b/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md @@ -54,7 +54,7 @@ The first version provides no durable mailbox, idempotency key, delivery receipt The subagent seam adds `registerContinuableSetup(contribution): () => void`, backed by `SubagentActivationSetupRegistry`. Each synchronous contribution receives the unpublished child context and returns the disposer for its installation. The continuation manager first applies base child composition, then current contributions in registration order through the same setup closure used for fresh creation and cold resume. -The registry owns registration, per-child installation records, setup rollback, child-scope cleanup, and immediate revocation. A throwing or concurrently revoked contribution rejects before Activation publication and rolls back the batch. New registrations affect a resident child only on its next Activation; removing a registration first closes it to new setup and then revokes every provisioning or resident installation immediately. Registration disposal and child-context disposal are idempotent and attempt every release before aggregating failures. +The registry owns registration, per-child installation records, setup rollback, child-scope cleanup, and immediate revocation. Applying a batch returns the Agent setup commit that revalidates provisioning after every setup await and immediately before Agent publication. A throwing or concurrently revoked contribution therefore rejects before either Agent or Session publication and rolls back the batch. New registrations affect a resident child only on its next Activation; removing a registration first closes it to new setup and then revokes every provisioning or resident installation immediately. Registration disposal and child-context disposal are idempotent and attempt every release before aggregating failures. This seam keeps the continuation manager unaware of tool names. The report package installs only `report`; `@deepseek-ai/dsh-tool-subagent-control` independently installs parent-side `send_message` and `list_agents`. A deployment can install either direction, both, or neither. Providers remain data-only, durable descriptors do not snapshot report availability or delivery mode, and cold resume uses the deployment's current contributions and policy. @@ -94,6 +94,10 @@ Mutating or cold-resuming an absent parent requires a new durable addressing, au A result-bearing wrapper makes one report or one turn appear terminal and recreates the lifetime mismatch that continuable Activations removed. Explicit repeatable sends need no intermediate execution object. +### Validate setup after Agent creation + +A post-creation revocation check can reject the Activation only after the Agent and Session have been published. Disposing the returned handle removes the live objects but cannot delete persistence through the current seam, leaving a resumable child that the continuation manager said was never established. Returning an `AgentSetupCommit` instead lets the Agent factory perform the same mutable-state check synchronously at its publication boundary. + ## Consequences - A continuable in-process child exposes exactly one scope-local `report` schema only while the report package's contribution is installed; unrelated Agents never expose it. @@ -112,5 +116,3 @@ The acceptance boundary is weaker than durable end-to-end delivery. A crash can Wakeup mode can amplify model work when nested children report frequently. Deployment ownership and a quiet default limit but do not remove that risk. Registry presence is the parent liveness signal. A host-owned parent whose `AgentHandle.dispose()` has started but has not yet unwound its scope can still accept and append a report that it will not act on in this process. Closing that gap requires an Agent-level disposal-start signal rather than subagent-layer inference. - -The final setup-revocation check runs after `ctx.agents.create()` or `ctx.agents.resume()` returns, after lower-level Agent and Session publication. Revocation in this window rolls back the handle and prevents the subagent Activation start edge but may leave a persisted Session. Moving the cutoff before lower-level publication requires a future Agent-creation setup transaction seam. diff --git a/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.zh.md b/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.zh.md index bb0b1847f1..e7599c1d85 100644 --- a/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.zh.md @@ -54,7 +54,7 @@ root、one-shot child、伪造对象、陈旧 Agent 和同 id 替换对象都以 subagent seam 新增 `registerContinuableSetup(contribution): () => void`,由 `SubagentActivationSetupRegistry` 支撑。每个同步贡献都会接收尚未发布的 child 上下文,并返回其安装的 disposer。继续执行管理器首先应用基础 child 组合,然后通过同一个用于首次创建与冷恢复的设置闭包,按注册顺序应用当前贡献。 -注册表负责注册、每个 child 的安装记录、设置回滚、child 作用域清理和立即撤销。某项贡献抛出异常或被并发撤销时,会在 Activation 发布前拒绝操作并回滚该批次。新注册项只会在驻留 child 的下一个 Activation 生效;移除注册项时,会先将它对新设置关闭,再立即撤销为正在配置或驻留的每个 child 安装的实例。注册 dispose 与 child 上下文 dispose 都是幂等的,两者都会先尝试每项释放,再聚合失败。 +注册表负责注册、每个 child 的安装记录、设置回滚、child 作用域清理和立即撤销。应用一个批次会返回 Agent setup 提交对象,用于在所有 setup 的 await 均结算后、紧邻 Agent 发布前重新校验配置状态。因此,某项贡献抛出异常或被并发撤销时,会在 Agent 与 Session 发布前拒绝操作并回滚该批次。新注册项只会在驻留 child 的下一个 Activation 生效;移除注册项时,会先将它对新设置关闭,再立即撤销为正在配置或驻留的每个 child 安装的实例。注册 dispose 与 child 上下文 dispose 都是幂等的,两者都会先尝试每项释放,再聚合失败。 该 seam 使继续执行管理器无需知道工具名。report 包只安装 `report`;`@deepseek-ai/dsh-tool-subagent-control` 则独立安装 parent 侧的 `send_message` 和 `list_agents`。部署时可安装任一方向、同时安装两者或两者均不安装。提供方仍只负责数据,持久化描述符不会对 report 可用性或投递模式建立快照,冷恢复则使用部署当前的贡献与策略。 @@ -94,6 +94,10 @@ ACP(Agent Client Protocol)快照 harness 新增 `waitForSubagentTurnEnd`, 承载结果的包装层会让一次报告或一个轮次看似具有终止性,并重新引入可继续 Activation 已经移除的生命周期不匹配。显式、可重复的发送无需中间执行对象。 +### 在 Agent 创建后校验 setup + +创建完成后的撤销检查只能在 Agent 与 Session 均已发布后拒绝 Activation。对返回的 handle 执行 dispose 会移除实时对象,但当前 seam 无法删除持久化内容,因此会留下一个仍可恢复的 child,而继续执行管理器却判定它从未建立。改为返回 `AgentSetupCommit`,Agent 工厂便可在自身的发布边界同步执行同一项可变状态检查。 + ## 影响 - 只有安装 report 包贡献时,可继续进程内 child 才会恰好暴露一个作用域局部 `report` schema;无关 Agent 永远不会暴露该 schema。 @@ -112,5 +116,3 @@ ACP(Agent Client Protocol)快照 harness 新增 `waitForSubagentTurnEnd`, wakeup 模式可能在嵌套 child 频繁报告时放大模型工作量。由部署所有者控制并默认静默,可以限制该风险,但无法完全消除。 注册表中的存在性就是 parent 在线信号。宿主拥有的 parent 如果已开始 `AgentHandle.dispose()` 但尚未展开其作用域,仍可能接受并追加一条本进程不会再处理的报告。要弥合这个缺口,需要 Agent 层面的 dispose 开始信号,不能由 subagent 层推断。 - -最终 setup 撤销检查发生在 `ctx.agents.create()` 或 `ctx.agents.resume()` 返回之后,此时底层 Agent 和 Session 已经发布。在该窗口内撤销会回滚 handle,并阻止 subagent Activation 的 start 边,但可能留下持久化 Session。若要把截止点移到底层发布之前,需要未来提供 Agent 创建 setup 事务 seam。 diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 576ef05d32..a0d59b2993 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.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/architecture.md -architecture.md: 6aa942ba2702d8d30ae94d9968f07abb5e1fe88d -architecture.zh.md: c8aaa68527f34f4879f882a08260a4e0bd4f4c5f +architecture.md: add76b6016ae5243115e88173971468a2c93287f +architecture.zh.md: 595f14566cac4301a0b52ded1140511cab948d90 diff --git a/docs/architecture.md b/docs/architecture.md index 6aa942ba27..add76b6016 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -76,7 +76,7 @@ Creation without an id mints `-session-`; `sessionId` resumes o ```text choose declarative identity and fresh/resume path - -> prepare private session + agent.ctx -> await unpublished setup + -> prepare private session + agent.ctx -> await unpublished setup -> invoke optional synchronous setup commit -> enter session + agent -> session/created -> agent/created -> enable driving -> agent/session-start(source) -> start driver forever: @@ -139,7 +139,7 @@ Turn and step events are turn-enclosed. Idle `user/message` and standalone `comp ### Agent Scope -Each agent owns scoped `agent.ctx`; shared storage overlays its tool, prompt, and command entries on globals while preserving domain views ([decision](../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md)). Scoped listeners filter dispatch; contributions unwind with awaited cleanup. `CreateAgentOptions.setup(agentCtx)` composes before publication. Typed resolvers derive carrier checks from merged `Events` and `scopeTarget` ([semantic gates](../.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md)). Details: [agent scope](../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md), [subagent composition](../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md). `AgentLoop` runs under `ctx.agents.withInitiator()`; private orchestration derives `agent.session`, but turn, step, signal, cwd, and authority stay explicit ([decision](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md)). +Each agent owns scoped `agent.ctx`; shared storage overlays its tool, prompt, and command entries on globals while preserving domain views ([decision](../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md)). Scoped listeners filter dispatch; contributions unwind with awaited cleanup. `CreateAgentOptions.setup(agentCtx)` composes before publication and may return a synchronous commit that the factory invokes immediately before registry entry, after every setup await. Typed resolvers derive carrier checks from merged `Events` and `scopeTarget` ([semantic gates](../.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md)). Details: [agent scope](../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md), [subagent composition](../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md). `AgentLoop` runs under `ctx.agents.withInitiator()`; private orchestration derives `agent.session`, but turn, step, signal, cwd, and authority stay explicit ([decision](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md)). ## State diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index c8aaa68527..595f14566c 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -76,7 +76,7 @@ waterfall(瀑布式事件)是环绕中间件:监听器通过 `next()` 委 ```text choose declarative identity and fresh/resume path - -> prepare private session + agent.ctx -> await unpublished setup + -> prepare private session + agent.ctx -> await unpublished setup -> invoke optional synchronous setup commit -> enter session + agent -> session/created -> agent/created -> enable driving -> agent/session-start(source) -> start driver forever: @@ -139,7 +139,7 @@ idle inject: ### Agent 作用域 -每个 agent 都拥有作用域化的 `agent.ctx`;共享存储会将其工具、提示词和命令条目叠加到全局条目之上,同时保留各领域视图([决策](../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md))。作用域监听器会过滤分派;贡献都会在撤销时等待清理完成。`CreateAgentOptions.setup(agentCtx)` 在发布前完成组合。类型化解析器从合并后的 `Events` 和 `scopeTarget` 推导载体检查([语义门禁](../.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md))。详情见 [agent 作用域](../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md)和 [subagent 组合](../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md)。`AgentLoop` 在 `ctx.agents.withInitiator()` 内运行;私有编排会派生 `agent.session`,但轮次、步骤、信号、cwd 和权限仍保持显式([决策](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md))。 +每个 agent 都拥有作用域化的 `agent.ctx`;共享存储会将其工具、提示词和命令条目叠加到全局条目之上,同时保留各领域视图([决策](../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md))。作用域监听器会过滤分派;贡献都会在撤销时等待清理完成。`CreateAgentOptions.setup(agentCtx)` 在发布前完成组合,并可返回一个同步提交操作;所有 setup 的 await 均完成后,工厂会在进入注册表前立即调用该操作。类型化解析器从合并后的 `Events` 和 `scopeTarget` 推导载体检查([语义门禁](../.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md))。详情见 [agent 作用域](../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md)和 [subagent 组合](../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md)。`AgentLoop` 在 `ctx.agents.withInitiator()` 内运行;私有编排会派生 `agent.session`,但轮次、步骤、信号、cwd 和权限仍保持显式([决策](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md))。 ## 状态 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 96b876a0eb..4744710ee3 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -216,7 +216,7 @@ roots(): Agent[] Types: [Agent](../core-data-structures/core.md) · [SessionId](../core-data-structures/core.md) -Source: [`packages/core/agent/src/index.ts:217`](../../packages/core/agent/src/index.ts) +Source: [`packages/core/agent/src/index.ts:242`](../../packages/core/agent/src/index.ts) ## `ctx.approval` — `ApprovalService` diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index a2a1bf3c5d..52610d78ad 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1607,6 +1607,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'AgentOptions', declaration: 'export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n}', }, + { + name: 'AgentSetup', + declaration: 'export type AgentSetup = (agentCtx: Context) => AgentSetupCommit | Promise | void;', + }, + { + name: 'AgentSetupCommit', + declaration: 'export interface AgentSetupCommit {\n commit(): void;\n}', + }, { name: 'AgentStatus', declaration: 'export type AgentStatus = \'idle\' | \'running\';', @@ -1833,7 +1841,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'CreateAgentOptions', - declaration: 'export interface CreateAgentOptions {\n readonly sessionId: SessionId;\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n };\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise | void;\n}', + declaration: 'export interface CreateAgentOptions {\n readonly sessionId: SessionId;\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n };\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: AgentSetup;\n}', }, { name: 'CreateGoalRequest', @@ -2337,7 +2345,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ResumeAgentOptions', - declaration: 'export interface ResumeAgentOptions {\n readonly resumeSessionId: SessionId;\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise | void;\n}', + declaration: 'export interface ResumeAgentOptions {\n readonly resumeSessionId: SessionId;\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: AgentSetup;\n}', }, { name: 'SandboxEnforcement', diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml index 6e9e63a22f..00643acbbc 100644 --- a/packages/core/agent-loop/README.i18n.yaml +++ b/packages/core/agent-loop/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/core/agent-loop/README.md -README.md: 1662b1076cc116888d048cb6af1be1c7ab8196f6 -README.zh.md: 2fca32a02fdd73961c912c988933e1cd1a1a5817 +README.md: f146ea3d2cc379303514286c5cfd2833397f23ed +README.zh.md: 767fbd420709be05e9a8f85dc8ec6fe03ae6aa66 diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 1662b1076c..f146ea3d2c 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -10,7 +10,7 @@ This is the only package in the harness that contains concrete loop logic. Every ### Public API -Creation and resume are one rollback-covered transaction: construct a private session, concrete agent, and scoped context; await optional setup; enter both registries; announce `session/created` then `agent/created`; emit `agent/session-start`; and only then start the driver. Setup receives the full scoped `Context` as trusted same-process composition code and must not drive the unpublished agent. Ordinary typed identity and option inputs are borrowed under their readonly contract, while seed events and session metadata are validated and snapshotted because they cross the durable session boundary. An optional `AbortSignal` cancels only load/setup/publication and is detached before the returned handle becomes visible. +Creation and resume are one rollback-covered transaction: construct a private session, concrete agent, and scoped context; await optional setup; synchronously invoke its optional publication commit; enter both registries; announce `session/created` then `agent/created`; emit `agent/session-start`; and only then start the driver. Setup receives the full scoped `Context` as trusted same-process composition code and must not drive the unpublished agent. Its optional commit revalidates mutable provisioning after every setup await and immediately before registry entry; a throw rolls the private transaction back without publishing either id. Ordinary typed identity and option inputs are borrowed under their readonly contract, while seed events and session metadata are validated and snapshotted because they cross the durable session boundary. An optional `AbortSignal` cancels only load/setup/publication and is detached before the returned handle becomes visible. The caller fiber and the AgentLoop provider are co-owners. `AgentFactory.createAgent(ownerCtx, options)` and `resume(ownerCtx, options)` receive caller ownership explicitly, while the factory keeps its own dependency context for `sessions`/`llm`/`tools`/`systemPrompt`; this lets a caller inject only `agents` without shrinking the new agent's service surface. Caller unload, handle disposal, or provider unload converge on one memoized quiescence boundary. Provider shutdown waits both resource teardown and the public create/resume wrapper that observed deactivation, so no continuation can publish after dependencies disappear. @@ -20,8 +20,8 @@ Each agent and its session share one caller-chosen `SessionId`, assumed globally `AgentLoop` also implements the `AgentFactory` seam and registers itself via `ctx.agents.setFactory(this)`, so plugins create/resume agents through `ctx.agents` (the interface): -- `ctx.agents.create({ sessionId, meta?, seed?, agentOptions?, setup?, signal? }): Promise` — programmatic create under the caller-supplied shared id. It awaits the unpublished setup transaction before returning; `meta` carries cwd/lineage/seed-boundary metadata and `seed` reconstructs a forked child prefix after the session boundary validates and snapshots the durable values. `signal` applies only until this promise settles. The resolved [`AgentHandle`](../agent/README.md) owns exact teardown. -- `ctx.agents.resume({ resumeSessionId, agentOptions?, setup?, signal? }): Promise` — load a persisted session via `ctx.sessionPersistence` ([session persistence](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)), register the agent under that same id, reconstruct its history, then await setup against a fresh unpublished agent scope before rollback-covered publication. Turn numbering and derived history continue from the loaded log. Requires a session-persistence backend (NOT hard-injected — non-persistent demos still work; `resume` rejects with a clear error when persistence is absent). `signal` is creation-only. Returns an `AgentHandle`. +- `ctx.agents.create({ sessionId, meta?, seed?, agentOptions?, setup?, signal? }): Promise` — programmatic create under the caller-supplied shared id. It awaits unpublished setup, invokes its optional synchronous commit at the publication boundary, and then enters both registries; `meta` carries cwd/lineage/seed-boundary metadata and `seed` reconstructs a forked child prefix after the session boundary validates and snapshots the durable values. `signal` applies only until this promise settles. The resolved [`AgentHandle`](../agent/README.md) owns exact teardown. +- `ctx.agents.resume({ resumeSessionId, agentOptions?, setup?, signal? }): Promise` — load a persisted session via `ctx.sessionPersistence` ([session persistence](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)), reconstruct its history under the same id, await setup against a fresh unpublished agent scope, invoke its optional synchronous commit, then use the same rollback-covered publication sequence. Turn numbering and derived history continue from the loaded log. Requires a session-persistence backend (NOT hard-injected — non-persistent demos still work; `resume` rejects with a clear error when persistence is absent). `signal` is creation-only. Returns an `AgentHandle`. The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loop fiber (it discards the handle). For a programmatic agent, the handle holder is the only consumer-facing teardown capability; AgentLoop provider unload is the independent structural teardown edge, not another handle exposed to application code. diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md index 2fca32a02f..767fbd4207 100644 --- a/packages/core/agent-loop/README.zh.md +++ b/packages/core/agent-loop/README.zh.md @@ -10,7 +10,7 @@ ### 公开 API -创建与恢复属于同一个受回滚保护的事务:构造私有会话、实体 agent 和带作用域的上下文;等待可选 setup;进入两个注册表;依次宣告 `session/created` 和 `agent/created`;发出 `agent/session-start`;此后才启动驱动器。Setup 接收完整的带作用域 `Context`,作为受信任的同进程组合代码,并且不得驱动尚未发布的 agent。普通的类型化身份与选项输入遵循只读契约以借用方式传入;seed 事件与会话元数据会跨越持久会话边界,因此系统会验证并快照它们。可选的 `AbortSignal` 只取消加载/setup/发布,并在返回的 handle 可见前分离。 +创建与恢复属于同一个受回滚保护的事务:构造私有会话、实体 agent 和带作用域的上下文;等待可选 setup;同步调用其可选的发布提交;进入两个注册表;依次宣告 `session/created` 和 `agent/created`;发出 `agent/session-start`;此后才启动驱动器。Setup 接收完整的带作用域 `Context`,作为受信任的同进程组合代码,并且不得驱动尚未发布的 agent。其可选提交会在所有 setup 的 await 均结算后、进入注册表之前立即重新校验可变的配置状态;若其抛出异常,则回滚私有事务且不发布任何一个 id。普通的类型化身份与选项输入遵循只读契约以借用方式传入;seed 事件与会话元数据会跨越持久会话边界,因此系统会验证并快照它们。可选的 `AbortSignal` 只取消加载/setup/发布,并在返回的 handle 可见前分离。 调用方 fiber 与 AgentLoop 提供方共同拥有 agent。`AgentFactory.createAgent(ownerCtx, options)` 与 `resume(ownerCtx, options)` 显式接收调用方所有权,而工厂为 `sessions`/`llm`/`tools`/`systemPrompt` 保留自身的依赖上下文;这样,调用方可以只注入 `agents`,而不会缩减新 agent 的服务接口。调用方卸载、handle dispose(资源释放)或提供方卸载都会汇合到同一个记忆化的完全停稳边界。提供方关闭会同时等待资源 teardown,以及已经观测到停用的公开 create/resume 包装层,因此依赖消失后,任何 continuation 都无法继续发布。 @@ -20,8 +20,8 @@ `AgentLoop` 还实现 `AgentFactory` seam,并通过 `ctx.agents.setFactory(this)` 注册自身,因此插件会通过接口 `ctx.agents` 创建/恢复 agent: -- `ctx.agents.create({ sessionId, meta?, seed?, agentOptions?, setup?, signal? }): Promise`:使用调用方提供的共享 id 以编程方式创建。它会等待尚未发布的 setup 事务,然后才返回;`meta` 携带 cwd/谱系/seed 边界元数据,`seed` 则在会话边界验证并快照持久值后,重建 fork 子级的前缀。`signal` 只在此 Promise 结算前生效。解析得到的 [`AgentHandle`](../agent/README.md) 拥有确切的 teardown。 -- `ctx.agents.resume({ resumeSessionId, agentOptions?, setup?, signal? }): Promise`:通过 `ctx.sessionPersistence` 加载持久化会话(参见[会话持久化](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)),使用同一 id 注册 agent,重建历史,然后针对全新且尚未发布的 agent 作用域等待 setup,再执行受回滚保护的发布。轮次编号和派生历史从已加载日志继续。此操作要求存在会话持久化后端(不会硬注入,因此非持久化 demo 仍能工作;缺少持久化时,`resume` 会以明确错误拒绝)。`signal` 仅用于创建。返回 `AgentHandle`。 +- `ctx.agents.create({ sessionId, meta?, seed?, agentOptions?, setup?, signal? }): Promise`:使用调用方提供的共享 id 以编程方式创建。它会等待尚未发布的 setup,在发布边界调用其可选的同步提交,然后进入两个注册表;`meta` 携带 cwd/谱系/seed 边界元数据,`seed` 则在会话边界验证并快照持久值后,重建 fork 子级的前缀。`signal` 只在此 Promise 结算前生效。解析得到的 [`AgentHandle`](../agent/README.md) 拥有确切的 teardown。 +- `ctx.agents.resume({ resumeSessionId, agentOptions?, setup?, signal? }): Promise`:通过 `ctx.sessionPersistence` 加载持久化会话(参见[会话持久化](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)),在同一 id 下重建历史,针对全新且尚未发布的 agent 作用域等待 setup,调用其可选的同步提交,然后使用相同的受回滚保护发布序列。轮次编号和派生历史从已加载日志继续。此操作要求存在会话持久化后端(不会硬注入,因此非持久化 demo 仍能工作;缺少持久化时,`resume` 会以明确错误拒绝)。`signal` 仅用于创建。返回 `AgentHandle`。 配置驱动的 `ctx.agentLoop.create()` 路径让循环 fiber 拥有其 agent(该路径会丢弃 handle)。对于以编程方式创建的 agent,handle 持有者是唯一面向消费方的 teardown 能力;AgentLoop 提供方卸载是一条独立的结构化 teardown 边,而不是向应用代码公开的另一个 handle。 diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 1342fbd885..54b8f5c957 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -558,7 +558,10 @@ export class AgentLoop extends Service implements AgentFactory { const prepared = this.prepare(ownerCtx, options.sessionId, options.agentOptions ?? {}, session, options.signal) const published = (async () => { try { - await raceAbort(options.setup?.(prepared.agent.ctx), prepared.signal, options.sessionId) + const setupCommit = await raceAbort( + options.setup?.(prepared.agent.ctx), prepared.signal, options.sessionId, + ) + setupCommit?.commit() return prepared.publish('startup') } catch (error: unknown) { await prepared.dispose() @@ -617,7 +620,8 @@ export class AgentLoop extends Service implements AgentFactory { }) const prepared = this.prepare(ownerCtx, id, options.agentOptions ?? {}, session, options.signal) try { - await raceAbort(options.setup?.(prepared.agent.ctx), prepared.signal, id) + const setupCommit = await raceAbort(options.setup?.(prepared.agent.ctx), prepared.signal, id) + setupCommit?.commit() return prepared.publish('resume') } catch (error: unknown) { await prepared.dispose() diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index 7844aebd73..0bb7ce7d2e 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -291,6 +291,13 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', setupStarted.resolve(undefined) await gate.promise order.push('setup:end') + return { + commit: () => { + expect(ctx.agents.get(sessionId)).toBeUndefined() + expect(ctx.sessions.get(sessionId)).toBeUndefined() + order.push('setup:commit') + }, + } }, }) @@ -304,6 +311,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', expect(order).toEqual([ 'setup:start', 'setup:end', + 'setup:commit', 'session/created', 'setup-listener:session/created', 'agent/created', @@ -359,6 +367,33 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', await ctx.fiber.dispose() }) + it('resume setup commit rejection publishes nothing and releases the identity', async () => { + const sessionId = SessionId('resume-setup-commit-reject') + const root = await persistSession(sessionId) + const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')])) + const published: string[] = [] + ctx.on('session/created', () => void published.push('session/created')) + ctx.on('agent/created', () => void published.push('agent/created')) + + await expect(ctx.agents.resume({ + resumeSessionId: sessionId, + agentOptions: { provider: 'mock', model: 'mock' }, + setup: () => ({ + commit: () => { throw new Error('resume setup commit failed') }, + }), + })).rejects.toThrow('resume setup commit failed') + + expect(published).toEqual([]) + expect(ctx.agents.get(sessionId)).toBeUndefined() + expect(ctx.sessions.get(sessionId)).toBeUndefined() + const retry = await ctx.agents.resume({ + resumeSessionId: sessionId, + agentOptions: { provider: 'mock', model: 'mock' }, + }) + await retry.dispose() + await ctx.fiber.dispose() + }) + it('owner unload aborts resume setup and cannot publish after the callback settles', async () => { const sessionId = SessionId('resume-setup-owner-unload') const root = await persistSession(sessionId) diff --git a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts index 5f4e51784a..2420d20e8f 100644 --- a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts @@ -264,6 +264,13 @@ describe('agent scope lifecycle', () => { setupStarted.resolve(undefined) await gate.promise order.push('setup:end') + return { + commit: () => { + expect(ctx.agents.get(SessionId('atomic'))).toBeUndefined() + expect(ctx.sessions.get(SessionId('atomic'))).toBeUndefined() + order.push('setup:commit') + }, + } }, }) await setupStarted.promise @@ -276,6 +283,7 @@ describe('agent scope lifecycle', () => { expect(order).toEqual([ 'setup:start', 'setup:end', + 'setup:commit', 'session/created', 'setup-listener:session/created', 'agent/created', diff --git a/packages/core/agent/README.i18n.yaml b/packages/core/agent/README.i18n.yaml index 78473df4a7..1461dcf6da 100644 --- a/packages/core/agent/README.i18n.yaml +++ b/packages/core/agent/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/core/agent/README.md -README.md: 8a6028352127c4638c0b5e0e3ee85964d1d7d734 -README.zh.md: ffa71ea987ab355ff2f30b6164376199cd5d0170 +README.md: 5f9fdc44794a562c2b0da39e1a43a01aa19c451b +README.zh.md: f088b8c354f0aec1b51d7ff8ae706ddc49265a20 diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 8a60283521..5f9fdc4479 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -12,7 +12,7 @@ Tracks live agents and carries the initiating Agent through asynchronous driver ### Public API -The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher for ordinary agent-subject operations (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. The registry lifecycle pair reuses one stable routing carrier. `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `installAgentLlmTarget(agentCtx, target)` snapshots a mutable provider/model/reasoning-effort selection during prompt assembly, applies the route to prompt variables, and applies the complete target to request routing for one step; an absent selected effort clears an inherited effort so the target uses adapter/provider defaults. `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while both objects remain unpublished. Setup is trusted, composition-only same-process code: drive the agent only after creation resolves. +The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher for ordinary agent-subject operations (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. The registry lifecycle pair reuses one stable routing carrier. `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `installAgentLlmTarget(agentCtx, target)` snapshots a mutable provider/model/reasoning-effort selection during prompt assembly, applies the route to prompt variables, and applies the complete target to request routing for one step; an absent selected effort clears an inherited effort so the target uses adapter/provider defaults. `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while both objects remain unpublished. Setup may return an `AgentSetupCommit`; after every setup await settles, the factory invokes its synchronous `commit()` immediately before registry entry, and a throw rolls the private transaction back without publishing either id. Setup remains trusted, composition-only same-process code: drive the agent only after creation resolves. `AgentOptions` supplies the initial provider/model route and an optional positive `maxTokens` output cap. The concrete loop resolves any exact-model adapter default, records the effective cap in the request header, and applies it to each conversation-model request; an explicit Agent option wins, while omission leaves the adapter or provider route default in control. @@ -39,8 +39,8 @@ The scope carries the `Agent` itself and is process-local. Ambient presence is n Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-agent-loop`), registered via `setFactory`. This keeps creation on the `dsh-agent` interface so consumers (UI, the ACP bridge) program against `ctx.agents` without depending on the concrete loop package. The registry canonicalizes an already traced Service to its concrete target and re-traces each call through the caller's context; this avoids nested Cordis shadows while passing an explicit caller-bound `ownerCtx` to plain factories. - `ctx.agents.setFactory(factory: AgentFactory): () => void` — register the creation factory (the loop calls this on construction). Throws on a second factory; the slot clears on dispose. -- `ctx.agents.create(options: CreateAgentOptions): Promise` — create a session and agent, await optional setup while unpublished, then publish through final `SessionStore.enter()` and `AgentRegistry.enter()` checks. Concurrent same-ID creation is unsupported: more than one operation may prepare, but only one can enter; every loser rolls its private scope/session/driver back. An optional creation-only `signal` cancels unpublished setup and is detached before the handle is returned; later cancellation uses `handle.dispose()` or `agent.cancel()`. Publication is rollback-covered and every delivered creation edge is paired during rollback. Rejects if no factory is registered. -- `ctx.agents.resume(options: ResumeAgentOptions): Promise` — load a persisted session ([session persistence](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)), mint a fresh unpublished agent scope, await optional setup, and use the same final-entry publication sequence. Its optional `signal` is likewise creation-only. Rejects if no factory is registered or session persistence is unconfigured. +- `ctx.agents.create(options: CreateAgentOptions): Promise` — create a session and agent, await optional setup while unpublished, invoke its optional synchronous commit, then publish through final `SessionStore.enter()` and `AgentRegistry.enter()` checks. Concurrent same-ID creation is unsupported: more than one operation may prepare, but only one can enter; every loser rolls its private scope/session/driver back. An optional creation-only `signal` cancels unpublished setup and is detached before the handle is returned; later cancellation uses `handle.dispose()` or `agent.cancel()`. Publication is rollback-covered and every delivered creation edge is paired during rollback. Rejects if no factory is registered. +- `ctx.agents.resume(options: ResumeAgentOptions): Promise` — load a persisted session ([session persistence](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)), mint a fresh unpublished agent scope, await optional setup, invoke its optional synchronous commit, and use the same final-entry publication sequence. Its optional `signal` is likewise creation-only. Rejects if no factory is registered or session persistence is unconfigured. `AgentHandle = { agent: Agent; dispose(): Promise }`. The disposer is a **consumer capability** — no observer holding the bare registry entry can tear the agent down. The caller fiber and the registered factory provider are structural co-owners: caller unload enforces structured ownership, while factory unload must stop old instances because their scoped dependency surface belongs to that provider. `dispose()` from any owner reaches one memoized quiescence boundary: it stops the loop, awaits its exit, unregisters the agent, removes its session from the store, and finally unwinds its scoped world. `ctx.agents.get(id)` still returns a bare `Agent`; the ACP bridge and in-process subagent backends hold consumer handles, while config-created agents are already owned by the loop fiber. diff --git a/packages/core/agent/README.zh.md b/packages/core/agent/README.zh.md index ffa71ea987..f088b8c354 100644 --- a/packages/core/agent/README.zh.md +++ b/packages/core/agent/README.zh.md @@ -12,7 +12,7 @@ Agent 接口、注册表、进程本地发起方作用域,以及 `agent/*` 事 ### 公开 API -带作用域的注册接口:`Agent.ctx` 是 agent 的作用域上下文(`dsh-scope`,键 = 该 agent)。通过它注册工具/段/变量/监听器,只对该 agent 生效,并在 dispose(资源释放)时全部撤销。`agentEvents(ctx, agent)` 是普通 agent 主体操作的融合分发器(一次完成载体 + 注入主体);其通知 mode 会调用每个监听器,并同时收容同步抛出和返回 Promise 的拒绝。注册表生命周期对复用一个稳定路由载体。`assembleContextFor(agent)` 构建按 agent 的组装上下文(同时包含 `agent` + `scope`)。`installAgentLlmTarget(agentCtx, target)` 在提示词组装期间快照可变的提供方/模型/推理(reasoning)强度选择,将路由应用到提示词变量,并将完整目标应用到一个步骤的请求路由;如果没有选定推理强度,则会清除继承的推理强度,使该目标使用适配器/提供方默认值。`CreateAgentOptions.setup(agentCtx)` 和 `ResumeAgentOptions.setup(agentCtx)` 在新建或恢复的 agent 尚未发布时,组合其带作用域的世界。Setup 是受信任、仅用于组合的同进程代码:只有创建完成后才能驱动 agent。 +带作用域的注册接口:`Agent.ctx` 是 agent 的作用域上下文(`dsh-scope`,键 = 该 agent)。通过它注册工具/段/变量/监听器,只对该 agent 生效,并在 dispose(资源释放)时全部撤销。`agentEvents(ctx, agent)` 是普通 agent 主体操作的融合分发器(一次完成载体 + 注入主体);其通知 mode 会调用每个监听器,并同时收容同步抛出和返回 Promise 的拒绝。注册表生命周期对复用一个稳定路由载体。`assembleContextFor(agent)` 构建按 agent 的组装上下文(同时包含 `agent` + `scope`)。`installAgentLlmTarget(agentCtx, target)` 在提示词组装期间快照可变的提供方/模型/推理(reasoning)强度选择,将路由应用到提示词变量,并将完整目标应用到一个步骤的请求路由;如果没有选定推理强度,则会清除继承的推理强度,使该目标使用适配器/提供方默认值。`CreateAgentOptions.setup(agentCtx)` 和 `ResumeAgentOptions.setup(agentCtx)` 在新建或恢复的 agent 尚未发布时,组合其带作用域的世界。Setup 可以返回一个 `AgentSetupCommit`;所有 setup 的 await 均结算后,工厂会在进入注册表前立即调用其同步 `commit()`,若其抛出异常,则回滚私有事务且不发布任何一个 id。Setup 仍是受信任、仅用于组合的同进程代码:只有创建完成后才能驱动 agent。 `AgentOptions` 提供初始的提供方/模型路由,以及可选的正数 `maxTokens` 输出上限。实体循环会解析确切模型的适配器默认值,把生效上限记录到请求 header,并应用到每次对话模型请求;显式 Agent 选项优先,省略时由适配器或提供方路由默认值控制。 @@ -39,8 +39,8 @@ Agent 接口、注册表、进程本地发起方作用域,以及 `agent/*` 事 Agent *创建* 由实现 `AgentFactory` 的插件(`dsh-agent-loop`)提供,并通过 `setFactory` 注册。这样,创建功能留在 `dsh-agent` 接口上,消费方(UI、ACP 桥接层)可以面向 `ctx.agents` 编程,而不依赖具体循环包。注册表会把已经 traced 的 Service 规范化为具体目标,并通过调用方上下文重新 trace 每次调用;这既避免嵌套 Cordis shadow,也会把显式、绑定调用方的 `ownerCtx` 传给普通工厂。 - `ctx.agents.setFactory(factory: AgentFactory): () => void`:注册创建工厂(循环在构造时调用)。第二个工厂会导致抛出;dispose 时清空槽位。 -- `ctx.agents.create(options: CreateAgentOptions): Promise`:创建会话和 agent,在不发布的情况下等待可选 setup,然后通过最终的 `SessionStore.enter()` 与 `AgentRegistry.enter()` 检查发布。不支持并发创建同一 ID:多个操作可以进行准备,但只有一个能进入;每个失败方都会回滚其私有作用域/会话/驱动器。可选且只用于创建的 `signal` 会取消未发布的 setup,并在返回 handle 前分离;之后的取消使用 `handle.dispose()` 或 `agent.cancel()`。发布包含在回滚范围内,回滚期间每条已交付创建边都会成对处理。未注册工厂时拒绝。 -- `ctx.agents.resume(options: ResumeAgentOptions): Promise`:加载持久化会话([会话持久化](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)),创建新的未发布 agent 作用域,等待可选 setup,并使用相同的最终进入发布序列。其可选 `signal` 同样只用于创建。未注册工厂或未配置会话持久化时拒绝。 +- `ctx.agents.create(options: CreateAgentOptions): Promise`:创建会话和 agent,在不发布的情况下等待可选 setup,调用其可选的同步提交,然后通过最终的 `SessionStore.enter()` 与 `AgentRegistry.enter()` 检查发布。不支持并发创建同一 ID:多个操作可以进行准备,但只有一个能进入;每个失败方都会回滚其私有作用域/会话/驱动器。可选且只用于创建的 `signal` 会取消未发布的 setup,并在返回 handle 前分离;之后的取消使用 `handle.dispose()` 或 `agent.cancel()`。发布包含在回滚范围内,回滚期间每条已交付创建边都会成对处理。未注册工厂时拒绝。 +- `ctx.agents.resume(options: ResumeAgentOptions): Promise`:加载持久化会话([会话持久化](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)),创建新的未发布 agent 作用域,等待可选 setup,调用其可选的同步提交,并使用相同的最终进入发布序列。其可选 `signal` 同样只用于创建。未注册工厂或未配置会话持久化时拒绝。 `AgentHandle = { agent: Agent; dispose(): Promise }`。Disposer 是一项 **消费方能力**;仅持有裸注册表条目的观察方不能 teardown agent。调用方 fiber 和已注册工厂提供方是结构化共同拥有者:调用方卸载会强制结构化所有权,而工厂卸载必须停止旧实例,因为它们的作用域依赖范围属于该提供方。任意拥有者调用 `dispose()` 都会到达同一个记忆化完全停稳边界:它停止循环,等待循环退出,注销 agent,从存储中移除其会话,最后撤销其作用域世界。`ctx.agents.get(id)` 仍返回裸 `Agent`;ACP 桥接层与进程内 subagent 后端持有消费方 handle,而配置创建的 agent 已由循环 fiber 拥有。 diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index d5c19aae3e..66dee4efb7 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -36,6 +36,27 @@ declare module 'cordis' { } } +/** + * Synchronous finalizer returned by unpublished Agent setup when its + * contributions need validation at the exact publication commit point. + */ +export interface AgentSetupCommit { + /** + * Validate and commit the prepared setup immediately before publication. + * @throws when publication must roll the unpublished Agent back. + */ + commit(): void +} + +/** + * Compose an unpublished Agent scope and optionally return its publication commit. + * @param agentCtx - unpublished Agent scope. + * @returns an optional synchronous commit invoked after setup awaits settle and immediately before publication. + */ +export type AgentSetup = ( + agentCtx: Context, +) => AgentSetupCommit | Promise | void + /** * Options for programmatically creating an agent through the registry factory * ({@link AgentRegistry.create}). The caller supplies the single live @@ -80,17 +101,21 @@ export interface CreateAgentOptions { * Creation-time composition of the agent's scoped world. The factory awaits * setup after minting `agentCtx` but BEFORE inserting or announcing either * the session or agent, so observers can never see a partially configured - * world. Everything registered through `agentCtx` (scoped tools, prompt - * sections/variables, `restrict()`, listeners, awaited child plugins) exists - * before `session/created`, `agent/created`, `agent/session-start`, and the - * first prompt assembly. A throw/rejection or owner disposal rolls the scope - * back without publishing either id. + * world. Setup may return an {@link AgentSetupCommit}; the factory invokes its + * synchronous `commit()` after every setup await settles and immediately + * before registry publication. This lets mutable provisioning revalidate at + * the exact publication boundary. Everything registered through `agentCtx` + * (scoped tools, prompt sections/variables, `restrict()`, listeners, awaited + * child plugins) exists before `session/created`, `agent/created`, + * `agent/session-start`, and the first prompt assembly. A setup + * throw/rejection, commit throw, or owner disposal rolls the scope back + * without publishing either id. * * **Setup composes, it never drives**: the callback is trusted same-process * code and receives the full scoped context, so this is a contract rather * than a runtime restriction. Drive the agent only after creation resolves. */ - readonly setup?: (agentCtx: Context) => Promise | void + readonly setup?: AgentSetup } /** @@ -108,12 +133,12 @@ export interface ResumeAgentOptions { * Resume-time composition of the agent's fresh scoped world. Persistence is * loaded first; the factory then mints `agentCtx` and awaits setup while the * reconstructed session and agent remain unpublished. The callback has the - * same trusted composition-only contract as - * {@link CreateAgentOptions.setup}: all registrations exist before either - * creation announcement, and rejection or owner disposal rolls the - * transaction back without publishing either id. + * same trusted composition-only contract and optional synchronous + * publication commit as {@link CreateAgentOptions.setup}: all registrations + * exist before either creation announcement, and rejection, commit failure, + * or owner disposal rolls the transaction back without publishing either id. */ - readonly setup?: (agentCtx: Context) => Promise | void + readonly setup?: AgentSetup } /** @@ -144,9 +169,9 @@ export interface AgentHandle { export interface AgentFactory { /** * Create a new agent on a caller-supplied session id. Async because creation - * awaits unpublished setup, inserts both session and agent, emits their - * creation notifications in order, emits `agent/session-start`, and only - * then starts the loop. The sequence is + * awaits unpublished setup, invokes its optional synchronous commit, inserts + * both session and agent, emits their creation notifications in order, emits + * `agent/session-start`, and only then starts the loop. The sequence is * rollback-covered, but notifications delivered before a later listener * failure remain observable; every agent or session creation announcement * that began is paired by `agent/disposed` or `session/disposed` during @@ -165,8 +190,8 @@ export interface AgentFactory { * Load a persisted session and resume an agent on it. Async because it awaits * both `ctx.sessionPersistence.load` and the optional unpublished setup * transaction; must be called after that service exists (consumers inject - * `sessionPersistence`). Publication follows the same ordered boundary as - * {@link createAgent}. + * `sessionPersistence`). Publication follows the same setup-commit and + * ordered boundary as {@link createAgent}. * @param ownerCtx - caller-bound context that owns load, setup, and the live handle. * @param options - persisted identity, configuration, and optional setup. * @returns the owned handle after setup, both announcements, and loop start complete. diff --git a/packages/subagent/subagent/src/activation-setup-registry.ts b/packages/subagent/subagent/src/activation-setup-registry.ts index dca194f113..3e8ef4fe61 100644 --- a/packages/subagent/subagent/src/activation-setup-registry.ts +++ b/packages/subagent/subagent/src/activation-setup-registry.ts @@ -12,6 +12,7 @@ */ import type { Context } from 'cordis' +import type { AgentSetupCommit } from '@deepseek-ai/dsh-agent' import { errorChain } from '@deepseek-ai/dsh-llm' import { SubagentError } from './error.ts' @@ -47,17 +48,6 @@ interface TransactionState { invalidated: boolean } -/** Package-private setup transaction consumed by the continuation manager. */ -export interface ActivationSetupTransaction { - /** - * Reject a batch invalidated by revocation before publication. - * @throws {SubagentError} code `ACTIVATION_SETUP_REVOKED` after revocation. - */ - assertIntact(): void - /** Promote this batch to resident installations. */ - commit(): void -} - /** Re-read mutable removal state after a contribution may have revoked itself. */ function isRemoved(registration: Registration): boolean { return registration.removed @@ -95,9 +85,9 @@ export class SubagentActivationSetupRegistry { /** * Install every live contribution into one unpublished child context. * @param childCtx - the child's unpublished scoped context. - * @returns the provisioning transaction. + * @returns the provisioning commit consumed at Agent publication. */ - apply(childCtx: Context): ActivationSetupTransaction { + apply(childCtx: Context): AgentSetupCommit { const state: TransactionState = { installations: [], invalidated: false } try { for (const registration of [...this.registrations]) { @@ -138,15 +128,14 @@ export class SubagentActivationSetupRegistry { throw error } return { - assertIntact: () => { - if (!state.invalidated) return - throw new SubagentError( - 'a continuable-subagent setup contribution was revoked while this child was being built; ' - + 'the child was not established', - 'ACTIVATION_SETUP_REVOKED', - ) - }, commit: () => { + if (state.invalidated) { + throw new SubagentError( + 'a continuable-subagent setup contribution was revoked while this child was being built; ' + + 'the child was not established', + 'ACTIVATION_SETUP_REVOKED', + ) + } for (const installation of state.installations) installation.transaction = undefined }, } diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index 82f66d08cb..f690cab6cf 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -20,6 +20,7 @@ import type { Agent, AgentHandle, AgentOptions, + AgentSetupCommit, CreateAgentOptions, } from '@deepseek-ai/dsh-agent' import { createUserMessage, errorChain } from '@deepseek-ai/dsh-llm' @@ -799,19 +800,9 @@ export class SubagentContinuationManager { // `AgentRegistry.enter()` is the authoritative collision boundary for an id // some other owner holds — a duplicate would reject there with rollback. inputs.signal.throwIfAborted() - const setup = (childCtx: Context): void => { + const setup = (childCtx: Context): AgentSetupCommit => { applyChildComposition(childCtx, inputs.composition) - const setupTransaction = this.setupRegistry.apply(childCtx) - // Validate and freeze the batch inside the creation callback, before the - // factory can publish the session: a revoked contribution must reject - // the create/resume call pre-publication, so no persisted session is - // ever left behind for a child the manager rejects — rollback only - // disposes the live handle, and the persistence seam has no delete, so - // a post-publication rejection would leave a resumable ghost child. - // Committing here also means a later contribution removal releases the - // installation instead of invalidating a child already being established. - setupTransaction.assertIntact() - setupTransaction.commit() + return this.setupRegistry.apply(childCtx) } const observer = this.host.observeActivation(provider, childId, parent) const { create } = inputs @@ -867,9 +858,8 @@ export class SubagentContinuationManager { for (const item of items) activation.accepted.delete(item.message.id) this.wake(activation) }) - // Setup already validated and committed inside the creation callback; - // revocations from here on are immediate live revocation, never - // creation invalidation. + // Agent creation committed setup at its publication boundary; + // revocations from here on are immediate live revocation. // Publish the start edge before any turn can run, so observers see this // epoch before its first request. observer.start(handle.agent) diff --git a/packages/subagent/subagent/tests/activation-setup-registry.spec.ts b/packages/subagent/subagent/tests/activation-setup-registry.spec.ts index 059befe236..6353f486c5 100644 --- a/packages/subagent/subagent/tests/activation-setup-registry.spec.ts +++ b/packages/subagent/subagent/tests/activation-setup-registry.spec.ts @@ -19,8 +19,7 @@ describe('SubagentActivationSetupRegistry', () => { const transaction = registry.apply(child.ctx) expect(order).toEqual(['first', 'second']) - expect(() => { transaction.assertIntact() }).not.toThrow() - transaction.commit() + expect(() => { transaction.commit() }).not.toThrow() expect(order).toEqual(['first', 'second']) }) @@ -68,7 +67,7 @@ describe('SubagentActivationSetupRegistry', () => { remove() expect(disposals).toBe(1) - expect(() => { transaction.assertIntact() }).toThrow(/revoked while this child was being built/) + expect(() => { transaction.commit() }).toThrow(/revoked while this child was being built/) }) it('catches a contribution revoked inside its own installer', () => { @@ -82,7 +81,7 @@ describe('SubagentActivationSetupRegistry', () => { const transaction = registry.apply(childContext().ctx) expect(disposals).toBe(1) - expect(() => { transaction.assertIntact() }).toThrow(/revoked/) + expect(() => { transaction.commit() }).toThrow(/revoked/) }) it('attempts every contribution-removal disposer before reporting failures', () => { diff --git a/packages/subagent/tool-subagent-report/README.i18n.yaml b/packages/subagent/tool-subagent-report/README.i18n.yaml index 389a16e620..869dee55bf 100644 --- a/packages/subagent/tool-subagent-report/README.i18n.yaml +++ b/packages/subagent/tool-subagent-report/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/subagent/tool-subagent-report/README.md -README.md: c1cff4d023e35ff246e592f58b0c85c8a10f327a -README.zh.md: 167a6338e8db9fbb5efce7037392f7c75e48f116 +README.md: cd73154dfb9c8b37f4a811c3beedbe6a63207f58 +README.zh.md: 4b31bed48ea0e50ec3a9d507548658defb94b8b8 diff --git a/packages/subagent/tool-subagent-report/README.md b/packages/subagent/tool-subagent-report/README.md index c1cff4d023..cd73154dfb 100644 --- a/packages/subagent/tool-subagent-report/README.md +++ b/packages/subagent/tool-subagent-report/README.md @@ -58,7 +58,6 @@ Append-only; the report follows the parent's reusable request prefix. Waking del ## Known Limitations and Deferred Work -- **Setup revocation can follow lower-level Session publication** — the final revocation check runs after `ctx.agents.create()` or `ctx.agents.resume()` returns, by which point that call has already published its Agent and Session. Revocation in this window rolls back the handle and prevents the subagent Activation start edge, but may leave a persisted Session. Closing this gap requires a future Agent-creation setup transaction seam before lower-level publication. - **A parent whose host-owned disposal already started can still accept** — `AgentHandle.dispose()` cancels, awaits quiescence, and only then unwinds the scope and leaves the registry; it exposes no signal for "disposal started." A report accepted in that window is appended to the parent's transcript, but that parent will not act on it in this process. A continuation-manager-owned parent rejects forest teardown through the manager's admission boundary. - **Acceptance is weaker than durable delivery** — there is no durable mailbox, idempotency key, delivery receipt, retry protocol, or exactly-once claim. A process failure after one side recorded acceptance leaves the outcome ambiguous, and an external retry may duplicate the report. - **A staged quiet report is not immediately reconstructable** — acceptance returns its stable `MessageId`, but the parent Session reconstructs the framed content only after pending context reaches its ordinary log boundary. diff --git a/packages/subagent/tool-subagent-report/README.zh.md b/packages/subagent/tool-subagent-report/README.zh.md index 167a6338e8..4b31bed48e 100644 --- a/packages/subagent/tool-subagent-report/README.zh.md +++ b/packages/subagent/tool-subagent-report/README.zh.md @@ -58,7 +58,6 @@ ## 已知限制与暂缓事项 -- **setup 撤销可能发生在底层 Session 发布之后**:最终撤销检查发生在 `ctx.agents.create()` 或 `ctx.agents.resume()` 返回之后,此时该调用已发布其 Agent 和 Session。在这个窗口内撤销会回滚 handle,并阻止 subagent Activation 的 start 边,但可能留下持久化 Session。要弥合这个缺口,需要未来在底层发布之前提供 Agent 创建 setup 事务 seam。 - **父级可能在宿主启动 dispose 后继续接受报告**:`AgentHandle.dispose()` 会先取消并等待完全停稳,然后才撤销作用域并离开注册表;它不公开「dispose 已开始」信号。在该窗口内接受的报告会追加到父级 transcript,但该父级不会在本进程中处理它。对于由延续管理器拥有的父级,管理器的准入边界会在整棵子树拆卸期间拒绝该上报。 - **接受弱于持久投递**:没有持久化 mailbox、幂等键、投递回执、重试协议,也不保证恰好一次。任一侧记录接受后若进程失败,结果都不明确;外部重试可能产生重复上报。 - **暂存的静默报告无法立即重建**:接受时会返回其稳定 `MessageId`,但只有当待处理上下文到达普通日志边界后,父级 Session 才能重建带前缀的内容。 diff --git a/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts b/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts index 4d44204fd6..84f33646bb 100644 --- a/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts +++ b/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts @@ -350,6 +350,34 @@ describe('dsh-tool-subagent-report', () => { expect(ctx.agents.list().map(agent => agent.id)).toEqual([parent.id]) }) + it('rolls back materialization when setup revocation lands before publication', async () => { + const { ctx, parent } = await setup({ load: false }) + const self: { revoke?: () => void } = {} + let installed = false + self.revoke = ctx.subagents.registerContinuableSetup(() => { + installed = true + queueMicrotask(() => { self.revoke?.() }) + return () => { installed = false } + }) + const announced: SessionId[] = [] + const removeListener = ctx.on('session/created', (session) => { announced.push(session.id) }) + + await expect(ctx.subagents.startContinuable({ + provider: 'spawn', + label: 'revoked child', + request: { + prompt: [{ type: 'text', text: 'revoked child' }], + parent, + }, + signal: testSignal, + })).rejects.toMatchObject({ code: 'ACTIVATION_SETUP_REVOKED' }) + removeListener() + expect(installed).toBe(false) + expect(announced).toEqual([]) + expect(ctx.agents.list().map(agent => agent.id)).toEqual([parent.id]) + expect(ctx.sessions.list()).toEqual([parent.session]) + }) + it('accepts a report into a host-disposing but still-registered parent', async () => { const { ctx } = await setup() const parentHandle = await ctx.agents.create({ From 069f2644fffd3ea81d997ce5d35a02afecfcbfa0 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 20:11:34 +0800 Subject: [PATCH 060/129] fix(web): preserve removal across stale catalog pulls The earlier removal fix invalidated parentAvailable immediately and queued a trailing subagent.list request, but it still applied the already in-flight success verbatim. That stale success reopened the composer and became the trailing request baseline. If the trailing request failed, its error snapshot preserved parentAvailable:true indefinitely. Record a false-only parent availability override on the exact in-flight catalog request when the owner removal frame arrives. Successful and failed responses now replay that request-local invalidation before publishing a snapshot, and addressed child Sessions receive the same effective value. The trailing request therefore starts from a false baseline and a later transport or business failure cannot resurrect the removed parent. Strengthen the regression to assert the catalog and selected child remain read-only immediately after a stale parentAvailable:true success, then fail the trailing pull and assert the error snapshot remains unavailable. The complete SessionManager test file passes all 39 tests, and the client runtime TypeScript project builds cleanly. --- .../runtime/src/client/sessions/manager.ts | 31 ++++++++++++++----- packages/client/runtime/tests/manager.spec.ts | 14 +++++++-- 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index 2d20875adb..96b790abd0 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -59,6 +59,8 @@ interface CatalogInflight { readonly promise: Promise readonly expandableRows: Set readonly activityRows: Map + /** Removal-time invalidation replayed over the response this request predates. */ + parentAvailableOverride: false | undefined } type SessionListMutation = @@ -312,22 +314,26 @@ export class SessionManager { try { const { result } = await this.api.subagents.list({ parentSessionId }) if (result.ok) { + const parentAvailable = this.catalogInflight.get(parentSessionId)?.parentAvailableOverride + ?? result.value.parentAvailable this.catalogs.set(parentSessionId, { ...result.value, entries: this.withCatalogMutations(result.value.entries, expandableRows, activityRows), + parentAvailable, state: 'ready', error: null, }) for (const [childId, address] of this.addresses) { if (address.parentSessionId !== parentSessionId) continue - this.sessions.get(childId)?.handleSubagentParentAvailable(result.value.parentAvailable) + this.sessions.get(childId)?.handleSubagentParentAvailable(parentAvailable) } } else { this.catalogs.set(parentSessionId, { entries: this.withCatalogMutations( previous?.entries ?? [], expandableRows, activityRows, ), - parentAvailable: previous?.parentAvailable ?? false, + parentAvailable: this.catalogInflight.get(parentSessionId)?.parentAvailableOverride + ?? previous?.parentAvailable ?? false, state: 'error', error: result.error, }) @@ -338,7 +344,8 @@ export class SessionManager { entries: this.withCatalogMutations( previous?.entries ?? [], expandableRows, activityRows, ), - parentAvailable: previous?.parentAvailable ?? false, + parentAvailable: this.catalogInflight.get(parentSessionId)?.parentAvailableOverride + ?? previous?.parentAvailable ?? false, state: 'error', error: folded.ok ? null : folded.error, }) @@ -351,7 +358,12 @@ export class SessionManager { this.notifier.markDirty() } })() - this.catalogInflight.set(parentSessionId, { promise: operation, expandableRows, activityRows }) + this.catalogInflight.set(parentSessionId, { + promise: operation, + expandableRows, + activityRows, + parentAvailableOverride: undefined, + }) return operation } @@ -690,9 +702,14 @@ export class SessionManager { if (!durableSubagent) this.projectionStores.delete(frame.sessionId) // A pull already in flight was requested before this removal and can // carry the pre-removal parentAvailable:true, which would resurrect - // the writable editor this invalidation just closed. Queue one - // trailing refresh so the post-removal host truth converges. - if (this.catalogInflight.has(frame.sessionId)) this.catalogStale.add(frame.sessionId) + // the writable editor this invalidation just closed. Replay false over + // that response and queue one trailing refresh so the post-removal + // host truth converges. + const inflightCatalog = this.catalogInflight.get(frame.sessionId) + if (inflightCatalog !== undefined) { + inflightCatalog.parentAvailableOverride = false + this.catalogStale.add(frame.sessionId) + } // The removed session can no longer be the delivery owner of its // catalog: invalidate availability immediately. Removal schedules no // catalog refresh, and without this an addressed child keeps a diff --git a/packages/client/runtime/tests/manager.spec.ts b/packages/client/runtime/tests/manager.spec.ts index c37f0b88a1..8ec3df98d8 100644 --- a/packages/client/runtime/tests/manager.spec.ts +++ b/packages/client/runtime/tests/manager.spec.ts @@ -588,7 +588,7 @@ describe('subagent catalogs', () => { } }) - it('does not let a stale in-flight pull resurrect a removed parent\'s availability', async () => { + it('keeps removal invalidation across a stale success and failed trailing pull', async () => { const api = new FakeApiClient() const root = 'fk-root' as SessionId const child = () => ({ @@ -616,8 +616,16 @@ describe('subagent catalogs', () => { api.onSubagentList = () => trailing.promise mid.resolve(ok({ entries: [child()] as never[], parentAvailable: true })) await midRefresh - trailing.resolve(ok({ entries: [child()] as never[], parentAvailable: false })) - await trailing.promise + expect(manager.getListSnapshot().subagentsByParent[root]?.parentAvailable).toBe(false) + expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: false }) + + trailing.resolve(err({ code: 'internal', message: 'trailing pull failed', details: {} })) + await vi.waitFor(() => { + expect(manager.getListSnapshot().subagentsByParent[root]).toMatchObject({ + state: 'error', + parentAvailable: false, + }) + }) const rootCalls = api.callsOf('subagent.list') .filter(call => (call as { parentSessionId: SessionId }).parentSessionId === root) From 7b40fd54196b37221ffa9c710ce22e07da7b9f8c Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 20:13:28 +0800 Subject: [PATCH 061/129] perf(web): trail only membership-invalidated catalogs refreshSubagents previously treated every overlapping caller as proof that the in-flight response was stale. Selection, menu opening, and reconnect paths can legitimately request the same catalog concurrently without any host mutation, so those reads were coalesced and then followed by an unnecessary second RPC. Restore ordinary in-flight coalescing at the public refresh boundary. The debounced host/session-added path now owns the membership-specific stale mark: if its timer fires during an older pull, it queues one trailing request; otherwise it starts the refresh directly. Parent removal keeps its separate explicit invalidation and trailing-refresh path. Add a regression proving two overlapping reads share one Promise and issue one RPC. Rework the membership test to start from a restored selected parent, so only the host membership frame can request the trailing pull instead of the test priming the stale bit with an unrelated duplicate read. Validated with both focused catalog cases, all 40 SessionManager tests, and the client runtime TypeScript project build. --- .../runtime/src/client/sessions/manager.ts | 20 +++++++++---------- packages/client/runtime/tests/manager.spec.ts | 19 ++++++++++++++++-- 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index 96b790abd0..afbbe9e6ad 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -290,16 +290,7 @@ export class SessionManager { */ refreshSubagents(parentSessionId: SessionId): Promise { const existing = this.catalogInflight.get(parentSessionId) - if (existing !== undefined) { - // A refresh requested while a pull is in flight must not be silently - // coalesced into it: the in-flight response was requested before the - // triggering change (a membership frame or an opened menu), so it can - // never contain that change. Queue one trailing refresh that runs after - // the pull settles; without it the change stays invisible until an - // unrelated later trigger (reselection, menu reopen, reconnect). - this.catalogStale.add(parentSessionId) - return existing.promise - } + if (existing !== undefined) return existing.promise const previous = this.catalogs.get(parentSessionId) const expandableRows = new Set() const activityRows = new Map() @@ -774,11 +765,18 @@ export class SessionManager { for (const session of this.sessions.values()) void session.resync() } - /** Debounce membership refetches while one parent catalog is open. */ + /** Debounce membership refetches while one parent catalog is selected or open. */ private scheduleCatalogRefresh(parentSessionId: SessionId): void { if (this.catalogDebounce.has(parentSessionId)) return const timer = setTimeout(() => { this.catalogDebounce.delete(parentSessionId) + // The in-flight response predates the membership frame that scheduled + // this callback. Queue one post-settlement pull instead of treating an + // ordinary overlapping read as evidence that catalog membership changed. + if (this.catalogInflight.has(parentSessionId)) { + this.catalogStale.add(parentSessionId) + return + } void this.refreshSubagents(parentSessionId) }, 50) this.catalogDebounce.set(parentSessionId, timer) diff --git a/packages/client/runtime/tests/manager.spec.ts b/packages/client/runtime/tests/manager.spec.ts index 8ec3df98d8..f6d7baf318 100644 --- a/packages/client/runtime/tests/manager.spec.ts +++ b/packages/client/runtime/tests/manager.spec.ts @@ -530,6 +530,22 @@ describe('subagent catalogs', () => { ]) }) + it('coalesces overlapping catalog reads without scheduling a trailing pull', async () => { + const api = new FakeApiClient() + const root = 'fk-root' as SessionId + const first = deferred>>() + api.onSubagentList = () => first.promise + const manager = new SessionManager(api) + + const refresh = manager.refreshSubagents(root) + expect(manager.refreshSubagents(root)).toBe(refresh) + api.onSubagentList = () => Promise.resolve(ok({ entries: [], parentAvailable: true })) + first.resolve(ok({ entries: [], parentAvailable: true })) + await refresh + + expect(api.callsOf('subagent.list')).toHaveLength(1) + }) + it('runs one trailing catalog refresh for a membership change coalesced into an in-flight pull', async () => { vi.useFakeTimers() try { @@ -538,8 +554,7 @@ describe('subagent catalogs', () => { const first = deferred>>() const second = deferred>>() api.onSubagentList = () => first.promise - const manager = new SessionManager(api) - manager.setSubagentCatalogOpen(root, true) + const manager = new SessionManager(api, root) const refresh = manager.refreshSubagents(root) // A membership frame arrives while the pull is in flight; the debounced From ac88b6e4c79df1a8e109009a02b63a7955bbf469 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 20:14:46 +0800 Subject: [PATCH 062/129] fix(acp): retain nested teardown diagnostics ACP waits for every owned Agent disposal and throws one AggregateError when any Session teardown fails. The aggregate message embedded each rejected value with String(failure) because the connection-close logger itself renders only the outer error message. String preserves only an Error name and message, so causes and AggregateError members disappeared from the operational warning. Render each per-session rejection with the existing errorChain diagnostic helper before joining it into the outer message. The original rejected values remain in AggregateError.errors for programmatic inspection, while the message now carries cause chains and nested aggregate members through the String-based logger. Exercise a disposal failure containing both AggregateError members and a nested cause, while retaining the existing barrier that proves the second Session finishes disposal before any warning is emitted. All 10 ACP disposal tests pass and the ACP TypeScript project builds cleanly. --- packages/acp/acp/src/index.ts | 11 +++++------ packages/acp/acp/tests/dispose.spec.ts | 12 +++++++++--- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/packages/acp/acp/src/index.ts b/packages/acp/acp/src/index.ts index 7af26b594a..b88322012c 100644 --- a/packages/acp/acp/src/index.ts +++ b/packages/acp/acp/src/index.ts @@ -14,7 +14,7 @@ import { randomUUID } from 'node:crypto' import { isAbsolute } from 'node:path' import { Readable, Writable } from 'node:stream' import Schema from 'schemastery' -import { createUserMessage } from '@deepseek-ai/dsh-llm' +import { createUserMessage, errorChain } from '@deepseek-ai/dsh-llm' import { AgentSideConnection, ndJsonStream, @@ -368,11 +368,10 @@ export function apply(ctx: Context, config: AcpConfig): void { if (result.status === 'rejected') failures.push(result.reason as unknown) } if (failures.length > 0) { - // The only production consumer logs this error through `String`, which - // renders the message alone — without the joined reasons, per-session - // disposal failures would vanish from operational logs. Join them like - // the subagent seam's own aggregate disposal messages. - const detail = failures.map(failure => String(failure)).join('; ') + // The production consumer logs this AggregateError through `String`, + // which renders only its message. Embed every per-session diagnostic, + // including nested causes and aggregate members, in that message. + const detail = failures.map(failure => errorChain(failure)).join('; ') throw new AggregateError( failures, `ACP agent teardown failed for ${failures.length} session(s): ${detail}`, diff --git a/packages/acp/acp/tests/dispose.spec.ts b/packages/acp/acp/tests/dispose.spec.ts index 0eea014eeb..b4b1eaeef2 100644 --- a/packages/acp/acp/tests/dispose.spec.ts +++ b/packages/acp/acp/tests/dispose.spec.ts @@ -96,7 +96,7 @@ describe('ACP connection ownership', () => { expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined() }) - it('awaits every owned session disposal before reporting one failure', async () => { + it('awaits every owned session disposal and reports nested failure reasons', async () => { harness = await makeBridgeHarness() const create = harness.ctx.agents.create.bind(harness.ctx.agents) const releaseSecond = Promise.withResolvers() @@ -110,7 +110,10 @@ describe('ACP connection ownership', () => { if (created++ === 0) { handle.dispose = async () => { await originalDispose() - throw new Error('first session cleanup failed') + throw new AggregateError([ + new Error('scope cleanup failed', { cause: new Error('sqlite busy') }), + new Error('hook cleanup failed'), + ], 'first session cleanup failed') } } else { handle.dispose = async () => { @@ -132,7 +135,10 @@ describe('ACP connection ownership', () => { releaseSecond.resolve(undefined) await vi.waitFor(() => { expect(warnings.some(warning => - warning.includes('ACP agent teardown failed for 1 session(s): Error: first session cleanup failed'))).toBe(true) + warning.includes( + 'ACP agent teardown failed for 1 session(s): ' + + 'first session cleanup failed [scope cleanup failed: sqlite busy; hook cleanup failed]', + ))).toBe(true) expect(harness!.ctx.agents.get(SessionId(first.sessionId))).toBeUndefined() expect(harness!.ctx.agents.get(SessionId(second.sessionId))).toBeUndefined() }) From 3fc4142c04f5fd3f060266f5cb4c63dc870cb7d7 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 20:17:34 +0800 Subject: [PATCH 063/129] fix(web): pluralize singular subagent counts The localized catalog exposed one count.total and one count.running string for every cardinality. The English dictionary therefore rendered both the visible trigger and its accessibility label as 1 subagents, and the assembled Web golden had begun preserving that grammar error. Split both count families into explicit one and other keys, following the existing client locale convention. SubagentCatalogAction selects the pair from the effective descendant count; English uses subagent for one and subagents otherwise, while Chinese keeps its unchanged classifier text under the same key domain. Add a component regression proving a single running descendant selects both singular keys. Update the real Web E2E locator and keyless assembled aria golden from 1 subagents to 1 subagent. Both ui-subagent test files pass all 28 tests and the package TypeScript project builds cleanly. --- .../subagent-conversation/ui.expected.md | 4 ++-- apps/web/tests/subagent-conversation.e2e.ts | 2 +- .../src/client/SubagentCatalogAction.tsx | 6 ++++-- .../client/ui-subagent/src/client/locales.ts | 12 ++++++++---- .../ui-subagent/tests/conversation-ui.spec.tsx | 18 ++++++++++++++++++ 5 files changed, 33 insertions(+), 9 deletions(-) diff --git a/apps/web/tests/snapshots/subagent-conversation/ui.expected.md b/apps/web/tests/snapshots/subagent-conversation/ui.expected.md index 3a9b03fffe..14b397d9b4 100644 --- a/apps/web/tests/snapshots/subagent-conversation/ui.expected.md +++ b/apps/web/tests/snapshots/subagent-conversation/ui.expected.md @@ -3,8 +3,8 @@ - button "Ask a research subagent to" - text: / - button "event-sourcing researcher" [disabled] - - button "1 subagents": - - text: 1 subagents + - button "1 subagent": + - text: 1 subagent - img - tablist: - tab "Chat" [selected] diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index 83ce78f3e4..904fcfcd09 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -331,7 +331,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = it('opens an unavailable persisted grandchild after recording the available child', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-grandchild')) - await page.getByRole('button', { name: '1 subagents' }).click() + await page.getByRole('button', { name: '1 subagent' }).click() await page.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }).click() await page.getByText('The parent session is offline; reopen it to continue sending messages.').waitFor() const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' }) diff --git a/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx b/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx index 359827780f..14f8169d82 100644 --- a/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx +++ b/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx @@ -324,6 +324,8 @@ export function SubagentCatalogAction({ // The catalog can arrive before the session-list baseline; never undercount // the already-visible direct rows during that short bootstrap window. const descendantCount = Math.max(healthy.length, descendants.count) + const totalCountKey = descendantCount === 1 ? 'count.total.one' : 'count.total.other' + const runningCountKey = descendantCount === 1 ? 'count.running.one' : 'count.running.other' const observeCatalog = (parentSessionId: SessionId, next: boolean): void => { if (next) observedCatalogs.current.add(parentSessionId) @@ -432,7 +434,7 @@ export function SubagentCatalogAction({ className={css.trigger} aria-haspopup="tree" aria-expanded={open} - aria-label={t(descendants.running ? 'count.running' : 'count.total', { count: descendantCount })} + aria-label={t(descendants.running ? runningCountKey : totalCountKey, { count: descendantCount })} onClick={() => { changeOpen(!open) }} onKeyDown={(event) => { if (event.key !== 'ArrowDown') return @@ -444,7 +446,7 @@ export function SubagentCatalogAction({ {descendants.running && } - {t('count.total', { count: descendantCount })} + {t(totalCountKey, { count: descendantCount })} {open && ( diff --git a/packages/client/ui-subagent/src/client/locales.ts b/packages/client/ui-subagent/src/client/locales.ts index 2ecf1be4f5..86562534b0 100644 --- a/packages/client/ui-subagent/src/client/locales.ts +++ b/packages/client/ui-subagent/src/client/locales.ts @@ -24,8 +24,10 @@ export const zh = { 'activity.inactive': '当前未运行', 'branch.collapse': '收起 {label} 的下级子代理', 'branch.expand': '展开 {label} 的下级子代理', - 'count.total': '{count} 个子代理', - 'count.running': '{count} 个子代理,正在运行', + 'count.total.one': '{count} 个子代理', + 'count.total.other': '{count} 个子代理', + 'count.running.one': '{count} 个子代理,正在运行', + 'count.running.other': '{count} 个子代理,正在运行', 'tree.aria': '子代理会话', 'readonly.oneShot.title': '一次性子代理记录', 'readonly.title': '此子代理暂时只读', @@ -54,8 +56,10 @@ export const en: Record = { 'activity.inactive': 'not running', 'branch.collapse': 'Collapse {label} descendants', 'branch.expand': 'Expand {label} descendants', - 'count.total': '{count} subagents', - 'count.running': '{count} subagents running', + 'count.total.one': '{count} subagent', + 'count.total.other': '{count} subagents', + 'count.running.one': '{count} subagent running', + 'count.running.other': '{count} subagents running', 'tree.aria': 'Subagent sessions', 'readonly.oneShot.title': 'One-shot subagent record', 'readonly.title': 'This subagent is read-only for now', diff --git a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx b/packages/client/ui-subagent/tests/conversation-ui.spec.tsx index 1687b1ffc1..5649b2b257 100644 --- a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx +++ b/packages/client/ui-subagent/tests/conversation-ui.spec.tsx @@ -167,6 +167,24 @@ describe('SubagentCatalogAction', () => { expect(input.setCatalogOpen).toHaveBeenLastCalledWith(PARENT, false) }) + it('selects singular count keys for one descendant', () => { + const base = props(catalog({ + entries: [{ + kind: 'child', id: CHILD, mode: 'continuable', label: 'worker', + activity: 'running', hasChildren: false, + }], + }), {}, { + [CHILD]: { + ...summary(CHILD, Date.now()), parentId: PARENT, origin: 'subagent', running: true, + }, + }) + const translate = vi.fn(base.t) + render() + + expect(translate).toHaveBeenCalledWith('count.running.one', { count: 1 }) + expect(translate).toHaveBeenCalledWith('count.total.one', { count: 1 }) + }) + it('supports trigger/menu keyboard traversal, Escape focus restore, and outside close', async () => { const input = props(catalog()) render() From f91b4b2cdcd4c09d7d53cd8263594110eb5b0477 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 20:18:31 +0800 Subject: [PATCH 064/129] docs(subagent): correct cold-resume parent contract The descriptor module claimed that no parent exists during cold resume, using that as the reason maxTokens cannot be inherited. Continuable follow-up actually requires and authorizes the exact live direct parent before it loads the descriptor and materializes the child, so the stated lifecycle fact was false. Keep the real persistence decision explicit: per-activation budgets are not durable descriptor composition. Cold resume reconstructs child options only from the curated durable fields, so it deliberately neither restores the establishing budget nor inherits the live parent current transient budget; the resumed provider and model route defaults apply. This is a contract-only correction with no runtime change. The exported JSDoc gate remains clean. --- packages/subagent/subagent/src/descriptor.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/subagent/subagent/src/descriptor.ts b/packages/subagent/subagent/src/descriptor.ts index 4cec72e658..55ba73dcc4 100644 --- a/packages/subagent/subagent/src/descriptor.ts +++ b/packages/subagent/subagent/src/descriptor.ts @@ -13,9 +13,10 @@ * `delegationDepth` as the monotone floor — and `outputSchema`, which belongs * to one activation's result contract rather than durable child composition. * Per-activation knobs such as `maxTokens` are omitted for the same reason as - * `outputSchema`: they budget one activation and, on cold resume, no parent - * exists to inherit them from, so the resumed activation runs under the - * deployment defaults rather than restoring a stale budget. + * `outputSchema`: they budget one activation. Cold resume requires the exact + * live parent for authorization but reconstructs child options only from the + * durable descriptor, so it neither restores the prior budget nor inherits + * the parent's current one; the resumed route's defaults apply instead. * * @module @deepseek-ai/dsh-subagent/descriptor */ From ab4120ac967077296eef886e81edc13fd4d417f9 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 20:24:43 +0800 Subject: [PATCH 065/129] docs(subagent): keep intent note current The PR appended a superseded warning to one obsolete durability clause while leaving the same active decision record with mutually incompatible claims about Task-backed continuations, provider resume dispatch, and persistence guarantees. Because implemented Agent Notes are current authority rather than a review-history log, readers could still derive an API and ownership model that no longer exists. Rewrite the affected decision, alternatives, and consequences in place around the activation-based implementation: ordinary starts remain holder-owned one-shot runs; continuable starts return durable child and accepted message identities; the manager owns materialization, follow-up/report routing, cold resume, and teardown; providers only contribute detached first-create data through prepareContinuable; and flush participation is observable but is not proof that a persistence backend stored state. Keep the English and Chinese records equivalent, move the Chinese dispose glossary to its new first use, and refresh the pairing sidecar. This commit changes documentation authority only; it does not change runtime behavior. Validated with the scoped translation-pairing writer and checker, verify-md-wrap, verify-agent-note-format, verify-agent-note-classification, and git diff --cached --check. --- ...-subagent-continuation-operations.i18n.yaml | 4 ++-- ...t-named-subagent-continuation-operations.md | 18 +++++++++--------- ...amed-subagent-continuation-operations.zh.md | 18 +++++++++--------- 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.i18n.yaml index 659cebef8f..f0587a9b39 100644 --- a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.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 .agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md -2026-07-27-intent-named-subagent-continuation-operations.md: 00340ac53443741e9857cb238ddf95bced504c7f -2026-07-27-intent-named-subagent-continuation-operations.zh.md: 3184a066de98fb442cb5e2d305191419c27f278c +2026-07-27-intent-named-subagent-continuation-operations.md: e74d62b7582e92f8e5ce68327a677259c8453d24 +2026-07-27-intent-named-subagent-continuation-operations.zh.md: dae4dd37fa9950f0b8d1ba6ec5c46b99977a7201 diff --git a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md index 00340ac534..e74d62b758 100644 --- a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md +++ b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.md @@ -4,7 +4,7 @@ Status: implemented English | [中文](2026-07-27-intent-named-subagent-continuation-operations.zh.md) -The `followup` operation this record names is retained by [Continuable subagents](../feature/2026-07-28-continuable-subagent-conversations.md), which replaces its Task-backed return value with the accepted `MessageId`, retains its bare `Agent` parameter as exact live-direct-parent authority, and replaces provider `resume` dispatch with `prepareContinuable`. +The current activation-based realization is owned by [Continuable subagents](../feature/2026-07-28-continuable-subagent-conversations.md). It retains the `followup` operation this record names, returns the accepted `MessageId`, uses the bare `Agent` parameter as exact live-direct-parent authority, and limits provider participation in continuable children to `prepareContinuable`. ## Problem @@ -14,25 +14,25 @@ The durability boundary also exposed both `SessionStore.flush()` and `flushRequi ## Decision -`SubagentService` exposes three execution intents: `start(name, request)` for an ordinary holder-owned run, `startContinuable(spec)` for a durable Task-backed child, and `followup(parent, childId, content, { source, signal })` for later content. The last verb matches `Agent.followup()`, while `SubagentRun.steer()` remains the narrower confirmed live-activation capability. The model-facing tool keeps its stable `send_message` name and delegates routing to `followup()`. +`SubagentService` separates four execution intents: `start(name, request)` returns an ordinary holder-owned one-shot run; `startContinuable(spec)` establishes a durable child and returns its id plus the accepted initial `MessageId`; `followup(parent, childId, content, { source, signal })` sends later parent content; and `reportFrom(child, content, { delivery, signal })` sends selected child content to its direct parent. `followup` matches `Agent.followup()`, while `SubagentRun.steer()` remains the narrower confirmed live-run capability. The model-facing tools keep their stable `send_message` and `report` names and delegate routing to the corresponding intent methods. -Caller and provider requests are distinct. `SubagentStartRequest` contains only caller-supplied start data; `SubagentProviderStartRequest` adds service-resolved continuation state. Ordinary `start()` clears that state before provider dispatch. `SubagentProviderResumeRequest` remains part of the provider seam, but `SubagentService.resume()` is absent: the continuation manager loads the descriptor, authorizes the parent, and invokes private provider start/resume closures owned by the service. Provider dispatch still receives the same capability checks and run lifecycle observation without becoming a caller operation. +Caller and provider requests are distinct. `SubagentStartRequest` contains caller-supplied one-shot data; `ResolvedSubagentStartRequest` adds the service-resolved descriptor before `SubagentProvider.start()`. For continuable creation, the manager passes a `ContinuableCreateRequest` to optional `SubagentProvider.prepareContinuable()` and receives detached creation data only. `SubagentService.resume()` and provider resume dispatch are absent: the continuation manager loads the descriptor, authorizes the parent, and owns Agent materialization, prompt delivery, cold resume, and teardown. -`SessionStore.flush(session)` returns `Promise`. It resolves `true` after at least one scoped durability listener participates successfully, resolves `false` for an empty listener snapshot, and rejects with the first registered listener failure after all listeners settle. Ordinary checkpoints may ignore the boolean. A continuable provider requires `true` at its final result boundary and maps `false` or rejection to `DURABILITY_FAILED`. **Superseded** by the activation-based record [2026-07-28-continuable-subagent-conversations](../feature/2026-07-28-continuable-subagent-conversations.md): the continuation manager awaits the final `flush()` as a best-effort barrier and deliberately ignores the boolean, because listener participation cannot identify a persistence backend; a rejection is logged without changing the lifecycle result or host-drain outcome. +`SessionStore.flush(session)` is the single durability barrier and returns `Promise`. It resolves `true` after at least one scoped listener participates successfully, resolves `false` for an empty listener snapshot, and rejects with the first registered listener failure after all listeners settle. Participation cannot identify whether a selected persistence backend stored the state. Ordinary checkpoints may ignore the boolean; the continuation manager also treats its final flush as a best-effort barrier, deliberately ignores participation, logs rejection, and still disposes the child and releases ownership. ## Alternatives considered -**Keep public provider resume dispatch.** No production caller outside the continuation manager owns the descriptor lookup, direct-parent authorization, Task cancellation, and activation association needed to call it safely. A public method would expose resolved implementation data without a valid independent intent. +**Keep public provider resume dispatch.** No production caller outside the continuation manager owns descriptor lookup, direct-parent authorization, Agent materialization, Activation ownership, and child-first teardown. A public method would expose resolved implementation data without a valid independent intent; providers instead contribute detached first-creation data through `prepareContinuable` and never participate in cold resume. **Keep `sendMessage` on the service.** The model tool sends a message, but the service operation represents a follow-up that may steer or cold-resume. `followup` aligns with the structural `Agent` interface and does not promise a particular route. **Keep `flushRequired()`.** A second method hides only an empty-listener check. Returning participation from the existing barrier keeps dispatch in one implementation and lets each caller state whether absence is acceptable. -**Fold ordinary and continuable starts together.** A flag would make one method return either an awaited holder-owned run or immediate child/Task identities. Separate intent methods preserve the ownership and timing distinction without a return union. +**Fold ordinary and continuable starts together.** A flag would make one method return either an awaited holder-owned one-shot run or immediate durable child and message identities. Separate intent methods preserve the ownership and timing distinction without a return union. ## Consequences -- The Cordis service catalog contains only caller operations; provider reconstruction remains extensible through `SubagentProvider.resume?()` without exposing its resolved request as a service method. +- The Cordis service catalog contains only caller operations; a provider can opt into continuable first creation through `SubagentProvider.prepareContinuable?()` without receiving Agent lifecycle authority or a public resume operation. - Follow-up source and cancellation travel in one options object, matching the intent-helper shape on `Agent` while retaining the existing live-delivery and cold-resume semantics. -- Session durability has one barrier operation. Callers that require a backend must inspect its participation result rather than selecting a second dispatch method. -- The `send_message` schema, route results, Task ownership, durable event vocabulary, and model-visible transcript remain unchanged. +- Session durability has one barrier operation. Its participation result remains observable, but no continuable-child path treats arbitrary listener participation as proof that a persistence backend stored the state. +- The `send_message` and `report` schemas, accepted message identities, `AgentHandle` ownership, durable event vocabulary, and model-visible transcript follow the activation-based realization linked above. diff --git a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.zh.md b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.zh.md index 3184a066de..dae4dd37fa 100644 --- a/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-27-intent-named-subagent-continuation-operations.zh.md @@ -4,7 +4,7 @@ Status: implemented [English](2026-07-27-intent-named-subagent-continuation-operations.md) | 中文 -本记录命名的 `followup` 操作由[可继续的 subagent](../feature/2026-07-28-continuable-subagent-conversations.md)保留,但后者以已接受的 `MessageId` 替换其基于 Task 的返回值,保留裸 `Agent` 参数作为准确的实时直属父级权限,并以 `prepareContinuable` 替换提供方 `resume` 派发。 +当前基于 Activation 的实现由[可继续的 subagent](../feature/2026-07-28-continuable-subagent-conversations.md)负责。它保留本记录命名的 `followup` 操作,返回已接受的 `MessageId`,使用裸 `Agent` 参数作为确切的在线直属父级权限,并将提供方对可继续 child 的参与限制为 `prepareContinuable`。 ## 问题 @@ -14,25 +14,25 @@ Status: implemented ## 决策 -`SubagentService` 公开三种执行意图:`start(name, request)` 用于普通的、由持有方负责的 run;`startContinuable(spec)` 用于具备持久性且由 Task 支撑的 child;`followup(parent, childId, content, { source, signal })` 用于投递后续内容。最后一个动词与 `Agent.followup()` 一致,而 `SubagentRun.steer()` 仍是范围更窄的能力,仅向已确认仍在运行的激活提供 steering(中途引导)。面向模型的工具保留稳定的 `send_message` 名称,并将路由委托给 `followup()`。 +`SubagentService` 分离四种执行意图:`start(name, request)` 返回普通的、由持有方负责的 one-shot run;`startContinuable(spec)` 建立持久化 child,并返回其 id 与已接受的初始 `MessageId`;`followup(parent, childId, content, { source, signal })` 发送后续 parent 内容;`reportFrom(child, content, { delivery, signal })` 将选定的 child 内容发送给其直接 parent。`followup` 与 `Agent.followup()` 一致,而 `SubagentRun.steer()` 仍是范围更窄的能力,仅向已确认仍在运行的 run 提供 steering。面向模型的工具保留稳定的 `send_message` 与 `report` 名称,并将路由委托给对应的意图方法。 -调用方请求与提供方请求相互分离。`SubagentStartRequest` 只包含调用方提供的启动数据;`SubagentProviderStartRequest` 则加入由服务解析的继续执行状态。普通 `start()` 在分发给提供方之前会清除该状态。`SubagentProviderResumeRequest` 仍属于提供方 seam,但 `SubagentService.resume()` 不对外公开:继续执行管理器加载描述符、对 parent 进行鉴权,并调用由服务持有的私有提供方启动与恢复闭包。提供方分发仍会经过相同的功能检查和 run 生命周期观测,而无需将其变成调用方操作。 +调用方请求与提供方请求相互分离。`SubagentStartRequest` 包含调用方提供的 one-shot 数据;`ResolvedSubagentStartRequest` 会在调用 `SubagentProvider.start()` 前加入由服务解析的描述符。创建可继续 child 时,管理器将 `ContinuableCreateRequest` 传给可选的 `SubagentProvider.prepareContinuable()`,且只接收分离的创建数据。`SubagentService.resume()` 与提供方恢复分发均不存在:继续执行管理器加载描述符、对 parent 进行鉴权,并负责 Agent 实体化、提示词投递、冷恢复与 teardown。 -`SessionStore.flush(session)` 返回 `Promise`。至少一个作用域内的持久性监听器成功参与后,它解析为 `true`;监听器快照为空时解析为 `false`;所有监听器结算后,如有失败,则以注册顺序最靠前的监听器错误拒绝。普通检查点可以忽略该布尔值。可继续提供方在最终结果边界要求该值为 `true`,并将 `false` 或拒绝映射为 `DURABILITY_FAILED`。**已被取代**:激活化记录 [2026-07-28-continuable-subagent-conversations](../feature/2026-07-28-continuable-subagent-conversations.md) 规定延续管理器把最终 `flush()` 作为 best-effort 屏障并有意忽略布尔值——监听器参与度无法识别持久化后端;拒绝只记日志,不改变生命周期结果或宿主 drain 结果。 +`SessionStore.flush(session)` 是唯一的持久性屏障,并返回 `Promise`。至少一个作用域内监听器成功参与后,它解析为 `true`;监听器快照为空时解析为 `false`;所有监听器结算后,如有失败,则以注册顺序最靠前的监听器错误拒绝。参与结果无法表明所选的持久化后端是否已经存储状态。普通检查点可以忽略该布尔值;继续执行管理器同样将最终 flush 视为 best-effort 屏障,有意忽略参与结果,记录拒绝日志,并仍会对 child 执行 dispose(资源释放)并释放所有权。 ## 已考虑的替代方案 -**保留公开的提供方恢复分发。** 继续执行管理器之外没有任何生产调用方负责安全调用所需的描述符查找、直接 parent 鉴权、Task 取消与激活关联。公开方法会暴露已解析的实现数据,但并不存在与之对应的合理独立调用意图。 +**保留公开的提供方恢复分发。** 继续执行管理器之外,没有任何生产调用方同时负责安全调用所需的描述符查找、直接 parent 鉴权、Agent 实体化、Activation 所有权与 child-first teardown。公开方法会暴露已解析的实现数据,却没有合理的独立调用意图;提供方改为通过 `prepareContinuable` 贡献分离的首次创建数据,且从不参与冷恢复。 **在服务上保留 `sendMessage`。** 面向模型的工具发送消息,但服务操作表达的是后续操作,既可能对运行中的激活执行 steering,也可能从持久化存储恢复。`followup` 与结构化 `Agent` 接口保持一致,也不承诺特定路由。 **保留 `flushRequired()`。** 第二个方法只封装了空监听器检查。由现有屏障返回是否有监听器参与,可以让分发只保留一套实现,并让每个调用方自行判定缺少监听器是否可接受。 -**合并普通启动与可继续启动。** 一个标志会让同一方法要么等待由持有方负责的 run 就绪后返回,要么立即返回 child 和 Task 标识。按意图拆分的方法无需返回值联合类型即可保留所有权与时序差异。 +**合并普通启动与可继续启动。** 一个标志会让同一方法要么等待由持有方负责的 one-shot run 就绪后返回,要么立即返回持久化 child 与消息标识。按意图拆分的方法无需返回值联合类型即可保留所有权与时序差异。 ## 影响 -- Cordis 服务目录只包含调用方操作;提供方的重建能力仍可通过 `SubagentProvider.resume?()` 扩展,同时不会将已解析的请求暴露为服务方法。 +- Cordis 服务目录只包含调用方操作;提供方可以通过 `SubagentProvider.prepareContinuable?()` 选择参与可继续 child 的首次创建,但不会获得 Agent 生命周期权限或公开恢复操作。 - 后续操作的来源与取消信号通过同一个选项对象传递,与 `Agent` 上按意图命名的辅助方法形态一致,同时保留在线投递与从持久化存储恢复的语义。 -- 会话持久性只保留一个屏障操作。需要后端参与的调用方必须检查参与结果,而不是选择第二种分发方法。 -- `send_message` schema、路由结果、Task 所有权、持久化事件词汇与模型可见的 transcript(文本记录)保持不变。 +- 会话持久性只有一个屏障操作。参与结果仍可观测,但任何可继续 child 路径都不会将任意监听器参与视为持久化后端已存储状态的证明。 +- `send_message` 与 `report` schema、已接受的消息标识、`AgentHandle` 所有权、持久化事件词汇与模型可见的 transcript(文本记录)遵循上文链接的基于 Activation 的实现。 From a24f9b06e7e57bb62e58a6ef3f3cf071f2f8277b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 20:25:39 +0800 Subject: [PATCH 066/129] refactor(subagent): drop speculative effect rollback The PR moved child scope-effect registration into the contribution-installation try/catch solely to cover a hypothetical throw, while documenting that Context.effect cannot reject for the live unpublished scope passed to apply. The change therefore added control-flow and rollback implications for a failure mode the API does not expose, without changing observable behavior. Keep the rollback boundary focused on contribution installers, which are the operations that can actually fail and leave recorded installations to unwind. Register the child-scope cleanup effect immediately after that boundary, as before; its disposer still converges with contribution removal through the registry idempotence rules. This is a behavior-preserving removal of unnecessary code. The focused activation-setup-registry suite passes all 11 tests, the subagent TypeScript project checks cleanly, and the staged diff passes whitespace validation. --- packages/subagent/subagent/src/activation-setup-registry.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/subagent/subagent/src/activation-setup-registry.ts b/packages/subagent/subagent/src/activation-setup-registry.ts index 3e8ef4fe61..5681e89863 100644 --- a/packages/subagent/subagent/src/activation-setup-registry.ts +++ b/packages/subagent/subagent/src/activation-setup-registry.ts @@ -113,10 +113,6 @@ export class SubagentActivationSetupRegistry { // Dispose that escaped record and invalidate the provisioning batch. if (isRemoved(registration)) this.release(installation) } - // Register the scope-disposal release inside the same try so the - // setup-rollback catch also covers a hypothetical effect-registration - // throw; today effect() cannot reject on a live unpublished scope. - childCtx.effect(() => () => { this.releaseChild(childCtx) }, 'subagents.activationSetup()') } catch (error: unknown) { // Keep the installer failure authoritative, but attempt every rollback. try { @@ -127,6 +123,7 @@ export class SubagentActivationSetupRegistry { } throw error } + childCtx.effect(() => () => { this.releaseChild(childCtx) }, 'subagents.activationSetup()') return { commit: () => { if (state.invalidated) { From e4663cb10bf0288abc3d5cc3914499f1bb137e45 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 20:28:23 +0800 Subject: [PATCH 067/129] test(web): share the subagent locale translator The localized catalog spec introduced two package-local translation stubs: one manually looped over interpolation parameters and the other indexed the Chinese dictionary directly. That duplicates framework test plumbing and can drift from the shared lookup, fallback, and placeholder semantics used by the rest of the client suites. Use makeTranslate from dsh-client-test-runtime as the single Chinese translator for both catalog and read-only composer assertions. Record the test-only workspace dependency in the ui-subagent manifest and lockfile; no production dependency or runtime bundle edge is added. This removes twelve lines of local translation behavior while preserving the same Chinese assertions and exercising the shared interpolation path. Both ui-subagent test files pass with all 28 tests, the package TypeScript project checks cleanly, and the staged diff passes formatting, lint, and whitespace hooks. --- packages/client/ui-subagent/package.json | 1 + .../tests/conversation-ui.spec.tsx | 20 ++++--------------- pnpm-lock.yaml | 3 +++ 3 files changed, 8 insertions(+), 16 deletions(-) diff --git a/packages/client/ui-subagent/package.json b/packages/client/ui-subagent/package.json index a3e753d91b..6dc3f9bd7d 100644 --- a/packages/client/ui-subagent/package.json +++ b/packages/client/ui-subagent/package.json @@ -53,6 +53,7 @@ "devDependencies": { "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slash": "workspace:^", diff --git a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx b/packages/client/ui-subagent/tests/conversation-ui.spec.tsx index 5649b2b257..ded344145f 100644 --- a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx +++ b/packages/client/ui-subagent/tests/conversation-ui.spec.tsx @@ -1,16 +1,15 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import type { SessionId, SessionListState, SessionSummary, SubagentCatalogSnapshot, } from '@deepseek-ai/dsh-client-runtime/client' import { SubagentCatalogAction, type SubagentCatalogActionProps, } from '../src/client/SubagentCatalogAction.tsx' -import { - SubagentReadOnlyComposer, type SubagentReadOnlyComposerProps, -} from '../src/client/SubagentReadOnlyComposer.tsx' -import { zh, type SubagentKey } from '../src/client/locales.ts' +import { SubagentReadOnlyComposer } from '../src/client/SubagentReadOnlyComposer.tsx' +import { zh } from '../src/client/locales.ts' afterEach(() => { cleanup() @@ -20,6 +19,7 @@ afterEach(() => { const PARENT = 'parent' as SessionId const CHILD = 'child' as SessionId const GRANDCHILD = 'grandchild' as SessionId +const t: SubagentCatalogActionProps['t'] = makeTranslate(zh) function catalog(over: Partial = {}): SubagentCatalogSnapshot { return { @@ -66,15 +66,6 @@ function props( function useSessions(select: (snapshot: SessionListState) => T): T { return select(state) } - // The zh dictionary is the source of truth for this spec's assertions: - // the stub interpolates `{name}` params like the locale service does. - const t = ((key: SubagentKey, params?: Record): string => { - let text: string = zh[key] - for (const [name, value] of Object.entries(params ?? {})) { - text = text.replaceAll(`{${name}}`, String(value)) - } - return text - }) as SubagentCatalogActionProps['t'] return { sessionId: PARENT, useSessions, @@ -484,9 +475,6 @@ describe('SubagentCatalogAction', () => { }) describe('SubagentReadOnlyComposer', () => { - // The zh dictionary is the source of truth for this spec's assertions. - const t = ((key: SubagentKey): string => zh[key]) as SubagentReadOnlyComposerProps['t'] - it('explains the exact missing-parent recovery path', () => { render() expect(screen.getByRole('status').textContent).toContain('父会话当前不在线') diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d81b237b7c..c7053f6738 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1885,6 +1885,9 @@ importers: '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../runtime + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../test-runtime '@deepseek-ai/dsh-client-ui-conversation': specifier: workspace:^ version: link:../ui-conversation From a54eadf2f17e3835f6e4a806b6d03b3aef6a5ce3 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 20:30:01 +0800 Subject: [PATCH 068/129] test(web): pin the subagent snapshot to English The subagent conversation scenario asserts English role names and compares English accessibility goldens, but it opened a raw Playwright page while the rest of the English Web scenarios use the shared bootstrap that writes dsh.locale before client initialization. Once the subagent surface became localized, the raw page left those assertions dependent on ambient browser or persisted locale selection. Create the page through newEnglishPage so the product sees an explicit English preference before boot, while preserving the standard 1680 by 1000 viewport. Chinese-surface scenarios continue to bypass this helper and advertise their own locale explicitly. A fresh library build and production Vite build completed successfully. The focused assembled subagent-conversation Web suite then passed all 8 keyless replay tests against the updated singular-copy golden, and the staged diff passes formatting, lint, and whitespace hooks. --- apps/web/tests/subagent-conversation.e2e.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index 904fcfcd09..14080dca6f 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -15,7 +15,7 @@ import { launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { connectFreshWorkspace, saveFailureShot } from './support.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' const BASE_FIXTURE = fileURLToPath(new URL('./snapshots/live-interactions/session.jsonl', import.meta.url)) const AVAILABLE_CHILD_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/ui.expected.md', import.meta.url)) @@ -77,7 +77,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = paceMs: 25, }) browser = await chromium.launch() - page = await browser.newPage({ viewport: { width: 1680, height: 1000 } }) + page = await newEnglishPage(browser) page.on('request', (request) => { const path = new URL(request.url()).pathname if (path.startsWith('/api/')) apiCalls.push(path) From da80b0e5e62359d4972d1732e7488798f9fd4287 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 20:34:43 +0800 Subject: [PATCH 069/129] chore(docs): refresh the descriptor catalog pointer Correcting the cold-resume module contract added one JSDoc line above SubagentDescriptorData, but the generated persistence catalog still linked the durable subagent/descriptor event payload to descriptor.ts line 36. That left a dead source pointer and made the repository documentation gate fail even though the catalog content itself was otherwise current. Regenerate docs/persistence-catalog.md so its source link follows the declaration to line 37. This is a generated-reference correction only: it does not change the durable event vocabulary, payload shape, or runtime behavior. Validated with pnpm run verify-persistence-catalog and git diff --cached --check; the generator reports the catalog is up to date. --- docs/persistence-catalog.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 53502cc905..b3ac5aae70 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -531,7 +531,7 @@ Source: [`packages/core/session/src/types.ts:222`](../packages/core/session/src/ 'subagent/descriptor': SubagentDescriptorData ``` -Source: [`packages/subagent/subagent/src/descriptor.ts:36`](../packages/subagent/subagent/src/descriptor.ts) +Source: [`packages/subagent/subagent/src/descriptor.ts:37`](../packages/subagent/subagent/src/descriptor.ts) ### `todo/*` From 04d43e3b1932ee1b6282535230260b6404702493 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Sun, 2 Aug 2026 05:58:31 -0700 Subject: [PATCH 070/129] feat(llm-replay): resolve {{fromRequest:...}} placeholders against the live request A scripted sidecar cannot know values minted at run time, so terminal goal updates (which must echo the random goal id) were previously un-scriptable. Placeholders in scripted entries now resolve against the request corpus at stream time: last match wins, capture group 1 or the whole match substitutes, and unmatched/invalid/unterminated patterns fail loud. --- packages/support/llm-replay/README.i18n.yaml | 4 +- packages/support/llm-replay/README.md | 4 +- packages/support/llm-replay/README.zh.md | 4 +- packages/support/llm-replay/src/index.ts | 86 ++++++++++++++++++- .../llm-replay/tests/llm-replay.spec.ts | 71 ++++++++++++++- 5 files changed, 163 insertions(+), 6 deletions(-) diff --git a/packages/support/llm-replay/README.i18n.yaml b/packages/support/llm-replay/README.i18n.yaml index e4f6711ea7..b6a31ebb99 100644 --- a/packages/support/llm-replay/README.i18n.yaml +++ b/packages/support/llm-replay/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/support/llm-replay/README.md -README.md: 0deb6e76b29d40483b754ac01c98ee0e01bfcbe8 -README.zh.md: 7720e2d1bc6eb7bc5c89d5c1708767a54a7b0080 +README.md: ea52525ee85aae58006c852afe93291ea70807d5 +README.zh.md: e8c0ec225df29fc6f5493776d75bc7f9e3e078de diff --git a/packages/support/llm-replay/README.md b/packages/support/llm-replay/README.md index 0deb6e76b2..ea52525ee8 100644 --- a/packages/support/llm-replay/README.md +++ b/packages/support/llm-replay/README.md @@ -12,6 +12,8 @@ The fixture IS the persisted session log (`/session.jsonl`). Its `assi Two failure modes are not reconstructable from `assistant/chunk` alone — a pure throw before any chunk (e.g. an HTTP 401, where the log holds only a `turn/end {error}` and no chunks) and a cancel/hang (timing, not chunk content). A scenario that needs those supplies an optional sidecar (`/replay.override.json`) that either replaces the derived script (a bare `ReplayEntry[]`) or augments it (`{ patches: [{ at, entry }] }`: keep every JSONL-derived call and swap the named 0-based call indexes; `at` equal to the derived length appends the retry attempt after an injected transient throw). Patch indexes must be unique. The override document, each patch and entry, and every chunk discriminant are validated when the file loads. A `hang` entry may name `readyFile`; replay writes that empty marker after its prefix chunks reach the loop and before it waits for cancellation, so an external driver can cancel deterministically without observing a presentation update. +A scripted string may embed `{{fromRequest:}}` to fill a value no static sidecar can know — for example a randomly minted goal id the model must echo back into `update_goal`. At stream time every placeholder resolves against the live request: the corpus is every string leaf of the request messages joined by newlines, the pattern's LAST corpus match wins, and its first capture group (or the whole match without one) substitutes in place. A pattern that matches nothing, an invalid pattern, and an unterminated placeholder each fail loud; the first `}}` ends the placeholder, so patterns cannot contain `}}`. + ## Nested agents: per-session keying A scenario where a parent agent delegates to in-process subagents records more than one log: the parent (`session.jsonl`) plus one per child (`session.1.jsonl`, …). Each agent runs as its own `Session` on the same context, so replay must serve each one its own script. @@ -55,7 +57,7 @@ Replay keys every call by its calling session id (`GenerateOptions.sessionId`, s - `installLlmReplay(ctx, config)` — install the configured replay adapter or catch-all `llm/stream` listener; returns a `ReplayHandle` (`dispose()` for HMR safety plus `assertConsumed()`, the teardown check that every recorded script bound to a live session and every bound cursor drained — turning a scenario that silently drove fewer model calls than recorded into a crisp diagnostic). Use this in tests to drive replay without the Loader or env vars. - `loadSessionScripts(config)` — resolve the ordered `SessionScript[]` (primary + children) for a scenario, ready to bind to live sessions in first-call order. - `loadReplayScript(config)` — resolve the `ReplayEntry[]` for the primary session only (validated sidecar replacement/patches if present, else derived from the JSONL; fail-loud if the fixture is missing). -- `deriveReplayScript(events)` / `parseSessionLog(text)` / `parseSessionHeader(text)` — the pure helpers that turn a recorded session log into a script and read its header `id`/`createdAt`. A derived group must end in a `finish` chunk; a group without one is the fingerprint of a thrown `stream()` and must instead be expressed via an override sidecar. +- `deriveReplayScript(events)` / `parseSessionLog(text)` / `parseSessionHeader(text)` / `resolveScriptedEntry(entry, messages)` — the pure helpers that turn a recorded session log into a script, read its header `id`/`createdAt`, and resolve `{{fromRequest:...}}` placeholders against one live request. A derived group must end in a `finish` chunk; a group without one is the fingerprint of a thrown `stream()` and must instead be expressed via an override sidecar. - Types `ReplayEntry` / `ReplayOverrideDoc` / `ReplayOverridePatch` / `SessionScript` / `ReplayConfig` / `ReplayProviderConfig` / `ReplayModelConfig` / `ReplayHandle` / `Config`. ## Plugin export shape diff --git a/packages/support/llm-replay/README.zh.md b/packages/support/llm-replay/README.zh.md index 7720e2d1bc..e8c0ec225d 100644 --- a/packages/support/llm-replay/README.zh.md +++ b/packages/support/llm-replay/README.zh.md @@ -12,6 +12,8 @@ fixture 就是持久化的会话日志(`/session.jsonl`)。其 `as 有两种失败模式无法仅根据 `assistant/chunk` 重建:在产生任何分片前直接抛出异常(例如 HTTP 401,此时日志只有 `turn/end {error}` 而没有分片),以及取消或挂起(差异在时序,而非分片内容)。需要这些行为的场景可提供伴随文件(`/replay.override.json`):它可以替换派生脚本(裸 `ReplayEntry[]`),也可以增补派生脚本(`{ patches: [{ at, entry }] }`:保留所有从 JSONL 派生的调用,只替换指定的从 0 开始计数的调用索引;当 `at` 等于派生长度时,则在注入瞬态异常后的重试位置追加一次调用)。补丁索引不得重复。文件加载时会校验覆写文档、每个补丁和条目,以及每个分片的判别标签。`hang` 条目可以指定 `readyFile`;当前缀分片到达循环后、开始等待取消前,回放会写入这个空标记,使外部驱动程序无需观察展示层更新即可确定性地取消。 +脚本字符串可以内嵌 `{{fromRequest:}}`,用来填入静态伴随文件不可能预知的值——例如模型必须原样回填到 `update_goal` 的随机生成 goal id。回放时每个占位符针对实时请求解析:语料是请求消息的所有字符串叶子按换行拼接的结果,取该模式在语料中的最后一次匹配,用其第一个捕获组(无捕获组时用整个匹配)原位替换。模式匹配不到内容、模式非法、占位符未闭合都会明确报错;第一个 `}}` 即结束占位符,因此模式本身不能包含 `}}`。 + ## 嵌套 agent:每会话键控 父 agent 委托给进程内 subagent(子 agent)的场景会记录多个日志:父会话使用 `session.jsonl`,每个子会话各使用一个日志(`session.1.jsonl` 等)。每个 agent 都在同一上下文中作为独立的 `Session` 运行,因此回放必须为每个 agent 提供各自的脚本。 @@ -55,7 +57,7 @@ fixture 就是持久化的会话日志(`/session.jsonl`)。其 `as - `installLlmReplay(ctx, config)`:安装已配置回放适配器或 catch-all `llm/stream` 监听器;返回 `ReplayHandle`(包含用于保证 HMR(热模块替换)安全的 `dispose()`,以及清理阶段执行的 `assertConsumed()` 检查;后者确保每个已记录脚本都绑定到实时会话,且每个已绑定游标都已耗尽,从而将场景静默驱动的模型调用少于记录数转换为明确诊断)。在测试中使用它,可以不通过 Loader 或 env var 驱动回放。 - `loadSessionScripts(config)`:解析场景的有序的 `SessionScript[]`(主会话 + 子会话),准备按首次调用顺序绑定到实时会话。 - `loadReplayScript(config)`:只解析主会话的 `ReplayEntry[]`(如果伴随文件存在,则使用经校验的替换或补丁;否则从 JSONL 派生;fixture 缺失时明确报错)。 -- `deriveReplayScript(events)` / `parseSessionLog(text)` / `parseSessionHeader(text)`:将已记录会话日志转换为脚本并读取其 header `id`/`createdAt` 的纯辅助工具。派生分组必须以 `finish` 分片结束;没有该分片的分组是 `stream()` 抛出异常的指纹,必须改用 override sidecar 表达。 +- `deriveReplayScript(events)` / `parseSessionLog(text)` / `parseSessionHeader(text)` / `resolveScriptedEntry(entry, messages)`:将已记录会话日志转换为脚本、读取其 header `id`/`createdAt`、并针对单次实时请求解析 `{{fromRequest:...}}` 占位符的纯辅助工具。派生分组必须以 `finish` 分片结束;没有该分片的分组是 `stream()` 抛出异常的指纹,必须改用 override sidecar 表达。 - 类型 `ReplayEntry` / `ReplayOverrideDoc` / `ReplayOverridePatch` / `SessionScript` / `ReplayConfig` / `ReplayProviderConfig` / `ReplayModelConfig` / `ReplayHandle` / `Config`。 ## 插件导出形态 diff --git a/packages/support/llm-replay/src/index.ts b/packages/support/llm-replay/src/index.ts index bfb9859415..70dae88446 100644 --- a/packages/support/llm-replay/src/index.ts +++ b/packages/support/llm-replay/src/index.ts @@ -241,6 +241,90 @@ const REPLAY_CHUNK_TYPES = new Set([ 'finish', ]) +const FROM_REQUEST_OPEN = '{{fromRequest:' +const FROM_REQUEST_CLOSE = '}}' + +/** Collect every string leaf of one JSON-shaped value, in traversal order. */ +function collectStrings(value: unknown, out: string[]): void { + if (typeof value === 'string') { + out.push(value) + return + } + if (Array.isArray(value)) { + for (const item of value) collectStrings(item, out) + return + } + if (value !== null && typeof value === 'object') { + for (const item of Object.values(value)) collectStrings(item, out) + } +} + +/** Resolve one placeholder pattern against the request corpus; the LAST match wins. */ +function resolveFromRequest(pattern: string, corpus: string): string { + let regex: RegExp + try { + regex = new RegExp(pattern, 'g') + } catch (error) { + // RegExp construction only throws SyntaxError; String() carries its message. + throw new Error(`llm-replay: fromRequest has an invalid pattern ${JSON.stringify(pattern)}: ${String(error)}`) + } + let last: RegExpExecArray | undefined + for (const match of corpus.matchAll(regex)) last = match + if (last === undefined) { + throw new Error(`llm-replay: fromRequest pattern ${JSON.stringify(pattern)} matched nothing in the request`) + } + return last[1] ?? last[0] +} + +/** Replace every `{{fromRequest:}}` occurrence in one scripted string. */ +function substituteString(text: string, corpus: string): string { + let result = '' + let cursor = 0 + while (true) { + const open = text.indexOf(FROM_REQUEST_OPEN, cursor) + if (open === -1) return result + text.slice(cursor) + const close = text.indexOf(FROM_REQUEST_CLOSE, open + FROM_REQUEST_OPEN.length) + if (close === -1) { + throw new Error(`llm-replay: fromRequest placeholder is unterminated in ${JSON.stringify(text)}`) + } + const pattern = text.slice(open + FROM_REQUEST_OPEN.length, close) + result += text.slice(cursor, open) + resolveFromRequest(pattern, corpus) + cursor = close + FROM_REQUEST_CLOSE.length + } +} + +/** Deep-copy one JSON-shaped value with scripted placeholders resolved. */ +function substituteValue(value: unknown, corpus: string): unknown { + if (typeof value === 'string') { + return value.includes(FROM_REQUEST_OPEN) ? substituteString(value, corpus) : value + } + if (Array.isArray(value)) return value.map(item => substituteValue(item, corpus)) + if (value !== null && typeof value === 'object') { + return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, substituteValue(item, corpus)])) + } + return value +} + +/** + * Resolve every `{{fromRequest:}}` placeholder in one scripted entry + * against the live request. The corpus is every string leaf of the request + * messages joined by newlines; the pattern's LAST corpus match wins and its + * first capture group (or, without one, the whole match) substitutes in place. + * Scenario sidecars use this to script arguments no static file can know, + * such as a randomly minted goal id the model must echo back. A pattern that + * matches nothing, an invalid pattern, and an unterminated placeholder each + * fail loud. Patterns cannot contain `}}` — the first `}}` ends the placeholder. + * @param entry - the scripted entry about to replay. + * @param messages - the live request messages searched by the placeholders. + * @returns the entry itself when no placeholder appears, else a resolved deep copy. + */ +export function resolveScriptedEntry(entry: ReplayEntry, messages: GenerateOptions['messages']): ReplayEntry { + if (!JSON.stringify(entry).includes(FROM_REQUEST_OPEN)) return entry + const leaves: string[] = [] + collectStrings(messages, leaves) + return substituteValue(entry, leaves.join('\n')) as ReplayEntry +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } @@ -583,7 +667,7 @@ export function installLlmReplay(ctx: Context, config: ReplayConfig): ReplayHand + `but its script has only ${boundState.entries.length}; re-record the scenario`, ) } - yield* replayEntry(entry, options.signal, paceMs) + yield* replayEntry(resolveScriptedEntry(entry, options.messages), options.signal, paceMs) })() } const providers = config.providers ?? [] diff --git a/packages/support/llm-replay/tests/llm-replay.spec.ts b/packages/support/llm-replay/tests/llm-replay.spec.ts index d693c0571b..345aa22679 100644 --- a/packages/support/llm-replay/tests/llm-replay.spec.ts +++ b/packages/support/llm-replay/tests/llm-replay.spec.ts @@ -4,7 +4,7 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' import type { SessionEvent } from '@deepseek-ai/dsh-session' -import LlmService, { GenerateOptions, LlmAdapter, StreamChunk } from '@deepseek-ai/dsh-llm' +import LlmService, { CallId, createUserMessage, GenerateOptions, LlmAdapter, StreamChunk } from '@deepseek-ai/dsh-llm' import { type ReplayEntry, type SessionScript, @@ -17,6 +17,7 @@ import { name, parseSessionHeader, parseSessionLog, + resolveScriptedEntry, } from '../src/index.ts' /** @@ -310,6 +311,74 @@ describe('installLlmReplay (through the real LlmService)', () => { expect(await drain(ctx.llm.stream({ provider: 'm', model: 'm', messages: [] }))).toEqual(TEXT_CHUNKS) }) + describe('{{fromRequest:...}} substitution', () => { + const requestMessages = [createUserMessage({ + content: [{ type: 'text' as const, text: 'stale {"goal":{"id":"goal-old"}} then {"goal":{"id":"goal-42ab"}}' }], + source: { kind: 'user' as const }, + })] + + function scriptedCall(argumentsDelta: string): StreamChunk[] { + return [ + { type: 'block-start', index: 0, blockType: 'tool-call' }, + { type: 'tool-call-delta', index: 0, id: CallId('c1'), name: 'update_goal', argumentsDelta }, + { type: 'block-end', index: 0, block: { type: 'tool-call', id: CallId('c1'), name: 'update_goal', arguments: argumentsDelta } }, + { type: 'finish', reason: { kind: 'tool-calls' } }, + ] + } + + async function streamScripted(argumentsDelta: string): Promise { + writeLog(TEXT_CHUNKS) + const overrideFile = join(dir, 'replay.override.json') + writeFileSync(overrideFile, JSON.stringify([{ kind: 'chunks', chunks: scriptedCall(argumentsDelta) }]), 'utf8') + const ctx = new Context() + await ctx.plugin(LlmService) + installLlmReplay(ctx, { file, overrideFile }) + return drain(ctx.llm.stream({ provider: 'm', model: 'm', messages: requestMessages })) + } + + it('resolves the capture group from the LAST request match in every scripted string field', async () => { + const streamed = await streamScripted('{"goal_id":"{{fromRequest:"id":"(goal-[^"]+)"}}","revision":1}') + const delta = streamed.find(chunk => chunk.type === 'tool-call-delta') + expect(delta).toMatchObject({ argumentsDelta: '{"goal_id":"goal-42ab","revision":1}' }) + const end = streamed.find(chunk => chunk.type === 'block-end') + expect(end).toMatchObject({ block: { arguments: '{"goal_id":"goal-42ab","revision":1}' } }) + }) + + it('substitutes the whole match when the pattern has no capture group', async () => { + const streamed = await streamScripted('{"goal_id":"{{fromRequest:goal-[0-9a-z]+}}"}') + const delta = streamed.find(chunk => chunk.type === 'tool-call-delta') + expect(delta).toMatchObject({ argumentsDelta: '{"goal_id":"goal-42ab"}' }) + }) + + it('fails loud when a placeholder matches nothing in the request', async () => { + await expect(streamScripted('{"goal_id":"{{fromRequest:task-[0-9]+}}"}')) + .rejects.toThrow(/fromRequest.*matched nothing/) + }) + + it('fails loud on an invalid placeholder pattern', async () => { + await expect(streamScripted('{"goal_id":"{{fromRequest:(goal-}}"}')) + .rejects.toThrow(/fromRequest.*invalid pattern/) + }) + + it('fails loud on an unterminated placeholder', () => { + const entry: ReplayEntry = { kind: 'chunks', chunks: scriptedCall('{"goal_id":"{{fromRequest:goal-1"}') } + expect(() => resolveScriptedEntry(entry, requestMessages)).toThrow(/fromRequest placeholder is unterminated/) + }) + + it('returns the exact same entry when no placeholder appears', () => { + const entry: ReplayEntry = { kind: 'chunks', chunks: TEXT_CHUNKS } + expect(resolveScriptedEntry(entry, requestMessages)).toBe(entry) + }) + + it('skips non-string request leaves when building the corpus', () => { + const messages = requestMessages.map(message => ({ ...message, seq: 7 })) as unknown as GenerateOptions['messages'] + const entry: ReplayEntry = { kind: 'chunks', chunks: scriptedCall('{"goal_id":"{{fromRequest:goal-42[a-z]+}}"}') } + const resolved = resolveScriptedEntry(entry, messages) + if (resolved.kind !== 'chunks') throw new Error('expected chunks entry') + expect(resolved.chunks[1]).toMatchObject({ argumentsDelta: '{"goal_id":"goal-42ab"}' }) + }) + }) + it('registers a replay-only provider catalog when configured', async () => { writeLog(TEXT_CHUNKS) const ctx = new Context() From 666ef95f81058bb0dc6b99bcc359d97be267ee4c Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Sun, 2 Aug 2026 05:58:45 -0700 Subject: [PATCH 071/129] fix(tool-goal): let the model deliver a wrap-up message after a goal-round complete/blocked A goal round reporting complete or blocked used to conclude the turn at the tool result, so the model never spoke after the call and sessions ended on a bare update_goal card. The terminal update now defers one plugin-sourced / instruction onto its result asking for a grounded closing message without further tool calls; the turn then ends through the ordinary no-tool-calls stop. Direct-human mutations stay uninstructed. Wording chosen by A/B sampling on deepseek-v4-pro; one extra request per goal lifecycle. New keyless ACP snapshot goal-wrapup drives the shipped app through create -> round one -> autonomous complete and pins the injection, the same-turn closing message, and the completed turn end. --- ...-08-02-goal-round-wrapup-message.i18n.yaml | 6 ++ .../2026-08-02-goal-round-wrapup-message.md | 31 ++++++++++ ...2026-08-02-goal-round-wrapup-message.zh.md | 31 ++++++++++ ...26-07-19-model-facing-goal-tools.i18n.yaml | 6 +- .../2026-07-19-model-facing-goal-tools.md | 2 +- .../2026-07-19-model-facing-goal-tools.zh.md | 2 +- docs/config-catalog.md | 4 +- .../goal-snapshots/goal-wrapup/input.json | 13 ++++ .../goal-wrapup/replay.override.json | 42 +++++++++++++ .../goal-wrapup/session.expected.jsonl | 50 ++++++++++++++++ .../goal-snapshots/goal-wrapup/session.jsonl | 1 + .../goal-wrapup/stdout.expected.jsonl | 5 ++ examples/acp-agent/tests/goal.snapshot.ts | 59 +++++++++++++++++++ packages/goal/tool-goal/README.i18n.yaml | 4 +- packages/goal/tool-goal/README.md | 6 +- packages/goal/tool-goal/README.zh.md | 6 +- packages/goal/tool-goal/src/index.ts | 12 +++- packages/goal/tool-goal/src/wrapup.ts | 40 +++++++++++++ .../goal/tool-goal/tests/tool-goal.spec.ts | 33 ++++++++++- 19 files changed, 334 insertions(+), 19 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-02-goal-round-wrapup-message.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-02-goal-round-wrapup-message.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-02-goal-round-wrapup-message.zh.md create mode 100644 examples/acp-agent/tests/goal-snapshots/goal-wrapup/input.json create mode 100644 examples/acp-agent/tests/goal-snapshots/goal-wrapup/replay.override.json create mode 100644 examples/acp-agent/tests/goal-snapshots/goal-wrapup/session.expected.jsonl create mode 100644 examples/acp-agent/tests/goal-snapshots/goal-wrapup/session.jsonl create mode 100644 examples/acp-agent/tests/goal-snapshots/goal-wrapup/stdout.expected.jsonl create mode 100644 packages/goal/tool-goal/src/wrapup.ts diff --git a/.agents/notes/implemented/bug-fix/2026-08-02-goal-round-wrapup-message.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-02-goal-round-wrapup-message.i18n.yaml new file mode 100644 index 0000000000..c8784f5991 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-02-goal-round-wrapup-message.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/bug-fix/2026-08-02-goal-round-wrapup-message.md +2026-08-02-goal-round-wrapup-message.md: c6bc3d5912b0789efde55880c2be892e98e34a5b +2026-08-02-goal-round-wrapup-message.zh.md: 0a504b4dcc61feb932775b3d9ffd8424f3b0597d diff --git a/.agents/notes/implemented/bug-fix/2026-08-02-goal-round-wrapup-message.md b/.agents/notes/implemented/bug-fix/2026-08-02-goal-round-wrapup-message.md new file mode 100644 index 0000000000..c6bc3d5912 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-02-goal-round-wrapup-message.md @@ -0,0 +1,31 @@ +# Agent Note: Goal-round wrap-up message + +Status: implemented + +English | [中文](2026-08-02-goal-round-wrapup-message.zh.md) + +## Problem + +An autonomous goal round that reported `update_goal` `complete` or `blocked` concluded the physical turn at the tool result, so the model never spoke after the call. Sessions ended on a bare `update_goal` card, and internal testers read that as the agent stopping mid-sentence: the model's pre-call text routinely announces a report ("goal achieved, marking complete:") that never arrives, because the standard tool-use expectation is one more assistant message after a tool result and neither the goal-round prompt nor the tool description said the call was terminal. The hard stop came from the [goal-tool decision](../feature/2026-07-19-model-facing-goal-tools.md), whose turn-stop clause this note supersedes. + +## Decision + +A goal-round `complete` or `blocked` success no longer calls `concludeTurn()`. Instead the tool defers one wrap-up context onto its own result: a `{ kind: 'plugin', plugin: 'tool-goal' }`-sourced user message carrying a ``/`` instruction to write a grounded closing message to the user and call no more tools. The turn then ends through the agent loop's ordinary no-tool-calls stop, so no new loop primitive exists and steering semantics are untouched. Direct-human mutations remain uninstructed exactly as before. The cost is one additional model request per goal lifecycle, not per round. + +The instruction wording was selected by A/B sampling on `deepseek-v4-pro` with a reconstructed goal-round transcript: a structured instruction (outcome, verification, artifacts, next steps) consistently beat a minimal "summarize" one on completeness; adding a session-grounding clause shifted unsupported detail from asserted fact to hedged suggestion; and the no-instruction control produced high-variance closings, including confidently fabricated file-level detail. + +Scripting the keyless proof required one snapshot-harness addition: `dsh-llm-replay` resolves `{{fromRequest:}}` placeholders in scripted entries against the live request, because a static sidecar cannot know the randomly minted goal id the model must echo into `update_goal`. + +## Verification + +`tool-goal` package tests pin the injected context (source, tag, objective, no-more-tools clause) and the absent `concludesTurn` for both terminal actions, plus the uninstructed direct-human pause and complete paths, at 100% file coverage. `llm-replay` unit tests pin the placeholder contract: last-match-wins capture, whole-match fallback, and loud failures for unmatched, invalid, and unterminated patterns. The new keyless ACP snapshot `goal-wrapup` drives the shipped application through create → round one → autonomous complete and asserts the plugin-sourced wrap-up injection, the same-turn closing assistant message, and the `completed` turn end in both the durable session log and the ACP stdout stream. + +## Alternatives considered + +- **Surface the completion text on the `update_goal` UI card** — rejected: `complete` carries no free text today, and adding a `summary` argument would route a user-facing report through tool arguments while still cutting off the model's natural post-result message. +- **Keep `concludeTurn()` and add a "one more text-only step" loop primitive** — rejected: new `agent-loop` machinery for behavior the ordinary stop already provides once nothing concludes the turn. +- **Instruct inside the tool result content** — rejected: the goal tools' canonical output is compact JSON consumed programmatically; a prose instruction block inside it would mix the model-facing contract with the tool's replayable value. + +## Consequences + +Every autonomous goal ends with a user-facing closing message instead of a bare tool card, at the cost of one model request per goal lifecycle. `concludeTurn()` keeps its loop semantics but loses its only first-party caller outside subagent structured output. Snapshot scenarios can now script values that only exist at run time via `{{fromRequest:...}}`, which unblocks keyless coverage of any echo-an-id tool flow, goal or otherwise. diff --git a/.agents/notes/implemented/bug-fix/2026-08-02-goal-round-wrapup-message.zh.md b/.agents/notes/implemented/bug-fix/2026-08-02-goal-round-wrapup-message.zh.md new file mode 100644 index 0000000000..0a504b4dcc --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-02-goal-round-wrapup-message.zh.md @@ -0,0 +1,31 @@ +# Agent Note:Goal Round 收尾消息 + +Status: implemented + +[English](2026-08-02-goal-round-wrapup-message.md) | 中文 + +## 问题 + +自主 Goal Round 报告 `update_goal` `complete` 或 `blocked` 时,物理轮次在工具结果处直接终结,模型在调用之后再无发言机会。会话终止在一张裸的 `update_goal` 卡片上,内测同学的观感是 agent 话说到一半戛然而止:模型调用前的文本通常预告了一份汇报(“目标达成,标记完成:”)却永远没有下文,因为标准 tool-use 预期是工具结果之后还有一条 assistant 消息,而 Goal Round 提示词与工具描述都没有说明这次调用是终点。硬停止来自 [goal 工具决策](../feature/2026-07-19-model-facing-goal-tools.md),本 note 取代其中的轮次停止条款。 + +## 决策 + +Goal Round 的 `complete` 或 `blocked` 成功不再调用 `concludeTurn()`。工具改为在自己的结果上附带一条收尾上下文:以 `{ kind: 'plugin', plugin: 'tool-goal' }` 为 source 的 user 消息,携带 ``/`` 指令,要求模型向用户写出有依据的收尾消息且不再调用工具。之后轮次经由 agent loop 常规的无工具调用停止路径结束,因此不存在新的 loop 原语,steering 语义不受影响。人类直接变更保持原样、不注入指令。代价是每个 goal 生命周期一次额外模型请求,而非每轮一次。 + +指令措辞通过在 `deepseek-v4-pro` 上用重构的 Goal Round 转录做 A/B 采样选定:结构化指令(结果、验证、产物、后续)在完整度上稳定优于极简“总结一下”;补充“以会话内证据为准”的 grounding 条款让无依据细节从断言事实退为带保留的建议;而无指令对照组的收尾方差很大,包括言之凿凿的文件级细节编造。 + +为让 keyless 证明可脚本化,快照设施补了一项能力:`dsh-llm-replay` 会针对实时请求解析脚本条目中的 `{{fromRequest:}}` 占位符,因为静态伴随文件不可能预知模型必须回填进 `update_goal` 的随机生成 goal id。 + +## 验证 + +`tool-goal` 包测试钉住两个终态 action 注入的上下文(source、标签、objective、禁止再调工具条款)与不存在的 `concludesTurn`,以及人类直接 pause 与 complete 的不注入路径,文件覆盖率 100%。`llm-replay` 单元测试钉住占位符契约:最后一次匹配取胜的捕获、无捕获组时整体匹配回退,以及未匹配、非法、未闭合模式的明确报错。新增 keyless ACP 快照 `goal-wrapup` 驱动成品应用走完 create → 第一轮 → 自主 complete,并在持久会话日志与 ACP stdout 流中同时断言 plugin 来源的收尾注入、同轮内的收尾 assistant 消息与 `completed` 轮次结束。 + +## 曾考虑的替代方案 + +- **在 `update_goal` 的 UI 卡片上展示完成文本** — 拒绝:`complete` 如今不携带任何自由文本;新增 `summary` 参数会让面向用户的汇报走工具参数通道,而且依然砍掉了模型在结果之后的自然发言。 +- **保留 `concludeTurn()` 并新增“再多一步纯文本”的 loop 原语** — 拒绝:为常规停止路径已经能提供的行为(只要没有结果终结轮次)增加新的 `agent-loop` 机制。 +- **把指令写进工具结果内容** — 拒绝:goal 工具的规范输出是被程序化消费的紧凑 JSON;在其中混入散文指令会把模型侧契约和工具的可回放值搅在一起。 + +## Consequences + +每个自主 goal 都以一条面向用户的收尾消息结束,而非一张裸工具卡片,代价是每个 goal 生命周期一次模型请求。`concludeTurn()` 保留其 loop 语义,但在 subagent 结构化输出之外失去了唯一的一方调用者。快照场景现在可以通过 `{{fromRequest:...}}` 脚本化只在运行时才存在的值,为任何“回显 id”类工具流程(不限于 goal)解锁 keyless 覆盖。 diff --git a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml index 6cf16c04ad..1f787065ab 100644 --- a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml @@ -1,6 +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 -2026-07-19-model-facing-goal-tools.md: bc4305af80bb13ceeff1888d489dcd8a00132f94 -2026-07-19-model-facing-goal-tools.zh.md: b07f62aa526902c4b2e9c081777a76ca53783d31 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md +2026-07-19-model-facing-goal-tools.md: 18235c484194f5daf10556ebfc13bdc2d672be2e +2026-07-19-model-facing-goal-tools.zh.md: cc23a76e5faac2c203052d834ca0a87ca5dbed2a diff --git a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md index bc4305af80..18235c4841 100644 --- a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md +++ b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md @@ -22,7 +22,7 @@ The prompt tells the model that it may infer goal intent from a direct human req All three tools use exclusive execution so a model-ordered batch observes prior mutations and their new revisions. Results are compact JSON. UI presentation is a pure function of arguments and uses generic read or mutation cards; mutation cards select meaningful action values before the goal id, so accepted fillers cannot blank their input. Activation is reported only as live observation and is never written into replay state. -An autonomous goal round that successfully reports completion or blocking marks its tool result as concluding the physical turn, preventing an unnecessary follow-up request. Direct-human mutations do not conclude the turn: the assistant can acknowledge the change, and concurrent human steering remains available to ordinary stopping checks. +An autonomous goal round that successfully reports completion or blocking defers one wrap-up instruction onto its tool result so the model still addresses the user before the turn ends through the ordinary no-tool-calls stop; the original conclude-at-result stop is superseded by the [goal-round wrap-up decision](../bug-fix/2026-08-02-goal-round-wrapup-message.md). Direct-human mutations receive no instruction: the assistant can acknowledge the change, and concurrent human steering remains available to ordinary stopping checks. ### Execution authority diff --git a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md index b07f62aa52..cc23a76e5f 100644 --- a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md +++ b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md @@ -22,7 +22,7 @@ Status: implemented 三个工具都采用独占执行,使模型排序的批次可以观察此前变更及其新修订号。结果为紧凑 JSON。UI 展示是参数的纯函数,使用通用读取或变更卡片;变更卡片选择输入时,先取有实际意义的操作值,再取目标 id,因此允许的占位值不会使卡片输入留空。激活态仅作为实时观察返回,绝不会写入回放状态。 -自主目标回合成功报告完成或阻塞后,其工具结果会被标记为结束该物理轮次,避免再发起一次不必要的模型请求。直接人类发起的变更不会结束轮次:agent 可以确认该变更,并且并发的人类 steering(中途引导)仍可参与普通的停止检查。 +自主目标回合成功报告完成或阻塞后,其工具结果会附带一条收尾指令,模型仍会在轮次经由常规无工具调用停止路径结束前向用户发言;原先在结果处终结轮次的做法已被[Goal Round 收尾决策](../bug-fix/2026-08-02-goal-round-wrapup-message.md)取代。直接人类发起的变更不会收到指令:agent 可以确认该变更,并且并发的人类 steering(中途引导)仍可参与普通的停止检查。 ### 执行权限 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 2a40c7d800..c9fa141dd6 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -765,7 +765,7 @@ export interface ReplayModelConfig { Depends on: [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) -Source: [`packages/support/llm-replay/src/index.ts:617`](../packages/support/llm-replay/src/index.ts) +Source: [`packages/support/llm-replay/src/index.ts:701`](../packages/support/llm-replay/src/index.ts) ## `@deepseek-ai/dsh-llm-retry` @@ -1772,7 +1772,7 @@ export interface Config { } ``` -Source: [`packages/goal/tool-goal/src/index.ts:25`](../packages/goal/tool-goal/src/index.ts) +Source: [`packages/goal/tool-goal/src/index.ts:26`](../packages/goal/tool-goal/src/index.ts) ## `@deepseek-ai/dsh-tool-lsp` diff --git a/examples/acp-agent/tests/goal-snapshots/goal-wrapup/input.json b/examples/acp-agent/tests/goal-snapshots/goal-wrapup/input.json new file mode 100644 index 0000000000..8b6865a6ed --- /dev/null +++ b/examples/acp-agent/tests/goal-snapshots/goal-wrapup/input.json @@ -0,0 +1,13 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { + "op": "promptAndWaitForAgentMessage", + "text": "Create a durable goal for the wrap-up snapshot, then report readiness.", + "waitForText": "GOAL READY" + }, + { "op": "waitForTurnStart", "minimumTurn": 2 }, + { "op": "waitForTurnEnd" } + ] +} diff --git a/examples/acp-agent/tests/goal-snapshots/goal-wrapup/replay.override.json b/examples/acp-agent/tests/goal-snapshots/goal-wrapup/replay.override.json new file mode 100644 index 0000000000..d36b73f313 --- /dev/null +++ b/examples/acp-agent/tests/goal-snapshots/goal-wrapup/replay.override.json @@ -0,0 +1,42 @@ +[ + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "tool-call" }, + { "type": "tool-call-delta", "index": 0, "id": "call_goal_create", "name": "create_goal", "argumentsDelta": "{\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"max_goal_rounds\":2}" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_goal_create", "name": "create_goal", "arguments": "{\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"max_goal_rounds\":2}" } }, + { "type": "usage", "usage": { "inputTokens": 20, "outputTokens": 8 } }, + { "type": "finish", "reason": { "kind": "tool-calls" } } + ] + }, + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "text" }, + { "type": "text-delta", "index": 0, "text": "GOAL READY" }, + { "type": "block-end", "index": 0, "block": { "type": "text", "text": "GOAL READY" } }, + { "type": "usage", "usage": { "inputTokens": 28, "outputTokens": 2 } }, + { "type": "finish", "reason": { "kind": "stop" } } + ] + }, + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "tool-call" }, + { "type": "tool-call-delta", "index": 0, "id": "call_goal_complete", "name": "update_goal", "argumentsDelta": "{\"goal_id\":\"{{fromRequest:goal-[0-9a-f-]+}}\",\"revision\":1,\"action\":\"complete\"}" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_goal_complete", "name": "update_goal", "arguments": "{\"goal_id\":\"{{fromRequest:goal-[0-9a-f-]+}}\",\"revision\":1,\"action\":\"complete\"}" } }, + { "type": "usage", "usage": { "inputTokens": 40, "outputTokens": 9 } }, + { "type": "finish", "reason": { "kind": "tool-calls" } } + ] + }, + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "text" }, + { "type": "text-delta", "index": 0, "text": "GOAL WRAP-UP: the snapshot objective is achieved and this closing message reaches the user." }, + { "type": "block-end", "index": 0, "block": { "type": "text", "text": "GOAL WRAP-UP: the snapshot objective is achieved and this closing message reaches the user." } }, + { "type": "usage", "usage": { "inputTokens": 52, "outputTokens": 14 } }, + { "type": "finish", "reason": { "kind": "stop" } } + ] + } +] diff --git a/examples/acp-agent/tests/goal-snapshots/goal-wrapup/session.expected.jsonl b/examples/acp-agent/tests/goal-snapshots/goal-wrapup/session.expected.jsonl new file mode 100644 index 0000000000..bf708d211b --- /dev/null +++ b/examples/acp-agent/tests/goal-snapshots/goal-wrapup/session.expected.jsonl @@ -0,0 +1,50 @@ +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Create a durable goal for the wrap-up snapshot, then report readiness."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":0,"data":{"title":"Create a durable goal for","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"user/message","seq":3,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"step/start","seq":4,"time":0,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":5,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":6,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_goal_create","name":"create_goal","argumentsDelta":"{\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"max_goal_rounds\":2}"}}} +{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"max_goal_rounds\":2}"}}}} +{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":20,"outputTokens":8}}}} +{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":12,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"max_goal_rounds\":2}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":20,"outputTokens":8}},"sourceEventSeqs":[7,8,9,10,11],"surfaceOp":"append"} +{"type":"tool/call","seq":13,"time":0,"data":{"turn":1,"step":1,"callId":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"max_goal_rounds\":2}"}} +{"type":"tool/result","seq":14,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_goal_create"},"content":[{"type":"tool-result","toolCallId":"call_goal_create","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":2},\"activation\":\"armed\"}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[13],"surfaceOp":"append"} +{"type":"user/message","seq":15,"time":0,"data":{"content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"phase\":\"active\",\"maxGoalRounds\":2},\"roundsStarted\":0,\"createdAt\":0,\"updatedAt\":0}"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":0,"change":{"kind":"goal/change","version":1,"operation":"create","goal":{"id":"goal-{{sessionId}}","revision":1,"objective":"Finish the ACP goal wrap-up snapshot proof","phase":"active","maxGoalRounds":2},"roundsStarted":0,"createdAt":0,"updatedAt":0}},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"step/end","seq":16,"time":0,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":17,"time":0,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"GOAL READY"}}} +{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"GOAL READY"}}}} +{"type":"assistant/chunk","seq":21,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":28,"outputTokens":2}}}} +{"type":"assistant/chunk","seq":22,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":23,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"GOAL READY"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":28,"outputTokens":2}},"sourceEventSeqs":[18,19,20,21,22],"surfaceOp":"append"} +{"type":"step/end","seq":24,"time":0,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":25,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"turn/start","seq":26,"time":0,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":1}}}} +{"type":"user/message","seq":27,"time":0,"data":{"content":[{"type":"text","text":"\nObjective: \"Finish the ACP goal wrap-up snapshot proof\"\nRound: 1/2\n\nContinue working toward the objective in this same session. Treat the current workspace, tool results, and durable session state as authoritative; inspect them instead of assuming earlier narration is still current. Make concrete progress and verify the result. Before claiming completion, gather evidence that the whole objective is achieved, read the current goal, and mark it complete. If work remains, leave the goal active for the next round. Follow the configured goal-tool policy before reporting a blocker.\n"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":1},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"step/start","seq":28,"time":0,"data":{"turn":2,"step":1}} +{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":30,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_goal_complete","name":"update_goal","argumentsDelta":"{\"goal_id\":\"goal-{{sessionId}}\",\"revision\":1,\"action\":\"complete\"}"}}} +{"type":"assistant/chunk","seq":31,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_complete","name":"update_goal","arguments":"{\"goal_id\":\"goal-{{sessionId}}\",\"revision\":1,\"action\":\"complete\"}"}}}} +{"type":"assistant/chunk","seq":32,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":40,"outputTokens":9}}}} +{"type":"assistant/chunk","seq":33,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":34,"time":0,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_goal_complete","name":"update_goal","arguments":"{\"goal_id\":\"goal-{{sessionId}}\",\"revision\":1,\"action\":\"complete\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":40,"outputTokens":9}},"sourceEventSeqs":[29,30,31,32,33],"surfaceOp":"append"} +{"type":"tool/call","seq":35,"time":0,"data":{"turn":2,"step":1,"callId":"call_goal_complete","name":"update_goal","arguments":"{\"goal_id\":\"goal-{{sessionId}}\",\"revision\":1,\"action\":\"complete\"}"}} +{"type":"tool/result","seq":36,"time":0,"data":{"turn":2,"step":1,"message":{"source":{"kind":"tool","callId":"call_goal_complete"},"content":[{"type":"tool-result","toolCallId":"call_goal_complete","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":2,\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"phase\":\"complete\",\"roundsStarted\":1,\"maxGoalRounds\":2},\"activation\":\"disarmed\"}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[35],"surfaceOp":"append"} +{"type":"user/message","seq":37,"time":0,"data":{"content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":2,\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"phase\":\"complete\",\"maxGoalRounds\":2},\"roundsStarted\":1,\"createdAt\":0,\"updatedAt\":0}"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":2,"round":0,"change":{"kind":"goal/change","version":1,"operation":"complete","goal":{"id":"goal-{{sessionId}}","revision":2,"objective":"Finish the ACP goal wrap-up snapshot proof","phase":"complete","maxGoalRounds":2},"roundsStarted":1,"createdAt":0,"updatedAt":0}},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","seq":38,"time":0,"data":{"content":[{"type":"text","text":"\nObjective: \"Finish the ACP goal wrap-up snapshot proof\"\nThe goal is marked complete and this autonomous run is ending. Write the closing message to the user now: state the outcome, summarize what was done and how it was verified, and point to the concrete results (files, commits, or other artifacts). Report only what earlier rounds and tool results in this session actually establish; when a detail is not in the session, say so instead of inventing it. Note anything the user should review or do next. Address the user directly. Do not call any more tools.\n"}],"source":{"kind":"plugin","plugin":"tool-goal"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"step/end","seq":39,"time":0,"data":{"turn":2,"step":1}} +{"type":"step/start","seq":40,"time":0,"data":{"turn":2,"step":2}} +{"type":"assistant/chunk","seq":41,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":42,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"text-delta","index":0,"text":"GOAL WRAP-UP: the snapshot objective is achieved and this closing message reaches the user."}}} +{"type":"assistant/chunk","seq":43,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"GOAL WRAP-UP: the snapshot objective is achieved and this closing message reaches the user."}}}} +{"type":"assistant/chunk","seq":44,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":52,"outputTokens":14}}}} +{"type":"assistant/chunk","seq":45,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":46,"time":0,"data":{"turn":2,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"GOAL WRAP-UP: the snapshot objective is achieved and this closing message reaches the user."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":52,"outputTokens":14}},"sourceEventSeqs":[41,42,43,44,45],"surfaceOp":"append"} +{"type":"step/end","seq":47,"time":0,"data":{"turn":2,"step":2}} +{"type":"turn/end","seq":48,"time":0,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/goal-snapshots/goal-wrapup/session.jsonl b/examples/acp-agent/tests/goal-snapshots/goal-wrapup/session.jsonl new file mode 100644 index 0000000000..94002ff85d --- /dev/null +++ b/examples/acp-agent/tests/goal-snapshots/goal-wrapup/session.jsonl @@ -0,0 +1 @@ +{"type":"session","version":0,"id":"goal-wrapup-placeholder","createdAt":0,"cwd":"{{cwd}}"} diff --git a/examples/acp-agent/tests/goal-snapshots/goal-wrapup/stdout.expected.jsonl b/examples/acp-agent/tests/goal-snapshots/goal-wrapup/stdout.expected.jsonl new file mode 100644 index 0000000000..e5c0dbb921 --- /dev/null +++ b/examples/acp-agent/tests/goal-snapshots/goal-wrapup/stdout.expected.jsonl @@ -0,0 +1,5 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"GOAL READY"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"GOAL WRAP-UP: the snapshot objective is achieved and this closing message reaches the user."}}}} diff --git a/examples/acp-agent/tests/goal.snapshot.ts b/examples/acp-agent/tests/goal.snapshot.ts index b6a44cb66c..a7757eacf7 100644 --- a/examples/acp-agent/tests/goal.snapshot.ts +++ b/examples/acp-agent/tests/goal.snapshot.ts @@ -21,6 +21,7 @@ const fixtureFile = join(scenarioDir, 'session.jsonl') const overrideFile = join(scenarioDir, 'replay.override.json') const stdoutExpected = join(scenarioDir, 'stdout.expected.jsonl') const sessionExpected = join(scenarioDir, 'session.expected.jsonl') +const wrapupDir = join(dirname(fileURLToPath(import.meta.url)), 'goal-snapshots/goal-wrapup') const refreshing = process.env.DSH_SNAPSHOT === 'refresh' const agent: AgentUnderTest = { @@ -112,4 +113,62 @@ describe('same-session goal snapshot through the ACP automation driver', () => { expect(stdout).toBe(await readFile(stdoutExpected, 'utf8')) expect(session).toBe(await readFile(sessionExpected, 'utf8')) }) + + it('injects the wrap-up instruction after an autonomous completion and delivers a closing message', async () => { + const input = JSON.parse(await readFile(join(wrapupDir, 'input.json'), 'utf8')) as InputScript + const result = await runScenario(input, { + agent, + mode: 'replay', + fixtureFile: join(wrapupDir, 'session.jsonl'), + overrideFile: join(wrapupDir, 'replay.override.json'), + configPath: agent.configPath, + }) + + expect(result.stderr).toBe('') + expect(result.sessionLogs).toHaveLength(1) + const log = result.sessionLogs[0] + if (log === undefined) throw new Error('goal wrap-up snapshot did not persist its session') + const records = parseJsonl(log.content) + const events = records.slice(1) as unknown as SessionEvent[] + const calls = events.filter(event => event.type === 'tool/call').map(event => event.data.name) + expect(calls).toEqual(['create_goal', 'update_goal']) + expect(foldGoal(events)).toMatchObject({ + goal: { + objective: 'Finish the ACP goal wrap-up snapshot proof', + phase: 'complete', + revision: 2, + }, + roundsStarted: 1, + }) + // The wrap-up instruction is one plugin-sourced context injected after the + // terminal tool result, and the model still answers inside the same turn. + const wrapups = events.filter(event => event.type === 'user/message' + && event.data.source.kind === 'plugin' && event.data.source.plugin === 'tool-goal') + expect(wrapups).toHaveLength(1) + const wrapupText = wrapups.map(event => event.type === 'user/message' ? event.data.content : [])[0] + expect(JSON.stringify(wrapupText)).toContain('') + const closing = events.filter(event => event.type === 'assistant/message') + .flatMap(event => event.data.message.content) + .filter(block => block.type === 'text' && block.text.startsWith('GOAL WRAP-UP')) + expect(closing).toHaveLength(1) + const roundTurnEnds = events.filter(event => event.type === 'turn/end' && event.data.turn === 2) + expect(roundTurnEnds).toEqual([expect.objectContaining({ data: { turn: 2, reason: { kind: 'completed' } } })]) + + const context: NormalizeContext = { + sessionIds: [result.sessionId, log.id].filter((id): id is string => id !== undefined), + cwd: result.cwd, + } + const stdout = normalizeStdout(result.rawStdout, context) + const session = normalizeGoalLog(log.content, context) + const wrapupStdoutExpected = join(wrapupDir, 'stdout.expected.jsonl') + const wrapupSessionExpected = join(wrapupDir, 'session.expected.jsonl') + if (refreshing) { + await Promise.all([ + writeFile(wrapupStdoutExpected, stdout), + writeFile(wrapupSessionExpected, session), + ]) + } + expect(stdout).toBe(await readFile(wrapupStdoutExpected, 'utf8')) + expect(session).toBe(await readFile(wrapupSessionExpected, 'utf8')) + }) }) diff --git a/packages/goal/tool-goal/README.i18n.yaml b/packages/goal/tool-goal/README.i18n.yaml index 3b9dd9f964..354456002b 100644 --- a/packages/goal/tool-goal/README.i18n.yaml +++ b/packages/goal/tool-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/goal/tool-goal/README.md -README.md: aaed61dd517aeb2f94efa22c34c64d1068155d46 -README.zh.md: 5365b64ef65fb3d3f00e19357327479ebd8285a8 +README.md: 2fa80c2e5fa3d675a48fc18506635fd811ac8f80 +README.zh.md: c6c39e3cc739fb39a4a36080db5246e7c7349147 diff --git a/packages/goal/tool-goal/README.md b/packages/goal/tool-goal/README.md index aaed61dd51..2fa80c2e5f 100644 --- a/packages/goal/tool-goal/README.md +++ b/packages/goal/tool-goal/README.md @@ -14,7 +14,7 @@ All calls are exclusive, so a model-ordered batch observes earlier mutations and All three canonical values match the compact JSON already rendered to Native callers: `{ goal: null }` or `{ goal: { id, revision, objective, phase, roundsStarted, maxGoalRounds, blockedReason? }, activation }`. Programmatic consumers therefore receive the same domain structure without parsing the rendered JSON. -An autonomous goal round that successfully reports `complete` or `blocked` marks that tool execution with `concludeTurn()` so the physical turn stops after the step. Direct-human mutations never contribute this stop: the assistant may acknowledge the change and concurrent human steering remains available to the loop. +An autonomous goal round that successfully reports `complete` or `blocked` defers one wrap-up context onto that tool result: an injected instruction telling the model to write a final closing message to the user and call no more tools, after which the turn ends through the ordinary no-tool-calls stop. Direct-human mutations receive no instruction: the assistant may acknowledge the change and concurrent human steering remains available to the loop. ## Authority @@ -61,11 +61,11 @@ Prefix-stable while the plugin scope, configured threshold, and guidance text ar #### What the model sees -The generated [`get_goal`, `create_goal`, and `update_goal` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-goal). Successful results are compact JSON. Mutation results are followed by the goal domain's raw `` snapshot after the tool batch. `activation` in a result is a live observation and never becomes replay authority. +The generated [`get_goal`, `create_goal`, and `update_goal` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-goal). Successful results are compact JSON. Mutation results are followed by the goal domain's raw `` snapshot after the tool batch. `activation` in a result is a live observation and never becomes replay authority. A goal-round `complete` or `blocked` result additionally injects one ``/`` wrap-up instruction that asks for a grounded closing message to the user without further tool calls. #### Token effect -Fixed schema cost plus one compact result per call. Mutations also retain the domain snapshot until compaction. +Fixed schema cost plus one compact result per call. Mutations also retain the domain snapshot until compaction. A goal-round terminal update adds the injected wrap-up instruction and one further model request for the closing message — once per goal lifecycle, not per round. #### KV Cache effect diff --git a/packages/goal/tool-goal/README.zh.md b/packages/goal/tool-goal/README.zh.md index 5365b64ef6..c6c39e3cc7 100644 --- a/packages/goal/tool-goal/README.zh.md +++ b/packages/goal/tool-goal/README.zh.md @@ -14,7 +14,7 @@ 3 个规范值都与已经渲染给 Native 调用方的紧凑 JSON 一致:`{ goal: null }` 或 `{ goal: { id, revision, objective, phase, roundsStarted, maxGoalRounds, blockedReason? }, activation }`。因此,编程消费方无需解析渲染后的 JSON,即可收到相同领域结构。 -自主 Goal Round 成功报告 `complete` 或 `blocked` 时,会用 `concludeTurn()` 标记该次工具执行,使物理轮次在该步骤后停止。人类直接变更绝不会导致这种停止:assistant 可以确认变更,循环仍可接收并发的人类 steering(中途引导)。 +自主 Goal Round 成功报告 `complete` 或 `blocked` 时,会在该次工具结果上附带一条收尾注入指令,要求模型面向用户写出最终收尾消息、不再调用工具,之后轮次经由常规的无工具调用停止路径结束。人类直接变更不会收到这条指令:assistant 可以确认变更,循环仍可接收并发的人类 steering(中途引导)。 ## 权限 @@ -61,11 +61,11 @@ Use goal tools for one long-running completion objective in the current session. #### 模型看到的内容 -生成的 [`get_goal`、`create_goal` 和 `update_goal` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-goal)。成功结果是紧凑 JSON。变更结果之后是工具批次结束后由 goal 领域产生的原始 `` 快照。结果中的 `activation` 是实时观察值,绝不会成为回放权限依据。 +生成的 [`get_goal`、`create_goal` 和 `update_goal` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-goal)。成功结果是紧凑 JSON。变更结果之后是工具批次结束后由 goal 领域产生的原始 `` 快照。结果中的 `activation` 是实时观察值,绝不会成为回放权限依据。Goal Round 的 `complete`/`blocked` 结果还会额外注入一条 ``/`` 收尾指令,要求模型向用户写出有依据的收尾消息且不再调用工具。 #### Token 影响 -固定 schema 成本,加上每次调用的一条紧凑结果。变更还会保留领域快照,直到压缩(compaction)。 +固定 schema 成本,加上每次调用的一条紧凑结果。变更还会保留领域快照,直到压缩(compaction)。Goal Round 的终态更新会增加注入的收尾指令和一次额外的模型请求用于收尾消息——每个 goal 生命周期一次,而非每轮一次。 #### KV Cache 影响 diff --git a/packages/goal/tool-goal/src/index.ts b/packages/goal/tool-goal/src/index.ts index e62510b06f..9ceed21bc9 100644 --- a/packages/goal/tool-goal/src/index.ts +++ b/packages/goal/tool-goal/src/index.ts @@ -8,7 +8,7 @@ import type { Context } from 'cordis' import z from 'schemastery' import { GoalId } from '@deepseek-ai/dsh-goal' import type { GoalRef, GoalView } from '@deepseek-ai/dsh-goal' -import { HarnessError } from '@deepseek-ai/dsh-llm' +import { createUserMessage, HarnessError } from '@deepseek-ai/dsh-llm' import { defineTool } from '@deepseek-ai/dsh-tools' import type { GenericCallView } from '@deepseek-ai/dsh-tools' import type {} from '@deepseek-ai/dsh-system-prompt' @@ -17,6 +17,7 @@ import { goalToolExecution, requireDirectHuman, } from './authority.ts' +import { renderWrapupContext } from './wrapup.ts' export const name = 'tool-goal' export const inject = ['agents', 'goals', 'tools', 'systemPrompt'] @@ -309,7 +310,14 @@ export function apply(ctx: Context, config: Config): void { code: 'model-reported', message: args.blocked_reason as string, }) - if (authority.kind === 'goal-round') exec.concludeTurn() + if (authority.kind === 'goal-round') { + exec.deferContext(createUserMessage({ + content: args.action === 'complete' + ? renderWrapupContext(goal.objective) + : renderWrapupContext(goal.objective, args.blocked_reason as string), + source: { kind: 'plugin', plugin: 'tool-goal' }, + })) + } return Promise.resolve(goalValue(goal)) }, presentCall: args => present( diff --git a/packages/goal/tool-goal/src/wrapup.ts b/packages/goal/tool-goal/src/wrapup.ts new file mode 100644 index 0000000000..4f16fdd924 --- /dev/null +++ b/packages/goal/tool-goal/src/wrapup.ts @@ -0,0 +1,40 @@ +/** Model-visible wrap-up instruction for a terminal autonomous goal update. */ + +import type { ContentBlock } from '@deepseek-ai/dsh-llm' + +const GROUNDING = + 'Report only what earlier rounds and tool results in this session actually establish; ' + + 'when a detail is not in the session, say so instead of inventing it. ' + +/** + * Render the closing-message instruction injected after an autonomous goal + * round reports `complete` or `blocked`, replacing the former hard turn stop + * so the model still addresses the user once before the turn ends. + * @param objective - the terminal goal's objective, echoed for grounding. + * @param blockedReason - the validated report for `blocked`; omitted for `complete`. + * @returns a fresh one-block context for `ToolRunContext.deferContext()`. + */ +export function renderWrapupContext(objective: string, blockedReason?: string): ContentBlock[] { + const heading = `Objective: ${JSON.stringify(objective)}\n` + const text = blockedReason === undefined + ? '\n' + + heading + + 'The goal is marked complete and this autonomous run is ending. Write the closing ' + + 'message to the user now: state the outcome, summarize what was done and how it was ' + + 'verified, and point to the concrete results (files, commits, or other artifacts). ' + + GROUNDING + + 'Note anything the user should review or do next. Address the user directly. Do not ' + + 'call any more tools.\n' + + '' + : '\n' + + heading + + `Blocked: ${JSON.stringify(blockedReason)}\n` + + 'The goal is marked blocked and this autonomous run is ending. Write the closing ' + + 'message to the user now: state what has been completed so far, describe the concrete ' + + 'blocking condition and what you tried, and say exactly what you need from the user to ' + + 'continue. ' + + GROUNDING + + 'Address the user directly. Do not call any more tools.\n' + + '' + return [{ type: 'text', text }] +} diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index 4278fc5a50..2461a7d5fc 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -347,7 +347,7 @@ describe('goal tool state transitions', () => { expect(goal).toMatchObject({ phase: 'active', revision: 4 }) }) - it('terminal-stops an autonomous completion but leaves a human pause interactive', async () => { + it('injects one wrap-up instruction for an autonomous completion but leaves a human pause interactive', async () => { const { ctx, root } = await harness() const humanTurn = openTurn(root, { kind: 'user' }) const created = ctx.goals.create(root.agent, { objective: 'pause cleanly' }) @@ -356,6 +356,7 @@ describe('goal tool state transitions', () => { }, root.agent) expect(resultGoal(paused)).toMatchObject({ phase: 'paused' }) expect(paused.concludesTurn).toBeUndefined() + expect(paused.additionalContexts).toBeUndefined() const resumed = resultGoal(await execute(ctx, 'update_goal', { goal_id: created.id, revision: 2, action: 'resume', }, root.agent)) @@ -368,7 +369,27 @@ describe('goal tool state transitions', () => { goal_id: created.id, revision: resumed['revision'], action: 'complete', }, root.agent) expect(resultGoal(complete)).toMatchObject({ phase: 'complete' }) - expect(complete.concludesTurn).toBe(true) + expect(complete.concludesTurn).toBeUndefined() + const contexts = complete.additionalContexts ?? [] + expect(contexts).toHaveLength(1) + expect(contexts[0]?.source).toEqual({ kind: 'plugin', plugin: 'tool-goal' }) + const block = contexts[0]?.content[0] + if (block?.type !== 'text') throw new Error('expected one text wrap-up block') + expect(block.text).toContain('') + expect(block.text).toContain('"pause cleanly"') + expect(block.text).toContain('Do not call any more tools.') + }) + + it('completes without a wrap-up instruction under direct human authority', async () => { + const { ctx, root } = await harness() + openTurn(root, { kind: 'user' }) + const created = ctx.goals.create(root.agent, { objective: 'finish now' }) + const complete = await execute(ctx, 'update_goal', { + goal_id: created.id, revision: created.revision, action: 'complete', + }, root.agent) + expect(resultGoal(complete)).toMatchObject({ phase: 'complete' }) + expect(complete.concludesTurn).toBeUndefined() + expect(complete.additionalContexts).toBeUndefined() }) it('rearms a restored active goal only after a new direct human prompt', async () => { @@ -550,6 +571,14 @@ describe('goal tool state transitions', () => { blockedReason: { code: 'model-reported', message: 'The required credential is still unavailable.' }, roundsStarted: 3, }) + expect(blocked.concludesTurn).toBeUndefined() + const contexts = blocked.additionalContexts ?? [] + expect(contexts).toHaveLength(1) + const block = contexts[0]?.content[0] + if (block?.type !== 'text') throw new Error('expected one text wrap-up block') + expect(block.text).toContain('') + expect(block.text).toContain('The required credential is still unavailable.') + expect(block.text).toContain('Do not call any more tools.') }) it('lets direct human authority block before the model threshold', async () => { From 711a33ea8dbe69c852d6364f665dedf830818b9a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 21:22:39 +0800 Subject: [PATCH 072/129] fix(web): keep known subagent chooser visible --- ...07-27-web-subagent-conversations.i18n.yaml | 4 +- .../2026-07-27-web-subagent-conversations.md | 6 +-- ...026-07-27-web-subagent-conversations.zh.md | 6 +-- .../stale-catalog.expected.md | 3 ++ apps/web/tests/subagent-conversation.e2e.ts | 54 +++++++++++++++++++ .../src/client/SubagentCatalogAction.tsx | 19 +++++-- .../tests/conversation-ui.spec.tsx | 26 +++++++++ 7 files changed, 107 insertions(+), 11 deletions(-) create mode 100644 apps/web/tests/snapshots/subagent-conversation/stale-catalog.expected.md diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml index bd780ca60d..dc59a0ccb1 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.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 .agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md -2026-07-27-web-subagent-conversations.md: 34acb1410cf6316bca2980ed012046ffab9623f6 -2026-07-27-web-subagent-conversations.zh.md: 5dcd7025c5cd03fed34266834795de1f2b630648 +2026-07-27-web-subagent-conversations.md: b959fd35a4f5e2a6fa68deed8776ccbae86a0647 +2026-07-27-web-subagent-conversations.zh.md: dc0297d92acb5ab05dcdc5c682fd0a7fe2a2a18a diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md index 34acb1410c..b959fd35a4 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md @@ -37,7 +37,7 @@ The Figma [subagent list](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Ha ## Product contract -The header action is absent only after a complete empty direct-catalog response. Its trigger counts every known session-summary descendant reached through an uninterrupted `origin: 'subagent'` lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. Every healthy direct-catalog row carries a read-time `hasChildren` hint derived only from direct lineage headers with durable `origin: 'subagent'`; normal healthy and diagnostic subagent candidates carry that marker, while ordinary forks do not. This lookahead reads no descendant event log, and the descriptor-backed catalog loaded after disclosure remains authoritative. The UI omits disclosure for a known leaf before interaction; the hint does not promise that the child will remain a leaf. While an expanded direct catalog is loading, known lineage reserves one disabled loading row per direct descendant without recursively fetching descendant catalogs. The tree then presents continuable and one-shot rows, falling back to the session id when an optional one-shot label is absent. Corrupt, unsupported, and unavailable candidates remain visible as disabled diagnostic rows. +The header action is absent only when a complete empty direct-catalog response agrees with the session-summary projection that no subagent descendants are known. Its trigger counts every known session-summary descendant reached through an uninterrupted `origin: 'subagent'` lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. Every healthy direct-catalog row carries a read-time `hasChildren` hint derived only from direct lineage headers with durable `origin: 'subagent'`; normal healthy and diagnostic subagent candidates carry that marker, while ordinary forks do not. This lookahead reads no descendant event log, and the descriptor-backed catalog loaded after disclosure remains authoritative. When summaries establish descendants before that catalog exists or after a stale empty response, the action stays visible and exposes only disabled loading rows until opening it refreshes the catalog; summary-only rows never grant navigation. The UI omits disclosure for a known leaf before interaction; the hint does not promise that the child will remain a leaf. While an expanded direct catalog is loading, known lineage reserves one disabled loading row per direct descendant without recursively fetching descendant catalogs. The tree then presents continuable and one-shot rows, falling back to the session id when an optional one-shot label is absent. Corrupt, unsupported, and unavailable candidates remain visible as disabled diagnostic rows. `running` means the exact child Agent driver is draining work at the Host sampling boundary; `inactive` means that driver is idle or absent. The UI does not translate either value into success, failure, cancellation, completeness, or resumability. `subagent.list` supplies the current driver-status baseline, `host/session-status` updates known activity in place, request-local replay prevents an older in-flight list response from overwriting a newer transition, and `host/session-removed` returns a known row to `inactive`; reconnect reads a fresh baseline. A `host/session-added` frame for a direct subagent immediately flips any loaded parent row to `hasChildren: true`, and that positive hint survives an older in-flight catalog response; membership, labels, mode, diagnostics, and the authoritative snapshot still require a debounced `subagent.list` refresh while the affected branch is open. A prompt response remains delivery-time authority. @@ -102,8 +102,8 @@ The shipped Web composition mounts SQLite session query beside JSONL persistence - Host protocol tests pin schemas including required boolean expandability, id echoing, mode verification, non-activating history, exact-parent enforcement, FIFO admission receipts, cancellation, and sanitized failure mapping. - Generic Host tests pin attached and cold history and forks without Agent publication, cold projection folding, descriptor/origin/runtime-owner denial, explicit-id adoption denial, and the direct queue-control fence. - Client object tests pin retained and restored addresses, one-shot read-only rejection, history routing, continuable prompt routing, no addressed cancellation, suppression of Agent-bound model controls, live activity flips including in-flight response replay and detach fallback, subagent-parent expandability flips, and membership refresh. -- jsdom tests pin the aggregate descendant count and activity, known loading-row shape, mixed-mode rows, pre-click leaf disclosure, diagnostics, lazy descendant disclosure, direct-parent addresses, keyboard behavior, and both read-only reasons. -- The keyless assembled Web snapshot contains an inactive continuable child, an inactive one-shot sibling, and a persisted grandchild; it pins the three-descendant trigger and aggregate running transition, expands without activation, opens persisted history, admits a human FIFO follow-up, reconciles child mux events, and proves one-shot history remains read-only. +- jsdom tests pin the aggregate descendant count and activity, the summary-backed root action across absent and stale-empty catalogs, known loading-row shape, mixed-mode rows, pre-click leaf disclosure, diagnostics, lazy descendant disclosure, direct-parent addresses, keyboard behavior, and both read-only reasons. +- The keyless assembled Web snapshot contains an inactive continuable child, an inactive one-shot sibling, and a persisted grandchild; it pins the three-descendant trigger across a stale empty catalog response and aggregate running transition, expands without activation, opens persisted history, admits a human FIFO follow-up, reconciles child mux events, and proves one-shot history remains read-only. - Navigation tests pin subagent-only breadcrumbs, workspace placement for forks created from subagents, and `origin: 'subagent'` sidebar filtering without hiding ordinary forks. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md index 5dcd7025c5..dc0297d92a 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md @@ -37,7 +37,7 @@ Figma 中的 [subagent 列表](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5 ## 产品契约 -只有在完整的直接目录响应为空后,才不显示页头操作。其触发器会统计经不间断的 `origin: 'subagent'` 谱系可达的每个已知会话摘要后代,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。每个健康的直接目录行都携带读取时的 `hasChildren` 提示,该值只根据持久化 `origin: 'subagent'` 的直接谱系 header 派生;正常的健康与 diagnostic subagent 候选都会携带该标记,而普通 fork 不会。该预查不读取任何后代事件日志,展开后仍以描述符支撑的目录为权威依据。UI 会在交互前就省略已知叶子节点的展开控件;该提示不承诺 child 会一直是叶子。已展开的直接目录加载期间,已知谱系会为每个直接后代预留一行禁用的加载行,而不会递归获取后代目录。随后树会呈现可继续与 one-shot 行;one-shot 的可选 label 缺失时,回退到其会话 id。损坏、不受支持或不可用的候选仍以禁用的 diagnostic 行显示。 +只有当完整的直接目录空响应与会话摘要投影相符,二者均表明没有已知的 subagent 后代时,才不显示页头操作。其触发器会统计经不间断的 `origin: 'subagent'` 谱系可达的每个已知会话摘要后代,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。每个健康的直接目录行都携带读取时的 `hasChildren` 提示,该值只根据持久化 `origin: 'subagent'` 的直接谱系 header 派生;正常的健康与 diagnostic subagent 候选都会携带该标记,而普通 fork 不会。该预查不读取任何后代事件日志,展开后仍以描述符支撑的目录为权威依据。当摘要在该目录尚不存在时或在一次陈旧的空响应后确认已有后代时,该操作会保持可见,并且在打开它以刷新目录之前仅显示禁用的加载行;仅由摘要支撑的行绝不会提供导航能力。UI 会在交互前就省略已知叶子节点的展开控件;该提示不承诺 child 会一直是叶子。已展开的直接目录加载期间,已知谱系会为每个直接后代预留一行禁用的加载行,而不会递归获取后代目录。随后树会呈现可继续与 one-shot 行;one-shot 的可选 label 缺失时,回退到其会话 id。损坏、不受支持或不可用的候选仍以禁用的 diagnostic 行显示。 `running` 表示在 Host 采样边界,确切 child Agent driver 正在处理工作;`inactive` 表示该 driver 空闲或不存在。UI 不会把任一值解释为成功、失败、取消、完成状态或可恢复性。`subagent.list` 提供当前 driver 状态基线,`host/session-status` 会就地更新已知活动状态,请求内回放会阻止更早发起但尚未完成的列表响应覆盖较新的状态转换,`host/session-removed` 则会使已知行恢复为 `inactive`;重连时会读取新的基线。直接 subagent 的 `host/session-added` 帧会立即把任何已加载的 parent 行翻转为 `hasChildren: true`,并使这项正向提示不被更早发起但尚未完成的目录响应覆盖;受影响分支打开期间,成员、label、mode、diagnostic 与权威快照仍需要通过去抖动的 `subagent.list` 刷新来更新。消息投递时仍以提示词响应为权威依据。 @@ -102,8 +102,8 @@ one-shot 行始终会用文案替代输入框,说明执行记录为只读。 - 宿主协议测试固定 schema(包括必需的布尔可展开性)、id 回显、mode 校验、非激活式历史、确切 parent 强制要求、FIFO 准入回执、取消与脱敏后的失败映射。 - 通用 Host 测试固定在不发布 Agent 的情况下读取已附加与冷态历史及执行 fork、冷态投影归并、按描述符/origin/运行时 owner 拒绝、拒绝显式 id 接纳,以及直接队列控制栅栏。 - 客户端对象测试固定已保留与已恢复的地址、one-shot 只读拒绝、历史路由、可继续提示词路由、已寻址对话不提供取消、屏蔽绑定到 agent 的模型控件、实时活动状态翻转(包括在途响应回放与 detach 回退)、subagent parent 可展开性翻转与成员刷新。 -- jsdom 测试固定后代聚合计数与活动状态、已知加载行的形态、混合 mode 行、点击前的叶子展开控件、diagnostic、后代懒加载展开、直接 parent 地址、键盘行为与两种只读原因。 -- 无密钥的组装 Web 快照包含一个 inactive 的可继续 child、一个 inactive 的 one-shot sibling 和一个持久化 grandchild;它会固定触发器显示三个后代及聚合 `running` 状态转换,在不激活的情况下展开、打开持久化历史、准入一条用户 FIFO 后续消息、归并 child mux 事件,并证明 one-shot 历史仍然只读。 +- jsdom 测试固定后代聚合计数与活动状态、目录缺失或为陈旧空目录时由摘要支撑的根操作、已知加载行的形态、混合 mode 行、点击前的叶子展开控件、diagnostic、后代懒加载展开、直接 parent 地址、键盘行为与两种只读原因。 +- 无密钥的组装 Web 快照包含一个 inactive 的可继续 child、一个 inactive 的 one-shot sibling 和一个持久化 grandchild;它会固定触发器在一次陈旧的空目录响应后仍显示三个后代,并固定聚合 `running` 状态转换,在不激活的情况下展开、打开持久化历史、准入一条用户 FIFO 后续消息、归并 child mux 事件,并证明 one-shot 历史仍然只读。 - 导航测试固定仅含 subagent 的面包屑导航、从 subagent 创建 fork 时的 Workspace 归属,以及 `origin: 'subagent'` 侧边栏过滤,同时不隐藏普通 fork。 ## 后果 diff --git a/apps/web/tests/snapshots/subagent-conversation/stale-catalog.expected.md b/apps/web/tests/snapshots/subagent-conversation/stale-catalog.expected.md new file mode 100644 index 0000000000..bd2a1b7918 --- /dev/null +++ b/apps/web/tests/snapshots/subagent-conversation/stale-catalog.expected.md @@ -0,0 +1,3 @@ +- tree "Subagent sessions": + - treeitem "Loading subagents" [disabled] [level=1]: Loading subagents… + - treeitem "Loading subagents" [disabled] [level=1]: Loading subagents… diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index 14080dca6f..53e61d829f 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -20,6 +20,7 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor const BASE_FIXTURE = fileURLToPath(new URL('./snapshots/live-interactions/session.jsonl', import.meta.url)) const AVAILABLE_CHILD_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/ui.expected.md', import.meta.url)) const TREE_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/tree.expected.md', import.meta.url)) +const STALE_CATALOG_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/stale-catalog.expected.md', import.meta.url)) const SIDEBAR_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/sidebar.expected.md', import.meta.url)) const UNAVAILABLE_GRANDCHILD_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/nested.expected.md', import.meta.url)) const FORK_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/fork.expected.md', import.meta.url)) @@ -239,6 +240,59 @@ describe('web e2e: persisted subagent conversation and human continuation', () = if (failures.length > 1) throw new AggregateError(failures, 'subagent Web teardown failed') }) + it('keeps known descendants reachable across a stale empty catalog response', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-stale-catalog')) + const pattern = '**/api/subagent.list' + let firstClaimed = false + let emptyDelivered = false + let trailingRequested = false + let releaseCatalog = (): void => {} + const catalogHeld = new Promise((resolve) => { releaseCatalog = resolve }) + await page.route(pattern, async (route) => { + if (firstClaimed) { + const response = await route.fetch() + trailingRequested = true + await catalogHeld + await route.fulfill({ response }) + return + } + firstClaimed = true + const response = await route.fetch() + const body = await response.json() as { + result: { ok: true; value: { entries: unknown[] } } | { ok: false } + } + if (body.result.ok) body.result.value.entries = [] + await route.fulfill({ response, json: body }) + emptyDelivered = true + }) + + const warningStart = tripwire.warnings.length + try { + await page.reload({ waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await expect.poll(() => emptyDelivered, { timeout: 15_000 }).toBe(true) + await page.getByRole('button', { name: '3 subagents' }).waitFor({ timeout: 15_000 }) + acknowledgeReloadConnectionLoss(tripwire, warningStart) + + await page.getByRole('button', { name: '3 subagents' }).click() + await expect.poll(() => trailingRequested, { timeout: 15_000 }).toBe(true) + const tree = page.getByRole('tree', { name: 'Subagent sessions' }) + await tree.getByRole('treeitem', { name: 'Loading subagents' }).first().waitFor() + expect(await tree.getByRole('treeitem', { name: 'Loading subagents' }).count()).toBe(2) + await compareOrRefreshGolden( + STALE_CATALOG_EXPECTED, + await captureStableAria(page, '[role="tree"][aria-label="Subagent sessions"]', scaffold.workspaceCwd), + MODE, + ) + releaseCatalog() + await tree.getByRole('treeitem', { name: new RegExp(LABEL) }).waitFor({ timeout: 15_000 }) + await tree.press('Escape') + } finally { + releaseCatalog() + await page.unroute(pattern) + } + }) + it('expands a persisted grandchild progressively without activating either level', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-tree')) await page.getByRole('button', { name: '3 subagents' }).click() diff --git a/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx b/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx index 14f8169d82..4953b89591 100644 --- a/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx +++ b/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx @@ -304,7 +304,7 @@ function CatalogRows({ /** * Render the current session's direct catalog and lazily expanded descendants. * @param props - session standard props plus catalog navigation actions. - * @returns The action only after a non-empty catalog arrives. + * @returns The action while the catalog is pending or summaries establish descendants. */ export function SubagentCatalogAction({ sessionId, useSessions, openChild, refresh, setCatalogOpen, t, @@ -326,6 +326,18 @@ export function SubagentCatalogAction({ const descendantCount = Math.max(healthy.length, descendants.count) const totalCountKey = descendantCount === 1 ? 'count.total.one' : 'count.total.other' const runningCountKey = descendantCount === 1 ? 'count.running.one' : 'count.running.other' + // Session summaries can announce membership before the descriptor-backed catalog catches up. + // Keep that entry point visible through disabled loading rows; only catalog rows are navigable. + const summaryBackedLoading = descendants.count > 0 + && (catalog === undefined || (catalog.state === 'ready' && catalog.entries.length === 0)) + const presentedCatalog: SubagentCatalogSnapshot | undefined = summaryBackedLoading + ? { + entries: [], + parentAvailable: catalog?.parentAvailable ?? false, + state: 'loading', + error: null, + } + : catalog const observeCatalog = (parentSessionId: SessionId, next: boolean): void => { if (next) observedCatalogs.current.add(parentSessionId) @@ -390,7 +402,8 @@ export function SubagentCatalogAction({ observedCatalogs.current.clear() }, []) - const visible = catalog !== undefined && (catalog.state !== 'ready' || catalog.entries.length > 0) + const visible = presentedCatalog !== undefined + && (presentedCatalog.state !== 'ready' || presentedCatalog.entries.length > 0) useEffect(() => { if (visible || !open) return setOpen(false) @@ -453,7 +466,7 @@ export function SubagentCatalogAction({
{ expect(failed.refresh).toHaveBeenCalledWith(PARENT) }) + it('keeps known descendants reachable while their catalog is absent or stale-empty', () => { + const second = 'child-2' as SessionId + const summaries = { + [CHILD]: { + ...summary(CHILD, 1), parentId: PARENT, origin: 'subagent' as const, + }, + [second]: { + ...summary(second, 1), parentId: PARENT, origin: 'subagent' as const, running: true, + }, + } + const absent = props(undefined, {}, summaries) + const view = render() + + const trigger = screen.getByRole('button', { name: '2 个子代理,正在运行' }) + fireEvent.click(trigger) + expect(absent.setCatalogOpen).toHaveBeenCalledWith(PARENT, true) + expect(screen.getAllByRole('treeitem', { name: '正在加载子代理' })).toHaveLength(2) + expect(absent.openChild).not.toHaveBeenCalled() + + const staleEmpty = props(catalog({ entries: [] }), {}, summaries) + view.rerender() + expect(screen.getByRole('button', { name: '2 个子代理,正在运行' })).toBeTruthy() + expect(screen.getAllByRole('treeitem', { name: '正在加载子代理' })).toHaveLength(2) + expect(staleEmpty.openChild).not.toHaveBeenCalled() + }) + it('renders empty loading and fallback error states without focusable rows', async () => { const loading = props(catalog({ entries: [], state: 'loading' })) const view = render() From 820a5a97f1ca54a40ea62b62af7879cebbc5d4f4 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Sun, 2 Aug 2026 07:19:26 -0700 Subject: [PATCH 073/129] review(tool-goal,llm-replay): address ds-review-bot round one - fromRequest placeholder: the last two braces of a consecutive } run now terminate the placeholder, so patterns may end with a brace quantifier (bot warning; the truncated pattern could even silently mis-match since an unclosed { is literal in JS regexes) - document that derived JSONL entries pass through the same resolution - widen ToolRunContext/deferContext seam docs beyond composite-only usage (source JSDoc, README pair, core-data-structures type-equiv blocks) - pin direct-human blocked as uninstructed, completing the goal-round/direct-human x complete/blocked test quadrant --- docs/config-catalog.md | 4 ++-- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/tools.i18n.yaml | 4 ++-- docs/core-data-structures/tools.md | 17 ++++++++++------- docs/core-data-structures/tools.zh.md | 17 ++++++++++------- packages/core/tools/README.i18n.yaml | 4 ++-- packages/core/tools/README.md | 2 +- packages/core/tools/README.zh.md | 2 +- packages/core/tools/src/index.ts | 15 +++++++++------ packages/goal/tool-goal/tests/tool-goal.spec.ts | 2 ++ packages/support/llm-replay/README.i18n.yaml | 4 ++-- packages/support/llm-replay/README.md | 2 +- packages/support/llm-replay/README.zh.md | 2 +- packages/support/llm-replay/src/index.ts | 10 ++++++++-- .../support/llm-replay/tests/llm-replay.spec.ts | 6 ++++++ 15 files changed, 58 insertions(+), 35 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index c9fa141dd6..cc85531d4d 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -765,7 +765,7 @@ export interface ReplayModelConfig { Depends on: [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) -Source: [`packages/support/llm-replay/src/index.ts:701`](../packages/support/llm-replay/src/index.ts) +Source: [`packages/support/llm-replay/src/index.ts:707`](../packages/support/llm-replay/src/index.ts) ## `@deepseek-ai/dsh-llm-retry` @@ -2039,7 +2039,7 @@ export interface Config { export type ToolPresentationMode = 'native' | 'code' | 'both' ``` -Source: [`packages/core/tools/src/index.ts:589`](../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:592`](../packages/core/tools/src/index.ts) ## `@deepseek-ai/dsh-tui` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 96b876a0eb..ba32a8dded 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2428,7 +2428,7 @@ async execute(exec: ToolExecutionInput): Promise Types: [ScopeKey](../core-data-structures/scope.md) · [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionMode](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolGuard](../core-data-structures/tools.md) · [ToolRestriction](../core-data-structures/tools.md) · [ToolSchema](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:711`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:714`](../../packages/core/tools/src/index.ts) ## `ctx.tui` — `TuiExtensionService` (abstract seam) diff --git a/docs/core-data-structures/tools.i18n.yaml b/docs/core-data-structures/tools.i18n.yaml index 156324472b..a955adc963 100644 --- a/docs/core-data-structures/tools.i18n.yaml +++ b/docs/core-data-structures/tools.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/core-data-structures/tools.md -tools.md: 98b642b846b23e2b29e6c6d800fe4106235eda85 -tools.zh.md: 1ef90c1e76ace7485ed6267de5ee82cbb4de6aa6 +tools.md: acaf5d32dd5481aec495ac49f727c9b64f25211e +tools.zh.md: 5c39226fdf5d1406dab7383d40227c26ff1e447c diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md index 98b642b846..acaf5d32dd 100644 --- a/docs/core-data-structures/tools.md +++ b/docs/core-data-structures/tools.md @@ -200,20 +200,23 @@ interface ToolExecutionInput { } ``` -A tool body receives the runtime extension. `deferContext()` is the composite-tool channel: it records nested-dispatch context without injecting inside the still-open outer call. +A tool body receives the runtime extension. `deferContext()` attaches context to the execution's own result — the composite-tool nested-dispatch channel, also usable by a leaf tool minting a plugin-sourced instruction — without injecting inside the still-open outer call. ```ts type-equiv /** * Runtime context handed to a tool implementation after the registry has - * accepted a {@link ToolExecution}. A composite tool uses - * {@link deferContext} to ferry context produced by nested dispatches back to - * the outer result; the loop appends it only after the outer `tool/result`. + * accepted a {@link ToolExecution}. {@link deferContext} attaches context to + * this execution's own result — a composite tool ferries nested-dispatch + * context back to the outer result, and a leaf tool may mint a fresh + * plugin-sourced instruction; the loop appends it only after the + * `tool/result`. */ interface ToolRunContext extends ToolExecution { /** - * Defer one nested-dispatch context until this tool's final result reaches - * the agent loop. Contexts retain their individual source and metadata and - * are emitted in call order. + * Defer one context — typically a nested-dispatch context ferried by a + * composite tool, or a fresh plugin-sourced instruction — until this tool's + * final result reaches the agent loop. Contexts retain their individual + * source and metadata and are emitted in call order. */ deferContext(context: UserMessage): void /** diff --git a/docs/core-data-structures/tools.zh.md b/docs/core-data-structures/tools.zh.md index 1ef90c1e76..5c39226fdf 100644 --- a/docs/core-data-structures/tools.zh.md +++ b/docs/core-data-structures/tools.zh.md @@ -200,20 +200,23 @@ interface ToolExecutionInput { } ``` -工具函数体接收运行时扩展。`deferContext()` 是组合工具的通道:它记录嵌套分派产生的上下文,而不会在外层调用尚未结束时注入这些上下文。 +工具函数体接收运行时扩展。`deferContext()` 把上下文附着到本次执行自己的结果上——既是组合工具转运嵌套分派上下文的通道,也可供叶子工具铸造插件来源指令——而不会在外层调用尚未结束时注入这些上下文。 ```ts type-equiv /** * Runtime context handed to a tool implementation after the registry has - * accepted a {@link ToolExecution}. A composite tool uses - * {@link deferContext} to ferry context produced by nested dispatches back to - * the outer result; the loop appends it only after the outer `tool/result`. + * accepted a {@link ToolExecution}. {@link deferContext} attaches context to + * this execution's own result — a composite tool ferries nested-dispatch + * context back to the outer result, and a leaf tool may mint a fresh + * plugin-sourced instruction; the loop appends it only after the + * `tool/result`. */ interface ToolRunContext extends ToolExecution { /** - * Defer one nested-dispatch context until this tool's final result reaches - * the agent loop. Contexts retain their individual source and metadata and - * are emitted in call order. + * Defer one context — typically a nested-dispatch context ferried by a + * composite tool, or a fresh plugin-sourced instruction — until this tool's + * final result reaches the agent loop. Contexts retain their individual + * source and metadata and are emitted in call order. */ deferContext(context: UserMessage): void /** diff --git a/packages/core/tools/README.i18n.yaml b/packages/core/tools/README.i18n.yaml index d26f891f91..8fa82352b8 100644 --- a/packages/core/tools/README.i18n.yaml +++ b/packages/core/tools/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/core/tools/README.md -README.md: 15fc5839a3b0e3fa2d20c5a9cc50577e9807ffda -README.zh.md: 8547ee4a796dcd93945dfa40373c14c10d7d0c8a +README.md: 80ea3cc93437d48a7ea0ffba0ff4d2ef2407755f +README.zh.md: 1f0791c5df7afd4a3479afdd827c4fc148cf8883 diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index 15fc5839a3..80ea3cc934 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -43,7 +43,7 @@ The live registry pipeline has three transformable waterfalls, then the definiti - `ToolExecutionInput` — the caller-supplied call description: `{ callId, name, arguments, signal, agent?, parent? }`; `signal` is required and readonly, callers may pass an enclosing execution's opaque token as `parent`, and callers never choose the new execution's own token. - `ToolExecutionToken` — a fresh branded `Symbol` assigned by the registry. It supports equality correlation only and never crosses a model, log, or worker boundary. - `ToolExecution` — the readonly pipeline view: immutable `{ token, callId, name, arguments, signal, agent?, parent? }`; the registry separately retains and re-fuses the original caller signal. `ToolDispatchExecution` is the `tools/execute`-only view whose required signal is mutable, so a wrapper may replace and restore it but cannot delete it. A nested call's `parent` is a `ToolExecutionToken`, not an execution object. -- `ToolRunContext` — the execution passed to a tool body, extending `ToolExecution` with `deferContext(context)`. Composite tools use it to ferry context produced by nested dispatches to the outer result even when the tool later throws or cancellation wins; it never injects immediately. +- `ToolRunContext` — the execution passed to a tool body, extending `ToolExecution` with `deferContext(context)`. It defers one context until the tool's final result reaches the loop — typically a nested-dispatch context ferried by a composite tool, or a fresh plugin-sourced instruction minted by a leaf tool (`tool-goal`'s wrap-up) — even when the tool later throws or cancellation wins; it never injects immediately. - `ToolExecutionResult` — discriminated execution-local outcome. Success is `{ isError:false, value:JsonValue, content, meta?, additionalContexts? }`; failure is `{ isError:true, error:{ message, info? }, content, meta?, additionalContexts? }` and has no value. Call identity stays on the immutable `ToolExecution`. The registry snapshots, validates, and freezes the canonical value before rendering, then materializes the durable presentation fields before final observation. `ToolFailure.info` carries an internal `{ name, code }` for a `HarnessError`; `additionalContexts` preserves every deferred or post-execute identified `UserMessage` for the loop's post-result FIFO. - `PreToolDecision` — `{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`. Input rewrite is deliberately not offered; `ask` is serviced by [`ctx.approval`](../../ui/user-approval/README.md) when mounted and otherwise degrades to deny. - `PostToolDecision` — accept may replace `content` or `value`, never both, and may attach `additionalContexts`; block turns feedback into a valueless failure. Content replacement preserves the canonical value and metadata. Value replacement is revalidated and rerenders content/metadata. Accept preserves tool-deferred contexts before decision contexts; block discards tool-deferred contexts and exposes only contexts explicitly supplied by the blocking decision. diff --git a/packages/core/tools/README.zh.md b/packages/core/tools/README.zh.md index 8547ee4a79..1f0791c5df 100644 --- a/packages/core/tools/README.zh.md +++ b/packages/core/tools/README.zh.md @@ -43,7 +43,7 @@ tools: - `ToolExecutionInput`:调用方提供的调用描述:`{ callId, name, arguments, signal, agent?, parent? }`;`signal` 必填且只读,调用方可以将外层执行的不透明 token 作为 `parent` 传入,但绝不能选择新执行自身的 token。 - `ToolExecutionToken`:注册表分配的全新带品牌 `Symbol`。它只支持通过相等性进行关联,绝不会跨越模型、日志或 worker 边界。 - `ToolExecution`:只读流水线视图:不可变的 `{ token, callId, name, arguments, signal, agent?, parent? }`;注册表会另行保留并重新融合调用方的原始信号。`ToolDispatchExecution` 是仅供 `tools/execute` 使用的视图,其必填信号可变,因此包装层可以替换并还原它,但不能删除它。嵌套调用的 `parent` 是 `ToolExecutionToken`,而不是执行对象。 -- `ToolRunContext`:传给工具主体的执行上下文,在 `ToolExecution` 基础上增加 `deferContext(context)`。组合工具借此把嵌套分发产生的上下文传递到外层结果,即使工具后来抛出或取消胜出也不例外;该方法绝不会立即注入上下文。 +- `ToolRunContext`:传给工具主体的执行上下文,在 `ToolExecution` 基础上增加 `deferContext(context)`。它把一条上下文推迟到该工具的最终结果抵达循环时——通常是组合工具转运的嵌套分发上下文,也可以是叶子工具铸造的全新插件来源指令(如 `tool-goal` 的收尾注入)——即使工具后来抛出或取消胜出也不例外;该方法绝不会立即注入上下文。 - `ToolExecutionResult`:可辨识的执行局部结果。成功形态为 `{ isError:false, value:JsonValue, content, meta?, additionalContexts? }`;失败形态为 `{ isError:true, error:{ message, info? }, content, meta?, additionalContexts? }`,且不含值。调用身份保留在不可变的 `ToolExecution` 上。注册表会在呈现前快照、验证并冻结规范值,随后在最终观测前实体化持久呈现字段。`ToolFailure.info` 携带内部的 `{ name, code }`,用于表示 `HarnessError`;`additionalContexts` 会保留每个通过延迟或 post-execute 加入且带标识的 `UserMessage`,供循环在结果后按 FIFO 顺序处理。 - `PreToolDecision`:`{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`。该类型有意不提供输入改写;`ask` 在挂载 [`ctx.approval`](../../ui/user-approval/README.md) 时由它处理,否则退化为拒绝。 - `PostToolDecision`:接受决定可以替换 `content` 或 `value`(不能同时替换),并可附加 `additionalContexts`;阻止决定会把反馈变成无值失败。替换内容会保留规范值和元数据。替换值会重新验证,并重新呈现内容/元数据。接受决定会先保留工具延迟的上下文,再附加决定上下文;阻止决定会丢弃工具延迟的上下文,只公开阻止决定显式提供的上下文。 diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index f30dce6cd0..72254e2abd 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -344,15 +344,18 @@ export interface ToolDispatchExecution extends Omit { /** * Runtime context handed to a tool implementation after the registry has - * accepted a {@link ToolExecution}. A composite tool uses - * {@link deferContext} to ferry context produced by nested dispatches back to - * the outer result; the loop appends it only after the outer `tool/result`. + * accepted a {@link ToolExecution}. {@link deferContext} attaches context to + * this execution's own result — a composite tool ferries nested-dispatch + * context back to the outer result, and a leaf tool may mint a fresh + * plugin-sourced instruction; the loop appends it only after the + * `tool/result`. */ export interface ToolRunContext extends ToolExecution { /** - * Defer one nested-dispatch context until this tool's final result reaches - * the agent loop. Contexts retain their individual source and metadata and - * are emitted in call order. + * Defer one context — typically a nested-dispatch context ferried by a + * composite tool, or a fresh plugin-sourced instruction — until this tool's + * final result reaches the agent loop. Contexts retain their individual + * source and metadata and are emitted in call order. */ deferContext(context: UserMessage): void /** diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index 2461a7d5fc..6d3957e982 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -599,5 +599,7 @@ describe('goal tool state transitions', () => { }, roundsStarted: 0, }) + expect(blocked.concludesTurn).toBeUndefined() + expect(blocked.additionalContexts).toBeUndefined() }) }) diff --git a/packages/support/llm-replay/README.i18n.yaml b/packages/support/llm-replay/README.i18n.yaml index b6a31ebb99..507c3da1a5 100644 --- a/packages/support/llm-replay/README.i18n.yaml +++ b/packages/support/llm-replay/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/support/llm-replay/README.md -README.md: ea52525ee85aae58006c852afe93291ea70807d5 -README.zh.md: e8c0ec225df29fc6f5493776d75bc7f9e3e078de +README.md: 85aa56705929e7630e4cfb6c2a3c9cbbd0d843a6 +README.zh.md: 751f75dea197ffb112cfa703e3a5dbfaffb8c0b2 diff --git a/packages/support/llm-replay/README.md b/packages/support/llm-replay/README.md index ea52525ee8..85aa567059 100644 --- a/packages/support/llm-replay/README.md +++ b/packages/support/llm-replay/README.md @@ -12,7 +12,7 @@ The fixture IS the persisted session log (`/session.jsonl`). Its `assi Two failure modes are not reconstructable from `assistant/chunk` alone — a pure throw before any chunk (e.g. an HTTP 401, where the log holds only a `turn/end {error}` and no chunks) and a cancel/hang (timing, not chunk content). A scenario that needs those supplies an optional sidecar (`/replay.override.json`) that either replaces the derived script (a bare `ReplayEntry[]`) or augments it (`{ patches: [{ at, entry }] }`: keep every JSONL-derived call and swap the named 0-based call indexes; `at` equal to the derived length appends the retry attempt after an injected transient throw). Patch indexes must be unique. The override document, each patch and entry, and every chunk discriminant are validated when the file loads. A `hang` entry may name `readyFile`; replay writes that empty marker after its prefix chunks reach the loop and before it waits for cancellation, so an external driver can cancel deterministically without observing a presentation update. -A scripted string may embed `{{fromRequest:}}` to fill a value no static sidecar can know — for example a randomly minted goal id the model must echo back into `update_goal`. At stream time every placeholder resolves against the live request: the corpus is every string leaf of the request messages joined by newlines, the pattern's LAST corpus match wins, and its first capture group (or the whole match without one) substitutes in place. A pattern that matches nothing, an invalid pattern, and an unterminated placeholder each fail loud; the first `}}` ends the placeholder, so patterns cannot contain `}}`. +A scripted string may embed `{{fromRequest:}}` to fill a value no static sidecar can know — for example a randomly minted goal id the model must echo back into `update_goal`. At stream time every placeholder resolves against the live request: the corpus is every string leaf of the request messages joined by newlines, the pattern's LAST corpus match wins, and its first capture group (or the whole match without one) substitutes in place. A pattern that matches nothing, an invalid pattern, and an unterminated placeholder each fail loud. The last two braces of a consecutive `}` run terminate the placeholder, so a pattern may end with a brace quantifier (`[0-9a-f]{4}`) but cannot contain `}}` followed by further pattern content. Resolution applies to every scripted entry, including ones derived from the recorded JSONL — a recorded fixture whose text legitimately contains the literal marker must be expressed through a sidecar without it. ## Nested agents: per-session keying diff --git a/packages/support/llm-replay/README.zh.md b/packages/support/llm-replay/README.zh.md index e8c0ec225d..751f75dea1 100644 --- a/packages/support/llm-replay/README.zh.md +++ b/packages/support/llm-replay/README.zh.md @@ -12,7 +12,7 @@ fixture 就是持久化的会话日志(`/session.jsonl`)。其 `as 有两种失败模式无法仅根据 `assistant/chunk` 重建:在产生任何分片前直接抛出异常(例如 HTTP 401,此时日志只有 `turn/end {error}` 而没有分片),以及取消或挂起(差异在时序,而非分片内容)。需要这些行为的场景可提供伴随文件(`/replay.override.json`):它可以替换派生脚本(裸 `ReplayEntry[]`),也可以增补派生脚本(`{ patches: [{ at, entry }] }`:保留所有从 JSONL 派生的调用,只替换指定的从 0 开始计数的调用索引;当 `at` 等于派生长度时,则在注入瞬态异常后的重试位置追加一次调用)。补丁索引不得重复。文件加载时会校验覆写文档、每个补丁和条目,以及每个分片的判别标签。`hang` 条目可以指定 `readyFile`;当前缀分片到达循环后、开始等待取消前,回放会写入这个空标记,使外部驱动程序无需观察展示层更新即可确定性地取消。 -脚本字符串可以内嵌 `{{fromRequest:}}`,用来填入静态伴随文件不可能预知的值——例如模型必须原样回填到 `update_goal` 的随机生成 goal id。回放时每个占位符针对实时请求解析:语料是请求消息的所有字符串叶子按换行拼接的结果,取该模式在语料中的最后一次匹配,用其第一个捕获组(无捕获组时用整个匹配)原位替换。模式匹配不到内容、模式非法、占位符未闭合都会明确报错;第一个 `}}` 即结束占位符,因此模式本身不能包含 `}}`。 +脚本字符串可以内嵌 `{{fromRequest:}}`,用来填入静态伴随文件不可能预知的值——例如模型必须原样回填到 `update_goal` 的随机生成 goal id。回放时每个占位符针对实时请求解析:语料是请求消息的所有字符串叶子按换行拼接的结果,取该模式在语料中的最后一次匹配,用其第一个捕获组(无捕获组时用整个匹配)原位替换。模式匹配不到内容、模式非法、占位符未闭合都会明确报错。连续右花括号串的最后两个花括号才是占位符结束符,因此模式可以以花括号量词收尾(如 `[0-9a-f]{4}`),但不能在 `}}` 之后还有后续模式内容。解析作用于所有脚本条目,包括从已记录 JSONL 派生的条目——若录制文本本身合法地含有该字面量标记,需改用不含标记的伴随文件表达。 ## 嵌套 agent:每会话键控 diff --git a/packages/support/llm-replay/src/index.ts b/packages/support/llm-replay/src/index.ts index 70dae88446..4b89a844bf 100644 --- a/packages/support/llm-replay/src/index.ts +++ b/packages/support/llm-replay/src/index.ts @@ -283,10 +283,13 @@ function substituteString(text: string, corpus: string): string { while (true) { const open = text.indexOf(FROM_REQUEST_OPEN, cursor) if (open === -1) return result + text.slice(cursor) - const close = text.indexOf(FROM_REQUEST_CLOSE, open + FROM_REQUEST_OPEN.length) + let close = text.indexOf(FROM_REQUEST_CLOSE, open + FROM_REQUEST_OPEN.length) if (close === -1) { throw new Error(`llm-replay: fromRequest placeholder is unterminated in ${JSON.stringify(text)}`) } + // The last two braces of a consecutive `}` run terminate the placeholder, + // so a pattern may end with a brace quantifier like `[0-9a-f]{4}`. + while (text[close + FROM_REQUEST_CLOSE.length] === '}') close += 1 const pattern = text.slice(open + FROM_REQUEST_OPEN.length, close) result += text.slice(cursor, open) + resolveFromRequest(pattern, corpus) cursor = close + FROM_REQUEST_CLOSE.length @@ -313,7 +316,10 @@ function substituteValue(value: unknown, corpus: string): unknown { * Scenario sidecars use this to script arguments no static file can know, * such as a randomly minted goal id the model must echo back. A pattern that * matches nothing, an invalid pattern, and an unterminated placeholder each - * fail loud. Patterns cannot contain `}}` — the first `}}` ends the placeholder. + * fail loud. The last two braces of a consecutive `}` run terminate the + * placeholder, so a pattern may end with a brace quantifier but cannot + * contain `}}` followed by further pattern content. Derived entries pass + * through the same resolution as sidecar entries. * @param entry - the scripted entry about to replay. * @param messages - the live request messages searched by the placeholders. * @returns the entry itself when no placeholder appears, else a resolved deep copy. diff --git a/packages/support/llm-replay/tests/llm-replay.spec.ts b/packages/support/llm-replay/tests/llm-replay.spec.ts index 345aa22679..8113dbc922 100644 --- a/packages/support/llm-replay/tests/llm-replay.spec.ts +++ b/packages/support/llm-replay/tests/llm-replay.spec.ts @@ -350,6 +350,12 @@ describe('installLlmReplay (through the real LlmService)', () => { expect(delta).toMatchObject({ argumentsDelta: '{"goal_id":"goal-42ab"}' }) }) + it('keeps a trailing brace quantifier inside the pattern (terminator is the run tail)', async () => { + const streamed = await streamScripted('{"goal_id":"{{fromRequest:goal-[0-9a-z]{4}}}"}') + const delta = streamed.find(chunk => chunk.type === 'tool-call-delta') + expect(delta).toMatchObject({ argumentsDelta: '{"goal_id":"goal-42ab"}' }) + }) + it('fails loud when a placeholder matches nothing in the request', async () => { await expect(streamScripted('{"goal_id":"{{fromRequest:task-[0-9]+}}"}')) .rejects.toThrow(/fromRequest.*matched nothing/) From e72978ba9880fc8ed1b440a820c07550604424f8 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Sun, 2 Aug 2026 07:29:18 -0700 Subject: [PATCH 074/129] fix(tool-goal): scope the wrap-up no-more-tools clause to the current run The injected instruction persists as a durable user message, so an unscoped 'Do not call any more tools.' stays in every later request's history. Scope it: '...in this run; further work waits for the user's next instruction.' A/B probes on deepseek-v4-pro show the scoped wording is non-inferior in-turn (4/4 zero tool calls, closing quality unchanged) and next-turn tool use is unaffected under both wordings; the scoped form states the instruction's actual lifetime. --- .../tests/goal-snapshots/goal-wrapup/session.expected.jsonl | 2 +- packages/goal/tool-goal/src/wrapup.ts | 5 +++-- packages/goal/tool-goal/tests/tool-goal.spec.ts | 4 ++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/examples/acp-agent/tests/goal-snapshots/goal-wrapup/session.expected.jsonl b/examples/acp-agent/tests/goal-snapshots/goal-wrapup/session.expected.jsonl index bf708d211b..4355ccb070 100644 --- a/examples/acp-agent/tests/goal-snapshots/goal-wrapup/session.expected.jsonl +++ b/examples/acp-agent/tests/goal-snapshots/goal-wrapup/session.expected.jsonl @@ -37,7 +37,7 @@ {"type":"tool/call","seq":35,"time":0,"data":{"turn":2,"step":1,"callId":"call_goal_complete","name":"update_goal","arguments":"{\"goal_id\":\"goal-{{sessionId}}\",\"revision\":1,\"action\":\"complete\"}"}} {"type":"tool/result","seq":36,"time":0,"data":{"turn":2,"step":1,"message":{"source":{"kind":"tool","callId":"call_goal_complete"},"content":[{"type":"tool-result","toolCallId":"call_goal_complete","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":2,\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"phase\":\"complete\",\"roundsStarted\":1,\"maxGoalRounds\":2},\"activation\":\"disarmed\"}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[35],"surfaceOp":"append"} {"type":"user/message","seq":37,"time":0,"data":{"content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":2,\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"phase\":\"complete\",\"maxGoalRounds\":2},\"roundsStarted\":1,\"createdAt\":0,\"updatedAt\":0}"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":2,"round":0,"change":{"kind":"goal/change","version":1,"operation":"complete","goal":{"id":"goal-{{sessionId}}","revision":2,"objective":"Finish the ACP goal wrap-up snapshot proof","phase":"complete","maxGoalRounds":2},"roundsStarted":1,"createdAt":0,"updatedAt":0}},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","seq":38,"time":0,"data":{"content":[{"type":"text","text":"\nObjective: \"Finish the ACP goal wrap-up snapshot proof\"\nThe goal is marked complete and this autonomous run is ending. Write the closing message to the user now: state the outcome, summarize what was done and how it was verified, and point to the concrete results (files, commits, or other artifacts). Report only what earlier rounds and tool results in this session actually establish; when a detail is not in the session, say so instead of inventing it. Note anything the user should review or do next. Address the user directly. Do not call any more tools.\n"}],"source":{"kind":"plugin","plugin":"tool-goal"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","seq":38,"time":0,"data":{"content":[{"type":"text","text":"\nObjective: \"Finish the ACP goal wrap-up snapshot proof\"\nThe goal is marked complete and this autonomous run is ending. Write the closing message to the user now: state the outcome, summarize what was done and how it was verified, and point to the concrete results (files, commits, or other artifacts). Report only what earlier rounds and tool results in this session actually establish; when a detail is not in the session, say so instead of inventing it. Note anything the user should review or do next. Address the user directly. Do not call any more tools in this run; further work waits for the user's next instruction.\n"}],"source":{"kind":"plugin","plugin":"tool-goal"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"step/end","seq":39,"time":0,"data":{"turn":2,"step":1}} {"type":"step/start","seq":40,"time":0,"data":{"turn":2,"step":2}} {"type":"assistant/chunk","seq":41,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} diff --git a/packages/goal/tool-goal/src/wrapup.ts b/packages/goal/tool-goal/src/wrapup.ts index 4f16fdd924..158f17e886 100644 --- a/packages/goal/tool-goal/src/wrapup.ts +++ b/packages/goal/tool-goal/src/wrapup.ts @@ -24,7 +24,7 @@ export function renderWrapupContext(objective: string, blockedReason?: string): + 'verified, and point to the concrete results (files, commits, or other artifacts). ' + GROUNDING + 'Note anything the user should review or do next. Address the user directly. Do not ' - + 'call any more tools.\n' + + "call any more tools in this run; further work waits for the user's next instruction.\n" + '' : '\n' + heading @@ -34,7 +34,8 @@ export function renderWrapupContext(objective: string, blockedReason?: string): + 'blocking condition and what you tried, and say exactly what you need from the user to ' + 'continue. ' + GROUNDING - + 'Address the user directly. Do not call any more tools.\n' + + 'Address the user directly. Do not call any more tools in this run; further work ' + + "waits for the user's next instruction.\n" + '' return [{ type: 'text', text }] } diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index 6d3957e982..9363081f85 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -377,7 +377,7 @@ describe('goal tool state transitions', () => { if (block?.type !== 'text') throw new Error('expected one text wrap-up block') expect(block.text).toContain('') expect(block.text).toContain('"pause cleanly"') - expect(block.text).toContain('Do not call any more tools.') + expect(block.text).toContain("Do not call any more tools in this run; further work waits for the user's next instruction.") }) it('completes without a wrap-up instruction under direct human authority', async () => { @@ -578,7 +578,7 @@ describe('goal tool state transitions', () => { if (block?.type !== 'text') throw new Error('expected one text wrap-up block') expect(block.text).toContain('') expect(block.text).toContain('The required credential is still unavailable.') - expect(block.text).toContain('Do not call any more tools.') + expect(block.text).toContain("Do not call any more tools in this run; further work waits for the user's next instruction.") }) it('lets direct human authority block before the model threshold', async () => { From b30686d634a3fb3bc5f93f89ca10bfcd631b847c Mon Sep 17 00:00:00 2001 From: kingwl Date: Sun, 2 Aug 2026 23:31:24 +0800 Subject: [PATCH 075/129] show subagent usage and active duration --- ...07-27-web-subagent-conversations.i18n.yaml | 4 +- .../2026-07-27-web-subagent-conversations.md | 14 +-- ...026-07-27-web-subagent-conversations.zh.md | 14 +-- .../subagent-conversation/tree.expected.md | 8 +- apps/web/tests/subagent-conversation.e2e.ts | 6 ++ docs/cordis-catalog/events.md | 8 +- docs/cordis-catalog/services.md | 2 +- docs/event-producer-consumer.md | 8 +- packages/client/runtime/README.i18n.yaml | 4 +- packages/client/runtime/README.md | 2 +- packages/client/runtime/README.zh.md | 2 +- .../runtime/src/client/sessions/lineage.ts | 5 + .../runtime/src/client/sessions/manager.ts | 13 ++- .../src/client/sessions/projection-store.ts | 15 +++ .../runtime/src/client/sessions/service.ts | 6 ++ .../runtime/tests/projection-store.spec.ts | 41 ++++++++ packages/client/ui-subagent/README.i18n.yaml | 4 +- packages/client/ui-subagent/README.md | 4 +- packages/client/ui-subagent/README.zh.md | 4 +- packages/client/ui-subagent/package.json | 4 + .../client/SubagentCatalogAction.module.css | 6 +- .../src/client/SubagentCatalogAction.tsx | 95 +++++++++++++++---- .../tests/conversation-ui.spec.tsx | 63 +++++++----- packages/client/ui-subagent/tsconfig.json | 6 ++ packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 2 + packages/subagent/subagent/README.zh.md | 2 + packages/subagent/subagent/package.json | 13 +++ packages/subagent/subagent/src/client.ts | 7 ++ packages/subagent/subagent/src/index.ts | 5 + .../subagent/subagent/src/projection-types.ts | 20 ++++ packages/subagent/subagent/src/projection.ts | 68 +++++++++++++ .../subagent/tests/timing-projection.spec.ts | 51 ++++++++++ packages/subagent/subagent/tsconfig.json | 3 + pnpm-lock.yaml | 13 +++ 35 files changed, 438 insertions(+), 88 deletions(-) create mode 100644 packages/subagent/subagent/src/client.ts create mode 100644 packages/subagent/subagent/src/projection-types.ts create mode 100644 packages/subagent/subagent/src/projection.ts create mode 100644 packages/subagent/subagent/tests/timing-projection.spec.ts diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml index bd780ca60d..594934ec07 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.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 .agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md -2026-07-27-web-subagent-conversations.md: 34acb1410cf6316bca2980ed012046ffab9623f6 -2026-07-27-web-subagent-conversations.zh.md: 5dcd7025c5cd03fed34266834795de1f2b630648 +2026-07-27-web-subagent-conversations.md: 6658bcc960ec691f3646f4ff08d8a065d3a8d70a +2026-07-27-web-subagent-conversations.zh.md: 5e89c9a7a420687c53be961245de7adb99bf3c44 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md index 34acb1410c..6658bcc960 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md @@ -33,7 +33,7 @@ The Figma [subagent list](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Ha | The session header opens a compact child list. | The trigger aggregates the complete subagent-only descendant lineage; the tree shows every direct catalog entry in service order, including disabled diagnostics. | | Selecting a row reuses the conversation UI. | Addressed history never activates the child; only a continuable row with a live parent retains the ordinary composer. | | Nested agents expand progressively. | Each row carries a one-level `hasChildren` snapshot; disclosure reserves known direct-descendant rows immediately, then loads only that row's direct catalog and retains its own parent address. | -| Rows show labels, state, and relative time without duplicating sidebar rows. | Mode and `running`/`inactive` activity are textual as well as visual; optional title and time come from summaries. `SessionHeader.origin` removes duplicate navigation rows but grants no capability. | +| Rows show labels, state, usage, and active duration without duplicating sidebar rows. | Mode and `running`/`inactive` activity are textual as well as visual; optional title, durable token usage, and exact active-turn duration come from the list's retained projection values. `SessionHeader.origin` removes duplicate navigation rows but grants no capability. | ## Product contract @@ -41,6 +41,8 @@ The header action is absent only after a complete empty direct-catalog response. `running` means the exact child Agent driver is draining work at the Host sampling boundary; `inactive` means that driver is idle or absent. The UI does not translate either value into success, failure, cancellation, completeness, or resumability. `subagent.list` supplies the current driver-status baseline, `host/session-status` updates known activity in place, request-local replay prevents an older in-flight list response from overwriting a newer transition, and `host/session-removed` returns a known row to `inactive`; reconnect reads a fresh baseline. A `host/session-added` frame for a direct subagent immediately flips any loaded parent row to `hasChildren: true`, and that positive hint survives an older in-flight catalog response; membership, labels, mode, diagnostics, and the authoritative snapshot still require a debounced `subagent.list` refresh while the affected branch is open. A prompt response remains delivery-time authority. +Healthy rows reuse the standard session projections retained in the list mirror. The token figure sums the four disjoint `tokenUsage` buckets across the durable log. `subagentTiming` resets at every descriptor so an inherited fork seed cannot enter the child's total, accumulates completed `turn/start` → `turn/end` spans, and carries the current turn's `activeSince`. The menu formats whole seconds and advances its local clock only while a known descendant is running; an inactive row uses settled duration, or the summary's last activity to bound an interrupted open turn, so reopening the menu never restarts completed work. Token chunks do not change `subagentTiming` and therefore do not add a per-token list update path. Neither metric implies a durable outcome. + Selecting a row records its exact address before opening the resident client `Session`. History pagination, event folding, tool render intents, titles, and live mux reconciliation reuse the ordinary conversation machinery. Breadcrumbs use catalog labels, follow parent links only through `origin: 'subagent'` rows, include the first ordinary owner, and keep ordinary forks single-level. Forking an addressed subagent creates an ordinary fork with direct source lineage and attaches it to the nearest workspace-owning ancestor. The catalog is an ARIA tree with lazy ArrowRight/ArrowLeft disclosure, linear ArrowUp/ArrowDown navigation, Home/End, Escape, and focus restoration. A one-shot row always replaces the composer with copy explaining that the execution record is read-only. A continuable row does so only while `parentAvailable` is false. When enabled, its Send action admits another FIFO turn even if the child is currently running; it never becomes Stop. Prompt failures retain the draft through the ordinary error behavior. @@ -65,7 +67,7 @@ The adapter stays in `dsh-host-apiproxy`; `dsh-host-webserver` remains a carrier ## Client object layer and presentation -The React-free runtime owns catalogs, single-flight refreshes, retained addresses, availability hints, and transport selection. Re-selecting a known child retains its address so navigation cannot silently switch to ordinary session APIs. A missing intermediate breadcrumb address can be recovered from an already-loaded ancestor catalog, but it is not retained for transport and creates no scope until the user selects that breadcrumb. Restored navigation persists the full mode-bearing address. +The React-free runtime owns catalogs, single-flight refreshes, retained addresses, availability hints, transport selection, and a reference-stable map of each list row's current projection values. Re-selecting a known child retains its address so navigation cannot silently switch to ordinary session APIs. A missing intermediate breadcrumb address can be recovered from an already-loaded ancestor catalog, but it is not retained for transport and creates no scope until the user selects that breadcrumb. Restored navigation persists the full mode-bearing address. Catalogs ride the standard `useSessions` snapshot. Component-local state owns menu visibility, expanded branches, and focus. `ui-conversation` declares the generic header-action list slot and dispatches the current conversation snapshot through its composer chain; it contains no subagent-specific takeover flag. `@deepseek-ai/dsh-client-ui-subagent` registers the catalog action and elects a reason-specific read-only composer from ordinary owner props. Components receive derived props and callbacks, never `ctx`. @@ -102,14 +104,14 @@ The shipped Web composition mounts SQLite session query beside JSONL persistence - Host protocol tests pin schemas including required boolean expandability, id echoing, mode verification, non-activating history, exact-parent enforcement, FIFO admission receipts, cancellation, and sanitized failure mapping. - Generic Host tests pin attached and cold history and forks without Agent publication, cold projection folding, descriptor/origin/runtime-owner denial, explicit-id adoption denial, and the direct queue-control fence. - Client object tests pin retained and restored addresses, one-shot read-only rejection, history routing, continuable prompt routing, no addressed cancellation, suppression of Agent-bound model controls, live activity flips including in-flight response replay and detach fallback, subagent-parent expandability flips, and membership refresh. -- jsdom tests pin the aggregate descendant count and activity, known loading-row shape, mixed-mode rows, pre-click leaf disclosure, diagnostics, lazy descendant disclosure, direct-parent addresses, keyboard behavior, and both read-only reasons. -- The keyless assembled Web snapshot contains an inactive continuable child, an inactive one-shot sibling, and a persisted grandchild; it pins the three-descendant trigger and aggregate running transition, expands without activation, opens persisted history, admits a human FIFO follow-up, reconciles child mux events, and proves one-shot history remains read-only. +- jsdom tests pin the aggregate descendant count and activity, token totals, second-precision running and frozen inactive durations, known loading-row shape, mixed-mode rows, pre-click leaf disclosure, diagnostics, lazy descendant disclosure, direct-parent addresses, keyboard behavior, and both read-only reasons. +- The keyless assembled Web snapshot contains an inactive continuable child with durable usage, an inactive one-shot sibling, and a persisted grandchild; it pins the three-descendant trigger, usage and timing rows, and aggregate running transition, expands without activation, opens persisted history, admits a human FIFO follow-up, reconciles child mux events, and proves one-shot history remains read-only. - Navigation tests pin subagent-only breadcrumbs, workspace placement for forks created from subagents, and `origin: 'subagent'` sidebar filtering without hiding ordinary forks. ## Consequences -- Catalog reads may rescan persisted lineage and each direct candidate's descriptor log, but expandability reuses only descendant headers already present in that trace; the Web activity baseline adds one Agent-registry lookup per healthy row and then uses existing live frames, while membership refresh stays debounced and single-flight. +- Catalog reads may rescan persisted lineage and each direct candidate's descriptor log, but expandability reuses only descendant headers already present in that trace; the Web activity baseline adds one Agent-registry lookup per healthy row and then uses existing live frames, while usage and duration reuse projection baselines and pushes with no per-row log read, and membership refresh stays debounced and single-flight. - Parent availability, child activity, and `hasChildren` are snapshots. Publication, disposal, another sender, or another process may win after listing; typed prompt failure remains expected. - A child may publish between history fetch and mux subscription, so the existing sequence reconciliation also covers the cold-to-live addressed path. - Persisted origin adds one deliberately weak product-classification field to child headers and list projections; it cannot become an authorization shortcut. -- The UI has no child cancellation, durable outcome, activation duration, deletion, or independently interactive offline mode, and its text must not imply those capabilities. +- The UI has no child cancellation, durable outcome, Activation identity, deletion, or independently interactive offline mode, and its text must not imply those capabilities. Active-turn duration measures logged work rather than Activation residency. diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md index 5dcd7025c5..5e89c9a7a4 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md @@ -33,7 +33,7 @@ Figma 中的 [subagent 列表](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5 | 会话页头可打开紧凑的 child 列表。 | 触发器会汇总仅含 subagent 的完整后代谱系;树按服务顺序显示每个直接目录条目,包括已禁用的 diagnostic。 | | 选择一行会复用对话 UI。 | 已寻址历史绝不激活 child;只有 parent 存活的可继续行才保留普通输入框。 | | 嵌套 agent 会逐层展开。 | 每行携带一层 `hasChildren` 快照;展开时会立即预留已知直接后代行,随后仍只加载该行的直接目录,并保留其自身的 parent 地址。 | -| 条目显示 label、状态与相对时间,同时避免侧边栏条目重复。 | mode 与 `running`/`inactive` 活动状态会同时以文字和视觉呈现;可选 title 与时间来自摘要。`SessionHeader.origin` 会移除重复的导航条目,但不授予任何功能权限。 | +| 条目显示 label、状态、用量与活跃耗时,同时避免侧边栏条目重复。 | mode 与 `running`/`inactive` 活动状态会同时以文字和视觉呈现;可选 title、持久化 token 用量与精确的活跃轮次耗时来自列表保留的投影值。`SessionHeader.origin` 会移除重复的导航条目,但不授予任何功能权限。 | ## 产品契约 @@ -41,6 +41,8 @@ Figma 中的 [subagent 列表](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5 `running` 表示在 Host 采样边界,确切 child Agent driver 正在处理工作;`inactive` 表示该 driver 空闲或不存在。UI 不会把任一值解释为成功、失败、取消、完成状态或可恢复性。`subagent.list` 提供当前 driver 状态基线,`host/session-status` 会就地更新已知活动状态,请求内回放会阻止更早发起但尚未完成的列表响应覆盖较新的状态转换,`host/session-removed` 则会使已知行恢复为 `inactive`;重连时会读取新的基线。直接 subagent 的 `host/session-added` 帧会立即把任何已加载的 parent 行翻转为 `hasChildren: true`,并使这项正向提示不被更早发起但尚未完成的目录响应覆盖;受影响分支打开期间,成员、label、mode、diagnostic 与权威快照仍需要通过去抖动的 `subagent.list` 刷新来更新。消息投递时仍以提示词响应为权威依据。 +健康行会复用列表镜像中保留的标准会话投影。token 数值会汇总持久化日志中四个互不重叠的 `tokenUsage` 桶。`subagentTiming` 会在每个描述符处重置,使继承的 fork 种子不会计入 child 总量;它会累加已完成的 `turn/start` → `turn/end` 时段,并携带当前轮次的 `activeSince`。菜单会以整秒格式化时间,且仅在有已知后代处于运行状态时才推进其本地时钟;对 inactive 行,菜单使用已结算耗时,或以摘要的最后活动为被中断未结束轮次的上界,因此重新打开菜单绝不会让已完成工作重新计时。token 分片不会改变 `subagentTiming`,因此不会增加按 token 更新列表的路径。这两项指标都不蕴含持久化结果语义。 + 选择一行后,系统会先记录其确切地址,再打开常驻客户端 `Session`。历史分页、事件 fold、工具渲染意图、title 与实时 mux 归并都会复用普通对话机制。面包屑导航使用目录 label,只会沿 `origin: 'subagent'` 行的父链接逐级回溯,包含第一个普通 owner,并让普通 fork 保持单层。从已寻址 subagent 创建 fork 时,会生成具有直接源谱系的普通 fork,并将其附加到最近拥有 Workspace 的祖先。目录是一棵 ARIA 树,支持懒加载式 ArrowRight/ArrowLeft 展开与折叠、线性 ArrowUp/ArrowDown 导航、Home/End、Escape 以及焦点恢复。 one-shot 行始终会用文案替代输入框,说明执行记录为只读。可继续行仅在 `parentAvailable` 为 false 时如此。启用后,即使 child 正在运行,其 Send 操作也会准入另一个 FIFO 轮次,绝不会变成 Stop。提示词失败会通过普通错误行为保留草稿。 @@ -65,7 +67,7 @@ one-shot 行始终会用文案替代输入框,说明执行记录为只读。 ## 客户端对象层与呈现 -不依赖 React 的运行时负责目录、单次并发刷新、保留的地址、可用性提示与传输选择。再次选择已知 child 时会保留其地址,避免导航静默切换到普通会话 API。缺失的中间面包屑地址可以从已加载的祖先目录恢复,但在用户选择该面包屑之前不会保留为传输地址,也不会创建 scope。恢复的导航会持久化包含 mode 的完整地址。 +不依赖 React 的运行时负责目录、单次并发刷新、保留的地址、可用性提示、传输选择,以及每个列表行当前投影值的引用稳定映射。再次选择已知 child 时会保留其地址,避免导航静默切换到普通会话 API。缺失的中间面包屑地址可以从已加载的祖先目录恢复,但在用户选择该面包屑之前不会保留为传输地址,也不会创建 scope。恢复的导航会持久化包含 mode 的完整地址。 目录通过标准 `useSessions` 快照传递。组件局部状态负责菜单可见性、已展开分支与焦点。`ui-conversation` 声明通用页头操作列表 slot,并通过其编辑器链分发当前对话快照;其中没有 subagent 专用的接管标记。`@deepseek-ai/dsh-client-ui-subagent` 注册目录操作,并根据普通 owner props 选择按原因区分的只读编辑器。组件只接收派生 props 与回调,绝不接收 `ctx`。 @@ -102,14 +104,14 @@ one-shot 行始终会用文案替代输入框,说明执行记录为只读。 - 宿主协议测试固定 schema(包括必需的布尔可展开性)、id 回显、mode 校验、非激活式历史、确切 parent 强制要求、FIFO 准入回执、取消与脱敏后的失败映射。 - 通用 Host 测试固定在不发布 Agent 的情况下读取已附加与冷态历史及执行 fork、冷态投影归并、按描述符/origin/运行时 owner 拒绝、拒绝显式 id 接纳,以及直接队列控制栅栏。 - 客户端对象测试固定已保留与已恢复的地址、one-shot 只读拒绝、历史路由、可继续提示词路由、已寻址对话不提供取消、屏蔽绑定到 agent 的模型控件、实时活动状态翻转(包括在途响应回放与 detach 回退)、subagent parent 可展开性翻转与成员刷新。 -- jsdom 测试固定后代聚合计数与活动状态、已知加载行的形态、混合 mode 行、点击前的叶子展开控件、diagnostic、后代懒加载展开、直接 parent 地址、键盘行为与两种只读原因。 -- 无密钥的组装 Web 快照包含一个 inactive 的可继续 child、一个 inactive 的 one-shot sibling 和一个持久化 grandchild;它会固定触发器显示三个后代及聚合 `running` 状态转换,在不激活的情况下展开、打开持久化历史、准入一条用户 FIFO 后续消息、归并 child mux 事件,并证明 one-shot 历史仍然只读。 +- jsdom 测试固定后代聚合计数与活动状态、token 总量、精确到秒的运行中耗时与冻结后 inactive 耗时、已知加载行的形态、混合 mode 行、点击前的叶子展开控件、diagnostic、后代懒加载展开、直接 parent 地址、键盘行为与两种只读原因。 +- 无密钥的组装 Web 快照包含一个具有持久化用量的 inactive 可继续 child、一个 inactive 的 one-shot sibling 和一个持久化 grandchild;它会固定触发器显示三个后代、用量与计时行,以及聚合 `running` 状态转换,在不激活的情况下展开、打开持久化历史、准入一条用户 FIFO 后续消息、归并 child mux 事件,并证明 one-shot 历史仍然只读。 - 导航测试固定仅含 subagent 的面包屑导航、从 subagent 创建 fork 时的 Workspace 归属,以及 `origin: 'subagent'` 侧边栏过滤,同时不隐藏普通 fork。 ## 后果 -- 目录读取可能重新扫描持久化谱系与每个直接候选的描述符日志,但可展开性只复用该追踪中已有的后代 header;Web 活动基线会为每个健康行增加一次 Agent 注册表查找,随后使用现有实时帧,而成员刷新保持去抖动和单次并发。 +- 目录读取可能重新扫描持久化谱系与每个直接候选的描述符日志,但可展开性只复用该追踪中已有的后代 header;Web 活动基线会为每个健康行增加一次 Agent 注册表查找,随后使用现有实时帧,而用量与耗时会复用投影基线和推送,无需按行读取日志,成员刷新则保持去抖动和单次并发。 - parent 可用性、child 活动状态与 `hasChildren` 都是快照。列出之后,发布、dispose、其他发送方或其他进程都可能抢先改变状态;类型化提示词失败仍属预期行为。 - child 可能在历史获取与 mux 订阅之间发布,因此现有序号归并也涵盖从冷态转为存活的已寻址路径。 - 持久化 origin 会为 child header 与列表投影添加一个有意保持弱约束的产品分类字段;它不能变成授权捷径。 -- UI 不提供 child 取消、持久化结果、激活耗时、删除或可独立交互的离线 mode,其文案不得暗示这些功能已经存在。 +- UI 不提供 child 取消、持久化结果、Activation 身份、删除或可独立交互的离线 mode,其文案不得暗示这些功能已经存在。活跃轮次耗时度量的是已记录工作,而非 Activation 驻留时间。 diff --git a/apps/web/tests/snapshots/subagent-conversation/tree.expected.md b/apps/web/tests/snapshots/subagent-conversation/tree.expected.md index 12520a65c4..b1e23e4020 100644 --- a/apps/web/tests/snapshots/subagent-conversation/tree.expected.md +++ b/apps/web/tests/snapshots/subagent-conversation/tree.expected.md @@ -1,8 +1,8 @@ - tree "子代理会话": - - treeitem "event-sourcing researcher Explain event sourcing in one · 可继续 · 当前未运行 刚刚" [expanded] [level=1]: + - treeitem "event-sourcing researcher Explain event sourcing in one · 可继续 · 当前未运行 7.9K tok · 2秒" [expanded] [level=1]: - button "收起 event-sourcing researcher 的下级子代理": - img - - text: event-sourcing researcher Explain event sourcing in one · 可继续 · 当前未运行 刚刚 + - text: event-sourcing researcher Explain event sourcing in one · 可继续 · 当前未运行 7.9K tok · 2秒 - group: - - treeitem "example editor 可继续 · 当前未运行 刚刚" [level=2] - - treeitem "event-sourcing reviewer 一次性 · 当前未运行 刚刚" [level=1] + - treeitem "example editor 可继续 · 当前未运行 0 tok · 0秒" [level=2] + - treeitem "event-sourcing reviewer 一次性 · 当前未运行 0 tok · 0秒" [level=1] diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index e71b54e0d9..2dff253b66 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -149,6 +149,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = data: { turn: 1, reason: { kind: 'completed' } }, }, ] as SessionEvent[]) + await scaffold.ctx.sessionProjectionCache.coldSnapshot(oneShotId) grandchildId = sessionId('recorded-grandchild') const authoredAt = Date.now() await scaffold.ctx.sessionPersistence.create({ @@ -192,6 +193,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = data: { turn: 1, reason: { kind: 'completed' } }, }, ] as SessionEvent[]) + await scaffold.ctx.sessionProjectionCache.coldSnapshot(grandchildId) expect(scaffold.ctx.agents.get(childId)).toBeUndefined() expect(scaffold.ctx.agents.get(oneShotId)).toBeUndefined() expect(scaffold.ctx.agents.get(grandchildId)).toBeUndefined() @@ -246,6 +248,10 @@ describe('web e2e: persisted subagent conversation and human continuation', () = name: `展开 ${ONE_SHOT_LABEL} 的下级子代理`, }).count()).toBe(0) await page.getByRole('button', { name: `展开 ${LABEL} 的下级子代理` }).click() + const childRow = page.getByRole('treeitem', { name: new RegExp(LABEL) }) + const childLabel = await childRow.getAttribute('aria-label') + await page.waitForTimeout(1_100) + expect(await childRow.getAttribute('aria-label')).toBe(childLabel) await page.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }).waitFor({ timeout: 15_000 }) expect(scaffold.ctx.agents.get(childId)).toBeUndefined() expect(scaffold.ctx.agents.get(grandchildId)).toBeUndefined() diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 521a2fa735..8f4385938c 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -794,7 +794,7 @@ A published child settled. Scope-filtered dispatch uses the same delegating pare Types: [Scoped](../core-data-structures/scope.md) · [SubagentService](../core-data-structures/subagent.md) -Source: [`packages/subagent/subagent/src/index.ts:158`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:160`](../../packages/subagent/subagent/src/index.ts) ### `subagent/provider-added` — emit @@ -811,7 +811,7 @@ A provider became resolvable in the registry. Types: [SubagentProvider](../core-data-structures/subagent.md) -Source: [`packages/subagent/subagent/src/index.ts:132`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:134`](../../packages/subagent/subagent/src/index.ts) ### `subagent/provider-removed` — emit @@ -826,7 +826,7 @@ A provider left the registry. Accepted runs remain holder-owned. 'subagent/provider-removed'(name: string): void ``` -Source: [`packages/subagent/subagent/src/index.ts:138`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:140`](../../packages/subagent/subagent/src/index.ts) ### `subagent/start` — emit @@ -848,7 +848,7 @@ A provider established a published child. For in-process providers, `ctx.agents. Types: [Scoped](../core-data-structures/scope.md) · [SubagentService](../core-data-structures/subagent.md) -Source: [`packages/subagent/subagent/src/index.ts:149`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:151`](../../packages/subagent/subagent/src/index.ts) ## `system-prompt/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 96b876a0eb..ae426792e1 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2072,7 +2072,7 @@ async start(name: string, request: SubagentStartRequest): Promise Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [ContinuableSetupContribution](../core-data-structures/subagent.md) · [ContinuableStart](../core-data-structures/subagent.md) · [ContinuableStartSpec](../core-data-structures/subagent.md) · [MessageId](../core-data-structures/core.md) · [SessionId](../core-data-structures/core.md) · [SubagentFollowupOptions](../core-data-structures/subagent.md) · [SubagentListEntry](../core-data-structures/subagent.md) · [SubagentProvider](../core-data-structures/subagent.md) · [SubagentReportOptions](../core-data-structures/subagent.md) · [SubagentRun](../core-data-structures/subagent.md) · [SubagentStartRequest](../core-data-structures/subagent.md) -Source: [`packages/subagent/subagent/src/index.ts:163`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:165`](../../packages/subagent/subagent/src/index.ts) ## `ctx.subprocess` — `SubprocessService` (abstract seam) diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index d1457a75af..b59b8733d5 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -41,10 +41,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `settings/document-updated` | `emit` | [`packages/settings/settings/src/index.ts:150`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` | | `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:137`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:188`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | [`tui`](../packages/ui/tui) | -| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:158`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) | -| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:132`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:138`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:149`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:160`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) | +| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:134`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:140`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:151`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `telemetry/record` | `waterfall` | [`packages/telemetry/session-telemetry/src/index.ts:41`](../packages/telemetry/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/telemetry/session-telemetry) (`waterfall`) | - | diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 6f31ca3beb..1ab6c96659 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: eca7db1f9b2d5c7e28fa86a363ca4408703b99df -README.zh.md: 6a2e8c6085d06a9f04c1270e5976452b995a7e77 +README.md: 82f1bc95a6128245f88f01a0de0849494cb98359 +README.zh.md: f3aba75d18671fe377ec8305693ea5025d51e0de diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index eca7db1f9b..82f1bc95a6 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager, and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. +Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager, and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions. ## Workspace and Session lists diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 6a2e8c6085..f3aba75d18 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态;SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本;WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager,并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed`、`settings/changed`、`credentials/changed`、`models/changed`),使各表面缓存无需触碰流即可重拉。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。契约:api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。 +客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态;SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本;WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager,并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed`、`settings/changed`、`credentials/changed`、`models/changed`),使各表面缓存无需触碰流即可重拉。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。契约:api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。 ## Workspace 与 Session 列表 diff --git a/packages/client/runtime/src/client/sessions/lineage.ts b/packages/client/runtime/src/client/sessions/lineage.ts index 08f8361a92..4f26674420 100644 --- a/packages/client/runtime/src/client/sessions/lineage.ts +++ b/packages/client/runtime/src/client/sessions/lineage.ts @@ -3,10 +3,13 @@ // Orphaned lineage degrades to root level; cycles fail soft and emit as roots. import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types' /** Host list summary enriched with the latest mux-projected durable title. */ export interface TitledSessionSummary extends SessionSummary { title?: string + /** Current host-computed projection values for list consumers. */ + projectionValues?: Readonly> } /** One flattened session-list row (summary + lineage indent depth + live pending-approval bit). */ @@ -21,6 +24,8 @@ export interface SessionListEntry { /** Coarse durable origin for navigation filtering; not a continuation capability. */ origin?: 'subagent' cwd?: string + /** Current host-computed projection values for list consumers. */ + projectionValues?: Readonly> /** An approval question is pending on this session (mux-frame derived; the sidebar's amber dot). */ waitingApproval: boolean /** Lineage indent depth: root = 0; the UI just multiplies by the indent width. */ diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index 1019327df5..d87ed0a04f 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -798,10 +798,14 @@ export class SessionManager { const merged: TitledSessionSummary[] = this.summaries.map((summary) => { // List rows read the generic 'title' projection key (host-computed unit // value; the bespoke session/title frame is retired). - const title = this.projectionStores.get(summary.sessionId)?.get('title') - return typeof title === 'string' && title !== '' - ? { ...summary, title } - : summary + const projectionStore = this.projectionStores.get(summary.sessionId) + const title = projectionStore?.get('title') + const projectionValues = projectionStore?.values() + return { + ...summary, + ...(typeof title === 'string' && title !== '' ? { title } : {}), + ...(projectionValues === undefined ? {} : { projectionValues }), + } }) const fresh = flattenLineage(merged, new Set(this.waitingApprovals.keys())) const items = fresh.map((entry) => { @@ -812,6 +816,7 @@ export class SessionManager { && prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd && prev.origin === entry.origin && prev.title === entry.title && prev.depth === entry.depth && prev.waitingApproval === entry.waitingApproval + && prev.projectionValues === entry.projectionValues ) return prev this.entryCache.set(entry.sessionId, entry) return entry diff --git a/packages/client/runtime/src/client/sessions/projection-store.ts b/packages/client/runtime/src/client/sessions/projection-store.ts index 4e6e7dd626..32401146af 100644 --- a/packages/client/runtime/src/client/sessions/projection-store.ts +++ b/packages/client/runtime/src/client/sessions/projection-store.ts @@ -74,6 +74,7 @@ interface Channel { export class ProjectionValueStore { private readonly rows = new Map() private readonly channels = new Map() + private valuesCache: Readonly> | undefined /** Coarse any-key channel (no snapshot cache to rebuild: reads hit rows directly). */ private readonly anyNotifier = new Notifier(() => {}) @@ -98,6 +99,19 @@ export class ProjectionValueStore { return this.rows.get(key)?.value } + /** + * Read every current projection value as one reference-stable snapshot. + * @returns The same frozen value map until a row changes. + */ + values(): Readonly> { + if (this.valuesCache === undefined) { + this.valuesCache = Object.freeze(Object.fromEntries( + [...this.rows].map(([key, row]) => [key, row.value]), + )) + } + return this.valuesCache + } + /** * Subscribe to any-key changes (microtask-batched) — the manager's list * rebuild channel. @@ -160,6 +174,7 @@ export class ProjectionValueStore { } private changed(key: string): void { + this.valuesCache = undefined this.channels.get(key)?.notifier.markDirty() this.anyNotifier.markDirty() } diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index 087a07b02d..47ee641053 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -25,6 +25,7 @@ import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-host-apiproxy/api' import type { HostObservable, SessionMaybeProvideInfo, SessionProvideInfo, } from '@deepseek-ai/dsh-client-ui-slots' +import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types' import type { SnapshotStore } from '../contract/store.ts' import { createSnapshotStore } from '../contract/store.ts' import type { SessionFace } from '../contract/session.ts' @@ -57,6 +58,8 @@ export interface SessionSummary { */ blank: boolean updatedAt: number + /** Current host-computed projection values retained by the object layer. */ + projectionValues?: Readonly> } /** @@ -613,6 +616,9 @@ export class SessionsService implements ISessions { waitingApproval: entry.waitingApproval, blank: entry.blank, updatedAt: entry.updatedAt, + ...(entry.projectionValues === undefined + ? {} + : { projectionValues: entry.projectionValues }), ...(entry.title !== undefined ? { title: entry.title } : {}), ...(entry.cwd !== undefined ? { cwd: entry.cwd } : {}), ...(entry.parentSessionId !== undefined ? { parentId: entry.parentSessionId } : {}), diff --git a/packages/client/runtime/tests/projection-store.spec.ts b/packages/client/runtime/tests/projection-store.spec.ts index eea43b67f3..143da92348 100644 --- a/packages/client/runtime/tests/projection-store.spec.ts +++ b/packages/client/runtime/tests/projection-store.spec.ts @@ -86,6 +86,17 @@ describe('ProjectionValueStore semantics', () => { const store = new ProjectionValueStore() expect(store.faceOf('test/marks')).toBe(store.faceOf('test/marks')) }) + + it('publishes one reference-stable whole-value snapshot until a row changes', () => { + const store = new ProjectionValueStore() + const empty = store.values() + expect(store.values()).toBe(empty) + store.apply('test/marks', { marks: ['a'] }, 1) + const populated = store.values() + expect(populated).toEqual({ 'test/marks': { marks: ['a'] } }) + expect(populated).not.toBe(empty) + expect(store.values()).toBe(populated) + }) }) describe('Session tail-page seeding', () => { @@ -167,6 +178,36 @@ describe('manager frame routing', () => { expect(manager.getListSnapshot().items[0]?.title).toBeUndefined() }) + it('projects every retained value into list rows with stable snapshot identity', async () => { + const api = new FakeApiClient() + const manager = new SessionManager(api) + api.onList = () => Promise.resolve(ok({ + items: [{ + sessionId: sid('s1'), updatedAt: 1, running: false, blank: false, + projections: { + asOfSeq: 2, + values: { 'test/marks': { marks: ['baseline'] } }, + }, + }], + }) as never) + await manager.refreshList() + const baseline = manager.getListSnapshot().items[0]?.projectionValues + expect(baseline).toEqual({ 'test/marks': { marks: ['baseline'] } }) + expect(manager.getListSnapshot().items[0]?.projectionValues).toBe(baseline) + + manager.handleMuxEnvelope({ + rpcId: 'p2' as never, + payload: { + type: 'session/projection', sessionId: sid('s1'), key: 'test/marks', + value: { marks: ['live'] }, seq: 3, + } as never, + }) + await Promise.resolve() + expect(manager.getListSnapshot().items[0]?.projectionValues) + .toEqual({ 'test/marks': { marks: ['live'] } }) + expect(manager.getListSnapshot().items[0]?.projectionValues).not.toBe(baseline) + }) + it('drops the projection store with the removed session', async () => { const api = new FakeApiClient() const manager = new SessionManager(api) diff --git a/packages/client/ui-subagent/README.i18n.yaml b/packages/client/ui-subagent/README.i18n.yaml index 65f99af124..8af7265330 100644 --- a/packages/client/ui-subagent/README.i18n.yaml +++ b/packages/client/ui-subagent/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-subagent/README.md -README.md: f6b3fa2e9cdf1479a739e0b4eab15a5423e878e4 -README.zh.md: fdfba385e9188cd42bd973b6f32bc01fe8d004f2 +README.md: 2d507dde49f3796f3bbd00c239a768cdfd8a561d +README.zh.md: d0143858ff7eac1437fce381378ebcfad2187f2a diff --git a/packages/client/ui-subagent/README.md b/packages/client/ui-subagent/README.md index f6b3fa2e9c..2d507dde49 100644 --- a/packages/client/ui-subagent/README.md +++ b/packages/client/ui-subagent/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Web subagent feature owner: contributes the lazily expandable catalog tree to `conversation.session.header.actions`, reason-specific read-only replacements to the conversation composer chain, and the existing `@` reference source to `ctx.slash`. -The header action reads `subagentsByParent` and session summaries through the standard `useSessions` hook. After a non-empty direct catalog arrives, its trigger counts the complete subagent-only descendant lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. The compact tree remains direct-catalog authoritative: continuable and one-shot rows display mode plus `running`/`inactive` activity, an optional log-backed title, and session-summary activity time; an unlabeled one-shot row falls back to its session id, while corrupt, unsupported, or unavailable rows remain readable but disabled. Each healthy row's `hasChildren` hint determines disclosure before interaction, so known leaves never show an arrow; expanding a branch immediately reserves one disabled loading row per known direct descendant, then lazily replaces them with that child's authoritative catalog. Every visible branch is reported to the runtime so membership frames cause a debounced refresh only where the tree is being consumed. Selecting any depth calls `SessionsService.openSubagent()` with the row's exact `{parentSessionId, childSessionId, mode}` address. Component-local state owns tree visibility, expanded branches, and keyboard focus. ArrowRight/ArrowLeft expand and collapse branches; ArrowUp/ArrowDown, Home, End, and Escape navigate or close the tree; closing returns focus to the trigger. Styling uses tokens only. +The header action reads `subagentsByParent` and session summaries through the standard `useSessions` hook. After a non-empty direct catalog arrives, its trigger counts the complete subagent-only descendant lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. The compact tree remains direct-catalog authoritative: continuable and one-shot rows display mode plus `running`/`inactive` activity, an optional log-backed title, total durable provider usage, and active-turn duration to the second; an unlabeled one-shot row falls back to its session id, while corrupt, unsupported, or unavailable rows remain readable but disabled. Token totals sum the four disjoint `tokenUsage` buckets. Duration sums completed `subagentTiming` turns, advances once per second only for an open turn on a running child, and freezes after the child becomes inactive; an interrupted open turn is bounded by the session summary's last activity. Each healthy row's `hasChildren` hint determines disclosure before interaction, so known leaves never show an arrow; expanding a branch immediately reserves one disabled loading row per known direct descendant, then lazily replaces them with that child's authoritative catalog. Every visible branch is reported to the runtime so membership frames cause a debounced refresh only where the tree is being consumed. Selecting any depth calls `SessionsService.openSubagent()` with the row's exact `{parentSessionId, childSessionId, mode}` address. Component-local state owns tree visibility, expanded branches, keyboard focus, and the running-duration clock. ArrowRight/ArrowLeft expand and collapse branches; ArrowUp/ArrowDown, Home, End, and Escape navigate or close the tree; closing returns focus to the trigger. Styling uses tokens only. A one-shot child always elects a read-only composer that identifies the transcript as a completed execution record. A continuable child does so only when its exact parent is unavailable, with copy explaining the recovery path. A continuable child with a live parent keeps the ordinary input chrome, whose Session routes through `subagent.prompt`; running input remains Send because every follow-up joins the child's FIFO inbox, and addressed sessions never expose Stop. This package never receives host context or calls a model-facing tool. The catalog and composer behavior are specified by the [Web subagent conversations Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md). @@ -30,5 +30,5 @@ Append-only. This package never edits earlier request tokens. ## Known Limitations and Deferred Work -- **The catalog has coarse activity only** — it cannot show durable outcome, elapsed time, Activation identity, or an authority-safe cancel button. +- **The catalog has no durable outcome** — activity and timing do not distinguish completion, failure, or cancellation, and the UI exposes neither Activation identity nor an authority-safe cancel button. - **`@` references remain display-title text** — duplicate or renamed labels are ambiguous, so they intentionally do not acquire continuation semantics. diff --git a/packages/client/ui-subagent/README.zh.md b/packages/client/ui-subagent/README.zh.md index fdfba385e9..d0143858ff 100644 --- a/packages/client/ui-subagent/README.zh.md +++ b/packages/client/ui-subagent/README.zh.md @@ -4,7 +4,7 @@ Web subagent 功能 owner:向 `conversation.session.header.actions` 贡献可懒加载展开的目录树,向会话编辑器链贡献按原因区分的只读替代呈现,并保留注册到 `ctx.slash` 的既有 `@` 引用 source。 -页头操作通过标准 `useSessions` 钩子读取 `subagentsByParent` 与会话摘要。非空直接目录到达后,其触发器会统计仅含 subagent 的完整后代谱系,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。紧凑树仍以直接目录为权威依据:可继续和 one-shot 行会显示 mode、`running`/`inactive` 活动状态、由日志支撑的可选 title 与会话摘要中的活动时间;没有 label 的 one-shot 行会回退到其会话 id,而损坏、不受支持或不可用的行仍保持可读但禁用。每个健康行的 `hasChildren` 提示会在交互前决定是否显示展开控件,因此已知叶子节点从不显示箭头;展开分支时,会立即为每个已知直接后代预留一行禁用的加载行,随后再用该 child 的权威目录懒加载结果替换这些占位行。每个可见分支都会上报给运行时,使成员帧只在树正被消费的位置触发去抖动刷新。选择任意深度的条目都会使用该行的确切地址 `{parentSessionId, childSessionId, mode}` 调用 `SessionsService.openSubagent()`。组件局部状态负责树的可见性、已展开分支与键盘焦点。ArrowRight/ArrowLeft 展开和折叠分支;ArrowUp/ArrowDown、Home、End 与 Escape 用于导航或关闭树;关闭后焦点返回触发器。样式只使用 token。 +页头操作通过标准 `useSessions` 钩子读取 `subagentsByParent` 与会话摘要。非空直接目录到达后,其触发器会统计仅含 subagent 的完整后代谱系,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。紧凑树仍以直接目录为权威依据:可继续和 one-shot 行会显示 mode、`running`/`inactive` 活动状态、由日志支撑的可选 title、提供方的持久化总用量,以及精确到秒的活跃轮次耗时;没有 label 的 one-shot 行会回退到其会话 id,而损坏、不受支持或不可用的行仍保持可读但禁用。token 总量为四个互不重叠的 `tokenUsage` 桶之和。耗时会累加已完成的 `subagentTiming` 轮次,仅在运行中 child 存在未结束轮次时每秒递增一次,并在 child 变为 inactive 后冻结;被中断的未结束轮次以会话摘要中的最后活动为上界。每个健康行的 `hasChildren` 提示会在交互前决定是否显示展开控件,因此已知叶子节点从不显示箭头;展开分支时,会立即为每个已知直接后代预留一行禁用的加载行,随后再用该 child 的权威目录懒加载结果替换这些占位行。每个可见分支都会上报给运行时,使成员帧只在树正被消费的位置触发去抖动刷新。选择任意深度的条目都会使用该行的确切地址 `{parentSessionId, childSessionId, mode}` 调用 `SessionsService.openSubagent()`。组件局部状态负责树的可见性、已展开分支、键盘焦点与运行中耗时时钟。ArrowRight/ArrowLeft 展开和折叠分支;ArrowUp/ArrowDown、Home、End 与 Escape 用于导航或关闭树;关闭后焦点返回触发器。样式只使用 token。 one-shot child 始终选用只读编辑器,并将 transcript(文本记录)说明为已完成的执行记录。可继续 child 仅在其确切 parent 不可用时选用只读编辑器,并以文案说明恢复路径。确切 parent 存活时,可继续 child 保留普通输入 chrome,其 Session 会通过 `subagent.prompt` 路由;child 运行期间,输入操作仍为 Send,因为每条后续消息都会进入 child 的 FIFO inbox,且已寻址会话绝不公开 Stop。本包绝不接收宿主 context,也不调用面向模型的工具。目录与编辑器行为由 [Web subagent 对话 Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md)规定。 @@ -30,5 +30,5 @@ one-shot child 始终选用只读编辑器,并将 transcript(文本记录) ## 已知限制与暂缓事项 -- **目录只有粗粒度活动状态**:它不能显示持久化结果、耗时、Activation 身份或具备安全授权的取消按钮。 +- **目录没有持久化结果**:活动状态与计时无法区分完成、失败或取消,且 UI 既不公开 Activation 身份,也不公开具备安全授权的取消按钮。 - **`@` 引用仍是显示标题文本**:重复或改名后的 label 会有歧义,因此它们刻意不获得继续执行语义。 diff --git a/packages/client/ui-subagent/package.json b/packages/client/ui-subagent/package.json index efec3bc047..296b3d5ebf 100644 --- a/packages/client/ui-subagent/package.json +++ b/packages/client/ui-subagent/package.json @@ -46,6 +46,8 @@ "@deepseek-ai/dsh-client-ui-slash": "^0.0.1", "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-subagent": "^0.0.1", + "@deepseek-ai/dsh-token-meter": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { @@ -55,6 +57,8 @@ "@deepseek-ai/dsh-client-ui-slash": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-token-meter": "workspace:^", "@types/react": "~18.3.1", "cordis": "^4.0.0-rc.7" }, diff --git a/packages/client/ui-subagent/src/client/SubagentCatalogAction.module.css b/packages/client/ui-subagent/src/client/SubagentCatalogAction.module.css index e2133e6ce9..13004e6a47 100644 --- a/packages/client/ui-subagent/src/client/SubagentCatalogAction.module.css +++ b/packages/client/ui-subagent/src/client/SubagentCatalogAction.module.css @@ -173,15 +173,17 @@ } .summary, -.time { +.metrics { color: var(--dsw-alias-label-tertiary); font-size: 11px; line-height: 16px; } -.time { +.metrics { flex: none; margin-top: 16px; + font-variant-numeric: tabular-nums; + white-space: nowrap; } .children { diff --git a/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx b/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx index c23c82d77b..6bceec3105 100644 --- a/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx +++ b/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx @@ -2,13 +2,16 @@ import { useEffect, useRef, useState, type KeyboardEvent, type MouseEvent, } from 'react' import type { - SessionId, SessionListState, SessionSummary, SubagentAddress, SubagentCatalogSnapshot, + SessionId, SessionListState, SessionProjectionMap, SessionSummary, SubagentAddress, + SubagentCatalogSnapshot, } from '@deepseek-ai/dsh-client-runtime/client' import { IconChevronDownOutline14, IconChevronRightOutline14, IconRefreshOutline14, StateDot, } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type {} from '@deepseek-ai/dsh-subagent/client' +import type {} from '@deepseek-ai/dsh-token-meter/client' import css from './SubagentCatalogAction.module.css' type CatalogEntry = SubagentCatalogSnapshot['entries'][number] @@ -53,19 +56,53 @@ function treeItems(root: HTMLDivElement | null): HTMLElement[] { : Array.from(root.querySelectorAll('[role="treeitem"]:not([aria-disabled="true"])')) } -/** Compact trailing activity time for a catalog row. */ -function relativeTime(updatedAt: number | undefined, now: number): string | undefined { - if (updatedAt === undefined) return undefined - const minute = 60_000 - const hour = 60 * minute - const day = 24 * hour - const diff = Math.max(0, now - updatedAt) - if (diff < minute) return '刚刚' - if (diff < hour) return `${Math.floor(diff / minute)}分钟` - if (diff < day) return `${Math.floor(diff / hour)}小时` - if (diff < 30 * day) return `${Math.floor(diff / day)}天` - if (diff < 365 * day) return `${Math.floor(diff / (30 * day))}个月` - return `${Math.floor(diff / (365 * day))}年` +/** Compact token count shared in shape with the conversation stats strip. */ +function formatTokens(value: number): string { + const scaled = (next: number): string => next >= 100 + ? String(Math.round(next)) + : String(Math.round(next * 10) / 10) + if (value < 1_000) return String(value) + if (value < 1_000_000) return `${scaled(value / 1_000)}K` + return `${scaled(value / 1_000_000)}M` +} + +/** Sum the four disjoint durable provider-usage buckets. */ +function tokenTotal( + usage: SessionProjectionMap['tokenUsage'] | undefined, +): number | undefined { + return usage === undefined + ? undefined + : usage.uncachedInputTokens + usage.outputTokens + + usage.cacheReadTokens + usage.cacheWriteTokens +} + +/** Exact whole-second active-turn duration for one catalog row. */ +function activityDuration( + timing: SessionProjectionMap['subagentTiming'] | undefined, + activity: 'running' | 'inactive', + updatedAt: number | undefined, + now: number, +): number | undefined { + if (timing === undefined) return undefined + if (timing.activeSince === undefined) return timing.settledMs + const end = activity === 'running' ? now : updatedAt ?? timing.activeSince + return timing.settledMs + Math.max(0, end - timing.activeSince) +} + +/** Format a non-negative duration to seconds without dropping larger units. */ +function formatDuration(ms: number): string { + const totalSeconds = Math.floor(Math.max(0, ms) / 1_000) + const seconds = totalSeconds % 60 + const totalMinutes = Math.floor(totalSeconds / 60) + const minutes = totalMinutes % 60 + const hours = Math.floor(totalMinutes / 60) + if (hours > 0) { + return `${hours}小时${String(minutes).padStart(2, '0')}分${String(seconds).padStart(2, '0')}秒` + } + if (totalMinutes > 0) { + return `${totalMinutes}分${String(seconds).padStart(2, '0')}秒` + } + return `${seconds}秒` } /** Aggregate the complete subagent-only descendant subtree from flat summaries. */ @@ -190,7 +227,17 @@ function CatalogRows({ const secondary = [summary?.title, mode, activity] .filter(value => value !== undefined) .join(' · ') - const time = relativeTime(summary?.updatedAt, now) + const totalTokens = tokenTotal(summary?.projectionValues?.tokenUsage) + const durationMs = activityDuration( + summary?.projectionValues?.subagentTiming, + entry.activity, + summary?.updatedAt, + now, + ) + const metrics = [ + totalTokens === undefined ? undefined : `${formatTokens(totalTokens)} tok`, + durationMs === undefined ? undefined : formatDuration(durationMs), + ].filter(value => value !== undefined).join(' · ') const open = (): void => { openChild({ parentSessionId, childSessionId: entry.id, mode: entry.mode }) @@ -222,7 +269,7 @@ function CatalogRows({ role="treeitem" tabIndex={0} aria-level={level} - aria-label={[label, secondary, time].filter(value => value !== undefined).join(' ')} + aria-label={[label, secondary, metrics].filter(value => value !== '').join(' ')} {...knownLeaf ? {} : { 'aria-expanded': isExpanded }} className={css.row} onClick={open} @@ -247,7 +294,7 @@ function CatalogRows({ {label} {secondary} - {time !== undefined && {time}} + {metrics !== '' && {metrics}}
{isExpanded && !knownLeaf && ( @@ -300,6 +347,7 @@ export function SubagentCatalogAction({ const summaries = useSessions(state => state.byId) const catalog = catalogs[sessionId] const [open, setOpen] = useState(false) + const [now, setNow] = useState(() => Date.now()) const [expanded, setExpanded] = useState>(() => new Set()) const rootRef = useRef(null) const triggerRef = useRef(null) @@ -328,7 +376,10 @@ export function SubagentCatalogAction({ const changeOpen = (next: boolean, restoreFocus = false): void => { setOpen(next) - if (next) observeCatalog(sessionId, true) + if (next) { + setNow(Date.now()) + observeCatalog(sessionId, true) + } else closeAllCatalogs() if (restoreFocus) queueMicrotask(() => { triggerRef.current?.focus() }) } @@ -368,6 +419,12 @@ export function SubagentCatalogAction({ return () => { document.removeEventListener('pointerdown', closeOutside) } }, [open]) + useEffect(() => { + if (!open || !descendants.running) return + const timer = setInterval(() => { setNow(Date.now()) }, 1_000) + return () => { clearInterval(timer) } + }, [open, descendants.running]) + useEffect(() => () => { for (const parentSessionId of observedCatalogs.current) { setCatalogOpenRef.current(parentSessionId, false) @@ -443,7 +500,7 @@ export function SubagentCatalogAction({ summaries={summaries} expanded={expanded} level={1} - now={Date.now()} + now={now} openChild={openChild} refresh={refresh} toggleBranch={toggleBranch} diff --git a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx b/packages/client/ui-subagent/tests/conversation-ui.spec.tsx index 6eb9f881c0..d7098c1547 100644 --- a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx +++ b/packages/client/ui-subagent/tests/conversation-ui.spec.tsx @@ -11,6 +11,7 @@ import { SubagentReadOnlyComposer } from '../src/client/SubagentReadOnlyComposer afterEach(() => { cleanup() + vi.useRealTimers() vi.restoreAllMocks() }) @@ -217,42 +218,56 @@ describe('SubagentCatalogAction', () => { }) }) - it('renders compact activity times across every unit and clamps future timestamps', () => { + it('shows durable token totals, ticks active duration by seconds, and freezes inactive rows', async () => { const now = 2_000_000_000_000 - vi.spyOn(Date, 'now').mockReturnValue(now) - const minute = 60_000 - const hour = 60 * minute - const day = 24 * hour + vi.useFakeTimers() + vi.setSystemTime(now) const rows = [ - ['future', now + minute], - ['minutes', now - 2 * minute], - ['hours', now - 2 * hour], - ['days', now - 2 * day], - ['months', now - 60 * day], - ['years', now - 2 * 365 * day], + ['running', 'running', 65_000, now - 5_000, now], + ['finished', 'inactive', 3_723_000, undefined, now - 60_000], + ['interrupted', 'inactive', 2_000, now - 7_000, now - 3_000], ] as const - const entries = rows.map(([id]) => ({ + const entries = rows.map(([id, activity]) => ({ kind: 'child' as const, id: id as SessionId, mode: 'continuable' as const, label: id, - activity: 'inactive' as const, + activity, hasChildren: false, })) - const summaries = Object.fromEntries(rows.map(([id, updatedAt]) => [ - id, - summary(id as SessionId, updatedAt), - ])) as Record + const summaries = Object.fromEntries(rows.map(([id, activity, settledMs, activeSince, updatedAt]) => { + const childId = id as SessionId + return [id, { + ...summary(childId, updatedAt), + parentId: PARENT, + origin: 'subagent' as const, + running: activity === 'running', + projectionValues: { + subagentTiming: { + settledMs, + ...(activeSince === undefined ? {} : { activeSince }), + }, + tokenUsage: { + uncachedInputTokens: 1_000, + outputTokens: 200, + cacheReadTokens: 3_000, + cacheWriteTokens: 400, + }, + }, + }] + })) as Record const input = props(catalog({ entries }), {}, summaries) render() - fireEvent.click(screen.getByRole('button', { name: /6 个子代理/ })) + fireEvent.click(screen.getByRole('button', { name: /3 个子代理/ })) - expect(screen.getByRole('treeitem', { name: /future.*刚刚/ })).toBeTruthy() - expect(screen.getByRole('treeitem', { name: /minutes.*2分钟/ })).toBeTruthy() - expect(screen.getByRole('treeitem', { name: /hours.*2小时/ })).toBeTruthy() - expect(screen.getByRole('treeitem', { name: /days.*2天/ })).toBeTruthy() - expect(screen.getByRole('treeitem', { name: /months.*2个月/ })).toBeTruthy() - expect(screen.getByRole('treeitem', { name: /years.*2年/ })).toBeTruthy() + expect(screen.getByRole('treeitem', { name: /running.*4\.6K tok · 1分10秒/ })).toBeTruthy() + expect(screen.getByRole('treeitem', { name: /finished.*4\.6K tok · 1小时02分03秒/ })).toBeTruthy() + expect(screen.getByRole('treeitem', { name: /interrupted.*4\.6K tok · 6秒/ })).toBeTruthy() + + await vi.advanceTimersByTimeAsync(1_000) + expect(screen.getByRole('treeitem', { name: /running.*4\.6K tok · 1分11秒/ })).toBeTruthy() + expect(screen.getByRole('treeitem', { name: /finished.*4\.6K tok · 1小时02分03秒/ })).toBeTruthy() + expect(screen.getByRole('treeitem', { name: /interrupted.*4\.6K tok · 6秒/ })).toBeTruthy() }) it('lazily expands and collapses descendant catalogs with direct-parent navigation', () => { diff --git a/packages/client/ui-subagent/tsconfig.json b/packages/client/ui-subagent/tsconfig.json index 395281ce6d..0cae499e1a 100644 --- a/packages/client/ui-subagent/tsconfig.json +++ b/packages/client/ui-subagent/tsconfig.json @@ -26,6 +26,12 @@ { "path": "../ui-slots" }, + { + "path": "../../llm/token-meter" + }, + { + "path": "../../subagent/subagent" + }, { "path": "../../support/invariants" } diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index f7043e2403..76740d4506 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/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/subagent/subagent/README.md -README.md: 9aea27a0f150d90a41d9a7cb4cd422a75e6107fe -README.zh.md: 3f0b534deae53b8d5aff2765974050f26b931953 +README.md: ec4af55bcd9374b1abb55d7bb098eef568449684 +README.zh.md: 8323853ff0de2a15da6475fc1433a68f0074ee93 diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 9aea27a0f1..ec4af55bcd 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -92,6 +92,8 @@ Provider additions and removals also emit `subagent/provider-added` and `subagen Continuable children do not create `SubagentRun` or Tasks. The continuation manager directly owns one process-local Activation and retained `AgentHandle` per resident child Session, uses the Agent inbox as the only FIFO, and cold-resumes from the durable descriptor. Exact live direct-parent identity authorizes parent-to-child delivery. Exact live child identity authorizes reports; the manager derives the recipient from durable `parentSession`, and `MessageSource` remains provenance rather than authority. +When `ctx.sessionProjections` is available, the service registers `subagentTiming`. The projection resets at each descriptor so a fork seed's ancestor work cannot enter the child's total, then accumulates `turn/start` → `turn/end` active time and retains `activeSince` for an open turn. Only descriptors and turn boundaries change the value, so token chunks do not create timing updates. + `registerContinuableSetup()` lets optional packages add child-scoped capabilities without teaching the continuation manager their names. Contributions install synchronously before Activation publication, roll back with failed setup, and are released with the child scope. New grants wait for the next Activation, while contribution removal revokes every resident installation immediately. ## Collection model diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 3f0b534dea..8323853ff0 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -92,6 +92,8 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 可继续子级不会创建 `SubagentRun` 或 Task。延续管理器为每个驻留子 Session 直接拥有一个仅存在于当前进程的 Activation 和一个留存的 `AgentHandle`,使用 Agent inbox 作为唯一 FIFO,并从持久化描述符冷恢复。父到子投递由准确的实时直接父级身份授权。上报则由准确的实时子级身份授权;管理器根据持久化的 `parentSession` 推导接收方,`MessageSource` 仍只表示来源,不表示权限。 +当 `ctx.sessionProjections` 可用时,服务会注册 `subagentTiming`。该投影会在每个描述符处重置,使 fork 种子中的祖先工作不会计入 child 总量,随后累加 `turn/start` → `turn/end` 活跃时间,并为未结束的轮次保留 `activeSince`。只有描述符和轮次边界会改变该值,因此 token 分片不会产生计时更新。 + `registerContinuableSetup()` 允许可选包添加子级作用域功能,而无需让延续管理器知道这些功能的名称。贡献会在 Activation 发布前同步安装,在设置失败时一并回滚,并随子级作用域释放。新授权须等到下一个 Activation,移除贡献则会立即撤销每个驻留安装项。 ## 收集模型 diff --git a/packages/subagent/subagent/package.json b/packages/subagent/subagent/package.json index 863f04c117..8cd78c5526 100644 --- a/packages/subagent/subagent/package.json +++ b/packages/subagent/subagent/package.json @@ -15,17 +15,25 @@ "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, + "./client": { + "types": "./lib/types/client.d.ts", + "default": "./lib/types/client.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", "lib/invariant.js", + "lib/types/**/*.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", + "dependencies": { + "zod": "^4.4.3" + }, "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-brand": "^0.0.1", @@ -35,6 +43,7 @@ "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-persistence": "^0.0.1", "@deepseek-ai/dsh-session-query": "^0.0.1", + "@deepseek-ai/dsh-session-projection": "^0.0.1", "@deepseek-ai/dsh-tasks": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "cordis": "^4.0.0-rc.7" @@ -46,6 +55,9 @@ "@deepseek-ai/dsh-session-query": { "optional": true }, + "@deepseek-ai/dsh-session-projection": { + "optional": true + }, "@deepseek-ai/dsh-tasks": { "optional": true } @@ -59,6 +71,7 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-query": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-tasks": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "cordis": "^4.0.0-rc.7" diff --git a/packages/subagent/subagent/src/client.ts b/packages/subagent/subagent/src/client.ts new file mode 100644 index 0000000000..928637dc7a --- /dev/null +++ b/packages/subagent/subagent/src/client.ts @@ -0,0 +1,7 @@ +/** + * Browser-safe subagent projection vocabulary. + * + * @module @deepseek-ai/dsh-subagent/client + */ + +export type { SubagentTimingProjection } from './projection-types.ts' diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 19ad74ff3a..975d07ce4c 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -65,6 +65,7 @@ import type { ContinuableSetupContribution } from './activation-setup-registry.t import { listChildren as listSubagentChildren } from './list-children.ts' import type { SubagentListEntry } from './list-children.ts' import { snapshotSubagentDescriptor } from './descriptor.ts' +import { subagentTimingProjectionDefinition } from './projection.ts' export * from './out-of-process.ts' export { SubagentRunId } from './types.ts' @@ -117,6 +118,7 @@ export type { export type { ContinuableSetupContribution } from './activation-setup-registry.ts' export type { SubagentListEntry } from './list-children.ts' export type { SubagentRunEndInfo, SubagentRunInfo } from './types.ts' +export type { SubagentTimingProjection } from './projection-types.ts' declare module 'cordis' { interface Context { @@ -186,6 +188,9 @@ export class SubagentService extends Service { if (this.continuations === manager) this.continuations = undefined }, 'subagents.continuationBinding()') }) + ctx.inject(['sessionProjections'], (projectionCtx) => { + projectionCtx.sessionProjections.register(subagentTimingProjectionDefinition) + }) } /** diff --git a/packages/subagent/subagent/src/projection-types.ts b/packages/subagent/subagent/src/projection-types.ts new file mode 100644 index 0000000000..cefaec3727 --- /dev/null +++ b/packages/subagent/subagent/src/projection-types.ts @@ -0,0 +1,20 @@ +/** + * Pure client-safe subagent projection vocabulary. + * + * @module @deepseek-ai/dsh-subagent/projection-types + */ + +/** Durable active-turn timing for one descriptor-backed child session. */ +export interface SubagentTimingProjection { + /** Milliseconds accumulated across completed turns after the child's own descriptor. */ + settledMs: number + /** Start of the currently open turn, when one has not reached `turn/end`. */ + activeSince?: number +} + +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionMap { + /** Active-turn duration for a descriptor-backed subagent session. */ + subagentTiming: SubagentTimingProjection + } +} diff --git a/packages/subagent/subagent/src/projection.ts b/packages/subagent/subagent/src/projection.ts new file mode 100644 index 0000000000..6b15a66bbf --- /dev/null +++ b/packages/subagent/subagent/src/projection.ts @@ -0,0 +1,68 @@ +/** + * Pure session projection for subagent active-turn duration. + * + * @module @deepseek-ai/dsh-subagent/projection + */ + +import { z } from 'zod' +import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' +import type { SubagentTimingProjection } from './projection-types.ts' + +interface TimingState extends SubagentTimingProjection { + /** Latest pre-descriptor turn start, promoted when the child's own descriptor arrives. */ + pendingTurnStart?: number + /** Whether the fold has crossed a descriptor in this logical log. */ + descriptorSeen: boolean +} + +const projectionSchema = z.object({ + settledMs: z.number().int().nonnegative(), + activeSince: z.number().int().nonnegative().optional(), +}).strict() as unknown as z.ZodType + +/** + * Fold turn boundaries around the child's own durable descriptor. + * + * A fork seed may contain an ancestor descriptor and completed turns. Every + * descriptor therefore resets the accumulated state; the healthy catalog + * admits only a child with exactly one descriptor in its own suffix, making + * the final reset the child's authoritative timing origin. + */ +export const subagentTimingProjectionDefinition: +ProjectionDefinition<'subagentTiming', TimingState> = { + key: 'subagentTiming', + schema: projectionSchema, + init: () => ({ descriptorSeen: false, settledMs: 0 }), + apply: (state, event) => { + if (event.type === 'turn/start') { + return state.descriptorSeen + ? { ...state, activeSince: event.time } + : { ...state, pendingTurnStart: event.time } + } + if (event.type === 'subagent/descriptor') { + const activeSince = state.activeSince ?? state.pendingTurnStart + return { + descriptorSeen: true, + settledMs: 0, + ...(activeSince === undefined ? {} : { activeSince }), + } + } + if (event.type !== 'turn/end') return state + if (!state.descriptorSeen) { + if (state.pendingTurnStart === undefined) return state + const { pendingTurnStart: _closed, ...next } = state + return next + } + if (state.activeSince === undefined) return state + const { activeSince, ...rest } = state + return { + ...rest, + settledMs: state.settledMs + Math.max(0, event.time - activeSince), + } + }, + view: state => ({ + settledMs: state.settledMs, + ...(state.activeSince === undefined ? {} : { activeSince: state.activeSince }), + }), + stateVersion: 1, +} diff --git a/packages/subagent/subagent/tests/timing-projection.spec.ts b/packages/subagent/subagent/tests/timing-projection.spec.ts new file mode 100644 index 0000000000..41c8b8896c --- /dev/null +++ b/packages/subagent/subagent/tests/timing-projection.spec.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { subagentTimingProjectionDefinition } from '../src/projection.ts' + +function event(type: SessionEvent['type'], seq: number, time: number): SessionEvent { + return { type, seq, time, data: {} } as SessionEvent +} + +function fold(events: SessionEvent[]) { + let state = subagentTimingProjectionDefinition.init() + for (const item of events) state = subagentTimingProjectionDefinition.apply(state, item) + return subagentTimingProjectionDefinition.view(state) +} + +describe('subagent timing projection', () => { + it('resets inherited seed timing at the child descriptor and sums later completed turns', () => { + expect(fold([ + event('turn/start', 0, 100), + event('subagent/descriptor', 1, 110), + event('turn/end', 2, 300), + event('turn/start', 3, 1_000), + event('subagent/descriptor', 4, 1_100), + event('turn/end', 5, 4_100), + event('turn/start', 6, 10_000), + event('turn/end', 7, 12_000), + ])).toEqual({ settledMs: 5_100 }) + }) + + it('exposes an open turn start and never subtracts time for reversed boundaries', () => { + expect(fold([ + event('turn/start', 0, 1_000), + event('subagent/descriptor', 1, 1_100), + event('turn/end', 2, 900), + event('turn/start', 3, 2_000), + event('assistant/chunk', 4, 2_500), + ])).toEqual({ settledMs: 0, activeSince: 2_000 }) + }) + + it('ignores completed pre-descriptor turns and unrelated events', () => { + const initial = subagentTimingProjectionDefinition.init() + expect(subagentTimingProjectionDefinition.apply( + initial, + event('assistant/chunk', 0, 1), + )).toBe(initial) + expect(fold([ + event('turn/start', 0, 100), + event('turn/end', 1, 200), + event('subagent/descriptor', 2, 300), + ])).toEqual({ settledMs: 0 }) + }) +}) diff --git a/packages/subagent/subagent/tsconfig.json b/packages/subagent/subagent/tsconfig.json index 1c3a5fb6de..612330c646 100644 --- a/packages/subagent/subagent/tsconfig.json +++ b/packages/subagent/subagent/tsconfig.json @@ -32,6 +32,9 @@ { "path": "../../session-query/session-query" }, + { + "path": "../../session-projection/session-projection" + }, { "path": "../../tasks/tasks" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e1d8d15c03..2d45f42458 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1897,6 +1897,12 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants + '@deepseek-ai/dsh-subagent': + specifier: workspace:^ + version: link:../../subagent/subagent + '@deepseek-ai/dsh-token-meter': + specifier: workspace:^ + version: link:../../llm/token-meter '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -4882,6 +4888,10 @@ importers: version: link:../../../vendor/cordis packages/subagent/subagent: + dependencies: + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -4904,6 +4914,9 @@ importers: '@deepseek-ai/dsh-session-persistence': specifier: workspace:^ version: link:../../session-persistence/session-persistence + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session-projection/session-projection '@deepseek-ai/dsh-session-query': specifier: workspace:^ version: link:../../session-query/session-query From 3a920be9c56c5cc653b64f09dafa095adcb6e546 Mon Sep 17 00:00:00 2001 From: kingwl Date: Mon, 3 Aug 2026 00:22:01 +0800 Subject: [PATCH 076/129] stack subagent token and duration metrics --- .../subagent-conversation/tree.expected.md | 6 +++--- packages/client/ui-subagent/README.i18n.yaml | 4 ++-- packages/client/ui-subagent/README.md | 2 +- packages/client/ui-subagent/README.zh.md | 2 +- .../client/SubagentCatalogAction.module.css | 13 +++++++++++- .../src/client/SubagentCatalogAction.tsx | 20 ++++++++++++++----- .../tests/conversation-ui.spec.tsx | 9 +++++++-- 7 files changed, 41 insertions(+), 15 deletions(-) diff --git a/apps/web/tests/snapshots/subagent-conversation/tree.expected.md b/apps/web/tests/snapshots/subagent-conversation/tree.expected.md index 6b695ff029..43ed15c649 100644 --- a/apps/web/tests/snapshots/subagent-conversation/tree.expected.md +++ b/apps/web/tests/snapshots/subagent-conversation/tree.expected.md @@ -2,7 +2,7 @@ - treeitem "event-sourcing researcher Explain event sourcing in one · continuable · not running 7.9K tok · {{duration}}" [expanded] [level=1]: - button "Collapse event-sourcing researcher descendants": - img - - text: event-sourcing researcher Explain event sourcing in one · continuable · not running 7.9K tok · {{duration}} + - text: event-sourcing researcher Explain event sourcing in one · continuable · not running 7.9K tok {{duration}} - group: - - treeitem "example editor continuable · not running 0 tok · {{duration}}" [level=2] - - treeitem "event-sourcing reviewer one-shot · not running 0 tok · {{duration}}" [level=1] + - treeitem "example editor continuable · not running 0 tok · {{duration}}" [level=2]: example editor continuable · not running 0 tok {{duration}} + - treeitem "event-sourcing reviewer one-shot · not running 0 tok · {{duration}}" [level=1]: event-sourcing reviewer one-shot · not running 0 tok {{duration}} diff --git a/packages/client/ui-subagent/README.i18n.yaml b/packages/client/ui-subagent/README.i18n.yaml index 8af7265330..8d1f594300 100644 --- a/packages/client/ui-subagent/README.i18n.yaml +++ b/packages/client/ui-subagent/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-subagent/README.md -README.md: 2d507dde49f3796f3bbd00c239a768cdfd8a561d -README.zh.md: d0143858ff7eac1437fce381378ebcfad2187f2a +README.md: 538daeffb61f642b2e430cc1a6e2b1f3e3d5f55e +README.zh.md: a39b9c2dcec74a06e2074cc60f1c1f79b99b1ec0 diff --git a/packages/client/ui-subagent/README.md b/packages/client/ui-subagent/README.md index 2d507dde49..538daeffb6 100644 --- a/packages/client/ui-subagent/README.md +++ b/packages/client/ui-subagent/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Web subagent feature owner: contributes the lazily expandable catalog tree to `conversation.session.header.actions`, reason-specific read-only replacements to the conversation composer chain, and the existing `@` reference source to `ctx.slash`. -The header action reads `subagentsByParent` and session summaries through the standard `useSessions` hook. After a non-empty direct catalog arrives, its trigger counts the complete subagent-only descendant lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. The compact tree remains direct-catalog authoritative: continuable and one-shot rows display mode plus `running`/`inactive` activity, an optional log-backed title, total durable provider usage, and active-turn duration to the second; an unlabeled one-shot row falls back to its session id, while corrupt, unsupported, or unavailable rows remain readable but disabled. Token totals sum the four disjoint `tokenUsage` buckets. Duration sums completed `subagentTiming` turns, advances once per second only for an open turn on a running child, and freezes after the child becomes inactive; an interrupted open turn is bounded by the session summary's last activity. Each healthy row's `hasChildren` hint determines disclosure before interaction, so known leaves never show an arrow; expanding a branch immediately reserves one disabled loading row per known direct descendant, then lazily replaces them with that child's authoritative catalog. Every visible branch is reported to the runtime so membership frames cause a debounced refresh only where the tree is being consumed. Selecting any depth calls `SessionsService.openSubagent()` with the row's exact `{parentSessionId, childSessionId, mode}` address. Component-local state owns tree visibility, expanded branches, keyboard focus, and the running-duration clock. ArrowRight/ArrowLeft expand and collapse branches; ArrowUp/ArrowDown, Home, End, and Escape navigate or close the tree; closing returns focus to the trigger. Styling uses tokens only. +The header action reads `subagentsByParent` and session summaries through the standard `useSessions` hook. After a non-empty direct catalog arrives, its trigger counts the complete subagent-only descendant lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. The compact tree remains direct-catalog authoritative: continuable and one-shot rows display mode plus `running`/`inactive` activity and an optional log-backed title, while the trailing column stacks total durable provider usage above active-turn duration to the second; an unlabeled one-shot row falls back to its session id, while corrupt, unsupported, or unavailable rows remain readable but disabled. Token totals sum the four disjoint `tokenUsage` buckets. Duration sums completed `subagentTiming` turns, advances once per second only for an open turn on a running child, and freezes after the child becomes inactive; an interrupted open turn is bounded by the session summary's last activity. Each healthy row's `hasChildren` hint determines disclosure before interaction, so known leaves never show an arrow; expanding a branch immediately reserves one disabled loading row per known direct descendant, then lazily replaces them with that child's authoritative catalog. Every visible branch is reported to the runtime so membership frames cause a debounced refresh only where the tree is being consumed. Selecting any depth calls `SessionsService.openSubagent()` with the row's exact `{parentSessionId, childSessionId, mode}` address. Component-local state owns tree visibility, expanded branches, keyboard focus, and the running-duration clock. ArrowRight/ArrowLeft expand and collapse branches; ArrowUp/ArrowDown, Home, End, and Escape navigate or close the tree; closing returns focus to the trigger. Styling uses tokens only. A one-shot child always elects a read-only composer that identifies the transcript as a completed execution record. A continuable child does so only when its exact parent is unavailable, with copy explaining the recovery path. A continuable child with a live parent keeps the ordinary input chrome, whose Session routes through `subagent.prompt`; running input remains Send because every follow-up joins the child's FIFO inbox, and addressed sessions never expose Stop. This package never receives host context or calls a model-facing tool. The catalog and composer behavior are specified by the [Web subagent conversations Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md). diff --git a/packages/client/ui-subagent/README.zh.md b/packages/client/ui-subagent/README.zh.md index d0143858ff..a39b9c2dce 100644 --- a/packages/client/ui-subagent/README.zh.md +++ b/packages/client/ui-subagent/README.zh.md @@ -4,7 +4,7 @@ Web subagent 功能 owner:向 `conversation.session.header.actions` 贡献可懒加载展开的目录树,向会话编辑器链贡献按原因区分的只读替代呈现,并保留注册到 `ctx.slash` 的既有 `@` 引用 source。 -页头操作通过标准 `useSessions` 钩子读取 `subagentsByParent` 与会话摘要。非空直接目录到达后,其触发器会统计仅含 subagent 的完整后代谱系,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。紧凑树仍以直接目录为权威依据:可继续和 one-shot 行会显示 mode、`running`/`inactive` 活动状态、由日志支撑的可选 title、提供方的持久化总用量,以及精确到秒的活跃轮次耗时;没有 label 的 one-shot 行会回退到其会话 id,而损坏、不受支持或不可用的行仍保持可读但禁用。token 总量为四个互不重叠的 `tokenUsage` 桶之和。耗时会累加已完成的 `subagentTiming` 轮次,仅在运行中 child 存在未结束轮次时每秒递增一次,并在 child 变为 inactive 后冻结;被中断的未结束轮次以会话摘要中的最后活动为上界。每个健康行的 `hasChildren` 提示会在交互前决定是否显示展开控件,因此已知叶子节点从不显示箭头;展开分支时,会立即为每个已知直接后代预留一行禁用的加载行,随后再用该 child 的权威目录懒加载结果替换这些占位行。每个可见分支都会上报给运行时,使成员帧只在树正被消费的位置触发去抖动刷新。选择任意深度的条目都会使用该行的确切地址 `{parentSessionId, childSessionId, mode}` 调用 `SessionsService.openSubagent()`。组件局部状态负责树的可见性、已展开分支、键盘焦点与运行中耗时时钟。ArrowRight/ArrowLeft 展开和折叠分支;ArrowUp/ArrowDown、Home、End 与 Escape 用于导航或关闭树;关闭后焦点返回触发器。样式只使用 token。 +页头操作通过标准 `useSessions` 钩子读取 `subagentsByParent` 与会话摘要。非空直接目录到达后,其触发器会统计仅含 subagent 的完整后代谱系,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。紧凑树仍以直接目录为权威依据:可继续和 one-shot 行会显示 mode、`running`/`inactive` 活动状态和由日志支撑的可选 title,尾随列则将提供方的持久化总用量置于上行,将精确到秒的活跃轮次耗时置于下行;没有 label 的 one-shot 行会回退到其会话 id,而损坏、不受支持或不可用的行仍保持可读但禁用。token 总量为四个互不重叠的 `tokenUsage` 桶之和。耗时会累加已完成的 `subagentTiming` 轮次,仅在运行中 child 存在未结束轮次时每秒递增一次,并在 child 变为 inactive 后冻结;被中断的未结束轮次以会话摘要中的最后活动为上界。每个健康行的 `hasChildren` 提示会在交互前决定是否显示展开控件,因此已知叶子节点从不显示箭头;展开分支时,会立即为每个已知直接后代预留一行禁用的加载行,随后再用该 child 的权威目录懒加载结果替换这些占位行。每个可见分支都会上报给运行时,使成员帧只在树正被消费的位置触发去抖动刷新。选择任意深度的条目都会使用该行的确切地址 `{parentSessionId, childSessionId, mode}` 调用 `SessionsService.openSubagent()`。组件局部状态负责树的可见性、已展开分支、键盘焦点与运行中耗时时钟。ArrowRight/ArrowLeft 展开和折叠分支;ArrowUp/ArrowDown、Home、End 与 Escape 用于导航或关闭树;关闭后焦点返回触发器。样式只使用 token。 one-shot child 始终选用只读编辑器,并将 transcript(文本记录)说明为已完成的执行记录。可继续 child 仅在其确切 parent 不可用时选用只读编辑器,并以文案说明恢复路径。确切 parent 存活时,可继续 child 保留普通输入 chrome,其 Session 会通过 `subagent.prompt` 路由;child 运行期间,输入操作仍为 Send,因为每条后续消息都会进入 child 的 FIFO inbox,且已寻址会话绝不公开 Stop。本包绝不接收宿主 context,也不调用面向模型的工具。目录与编辑器行为由 [Web subagent 对话 Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md)规定。 diff --git a/packages/client/ui-subagent/src/client/SubagentCatalogAction.module.css b/packages/client/ui-subagent/src/client/SubagentCatalogAction.module.css index 13004e6a47..239081c59c 100644 --- a/packages/client/ui-subagent/src/client/SubagentCatalogAction.module.css +++ b/packages/client/ui-subagent/src/client/SubagentCatalogAction.module.css @@ -180,12 +180,23 @@ } .metrics { + display: grid; + grid-template-rows: 18px 16px; flex: none; - margin-top: 16px; font-variant-numeric: tabular-nums; + text-align: right; white-space: nowrap; } +.metricToken { + grid-row: 1; + line-height: 18px; +} + +.metricDuration { + grid-row: 2; +} + .children { position: relative; margin-left: 18px; diff --git a/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx b/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx index 8b6471fd46..2875e22ed5 100644 --- a/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx +++ b/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx @@ -248,10 +248,15 @@ function CatalogRows({ summary?.updatedAt, now, ) - const metrics = [ - totalTokens === undefined ? undefined : `${formatTokens(totalTokens)} tok`, - durationMs === undefined ? undefined : formatDuration(durationMs, t), - ].filter(value => value !== undefined).join(' · ') + const tokenMetric = totalTokens === undefined + ? undefined + : `${formatTokens(totalTokens)} tok` + const durationMetric = durationMs === undefined + ? undefined + : formatDuration(durationMs, t) + const metrics = [tokenMetric, durationMetric] + .filter(value => value !== undefined) + .join(' · ') const open = (): void => { openChild({ parentSessionId, childSessionId: entry.id, mode: entry.mode }) @@ -308,7 +313,12 @@ function CatalogRows({ {label} {secondary} - {metrics !== '' && {metrics}} + {metrics !== '' && ( + + {tokenMetric !== undefined && {tokenMetric}} + {durationMetric !== undefined && {durationMetric}} + + )}
{isExpanded && !knownLeaf && ( diff --git a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx b/packages/client/ui-subagent/tests/conversation-ui.spec.tsx index 9716b6bbf3..c43c31148f 100644 --- a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx +++ b/packages/client/ui-subagent/tests/conversation-ui.spec.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { cleanup, fireEvent, render, screen, within } from '@testing-library/react' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import type { SessionId, SessionListState, SessionSummary, SubagentCatalogSnapshot, @@ -282,7 +282,12 @@ describe('SubagentCatalogAction', () => { render() fireEvent.click(screen.getByRole('button', { name: /3 个子代理/ })) - expect(screen.getByRole('treeitem', { name: /running.*4\.6K tok · 1分10秒/ })).toBeTruthy() + const runningRow = screen.getByRole('treeitem', { name: /running.*4\.6K tok · 1分10秒/ }) + const runningMetrics = within(runningRow) + const tokenMetric = runningMetrics.getByText('4.6K tok') + const durationMetric = runningMetrics.getByText('1分10秒') + expect(tokenMetric.parentElement).toBe(durationMetric.parentElement) + expect(tokenMetric.nextElementSibling).toBe(durationMetric) expect(screen.getByRole('treeitem', { name: /finished.*4\.6K tok · 1小时02分03秒/ })).toBeTruthy() expect(screen.getByRole('treeitem', { name: /interrupted.*4\.6K tok · 6秒/ })).toBeTruthy() From c831c99981f280ca098b1883b9200f6b12d056c5 Mon Sep 17 00:00:00 2001 From: kingwl Date: Mon, 3 Aug 2026 01:08:32 +0800 Subject: [PATCH 077/129] keep subagent duration fix focused --- ...07-27-web-subagent-conversations.i18n.yaml | 4 +- .../2026-07-27-web-subagent-conversations.md | 10 ++-- ...026-07-27-web-subagent-conversations.zh.md | 10 ++-- .../subagent-conversation/tree.expected.md | 8 +-- docs/module-graph.md | 20 +++---- packages/client/ui-subagent/README.i18n.yaml | 4 +- packages/client/ui-subagent/README.md | 2 +- packages/client/ui-subagent/README.zh.md | 2 +- packages/client/ui-subagent/package.json | 2 - .../client/SubagentCatalogAction.module.css | 17 ++---- .../src/client/SubagentCatalogAction.tsx | 52 ++++--------------- .../tests/conversation-ui.spec.tsx | 27 +++------- packages/client/ui-subagent/tsconfig.json | 3 -- .../subagent/tests/timing-projection.spec.ts | 26 ++++++++++ pnpm-lock.yaml | 3 -- 15 files changed, 79 insertions(+), 111 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml index d07d50641b..168d28d13a 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.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 .agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md -2026-07-27-web-subagent-conversations.md: f4d2035dfc7224cd7a11575449ff79cdae3fce48 -2026-07-27-web-subagent-conversations.zh.md: 79ce2711af32fb67685f29d48fc53d29376907f4 +2026-07-27-web-subagent-conversations.md: 859c6c5c17e830ab55c8513d56741966655eaf7a +2026-07-27-web-subagent-conversations.zh.md: 05d5c0f1d59b0bdebdecb33dc360e937af44d7b6 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md index f4d2035dfc..859c6c5c17 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md @@ -33,7 +33,7 @@ The Figma [subagent list](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Ha | The session header opens a compact child list. | The trigger aggregates the complete subagent-only descendant lineage; the tree shows every direct catalog entry in service order, including disabled diagnostics. | | Selecting a row reuses the conversation UI. | Addressed history never activates the child; only a continuable row with a live parent retains the ordinary composer. | | Nested agents expand progressively. | Each row carries a one-level `hasChildren` snapshot; disclosure reserves known direct-descendant rows immediately, then loads only that row's direct catalog and retains its own parent address. | -| Rows show labels, state, usage, and active duration without duplicating sidebar rows. | Mode and `running`/`inactive` activity are textual as well as visual; optional title, durable token usage, and exact active-turn duration come from the list's retained projection values. `SessionHeader.origin` removes duplicate navigation rows but grants no capability. | +| Rows show labels, state, and active duration without duplicating sidebar rows. | Mode and `running`/`inactive` activity are textual as well as visual; optional title and exact active-turn duration come from the list's retained projection values. `SessionHeader.origin` removes duplicate navigation rows but grants no capability. | ## Product contract @@ -41,7 +41,7 @@ The header action is absent only when a complete empty direct-catalog response a `running` means the exact child Agent driver is draining work at the Host sampling boundary; `inactive` means that driver is idle or absent. The UI does not translate either value into success, failure, cancellation, completeness, or resumability. `subagent.list` supplies the current driver-status baseline, `host/session-status` updates known activity in place, request-local replay prevents an older in-flight list response from overwriting a newer transition, and `host/session-removed` returns a known row to `inactive`; reconnect reads a fresh baseline. A `host/session-added` frame for a direct subagent immediately flips any loaded parent row to `hasChildren: true`, and that positive hint survives an older in-flight catalog response; membership, labels, mode, diagnostics, and the authoritative snapshot still require a debounced `subagent.list` refresh while the affected branch is open. A prompt response remains delivery-time authority. -Healthy rows reuse the standard session projections retained in the list mirror. The token figure sums the four disjoint `tokenUsage` buckets across the durable log. `subagentTiming` resets at every descriptor so an inherited fork seed cannot enter the child's total, accumulates completed `turn/start` → `turn/end` spans, and carries the current turn's `activeSince`. The menu formats whole seconds and advances its local clock only while a known descendant is running; an inactive row uses settled duration, or the summary's last activity to bound an interrupted open turn, so reopening the menu never restarts completed work. Token chunks do not change `subagentTiming` and therefore do not add a per-token list update path. Neither metric implies a durable outcome. +Healthy rows reuse the standard session projections retained in the list mirror. `subagentTiming` resets at every descriptor so an inherited fork seed cannot enter the child's total, accumulates completed `turn/start` → `turn/end` spans, and carries the current turn's `activeSince`. The menu formats whole seconds and advances its local clock only while a known descendant is running; an inactive row uses settled duration, or the summary's last activity to bound an interrupted open turn, so reopening the menu never restarts completed work. The duration does not imply a durable outcome. Selecting a row records its exact address before opening the resident client `Session`. History pagination, event folding, tool render intents, titles, and live mux reconciliation reuse the ordinary conversation machinery. Breadcrumbs use catalog labels, follow parent links only through `origin: 'subagent'` rows, include the first ordinary owner, and keep ordinary forks single-level. Forking an addressed subagent creates an ordinary fork with direct source lineage and attaches it to the nearest workspace-owning ancestor. The catalog is an ARIA tree with lazy ArrowRight/ArrowLeft disclosure, linear ArrowUp/ArrowDown navigation, Home/End, Escape, and focus restoration. @@ -104,13 +104,13 @@ The shipped Web composition mounts SQLite session query beside JSONL persistence - Host protocol tests pin schemas including required boolean expandability, id echoing, mode verification, non-activating history, exact-parent enforcement, FIFO admission receipts, cancellation, and sanitized failure mapping. - Generic Host tests pin attached and cold history and forks without Agent publication, cold projection folding, descriptor/origin/runtime-owner denial, explicit-id adoption denial, and the direct queue-control fence. - Client object tests pin retained and restored addresses, one-shot read-only rejection, history routing, continuable prompt routing, no addressed cancellation, suppression of Agent-bound model controls, live activity flips including in-flight response replay and detach fallback, subagent-parent expandability flips, and membership refresh. -- jsdom tests pin the aggregate descendant count and activity, token totals, second-precision running and frozen inactive durations, the summary-backed root action across absent and stale-empty catalogs, known loading-row shape, mixed-mode rows, pre-click leaf disclosure, diagnostics, lazy descendant disclosure, direct-parent addresses, keyboard behavior, and both read-only reasons. -- The keyless assembled Web snapshot contains an inactive continuable child with durable usage, an inactive one-shot sibling, and a persisted grandchild; it pins the three-descendant trigger across a stale empty catalog response, usage and timing rows, and aggregate running transition, expands without activation, opens persisted history, admits a human FIFO follow-up, reconciles child mux events, and proves one-shot history remains read-only. +- jsdom tests pin the aggregate descendant count and activity, second-precision running and frozen inactive durations, the summary-backed root action across absent and stale-empty catalogs, known loading-row shape, mixed-mode rows, pre-click leaf disclosure, diagnostics, lazy descendant disclosure, direct-parent addresses, keyboard behavior, and both read-only reasons. +- The keyless assembled Web snapshot contains an inactive continuable child, an inactive one-shot sibling, and a persisted grandchild; it pins the three-descendant trigger across a stale empty catalog response, timing rows, and aggregate running transition, expands without activation, opens persisted history, admits a human FIFO follow-up, reconciles child mux events, and proves one-shot history remains read-only. - Navigation tests pin subagent-only breadcrumbs, workspace placement for forks created from subagents, and `origin: 'subagent'` sidebar filtering without hiding ordinary forks. ## Consequences -- Catalog reads may rescan persisted lineage and each direct candidate's descriptor log, but expandability reuses only descendant headers already present in that trace; the Web activity baseline adds one Agent-registry lookup per healthy row and then uses existing live frames, while usage and duration reuse projection baselines and pushes with no per-row log read, and membership refresh stays debounced and single-flight. +- Catalog reads may rescan persisted lineage and each direct candidate's descriptor log, but expandability reuses only descendant headers already present in that trace; the Web activity baseline adds one Agent-registry lookup per healthy row and then uses existing live frames, while duration reuses projection baselines and pushes with no per-row log read, and membership refresh stays debounced and single-flight. - Parent availability, child activity, and `hasChildren` are snapshots. Publication, disposal, another sender, or another process may win after listing; typed prompt failure remains expected. - A child may publish between history fetch and mux subscription, so the existing sequence reconciliation also covers the cold-to-live addressed path. - Persisted origin adds one deliberately weak product-classification field to child headers and list projections; it cannot become an authorization shortcut. diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md index 79ce2711af..05d5c0f1d5 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md @@ -33,7 +33,7 @@ Figma 中的 [subagent 列表](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5 | 会话页头可打开紧凑的 child 列表。 | 触发器会汇总仅含 subagent 的完整后代谱系;树按服务顺序显示每个直接目录条目,包括已禁用的 diagnostic。 | | 选择一行会复用对话 UI。 | 已寻址历史绝不激活 child;只有 parent 存活的可继续行才保留普通输入框。 | | 嵌套 agent 会逐层展开。 | 每行携带一层 `hasChildren` 快照;展开时会立即预留已知直接后代行,随后仍只加载该行的直接目录,并保留其自身的 parent 地址。 | -| 条目显示 label、状态、用量与活跃耗时,同时避免侧边栏条目重复。 | mode 与 `running`/`inactive` 活动状态会同时以文字和视觉呈现;可选 title、持久化 token 用量与精确的活跃轮次耗时来自列表保留的投影值。`SessionHeader.origin` 会移除重复的导航条目,但不授予任何功能权限。 | +| 条目显示 label、状态与活跃耗时,同时避免侧边栏条目重复。 | mode 与 `running`/`inactive` 活动状态会同时以文字和视觉呈现;可选 title 与精确的活跃轮次耗时来自列表保留的投影值。`SessionHeader.origin` 会移除重复的导航条目,但不授予任何功能权限。 | ## 产品契约 @@ -41,7 +41,7 @@ Figma 中的 [subagent 列表](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5 `running` 表示在 Host 采样边界,确切 child Agent driver 正在处理工作;`inactive` 表示该 driver 空闲或不存在。UI 不会把任一值解释为成功、失败、取消、完成状态或可恢复性。`subagent.list` 提供当前 driver 状态基线,`host/session-status` 会就地更新已知活动状态,请求内回放会阻止更早发起但尚未完成的列表响应覆盖较新的状态转换,`host/session-removed` 则会使已知行恢复为 `inactive`;重连时会读取新的基线。直接 subagent 的 `host/session-added` 帧会立即把任何已加载的 parent 行翻转为 `hasChildren: true`,并使这项正向提示不被更早发起但尚未完成的目录响应覆盖;受影响分支打开期间,成员、label、mode、diagnostic 与权威快照仍需要通过去抖动的 `subagent.list` 刷新来更新。消息投递时仍以提示词响应为权威依据。 -健康行会复用列表镜像中保留的标准会话投影。token 数值会汇总持久化日志中四个互不重叠的 `tokenUsage` 桶。`subagentTiming` 会在每个描述符处重置,使继承的 fork 种子不会计入 child 总量;它会累加已完成的 `turn/start` → `turn/end` 时段,并携带当前轮次的 `activeSince`。菜单会以整秒格式化时间,且仅在有已知后代处于运行状态时才推进其本地时钟;对 inactive 行,菜单使用已结算耗时,或以摘要的最后活动为被中断未结束轮次的上界,因此重新打开菜单绝不会让已完成工作重新计时。token 分片不会改变 `subagentTiming`,因此不会增加按 token 更新列表的路径。这两项指标都不蕴含持久化结果语义。 +健康行会复用列表镜像中保留的标准会话投影。`subagentTiming` 会在每个描述符处重置,使继承的 fork 种子不会计入 child 总量;它会累加已完成的 `turn/start` → `turn/end` 时段,并携带当前轮次的 `activeSince`。菜单会以整秒格式化时间,且仅在有已知后代处于运行状态时才推进其本地时钟;对 inactive 行,菜单使用已结算耗时,或以摘要的最后活动为被中断未结束轮次的上界,因此重新打开菜单绝不会让已完成工作重新计时。该耗时不蕴含持久化结果语义。 选择一行后,系统会先记录其确切地址,再打开常驻客户端 `Session`。历史分页、事件 fold、工具渲染意图、title 与实时 mux 归并都会复用普通对话机制。面包屑导航使用目录 label,只会沿 `origin: 'subagent'` 行的父链接逐级回溯,包含第一个普通 owner,并让普通 fork 保持单层。从已寻址 subagent 创建 fork 时,会生成具有直接源谱系的普通 fork,并将其附加到最近拥有 Workspace 的祖先。目录是一棵 ARIA 树,支持懒加载式 ArrowRight/ArrowLeft 展开与折叠、线性 ArrowUp/ArrowDown 导航、Home/End、Escape 以及焦点恢复。 @@ -104,13 +104,13 @@ one-shot 行始终会用文案替代输入框,说明执行记录为只读。 - 宿主协议测试固定 schema(包括必需的布尔可展开性)、id 回显、mode 校验、非激活式历史、确切 parent 强制要求、FIFO 准入回执、取消与脱敏后的失败映射。 - 通用 Host 测试固定在不发布 Agent 的情况下读取已附加与冷态历史及执行 fork、冷态投影归并、按描述符/origin/运行时 owner 拒绝、拒绝显式 id 接纳,以及直接队列控制栅栏。 - 客户端对象测试固定已保留与已恢复的地址、one-shot 只读拒绝、历史路由、可继续提示词路由、已寻址对话不提供取消、屏蔽绑定到 agent 的模型控件、实时活动状态翻转(包括在途响应回放与 detach 回退)、subagent parent 可展开性翻转与成员刷新。 -- jsdom 测试固定后代聚合计数与活动状态、token 总量、精确到秒的运行中耗时与冻结后 inactive 耗时、目录缺失或为陈旧空目录时由摘要支撑的根操作、已知加载行的形态、混合 mode 行、点击前的叶子展开控件、diagnostic、后代懒加载展开、直接 parent 地址、键盘行为与两种只读原因。 -- 无密钥的组装 Web 快照包含一个具有持久化用量的 inactive 可继续 child、一个 inactive 的 one-shot sibling 和一个持久化 grandchild;它会固定触发器在一次陈旧的空目录响应后仍显示三个后代,并固定用量与计时行以及聚合 `running` 状态转换,在不激活的情况下展开、打开持久化历史、准入一条用户 FIFO 后续消息、归并 child mux 事件,并证明 one-shot 历史仍然只读。 +- jsdom 测试固定后代聚合计数与活动状态、精确到秒的运行中耗时与冻结后 inactive 耗时、目录缺失或为陈旧空目录时由摘要支撑的根操作、已知加载行的形态、混合 mode 行、点击前的叶子展开控件、diagnostic、后代懒加载展开、直接 parent 地址、键盘行为与两种只读原因。 +- 无密钥的组装 Web 快照包含一个 inactive 的可继续 child、一个 inactive 的 one-shot sibling 和一个持久化 grandchild;它会固定触发器在一次陈旧的空目录响应后仍显示三个后代,并固定计时行以及聚合 `running` 状态转换,在不激活的情况下展开、打开持久化历史、准入一条用户 FIFO 后续消息、归并 child mux 事件,并证明 one-shot 历史仍然只读。 - 导航测试固定仅含 subagent 的面包屑导航、从 subagent 创建 fork 时的 Workspace 归属,以及 `origin: 'subagent'` 侧边栏过滤,同时不隐藏普通 fork。 ## 后果 -- 目录读取可能重新扫描持久化谱系与每个直接候选的描述符日志,但可展开性只复用该追踪中已有的后代 header;Web 活动基线会为每个健康行增加一次 Agent 注册表查找,随后使用现有实时帧,而用量与耗时会复用投影基线和推送,无需按行读取日志,成员刷新则保持去抖动和单次并发。 +- 目录读取可能重新扫描持久化谱系与每个直接候选的描述符日志,但可展开性只复用该追踪中已有的后代 header;Web 活动基线会为每个健康行增加一次 Agent 注册表查找,随后使用现有实时帧,而耗时会复用投影基线和推送,无需按行读取日志,成员刷新则保持去抖动和单次并发。 - parent 可用性、child 活动状态与 `hasChildren` 都是快照。列出之后,发布、dispose、其他发送方或其他进程都可能抢先改变状态;类型化提示词失败仍属预期行为。 - child 可能在历史获取与 mux 订阅之间发布,因此现有序号归并也涵盖从冷态转为存活的已寻址路径。 - 持久化 origin 会为 child header 与列表投影添加一个有意保持弱约束的产品分类字段;它不能变成授权捷径。 diff --git a/apps/web/tests/snapshots/subagent-conversation/tree.expected.md b/apps/web/tests/snapshots/subagent-conversation/tree.expected.md index 43ed15c649..c1174c042e 100644 --- a/apps/web/tests/snapshots/subagent-conversation/tree.expected.md +++ b/apps/web/tests/snapshots/subagent-conversation/tree.expected.md @@ -1,8 +1,8 @@ - tree "Subagent sessions": - - treeitem "event-sourcing researcher Explain event sourcing in one · continuable · not running 7.9K tok · {{duration}}" [expanded] [level=1]: + - treeitem "event-sourcing researcher Explain event sourcing in one · continuable · not running {{duration}}" [expanded] [level=1]: - button "Collapse event-sourcing researcher descendants": - img - - text: event-sourcing researcher Explain event sourcing in one · continuable · not running 7.9K tok {{duration}} + - text: event-sourcing researcher Explain event sourcing in one · continuable · not running {{duration}} - group: - - treeitem "example editor continuable · not running 0 tok · {{duration}}" [level=2]: example editor continuable · not running 0 tok {{duration}} - - treeitem "event-sourcing reviewer one-shot · not running 0 tok · {{duration}}" [level=1]: event-sourcing reviewer one-shot · not running 0 tok {{duration}} + - treeitem "example editor continuable · not running {{duration}}" [level=2] + - treeitem "event-sourcing reviewer one-shot · not running {{duration}}" [level=1] diff --git a/docs/module-graph.md b/docs/module-graph.md index 6687a583c1..313c58d914 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -737,6 +737,7 @@ flowchart TD pkg_subagent --> pkg_scope pkg_subagent --> pkg_session pkg_subagent --> pkg_session_persistence + pkg_subagent --> pkg_session_projection pkg_subagent --> pkg_session_query pkg_subagent --> pkg_tasks pkg_subagent --> pkg_tools @@ -832,13 +833,6 @@ flowchart TD pkg_client_ui_goal --> pkg_client_ui_slots pkg_client_ui_goal --> pkg_goal pkg_client_ui_goal --> pkg_invariants - pkg_client_ui_subagent --> pkg_client_locale - pkg_client_ui_subagent --> pkg_client_runtime - pkg_client_ui_subagent --> pkg_client_ui_conversation - pkg_client_ui_subagent --> pkg_client_ui_primitives - pkg_client_ui_subagent --> pkg_client_ui_slash - pkg_client_ui_subagent --> pkg_client_ui_slots - pkg_client_ui_subagent --> pkg_invariants pkg_session_reference --> pkg_agent pkg_session_reference --> pkg_compact pkg_session_reference --> pkg_invariants @@ -981,6 +975,14 @@ flowchart TD pkg_client_ui_plan --> pkg_client_ui_slots pkg_client_ui_plan --> pkg_invariants pkg_client_ui_plan --> pkg_plan_mode + pkg_client_ui_subagent --> pkg_client_locale + pkg_client_ui_subagent --> pkg_client_runtime + pkg_client_ui_subagent --> pkg_client_ui_conversation + pkg_client_ui_subagent --> pkg_client_ui_primitives + pkg_client_ui_subagent --> pkg_client_ui_slash + pkg_client_ui_subagent --> pkg_client_ui_slots + pkg_client_ui_subagent --> pkg_invariants + pkg_client_ui_subagent --> pkg_subagent pkg_agent_spine_demo --> pkg_agent pkg_agent_spine_demo --> pkg_agent_loop pkg_agent_spine_demo --> pkg_goal @@ -1201,7 +1203,7 @@ flowchart TD | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | -| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | +| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-query`](../packages/session-query/session-query), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-web`](../packages/web/tool-web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | | [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) | | [`timeout-policy`](../packages/timeout/timeout-policy) | `timeout` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | @@ -1218,7 +1220,6 @@ flowchart TD | [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`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-goal`](../packages/client/ui-goal) | `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), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | -| [`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) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | | [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | @@ -1239,6 +1240,7 @@ flowchart TD | [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`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-permission`](../packages/client/ui-permission) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-command`](../packages/client/ui-command), [`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), [`permission`](../packages/ui/permission) | | [`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) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks-local`](../packages/tasks/tasks-local), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`sdk-protocol`](../packages/sdk/sdk-protocol) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | diff --git a/packages/client/ui-subagent/README.i18n.yaml b/packages/client/ui-subagent/README.i18n.yaml index 8d1f594300..2b512252f0 100644 --- a/packages/client/ui-subagent/README.i18n.yaml +++ b/packages/client/ui-subagent/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-subagent/README.md -README.md: 538daeffb61f642b2e430cc1a6e2b1f3e3d5f55e -README.zh.md: a39b9c2dcec74a06e2074cc60f1c1f79b99b1ec0 +README.md: 16a54484fb53544c71af0806c1497a18f9141002 +README.zh.md: 166a25d095b3e7f10f7239262f39e27972344529 diff --git a/packages/client/ui-subagent/README.md b/packages/client/ui-subagent/README.md index 538daeffb6..16a54484fb 100644 --- a/packages/client/ui-subagent/README.md +++ b/packages/client/ui-subagent/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Web subagent feature owner: contributes the lazily expandable catalog tree to `conversation.session.header.actions`, reason-specific read-only replacements to the conversation composer chain, and the existing `@` reference source to `ctx.slash`. -The header action reads `subagentsByParent` and session summaries through the standard `useSessions` hook. After a non-empty direct catalog arrives, its trigger counts the complete subagent-only descendant lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. The compact tree remains direct-catalog authoritative: continuable and one-shot rows display mode plus `running`/`inactive` activity and an optional log-backed title, while the trailing column stacks total durable provider usage above active-turn duration to the second; an unlabeled one-shot row falls back to its session id, while corrupt, unsupported, or unavailable rows remain readable but disabled. Token totals sum the four disjoint `tokenUsage` buckets. Duration sums completed `subagentTiming` turns, advances once per second only for an open turn on a running child, and freezes after the child becomes inactive; an interrupted open turn is bounded by the session summary's last activity. Each healthy row's `hasChildren` hint determines disclosure before interaction, so known leaves never show an arrow; expanding a branch immediately reserves one disabled loading row per known direct descendant, then lazily replaces them with that child's authoritative catalog. Every visible branch is reported to the runtime so membership frames cause a debounced refresh only where the tree is being consumed. Selecting any depth calls `SessionsService.openSubagent()` with the row's exact `{parentSessionId, childSessionId, mode}` address. Component-local state owns tree visibility, expanded branches, keyboard focus, and the running-duration clock. ArrowRight/ArrowLeft expand and collapse branches; ArrowUp/ArrowDown, Home, End, and Escape navigate or close the tree; closing returns focus to the trigger. Styling uses tokens only. +The header action reads `subagentsByParent` and session summaries through the standard `useSessions` hook. After a non-empty direct catalog arrives, its trigger counts the complete subagent-only descendant lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. The compact tree remains direct-catalog authoritative: continuable and one-shot rows display mode plus `running`/`inactive` activity, an optional log-backed title, and active-turn duration to the second; an unlabeled one-shot row falls back to its session id, while corrupt, unsupported, or unavailable rows remain readable but disabled. Duration sums completed `subagentTiming` turns, advances once per second only for an open turn on a running child, and freezes after the child becomes inactive; an interrupted open turn is bounded by the session summary's last activity. Each healthy row's `hasChildren` hint determines disclosure before interaction, so known leaves never show an arrow; expanding a branch immediately reserves one disabled loading row per known direct descendant, then lazily replaces them with that child's authoritative catalog. Every visible branch is reported to the runtime so membership frames cause a debounced refresh only where the tree is being consumed. Selecting any depth calls `SessionsService.openSubagent()` with the row's exact `{parentSessionId, childSessionId, mode}` address. Component-local state owns tree visibility, expanded branches, keyboard focus, and the running-duration clock. ArrowRight/ArrowLeft expand and collapse branches; ArrowUp/ArrowDown, Home, End, and Escape navigate or close the tree; closing returns focus to the trigger. Styling uses tokens only. A one-shot child always elects a read-only composer that identifies the transcript as a completed execution record. A continuable child does so only when its exact parent is unavailable, with copy explaining the recovery path. A continuable child with a live parent keeps the ordinary input chrome, whose Session routes through `subagent.prompt`; running input remains Send because every follow-up joins the child's FIFO inbox, and addressed sessions never expose Stop. This package never receives host context or calls a model-facing tool. The catalog and composer behavior are specified by the [Web subagent conversations Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md). diff --git a/packages/client/ui-subagent/README.zh.md b/packages/client/ui-subagent/README.zh.md index a39b9c2dce..166a25d095 100644 --- a/packages/client/ui-subagent/README.zh.md +++ b/packages/client/ui-subagent/README.zh.md @@ -4,7 +4,7 @@ Web subagent 功能 owner:向 `conversation.session.header.actions` 贡献可懒加载展开的目录树,向会话编辑器链贡献按原因区分的只读替代呈现,并保留注册到 `ctx.slash` 的既有 `@` 引用 source。 -页头操作通过标准 `useSessions` 钩子读取 `subagentsByParent` 与会话摘要。非空直接目录到达后,其触发器会统计仅含 subagent 的完整后代谱系,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。紧凑树仍以直接目录为权威依据:可继续和 one-shot 行会显示 mode、`running`/`inactive` 活动状态和由日志支撑的可选 title,尾随列则将提供方的持久化总用量置于上行,将精确到秒的活跃轮次耗时置于下行;没有 label 的 one-shot 行会回退到其会话 id,而损坏、不受支持或不可用的行仍保持可读但禁用。token 总量为四个互不重叠的 `tokenUsage` 桶之和。耗时会累加已完成的 `subagentTiming` 轮次,仅在运行中 child 存在未结束轮次时每秒递增一次,并在 child 变为 inactive 后冻结;被中断的未结束轮次以会话摘要中的最后活动为上界。每个健康行的 `hasChildren` 提示会在交互前决定是否显示展开控件,因此已知叶子节点从不显示箭头;展开分支时,会立即为每个已知直接后代预留一行禁用的加载行,随后再用该 child 的权威目录懒加载结果替换这些占位行。每个可见分支都会上报给运行时,使成员帧只在树正被消费的位置触发去抖动刷新。选择任意深度的条目都会使用该行的确切地址 `{parentSessionId, childSessionId, mode}` 调用 `SessionsService.openSubagent()`。组件局部状态负责树的可见性、已展开分支、键盘焦点与运行中耗时时钟。ArrowRight/ArrowLeft 展开和折叠分支;ArrowUp/ArrowDown、Home、End 与 Escape 用于导航或关闭树;关闭后焦点返回触发器。样式只使用 token。 +页头操作通过标准 `useSessions` 钩子读取 `subagentsByParent` 与会话摘要。非空直接目录到达后,其触发器会统计仅含 subagent 的完整后代谱系,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。紧凑树仍以直接目录为权威依据:可继续和 one-shot 行会显示 mode、`running`/`inactive` 活动状态、由日志支撑的可选 title,以及精确到秒的活跃轮次耗时;没有 label 的 one-shot 行会回退到其会话 id,而损坏、不受支持或不可用的行仍保持可读但禁用。耗时会累加已完成的 `subagentTiming` 轮次,仅在运行中 child 存在未结束轮次时每秒递增一次,并在 child 变为 inactive 后冻结;被中断的未结束轮次以会话摘要中的最后活动为上界。每个健康行的 `hasChildren` 提示会在交互前决定是否显示展开控件,因此已知叶子节点从不显示箭头;展开分支时,会立即为每个已知直接后代预留一行禁用的加载行,随后再用该 child 的权威目录懒加载结果替换这些占位行。每个可见分支都会上报给运行时,使成员帧只在树正被消费的位置触发去抖动刷新。选择任意深度的条目都会使用该行的确切地址 `{parentSessionId, childSessionId, mode}` 调用 `SessionsService.openSubagent()`。组件局部状态负责树的可见性、已展开分支、键盘焦点与运行中耗时时钟。ArrowRight/ArrowLeft 展开和折叠分支;ArrowUp/ArrowDown、Home、End 与 Escape 用于导航或关闭树;关闭后焦点返回触发器。样式只使用 token。 one-shot child 始终选用只读编辑器,并将 transcript(文本记录)说明为已完成的执行记录。可继续 child 仅在其确切 parent 不可用时选用只读编辑器,并以文案说明恢复路径。确切 parent 存活时,可继续 child 保留普通输入 chrome,其 Session 会通过 `subagent.prompt` 路由;child 运行期间,输入操作仍为 Send,因为每条后续消息都会进入 child 的 FIFO inbox,且已寻址会话绝不公开 Stop。本包绝不接收宿主 context,也不调用面向模型的工具。目录与编辑器行为由 [Web subagent 对话 Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md)规定。 diff --git a/packages/client/ui-subagent/package.json b/packages/client/ui-subagent/package.json index 9e6b120c1e..573bf54097 100644 --- a/packages/client/ui-subagent/package.json +++ b/packages/client/ui-subagent/package.json @@ -49,7 +49,6 @@ "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", - "@deepseek-ai/dsh-token-meter": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { @@ -62,7 +61,6 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", - "@deepseek-ai/dsh-token-meter": "workspace:^", "@types/react": "~18.3.1", "cordis": "^4.0.0-rc.7" }, diff --git a/packages/client/ui-subagent/src/client/SubagentCatalogAction.module.css b/packages/client/ui-subagent/src/client/SubagentCatalogAction.module.css index 239081c59c..d8642879ea 100644 --- a/packages/client/ui-subagent/src/client/SubagentCatalogAction.module.css +++ b/packages/client/ui-subagent/src/client/SubagentCatalogAction.module.css @@ -173,30 +173,19 @@ } .summary, -.metrics { +.time { color: var(--dsw-alias-label-tertiary); font-size: 11px; line-height: 16px; } -.metrics { - display: grid; - grid-template-rows: 18px 16px; +.time { flex: none; + margin-top: 16px; font-variant-numeric: tabular-nums; - text-align: right; white-space: nowrap; } -.metricToken { - grid-row: 1; - line-height: 18px; -} - -.metricDuration { - grid-row: 2; -} - .children { position: relative; margin-left: 18px; diff --git a/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx b/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx index 2875e22ed5..d507b30019 100644 --- a/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx +++ b/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx @@ -12,7 +12,6 @@ import type { PropsLocale, PropsRuntime, TranslateNS } from '@deepseek-ai/dsh-cl import { NS } from './locales.ts' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-subagent/client' -import type {} from '@deepseek-ai/dsh-token-meter/client' import css from './SubagentCatalogAction.module.css' type CatalogEntry = SubagentCatalogSnapshot['entries'][number] @@ -60,36 +59,18 @@ function treeItems(root: HTMLDivElement | null): HTMLElement[] { : Array.from(root.querySelectorAll('[role="treeitem"]:not([aria-disabled="true"])')) } -/** Compact token count shared in shape with the conversation stats strip. */ -function formatTokens(value: number): string { - const scaled = (next: number): string => next >= 100 - ? String(Math.round(next)) - : String(Math.round(next * 10) / 10) - if (value < 1_000) return String(value) - if (value < 1_000_000) return `${scaled(value / 1_000)}K` - return `${scaled(value / 1_000_000)}M` -} - -/** Sum the four disjoint durable provider-usage buckets. */ -function tokenTotal( - usage: SessionProjectionMap['tokenUsage'] | undefined, -): number | undefined { - return usage === undefined - ? undefined - : usage.uncachedInputTokens + usage.outputTokens - + usage.cacheReadTokens + usage.cacheWriteTokens -} - /** Exact whole-second active-turn duration for one catalog row. */ function activityDuration( - timing: SessionProjectionMap['subagentTiming'] | undefined, + summary: SessionSummary | undefined, activity: 'running' | 'inactive', - updatedAt: number | undefined, now: number, ): number | undefined { + if (summary === undefined) return undefined + const timing: SessionProjectionMap['subagentTiming'] | undefined + = summary.projectionValues?.subagentTiming if (timing === undefined) return undefined if (timing.activeSince === undefined) return timing.settledMs - const end = activity === 'running' ? now : updatedAt ?? timing.activeSince + const end = activity === 'running' ? now : summary.updatedAt return timing.settledMs + Math.max(0, end - timing.activeSince) } @@ -241,22 +222,14 @@ function CatalogRows({ const secondary = [summary?.title, mode, activity] .filter(value => value !== undefined) .join(' · ') - const totalTokens = tokenTotal(summary?.projectionValues?.tokenUsage) const durationMs = activityDuration( - summary?.projectionValues?.subagentTiming, + summary, entry.activity, - summary?.updatedAt, now, ) - const tokenMetric = totalTokens === undefined - ? undefined - : `${formatTokens(totalTokens)} tok` - const durationMetric = durationMs === undefined + const duration = durationMs === undefined ? undefined : formatDuration(durationMs, t) - const metrics = [tokenMetric, durationMetric] - .filter(value => value !== undefined) - .join(' · ') const open = (): void => { openChild({ parentSessionId, childSessionId: entry.id, mode: entry.mode }) @@ -288,7 +261,9 @@ function CatalogRows({ role="treeitem" tabIndex={0} aria-level={level} - aria-label={[label, secondary, metrics].filter(value => value !== '').join(' ')} + aria-label={[label, secondary, duration] + .filter(value => value !== undefined) + .join(' ')} {...knownLeaf ? {} : { 'aria-expanded': isExpanded }} className={css.row} onClick={open} @@ -313,12 +288,7 @@ function CatalogRows({ {label} {secondary}
- {metrics !== '' && ( - - {tokenMetric !== undefined && {tokenMetric}} - {durationMetric !== undefined && {durationMetric}} - - )} + {duration !== undefined && {duration}} {isExpanded && !knownLeaf && ( diff --git a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx b/packages/client/ui-subagent/tests/conversation-ui.spec.tsx index c43c31148f..487cd14f35 100644 --- a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx +++ b/packages/client/ui-subagent/tests/conversation-ui.spec.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, fireEvent, render, screen, within } from '@testing-library/react' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import type { SessionId, SessionListState, SessionSummary, SubagentCatalogSnapshot, @@ -240,7 +240,7 @@ describe('SubagentCatalogAction', () => { }) }) - it('shows durable token totals, ticks active duration by seconds, and freezes inactive rows', async () => { + it('ticks active duration by seconds and freezes inactive rows', async () => { const now = 2_000_000_000_000 vi.useFakeTimers() vi.setSystemTime(now) @@ -269,12 +269,6 @@ describe('SubagentCatalogAction', () => { settledMs, ...(activeSince === undefined ? {} : { activeSince }), }, - tokenUsage: { - uncachedInputTokens: 1_000, - outputTokens: 200, - cacheReadTokens: 3_000, - cacheWriteTokens: 400, - }, }, }] })) as Record @@ -282,19 +276,14 @@ describe('SubagentCatalogAction', () => { render() fireEvent.click(screen.getByRole('button', { name: /3 个子代理/ })) - const runningRow = screen.getByRole('treeitem', { name: /running.*4\.6K tok · 1分10秒/ }) - const runningMetrics = within(runningRow) - const tokenMetric = runningMetrics.getByText('4.6K tok') - const durationMetric = runningMetrics.getByText('1分10秒') - expect(tokenMetric.parentElement).toBe(durationMetric.parentElement) - expect(tokenMetric.nextElementSibling).toBe(durationMetric) - expect(screen.getByRole('treeitem', { name: /finished.*4\.6K tok · 1小时02分03秒/ })).toBeTruthy() - expect(screen.getByRole('treeitem', { name: /interrupted.*4\.6K tok · 6秒/ })).toBeTruthy() + expect(screen.getByRole('treeitem', { name: /running.*1分10秒/ })).toBeTruthy() + expect(screen.getByRole('treeitem', { name: /finished.*1小时02分03秒/ })).toBeTruthy() + expect(screen.getByRole('treeitem', { name: /interrupted.*6秒/ })).toBeTruthy() await vi.advanceTimersByTimeAsync(1_000) - expect(screen.getByRole('treeitem', { name: /running.*4\.6K tok · 1分11秒/ })).toBeTruthy() - expect(screen.getByRole('treeitem', { name: /finished.*4\.6K tok · 1小时02分03秒/ })).toBeTruthy() - expect(screen.getByRole('treeitem', { name: /interrupted.*4\.6K tok · 6秒/ })).toBeTruthy() + expect(screen.getByRole('treeitem', { name: /running.*1分11秒/ })).toBeTruthy() + expect(screen.getByRole('treeitem', { name: /finished.*1小时02分03秒/ })).toBeTruthy() + expect(screen.getByRole('treeitem', { name: /interrupted.*6秒/ })).toBeTruthy() }) it('lazily expands and collapses descendant catalogs with direct-parent navigation', () => { diff --git a/packages/client/ui-subagent/tsconfig.json b/packages/client/ui-subagent/tsconfig.json index 27d06d16c0..e9d59a6fa1 100644 --- a/packages/client/ui-subagent/tsconfig.json +++ b/packages/client/ui-subagent/tsconfig.json @@ -29,9 +29,6 @@ { "path": "../ui-slots" }, - { - "path": "../../llm/token-meter" - }, { "path": "../../subagent/subagent" }, diff --git a/packages/subagent/subagent/tests/timing-projection.spec.ts b/packages/subagent/subagent/tests/timing-projection.spec.ts index 41c8b8896c..ac3cac9ebb 100644 --- a/packages/subagent/subagent/tests/timing-projection.spec.ts +++ b/packages/subagent/subagent/tests/timing-projection.spec.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import SessionStore from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import SubagentService from '../src/index.ts' import { subagentTimingProjectionDefinition } from '../src/projection.ts' function event(type: SessionEvent['type'], seq: number, time: number): SessionEvent { @@ -13,6 +17,16 @@ function fold(events: SessionEvent[]) { } describe('subagent timing projection', () => { + it('registers with the optional session projection registry', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(SubagentService) + + expect(ctx.sessionProjections.snapshot(ctx.sessions.create()).values.subagentTiming) + .toEqual({ settledMs: 0 }) + }) + it('resets inherited seed timing at the child descriptor and sums later completed turns', () => { expect(fold([ event('turn/start', 0, 100), @@ -42,6 +56,18 @@ describe('subagent timing projection', () => { initial, event('assistant/chunk', 0, 1), )).toBe(initial) + expect(subagentTimingProjectionDefinition.apply( + initial, + event('turn/end', 1, 2), + )).toBe(initial) + const descriptor = subagentTimingProjectionDefinition.apply( + initial, + event('subagent/descriptor', 2, 3), + ) + expect(subagentTimingProjectionDefinition.apply( + descriptor, + event('turn/end', 3, 4), + )).toBe(descriptor) expect(fold([ event('turn/start', 0, 100), event('turn/end', 1, 200), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0b634898ea..1b0e2f5542 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1906,9 +1906,6 @@ importers: '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../../subagent/subagent - '@deepseek-ai/dsh-token-meter': - specifier: workspace:^ - version: link:../../llm/token-meter '@types/react': specifier: ~18.3.1 version: 18.3.31 From 57e2433f455b10b37f959de710bda4532fda3118 Mon Sep 17 00:00:00 2001 From: kingwl Date: Mon, 3 Aug 2026 01:19:36 +0800 Subject: [PATCH 078/129] show subagent token metrics --- ...07-27-web-subagent-conversations.i18n.yaml | 4 +- .../2026-07-27-web-subagent-conversations.md | 10 ++--- ...026-07-27-web-subagent-conversations.zh.md | 10 ++--- .../subagent-conversation/tree.expected.md | 8 ++-- docs/module-graph.md | 3 +- packages/client/ui-subagent/README.i18n.yaml | 4 +- packages/client/ui-subagent/README.md | 2 +- packages/client/ui-subagent/README.zh.md | 2 +- packages/client/ui-subagent/package.json | 2 + .../client/SubagentCatalogAction.module.css | 17 ++++++-- .../src/client/SubagentCatalogAction.tsx | 41 +++++++++++++++--- .../tests/conversation-ui.spec.tsx | 42 +++++++++++++++---- packages/client/ui-subagent/tsconfig.json | 3 ++ pnpm-lock.yaml | 3 ++ 14 files changed, 114 insertions(+), 37 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml index 168d28d13a..aae06cf1d4 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.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 .agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md -2026-07-27-web-subagent-conversations.md: 859c6c5c17e830ab55c8513d56741966655eaf7a -2026-07-27-web-subagent-conversations.zh.md: 05d5c0f1d59b0bdebdecb33dc360e937af44d7b6 +2026-07-27-web-subagent-conversations.md: f4d2035dfc7224cd7a11575449ff79cdae3fce48 +2026-07-27-web-subagent-conversations.zh.md: 72ede6799de01961d82e93048652a70a7d3d83aa diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md index 859c6c5c17..f4d2035dfc 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md @@ -33,7 +33,7 @@ The Figma [subagent list](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Ha | The session header opens a compact child list. | The trigger aggregates the complete subagent-only descendant lineage; the tree shows every direct catalog entry in service order, including disabled diagnostics. | | Selecting a row reuses the conversation UI. | Addressed history never activates the child; only a continuable row with a live parent retains the ordinary composer. | | Nested agents expand progressively. | Each row carries a one-level `hasChildren` snapshot; disclosure reserves known direct-descendant rows immediately, then loads only that row's direct catalog and retains its own parent address. | -| Rows show labels, state, and active duration without duplicating sidebar rows. | Mode and `running`/`inactive` activity are textual as well as visual; optional title and exact active-turn duration come from the list's retained projection values. `SessionHeader.origin` removes duplicate navigation rows but grants no capability. | +| Rows show labels, state, usage, and active duration without duplicating sidebar rows. | Mode and `running`/`inactive` activity are textual as well as visual; optional title, durable token usage, and exact active-turn duration come from the list's retained projection values. `SessionHeader.origin` removes duplicate navigation rows but grants no capability. | ## Product contract @@ -41,7 +41,7 @@ The header action is absent only when a complete empty direct-catalog response a `running` means the exact child Agent driver is draining work at the Host sampling boundary; `inactive` means that driver is idle or absent. The UI does not translate either value into success, failure, cancellation, completeness, or resumability. `subagent.list` supplies the current driver-status baseline, `host/session-status` updates known activity in place, request-local replay prevents an older in-flight list response from overwriting a newer transition, and `host/session-removed` returns a known row to `inactive`; reconnect reads a fresh baseline. A `host/session-added` frame for a direct subagent immediately flips any loaded parent row to `hasChildren: true`, and that positive hint survives an older in-flight catalog response; membership, labels, mode, diagnostics, and the authoritative snapshot still require a debounced `subagent.list` refresh while the affected branch is open. A prompt response remains delivery-time authority. -Healthy rows reuse the standard session projections retained in the list mirror. `subagentTiming` resets at every descriptor so an inherited fork seed cannot enter the child's total, accumulates completed `turn/start` → `turn/end` spans, and carries the current turn's `activeSince`. The menu formats whole seconds and advances its local clock only while a known descendant is running; an inactive row uses settled duration, or the summary's last activity to bound an interrupted open turn, so reopening the menu never restarts completed work. The duration does not imply a durable outcome. +Healthy rows reuse the standard session projections retained in the list mirror. The token figure sums the four disjoint `tokenUsage` buckets across the durable log. `subagentTiming` resets at every descriptor so an inherited fork seed cannot enter the child's total, accumulates completed `turn/start` → `turn/end` spans, and carries the current turn's `activeSince`. The menu formats whole seconds and advances its local clock only while a known descendant is running; an inactive row uses settled duration, or the summary's last activity to bound an interrupted open turn, so reopening the menu never restarts completed work. Token chunks do not change `subagentTiming` and therefore do not add a per-token list update path. Neither metric implies a durable outcome. Selecting a row records its exact address before opening the resident client `Session`. History pagination, event folding, tool render intents, titles, and live mux reconciliation reuse the ordinary conversation machinery. Breadcrumbs use catalog labels, follow parent links only through `origin: 'subagent'` rows, include the first ordinary owner, and keep ordinary forks single-level. Forking an addressed subagent creates an ordinary fork with direct source lineage and attaches it to the nearest workspace-owning ancestor. The catalog is an ARIA tree with lazy ArrowRight/ArrowLeft disclosure, linear ArrowUp/ArrowDown navigation, Home/End, Escape, and focus restoration. @@ -104,13 +104,13 @@ The shipped Web composition mounts SQLite session query beside JSONL persistence - Host protocol tests pin schemas including required boolean expandability, id echoing, mode verification, non-activating history, exact-parent enforcement, FIFO admission receipts, cancellation, and sanitized failure mapping. - Generic Host tests pin attached and cold history and forks without Agent publication, cold projection folding, descriptor/origin/runtime-owner denial, explicit-id adoption denial, and the direct queue-control fence. - Client object tests pin retained and restored addresses, one-shot read-only rejection, history routing, continuable prompt routing, no addressed cancellation, suppression of Agent-bound model controls, live activity flips including in-flight response replay and detach fallback, subagent-parent expandability flips, and membership refresh. -- jsdom tests pin the aggregate descendant count and activity, second-precision running and frozen inactive durations, the summary-backed root action across absent and stale-empty catalogs, known loading-row shape, mixed-mode rows, pre-click leaf disclosure, diagnostics, lazy descendant disclosure, direct-parent addresses, keyboard behavior, and both read-only reasons. -- The keyless assembled Web snapshot contains an inactive continuable child, an inactive one-shot sibling, and a persisted grandchild; it pins the three-descendant trigger across a stale empty catalog response, timing rows, and aggregate running transition, expands without activation, opens persisted history, admits a human FIFO follow-up, reconciles child mux events, and proves one-shot history remains read-only. +- jsdom tests pin the aggregate descendant count and activity, token totals, second-precision running and frozen inactive durations, the summary-backed root action across absent and stale-empty catalogs, known loading-row shape, mixed-mode rows, pre-click leaf disclosure, diagnostics, lazy descendant disclosure, direct-parent addresses, keyboard behavior, and both read-only reasons. +- The keyless assembled Web snapshot contains an inactive continuable child with durable usage, an inactive one-shot sibling, and a persisted grandchild; it pins the three-descendant trigger across a stale empty catalog response, usage and timing rows, and aggregate running transition, expands without activation, opens persisted history, admits a human FIFO follow-up, reconciles child mux events, and proves one-shot history remains read-only. - Navigation tests pin subagent-only breadcrumbs, workspace placement for forks created from subagents, and `origin: 'subagent'` sidebar filtering without hiding ordinary forks. ## Consequences -- Catalog reads may rescan persisted lineage and each direct candidate's descriptor log, but expandability reuses only descendant headers already present in that trace; the Web activity baseline adds one Agent-registry lookup per healthy row and then uses existing live frames, while duration reuses projection baselines and pushes with no per-row log read, and membership refresh stays debounced and single-flight. +- Catalog reads may rescan persisted lineage and each direct candidate's descriptor log, but expandability reuses only descendant headers already present in that trace; the Web activity baseline adds one Agent-registry lookup per healthy row and then uses existing live frames, while usage and duration reuse projection baselines and pushes with no per-row log read, and membership refresh stays debounced and single-flight. - Parent availability, child activity, and `hasChildren` are snapshots. Publication, disposal, another sender, or another process may win after listing; typed prompt failure remains expected. - A child may publish between history fetch and mux subscription, so the existing sequence reconciliation also covers the cold-to-live addressed path. - Persisted origin adds one deliberately weak product-classification field to child headers and list projections; it cannot become an authorization shortcut. diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md index 05d5c0f1d5..72ede6799d 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md @@ -33,7 +33,7 @@ Figma 中的 [subagent 列表](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5 | 会话页头可打开紧凑的 child 列表。 | 触发器会汇总仅含 subagent 的完整后代谱系;树按服务顺序显示每个直接目录条目,包括已禁用的 diagnostic。 | | 选择一行会复用对话 UI。 | 已寻址历史绝不激活 child;只有 parent 存活的可继续行才保留普通输入框。 | | 嵌套 agent 会逐层展开。 | 每行携带一层 `hasChildren` 快照;展开时会立即预留已知直接后代行,随后仍只加载该行的直接目录,并保留其自身的 parent 地址。 | -| 条目显示 label、状态与活跃耗时,同时避免侧边栏条目重复。 | mode 与 `running`/`inactive` 活动状态会同时以文字和视觉呈现;可选 title 与精确的活跃轮次耗时来自列表保留的投影值。`SessionHeader.origin` 会移除重复的导航条目,但不授予任何功能权限。 | +| 条目显示 label、状态、token 用量与活跃耗时,同时避免侧边栏条目重复。 | mode 与 `running`/`inactive` 活动状态会同时以文字和视觉呈现;可选 title、持久化 token 用量与精确的活跃轮次耗时来自列表保留的投影值。`SessionHeader.origin` 会移除重复的导航条目,但不授予任何功能权限。 | ## 产品契约 @@ -41,7 +41,7 @@ Figma 中的 [subagent 列表](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5 `running` 表示在 Host 采样边界,确切 child Agent driver 正在处理工作;`inactive` 表示该 driver 空闲或不存在。UI 不会把任一值解释为成功、失败、取消、完成状态或可恢复性。`subagent.list` 提供当前 driver 状态基线,`host/session-status` 会就地更新已知活动状态,请求内回放会阻止更早发起但尚未完成的列表响应覆盖较新的状态转换,`host/session-removed` 则会使已知行恢复为 `inactive`;重连时会读取新的基线。直接 subagent 的 `host/session-added` 帧会立即把任何已加载的 parent 行翻转为 `hasChildren: true`,并使这项正向提示不被更早发起但尚未完成的目录响应覆盖;受影响分支打开期间,成员、label、mode、diagnostic 与权威快照仍需要通过去抖动的 `subagent.list` 刷新来更新。消息投递时仍以提示词响应为权威依据。 -健康行会复用列表镜像中保留的标准会话投影。`subagentTiming` 会在每个描述符处重置,使继承的 fork 种子不会计入 child 总量;它会累加已完成的 `turn/start` → `turn/end` 时段,并携带当前轮次的 `activeSince`。菜单会以整秒格式化时间,且仅在有已知后代处于运行状态时才推进其本地时钟;对 inactive 行,菜单使用已结算耗时,或以摘要的最后活动为被中断未结束轮次的上界,因此重新打开菜单绝不会让已完成工作重新计时。该耗时不蕴含持久化结果语义。 +健康行会复用列表镜像中保留的标准会话投影。token 用量数值会汇总持久化日志中四个互不重叠的 `tokenUsage` 桶。`subagentTiming` 会在每个描述符处重置,使继承的 fork 种子不会计入 child 总量;它会累加已完成的 `turn/start` → `turn/end` 时段,并携带当前轮次的 `activeSince`。菜单会以整秒格式化时间,且仅在有已知后代处于运行状态时才推进其本地时钟;对 inactive 行,菜单使用已结算耗时,或以摘要的最后活动为被中断未结束轮次的上界,因此重新打开菜单绝不会让已完成工作重新计时。token 分片不会改变 `subagentTiming`,因此不会增加按 token 更新列表的路径。这两项指标都不蕴含持久化结果语义。 选择一行后,系统会先记录其确切地址,再打开常驻客户端 `Session`。历史分页、事件 fold、工具渲染意图、title 与实时 mux 归并都会复用普通对话机制。面包屑导航使用目录 label,只会沿 `origin: 'subagent'` 行的父链接逐级回溯,包含第一个普通 owner,并让普通 fork 保持单层。从已寻址 subagent 创建 fork 时,会生成具有直接源谱系的普通 fork,并将其附加到最近拥有 Workspace 的祖先。目录是一棵 ARIA 树,支持懒加载式 ArrowRight/ArrowLeft 展开与折叠、线性 ArrowUp/ArrowDown 导航、Home/End、Escape 以及焦点恢复。 @@ -104,13 +104,13 @@ one-shot 行始终会用文案替代输入框,说明执行记录为只读。 - 宿主协议测试固定 schema(包括必需的布尔可展开性)、id 回显、mode 校验、非激活式历史、确切 parent 强制要求、FIFO 准入回执、取消与脱敏后的失败映射。 - 通用 Host 测试固定在不发布 Agent 的情况下读取已附加与冷态历史及执行 fork、冷态投影归并、按描述符/origin/运行时 owner 拒绝、拒绝显式 id 接纳,以及直接队列控制栅栏。 - 客户端对象测试固定已保留与已恢复的地址、one-shot 只读拒绝、历史路由、可继续提示词路由、已寻址对话不提供取消、屏蔽绑定到 agent 的模型控件、实时活动状态翻转(包括在途响应回放与 detach 回退)、subagent parent 可展开性翻转与成员刷新。 -- jsdom 测试固定后代聚合计数与活动状态、精确到秒的运行中耗时与冻结后 inactive 耗时、目录缺失或为陈旧空目录时由摘要支撑的根操作、已知加载行的形态、混合 mode 行、点击前的叶子展开控件、diagnostic、后代懒加载展开、直接 parent 地址、键盘行为与两种只读原因。 -- 无密钥的组装 Web 快照包含一个 inactive 的可继续 child、一个 inactive 的 one-shot sibling 和一个持久化 grandchild;它会固定触发器在一次陈旧的空目录响应后仍显示三个后代,并固定计时行以及聚合 `running` 状态转换,在不激活的情况下展开、打开持久化历史、准入一条用户 FIFO 后续消息、归并 child mux 事件,并证明 one-shot 历史仍然只读。 +- jsdom 测试固定后代聚合计数与活动状态、token 用量总计、精确到秒的运行中耗时与冻结后 inactive 耗时、目录缺失或为陈旧空目录时由摘要支撑的根操作、已知加载行的形态、混合 mode 行、点击前的叶子展开控件、diagnostic、后代懒加载展开、直接 parent 地址、键盘行为与两种只读原因。 +- 无密钥的组装 Web 快照包含一个具有持久化 token 用量的 inactive 可继续 child、一个 inactive 的 one-shot sibling 和一个持久化 grandchild;它会固定触发器在一次陈旧的空目录响应后仍显示三个后代,并固定 token 用量与计时行以及聚合 `running` 状态转换,在不激活的情况下展开、打开持久化历史、准入一条用户 FIFO 后续消息、归并 child mux 事件,并证明 one-shot 历史仍然只读。 - 导航测试固定仅含 subagent 的面包屑导航、从 subagent 创建 fork 时的 Workspace 归属,以及 `origin: 'subagent'` 侧边栏过滤,同时不隐藏普通 fork。 ## 后果 -- 目录读取可能重新扫描持久化谱系与每个直接候选的描述符日志,但可展开性只复用该追踪中已有的后代 header;Web 活动基线会为每个健康行增加一次 Agent 注册表查找,随后使用现有实时帧,而耗时会复用投影基线和推送,无需按行读取日志,成员刷新则保持去抖动和单次并发。 +- 目录读取可能重新扫描持久化谱系与每个直接候选的描述符日志,但可展开性只复用该追踪中已有的后代 header;Web 活动基线会为每个健康行增加一次 Agent 注册表查找,随后使用现有实时帧,而 token 用量与耗时会复用投影基线和推送,无需按行读取日志,成员刷新则保持去抖动和单次并发。 - parent 可用性、child 活动状态与 `hasChildren` 都是快照。列出之后,发布、dispose、其他发送方或其他进程都可能抢先改变状态;类型化提示词失败仍属预期行为。 - child 可能在历史获取与 mux 订阅之间发布,因此现有序号归并也涵盖从冷态转为存活的已寻址路径。 - 持久化 origin 会为 child header 与列表投影添加一个有意保持弱约束的产品分类字段;它不能变成授权捷径。 diff --git a/apps/web/tests/snapshots/subagent-conversation/tree.expected.md b/apps/web/tests/snapshots/subagent-conversation/tree.expected.md index c1174c042e..43ed15c649 100644 --- a/apps/web/tests/snapshots/subagent-conversation/tree.expected.md +++ b/apps/web/tests/snapshots/subagent-conversation/tree.expected.md @@ -1,8 +1,8 @@ - tree "Subagent sessions": - - treeitem "event-sourcing researcher Explain event sourcing in one · continuable · not running {{duration}}" [expanded] [level=1]: + - treeitem "event-sourcing researcher Explain event sourcing in one · continuable · not running 7.9K tok · {{duration}}" [expanded] [level=1]: - button "Collapse event-sourcing researcher descendants": - img - - text: event-sourcing researcher Explain event sourcing in one · continuable · not running {{duration}} + - text: event-sourcing researcher Explain event sourcing in one · continuable · not running 7.9K tok {{duration}} - group: - - treeitem "example editor continuable · not running {{duration}}" [level=2] - - treeitem "event-sourcing reviewer one-shot · not running {{duration}}" [level=1] + - treeitem "example editor continuable · not running 0 tok · {{duration}}" [level=2]: example editor continuable · not running 0 tok {{duration}} + - treeitem "event-sourcing reviewer one-shot · not running 0 tok · {{duration}}" [level=1]: event-sourcing reviewer one-shot · not running 0 tok {{duration}} diff --git a/docs/module-graph.md b/docs/module-graph.md index 313c58d914..8419646dca 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -983,6 +983,7 @@ flowchart TD pkg_client_ui_subagent --> pkg_client_ui_slots pkg_client_ui_subagent --> pkg_invariants pkg_client_ui_subagent --> pkg_subagent + pkg_client_ui_subagent --> pkg_token_meter pkg_agent_spine_demo --> pkg_agent pkg_agent_spine_demo --> pkg_agent_loop pkg_agent_spine_demo --> pkg_goal @@ -1240,7 +1241,7 @@ flowchart TD | [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`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-permission`](../packages/client/ui-permission) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-command`](../packages/client/ui-command), [`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), [`permission`](../packages/ui/permission) | | [`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) | +| [`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) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks-local`](../packages/tasks/tasks-local), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`sdk-protocol`](../packages/sdk/sdk-protocol) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | diff --git a/packages/client/ui-subagent/README.i18n.yaml b/packages/client/ui-subagent/README.i18n.yaml index 2b512252f0..76b8cb344e 100644 --- a/packages/client/ui-subagent/README.i18n.yaml +++ b/packages/client/ui-subagent/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-subagent/README.md -README.md: 16a54484fb53544c71af0806c1497a18f9141002 -README.zh.md: 166a25d095b3e7f10f7239262f39e27972344529 +README.md: 538daeffb61f642b2e430cc1a6e2b1f3e3d5f55e +README.zh.md: 10320fdde6fe2a881e0e1dfc218c87562c4d61b0 diff --git a/packages/client/ui-subagent/README.md b/packages/client/ui-subagent/README.md index 16a54484fb..538daeffb6 100644 --- a/packages/client/ui-subagent/README.md +++ b/packages/client/ui-subagent/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Web subagent feature owner: contributes the lazily expandable catalog tree to `conversation.session.header.actions`, reason-specific read-only replacements to the conversation composer chain, and the existing `@` reference source to `ctx.slash`. -The header action reads `subagentsByParent` and session summaries through the standard `useSessions` hook. After a non-empty direct catalog arrives, its trigger counts the complete subagent-only descendant lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. The compact tree remains direct-catalog authoritative: continuable and one-shot rows display mode plus `running`/`inactive` activity, an optional log-backed title, and active-turn duration to the second; an unlabeled one-shot row falls back to its session id, while corrupt, unsupported, or unavailable rows remain readable but disabled. Duration sums completed `subagentTiming` turns, advances once per second only for an open turn on a running child, and freezes after the child becomes inactive; an interrupted open turn is bounded by the session summary's last activity. Each healthy row's `hasChildren` hint determines disclosure before interaction, so known leaves never show an arrow; expanding a branch immediately reserves one disabled loading row per known direct descendant, then lazily replaces them with that child's authoritative catalog. Every visible branch is reported to the runtime so membership frames cause a debounced refresh only where the tree is being consumed. Selecting any depth calls `SessionsService.openSubagent()` with the row's exact `{parentSessionId, childSessionId, mode}` address. Component-local state owns tree visibility, expanded branches, keyboard focus, and the running-duration clock. ArrowRight/ArrowLeft expand and collapse branches; ArrowUp/ArrowDown, Home, End, and Escape navigate or close the tree; closing returns focus to the trigger. Styling uses tokens only. +The header action reads `subagentsByParent` and session summaries through the standard `useSessions` hook. After a non-empty direct catalog arrives, its trigger counts the complete subagent-only descendant lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. The compact tree remains direct-catalog authoritative: continuable and one-shot rows display mode plus `running`/`inactive` activity and an optional log-backed title, while the trailing column stacks total durable provider usage above active-turn duration to the second; an unlabeled one-shot row falls back to its session id, while corrupt, unsupported, or unavailable rows remain readable but disabled. Token totals sum the four disjoint `tokenUsage` buckets. Duration sums completed `subagentTiming` turns, advances once per second only for an open turn on a running child, and freezes after the child becomes inactive; an interrupted open turn is bounded by the session summary's last activity. Each healthy row's `hasChildren` hint determines disclosure before interaction, so known leaves never show an arrow; expanding a branch immediately reserves one disabled loading row per known direct descendant, then lazily replaces them with that child's authoritative catalog. Every visible branch is reported to the runtime so membership frames cause a debounced refresh only where the tree is being consumed. Selecting any depth calls `SessionsService.openSubagent()` with the row's exact `{parentSessionId, childSessionId, mode}` address. Component-local state owns tree visibility, expanded branches, keyboard focus, and the running-duration clock. ArrowRight/ArrowLeft expand and collapse branches; ArrowUp/ArrowDown, Home, End, and Escape navigate or close the tree; closing returns focus to the trigger. Styling uses tokens only. A one-shot child always elects a read-only composer that identifies the transcript as a completed execution record. A continuable child does so only when its exact parent is unavailable, with copy explaining the recovery path. A continuable child with a live parent keeps the ordinary input chrome, whose Session routes through `subagent.prompt`; running input remains Send because every follow-up joins the child's FIFO inbox, and addressed sessions never expose Stop. This package never receives host context or calls a model-facing tool. The catalog and composer behavior are specified by the [Web subagent conversations Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md). diff --git a/packages/client/ui-subagent/README.zh.md b/packages/client/ui-subagent/README.zh.md index 166a25d095..10320fdde6 100644 --- a/packages/client/ui-subagent/README.zh.md +++ b/packages/client/ui-subagent/README.zh.md @@ -4,7 +4,7 @@ Web subagent 功能 owner:向 `conversation.session.header.actions` 贡献可懒加载展开的目录树,向会话编辑器链贡献按原因区分的只读替代呈现,并保留注册到 `ctx.slash` 的既有 `@` 引用 source。 -页头操作通过标准 `useSessions` 钩子读取 `subagentsByParent` 与会话摘要。非空直接目录到达后,其触发器会统计仅含 subagent 的完整后代谱系,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。紧凑树仍以直接目录为权威依据:可继续和 one-shot 行会显示 mode、`running`/`inactive` 活动状态、由日志支撑的可选 title,以及精确到秒的活跃轮次耗时;没有 label 的 one-shot 行会回退到其会话 id,而损坏、不受支持或不可用的行仍保持可读但禁用。耗时会累加已完成的 `subagentTiming` 轮次,仅在运行中 child 存在未结束轮次时每秒递增一次,并在 child 变为 inactive 后冻结;被中断的未结束轮次以会话摘要中的最后活动为上界。每个健康行的 `hasChildren` 提示会在交互前决定是否显示展开控件,因此已知叶子节点从不显示箭头;展开分支时,会立即为每个已知直接后代预留一行禁用的加载行,随后再用该 child 的权威目录懒加载结果替换这些占位行。每个可见分支都会上报给运行时,使成员帧只在树正被消费的位置触发去抖动刷新。选择任意深度的条目都会使用该行的确切地址 `{parentSessionId, childSessionId, mode}` 调用 `SessionsService.openSubagent()`。组件局部状态负责树的可见性、已展开分支、键盘焦点与运行中耗时时钟。ArrowRight/ArrowLeft 展开和折叠分支;ArrowUp/ArrowDown、Home、End 与 Escape 用于导航或关闭树;关闭后焦点返回触发器。样式只使用 token。 +页头操作通过标准 `useSessions` 钩子读取 `subagentsByParent` 与会话摘要。非空直接目录到达后,其触发器会统计仅含 subagent 的完整后代谱系,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。紧凑树仍以直接目录为权威依据:可继续和 one-shot 行会显示 mode、`running`/`inactive` 活动状态和由日志支撑的可选 title,尾随列则在上行显示提供方的持久化 token 用量总计,在下行显示精确到秒的活跃轮次耗时;没有 label 的 one-shot 行会回退到其会话 id,而损坏、不受支持或不可用的行仍保持可读但禁用。token 用量总计为四个互不重叠的 `tokenUsage` 桶之和。耗时会累加已完成的 `subagentTiming` 轮次,仅在运行中 child 存在未结束轮次时每秒递增一次,并在 child 变为 inactive 后冻结;被中断的未结束轮次以会话摘要中的最后活动为上界。每个健康行的 `hasChildren` 提示会在交互前决定是否显示展开控件,因此已知叶子节点从不显示箭头;展开分支时,会立即为每个已知直接后代预留一行禁用的加载行,随后再用该 child 的权威目录懒加载结果替换这些占位行。每个可见分支都会上报给运行时,使成员帧只在树正被消费的位置触发去抖动刷新。选择任意深度的条目都会使用该行的确切地址 `{parentSessionId, childSessionId, mode}` 调用 `SessionsService.openSubagent()`。组件局部状态负责树的可见性、已展开分支、键盘焦点与运行中耗时时钟。ArrowRight/ArrowLeft 展开和折叠分支;ArrowUp/ArrowDown、Home、End 与 Escape 用于导航或关闭树;关闭后焦点返回触发器。样式只使用 token。 one-shot child 始终选用只读编辑器,并将 transcript(文本记录)说明为已完成的执行记录。可继续 child 仅在其确切 parent 不可用时选用只读编辑器,并以文案说明恢复路径。确切 parent 存活时,可继续 child 保留普通输入 chrome,其 Session 会通过 `subagent.prompt` 路由;child 运行期间,输入操作仍为 Send,因为每条后续消息都会进入 child 的 FIFO inbox,且已寻址会话绝不公开 Stop。本包绝不接收宿主 context,也不调用面向模型的工具。目录与编辑器行为由 [Web subagent 对话 Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md)规定。 diff --git a/packages/client/ui-subagent/package.json b/packages/client/ui-subagent/package.json index 573bf54097..9e6b120c1e 100644 --- a/packages/client/ui-subagent/package.json +++ b/packages/client/ui-subagent/package.json @@ -49,6 +49,7 @@ "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", + "@deepseek-ai/dsh-token-meter": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { @@ -61,6 +62,7 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-token-meter": "workspace:^", "@types/react": "~18.3.1", "cordis": "^4.0.0-rc.7" }, diff --git a/packages/client/ui-subagent/src/client/SubagentCatalogAction.module.css b/packages/client/ui-subagent/src/client/SubagentCatalogAction.module.css index d8642879ea..239081c59c 100644 --- a/packages/client/ui-subagent/src/client/SubagentCatalogAction.module.css +++ b/packages/client/ui-subagent/src/client/SubagentCatalogAction.module.css @@ -173,19 +173,30 @@ } .summary, -.time { +.metrics { color: var(--dsw-alias-label-tertiary); font-size: 11px; line-height: 16px; } -.time { +.metrics { + display: grid; + grid-template-rows: 18px 16px; flex: none; - margin-top: 16px; font-variant-numeric: tabular-nums; + text-align: right; white-space: nowrap; } +.metricToken { + grid-row: 1; + line-height: 18px; +} + +.metricDuration { + grid-row: 2; +} + .children { position: relative; margin-left: 18px; diff --git a/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx b/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx index d507b30019..b959786456 100644 --- a/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx +++ b/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx @@ -12,6 +12,7 @@ import type { PropsLocale, PropsRuntime, TranslateNS } from '@deepseek-ai/dsh-cl import { NS } from './locales.ts' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-subagent/client' +import type {} from '@deepseek-ai/dsh-token-meter/client' import css from './SubagentCatalogAction.module.css' type CatalogEntry = SubagentCatalogSnapshot['entries'][number] @@ -59,6 +60,26 @@ function treeItems(root: HTMLDivElement | null): HTMLElement[] { : Array.from(root.querySelectorAll('[role="treeitem"]:not([aria-disabled="true"])')) } +/** Compact token count shared in shape with the conversation stats strip. */ +function formatTokens(value: number): string { + const scaled = (next: number): string => next >= 100 + ? String(Math.round(next)) + : String(Math.round(next * 10) / 10) + if (value < 1_000) return String(value) + if (value < 1_000_000) return `${scaled(value / 1_000)}K` + return `${scaled(value / 1_000_000)}M` +} + +/** Sum the four disjoint durable provider-usage buckets. */ +function tokenTotal( + usage: SessionProjectionMap['tokenUsage'] | undefined, +): number | undefined { + return usage === undefined + ? undefined + : usage.uncachedInputTokens + usage.outputTokens + + usage.cacheReadTokens + usage.cacheWriteTokens +} + /** Exact whole-second active-turn duration for one catalog row. */ function activityDuration( summary: SessionSummary | undefined, @@ -222,14 +243,21 @@ function CatalogRows({ const secondary = [summary?.title, mode, activity] .filter(value => value !== undefined) .join(' · ') + const totalTokens = tokenTotal(summary?.projectionValues?.tokenUsage) const durationMs = activityDuration( summary, entry.activity, now, ) - const duration = durationMs === undefined + const tokenMetric = totalTokens === undefined + ? undefined + : `${formatTokens(totalTokens)} tok` + const durationMetric = durationMs === undefined ? undefined : formatDuration(durationMs, t) + const metrics = [tokenMetric, durationMetric] + .filter(value => value !== undefined) + .join(' · ') const open = (): void => { openChild({ parentSessionId, childSessionId: entry.id, mode: entry.mode }) @@ -261,9 +289,7 @@ function CatalogRows({ role="treeitem" tabIndex={0} aria-level={level} - aria-label={[label, secondary, duration] - .filter(value => value !== undefined) - .join(' ')} + aria-label={[label, secondary, metrics].filter(value => value !== '').join(' ')} {...knownLeaf ? {} : { 'aria-expanded': isExpanded }} className={css.row} onClick={open} @@ -288,7 +314,12 @@ function CatalogRows({ {label} {secondary}
- {duration !== undefined && {duration}} + {metrics !== '' && ( + + {tokenMetric !== undefined && {tokenMetric}} + {durationMetric !== undefined && {durationMetric}} + + )} {isExpanded && !knownLeaf && ( diff --git a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx b/packages/client/ui-subagent/tests/conversation-ui.spec.tsx index 487cd14f35..0862135126 100644 --- a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx +++ b/packages/client/ui-subagent/tests/conversation-ui.spec.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { cleanup, fireEvent, render, screen, within } from '@testing-library/react' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import type { SessionId, SessionListState, SessionSummary, SubagentCatalogSnapshot, @@ -240,7 +240,7 @@ describe('SubagentCatalogAction', () => { }) }) - it('ticks active duration by seconds and freezes inactive rows', async () => { + it('shows durable token totals, ticks active duration by seconds, and freezes inactive rows', async () => { const now = 2_000_000_000_000 vi.useFakeTimers() vi.setSystemTime(now) @@ -249,6 +249,26 @@ describe('SubagentCatalogAction', () => { ['finished', 'inactive', 3_723_000, undefined, now - 60_000], ['interrupted', 'inactive', 2_000, now - 7_000, now - 3_000], ] as const + const usageById = { + running: { + uncachedInputTokens: 1_000, + outputTokens: 200, + cacheReadTokens: 3_000, + cacheWriteTokens: 400, + }, + finished: { + uncachedInputTokens: 123, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + }, + interrupted: { + uncachedInputTokens: 123_000_000, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + }, + } as const const entries = rows.map(([id, activity]) => ({ kind: 'child' as const, id: id as SessionId, @@ -269,6 +289,7 @@ describe('SubagentCatalogAction', () => { settledMs, ...(activeSince === undefined ? {} : { activeSince }), }, + tokenUsage: usageById[id], }, }] })) as Record @@ -276,14 +297,19 @@ describe('SubagentCatalogAction', () => { render() fireEvent.click(screen.getByRole('button', { name: /3 个子代理/ })) - expect(screen.getByRole('treeitem', { name: /running.*1分10秒/ })).toBeTruthy() - expect(screen.getByRole('treeitem', { name: /finished.*1小时02分03秒/ })).toBeTruthy() - expect(screen.getByRole('treeitem', { name: /interrupted.*6秒/ })).toBeTruthy() + const runningRow = screen.getByRole('treeitem', { name: /running.*4\.6K tok · 1分10秒/ }) + const runningMetrics = within(runningRow) + const tokenMetric = runningMetrics.getByText('4.6K tok') + const durationMetric = runningMetrics.getByText('1分10秒') + expect(tokenMetric.parentElement).toBe(durationMetric.parentElement) + expect(tokenMetric.nextElementSibling).toBe(durationMetric) + expect(screen.getByRole('treeitem', { name: /finished.*123 tok · 1小时02分03秒/ })).toBeTruthy() + expect(screen.getByRole('treeitem', { name: /interrupted.*123M tok · 6秒/ })).toBeTruthy() await vi.advanceTimersByTimeAsync(1_000) - expect(screen.getByRole('treeitem', { name: /running.*1分11秒/ })).toBeTruthy() - expect(screen.getByRole('treeitem', { name: /finished.*1小时02分03秒/ })).toBeTruthy() - expect(screen.getByRole('treeitem', { name: /interrupted.*6秒/ })).toBeTruthy() + expect(screen.getByRole('treeitem', { name: /running.*4\.6K tok · 1分11秒/ })).toBeTruthy() + expect(screen.getByRole('treeitem', { name: /finished.*123 tok · 1小时02分03秒/ })).toBeTruthy() + expect(screen.getByRole('treeitem', { name: /interrupted.*123M tok · 6秒/ })).toBeTruthy() }) it('lazily expands and collapses descendant catalogs with direct-parent navigation', () => { diff --git a/packages/client/ui-subagent/tsconfig.json b/packages/client/ui-subagent/tsconfig.json index e9d59a6fa1..27d06d16c0 100644 --- a/packages/client/ui-subagent/tsconfig.json +++ b/packages/client/ui-subagent/tsconfig.json @@ -29,6 +29,9 @@ { "path": "../ui-slots" }, + { + "path": "../../llm/token-meter" + }, { "path": "../../subagent/subagent" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1b0e2f5542..0b634898ea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1906,6 +1906,9 @@ importers: '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../../subagent/subagent + '@deepseek-ai/dsh-token-meter': + specifier: workspace:^ + version: link:../../llm/token-meter '@types/react': specifier: ~18.3.1 version: 18.3.31 From 5ee544081ade777d4d72b2fd59f3d4c51cfb1ba3 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:56:31 -0700 Subject: [PATCH 079/129] fix(web): unblock remote welcome onboarding --- ...versioned-gui-welcome-onboarding.i18n.yaml | 4 +- ...-07-30-versioned-gui-welcome-onboarding.md | 8 +-- ...-30-versioned-gui-welcome-onboarding.zh.md | 8 +-- apps/web/tests/remote-welcome.e2e.ts | 53 +++++++++++++++++++ apps/web/tests/scaffold.ts | 15 ++++-- apps/web/tsconfig.json | 1 + .../ui-settings-general/README.i18n.yaml | 4 +- packages/client/ui-settings-general/README.md | 2 +- .../client/ui-settings-general/README.zh.md | 2 +- .../ui-settings-general/src/client/index.ts | 14 ++++- .../src/client/welcome-store.ts | 34 +++++++++--- .../ui-settings-general/tests/apply.spec.ts | 18 ++++++- .../tests/welcome-store.spec.ts | 15 ++++++ tsconfig.host.json | 1 + 14 files changed, 152 insertions(+), 27 deletions(-) create mode 100644 apps/web/tests/remote-welcome.e2e.ts diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.i18n.yaml index 579586f30a..6a109260c1 100644 --- a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.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 .agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md -2026-07-30-versioned-gui-welcome-onboarding.md: 8155838f3b6b50f3474ef6c30065ad0d79e6f8af -2026-07-30-versioned-gui-welcome-onboarding.zh.md: c221a6d663af60b03757f135045961bcbcdd0da7 +2026-07-30-versioned-gui-welcome-onboarding.md: 1199cec532dd23930c70b236e6fcac80832204ff +2026-07-30-versioned-gui-welcome-onboarding.zh.md: d59e8971689fad1836127fea79cd392d3d5787c3 diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md index 8155838f3b..1199cec532 100644 --- a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md +++ b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md @@ -14,15 +14,15 @@ The GUI's credential onboarding begins with a DeepSeek-specific readiness check, **Ownerless product onboarding belongs to `ui-settings-general`.** `src/onboarding-copy.ts` is the single editable source for the complete notice, the Continue label, and `WELCOME_NOTICE_VERSION`; both supported GUI locales intentionally render the same Chinese owner copy. Runtime locale dictionaries derive their welcome values from that file, and tests import the same owner instead of repeating paragraph text. The notice is browser UI only: it creates no Session event and contributes no model-visible content. The notice identifies `DSH_TELEMETRY_DISABLED=1` as the telemetry opt-out. -**Acknowledgement is durable per Harness profile.** The Host half registers a `ui-onboarding` section in the user-settings seam, stored under the active `$DSH_HOME/settings.yaml`. The browser shows the notice unless `welcomeNoticeVersion` equals the owner constant exactly. Continue applies one path mutation with the current version and calls `complete()` only after the Host commits it; a failed write leaves the notice open, and closing the page or process writes nothing. Bumping the constant intentionally makes every profile acknowledge the revised copy once. +**Loopback acknowledgement is durable per Harness profile.** The Host half registers a `ui-onboarding` section in the user-settings seam, stored under the active `$DSH_HOME/settings.yaml`. A loopback browser shows the notice unless `welcomeNoticeVersion` equals the owner constant exactly. Continue applies one path mutation with the current version and calls `complete()` only after the Host commits it; a failed write leaves the notice open, and closing the page or process writes nothing. Bumping the constant intentionally makes every profile acknowledge the revised copy once. A non-loopback browser must not call the loopback-only settings API. It presents the same notice, but explicit Continue completes the step only in the current browser process; reload or a new process presents it again. -**Concurrent views converge without stale replacement.** The acknowledgement write omits `expectedRevision` deliberately: every tab writes the same version to one path, so the operation is idempotent and preserves sibling fields instead of rebuilding the section. `settings/document-updated` becomes `host/settings-changed`; an already mounted tab refetches and advances when another tab or an external editor commits the current version. The API proxy exposes this one product namespace through a closed allowlist beside configurable-provider namespaces, without treating its changes as model-catalog invalidations. +**Concurrent loopback views converge without stale replacement.** The acknowledgement write omits `expectedRevision` deliberately: every loopback tab writes the same version to one path, so the operation is idempotent and preserves sibling fields instead of rebuilding the section. `settings/document-updated` becomes `host/settings-changed`; an already mounted loopback tab refetches and advances when another tab or an external editor commits the current version. The API proxy exposes this one product namespace through a closed allowlist beside configurable-provider namespaces, without treating its changes as model-catalog invalidations. **Onboarding temporarily owns the viewport as one continuous stage.** A solid product surface replaces the complete application view through a body-level portal and marks the underlying app root inert; the exact required mask remains mounted behind that surface with `position:absolute`, zero left/right/bottom offsets, `top:80px`, `rgba(0, 0, 0, 0.24)`, and `backdrop-filter: blur(2px)`. Welcome and conditional credential setup render as successive pages in this stage instead of independent modals. Both pages reuse the Web UI's black `BrandWordmark`. The welcome page preserves the four authored paragraphs verbatim under the `内测声明` title; every paragraph uses one 16/28 body scale, and only the requested action clause inside the final paragraph receives a subtle 500 weight. A short staggered opacity/vertical entrance supplies pacing without blocking interaction and disappears under reduced motion. The title receives initial focus, Continue is the sole button, and no close, Escape, or mask-click path exists. ## Alternatives considered -**Browser local storage** — rejected because acknowledgement would follow one browser profile rather than `$DSH_HOME`; a fresh Harness profile could incorrectly inherit a prior acknowledgement, and external profile edits would have no authoritative update stream. +**Browser local storage** — rejected because acknowledgement would follow one browser profile rather than `$DSH_HOME`; a fresh Harness profile could incorrectly inherit a prior acknowledgement, and external profile edits would have no authoritative update stream. Non-loopback fallback therefore remains process-local rather than browser-profile-local. **A second independent modal in `ui-settings-general`** — rejected because list registrants would still stack whenever welcome and credential readiness were both true. Ordered ownership belongs to the shell that declares and renders the list. @@ -32,4 +32,4 @@ The GUI's credential onboarding begins with a DeepSeek-specific readiness check, ## Consequences -A fresh profile always sees the welcome notice before provider-specific onboarding; an already configured credential skips only the later DeepSeek step. Reloading after Continue stays past the acknowledged version, changing the owner version presents it again, and closing before Continue leaves the next launch unchanged. Focused store and React tests pin exact-version comparison, write failure, sole-action behavior, no-dismiss paths, coordinator ordering, conditional DeepSeek transfer, and HMR cleanup. The real Chromium scenario boots the shipped Web composition with an isolated harness home, verifies the exact mask geometry and computed styles, reloads before and after acknowledgement, continues into missing-credential setup, confirms an acknowledged-version mismatch returns while the credential is configured, and checks the browser console. +A fresh profile always sees the welcome notice before provider-specific onboarding; an already configured credential skips only the later DeepSeek step. On loopback, reloading after Continue stays past the acknowledged version, changing the owner version presents it again, and closing before Continue leaves the next launch unchanged. On non-loopback, Continue advances the live process without a privileged settings request and reload presents the notice again. Focused store and React tests pin both persistence modes, exact-version comparison, write failure, sole-action behavior, no-dismiss paths, coordinator ordering, conditional DeepSeek transfer, and HMR cleanup. The real Chromium scenario boots the shipped Web composition with an isolated harness home, verifies the exact mask geometry and computed styles, reloads before and after acknowledgement, continues into missing-credential setup, confirms an acknowledged-version mismatch returns while the credential is configured, and checks the browser console. diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.zh.md b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.zh.md index c221a6d663..d59e897168 100644 --- a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.zh.md @@ -14,15 +14,15 @@ GUI 的凭据引导从 DeepSeek 专用的就绪状态检查开始,但内部测 **不属于单一功能的产品引导由 `ui-settings-general` 持有。** `src/onboarding-copy.ts` 是完整通知、「继续」按钮文案和 `WELCOME_NOTICE_VERSION` 的唯一可编辑来源;GUI 支持的两种 locale 都有意渲染同一份中文所有者文案。运行时 locale 字典从该文件派生欢迎文案,测试也导入同一个所有者,而不重复段落文本。该通知只存在于浏览器 UI:它不会创建会话事件,也不会贡献任何模型可见内容。通知明确以 `DSH_TELEMETRY_DISABLED=1` 作为遥测关闭方式。 -**确认状态按 Harness profile 持久化。** 宿主端在 user-settings seam 中注册 `ui-onboarding` 分节,并存入当前 `$DSH_HOME/settings.yaml`。除非 `welcomeNoticeVersion` 与文案所有者文件中的常量精确相等,否则浏览器会显示通知。「继续」会以当前版本执行一次路径变更,并且仅在宿主端提交成功后调用 `complete()`;写入失败时通知保持打开,关闭页面或进程则不会写入任何内容。提升该常量会有意要求每个 profile 对修订后的文案重新确认一次。 +**loopback 确认状态按 Harness profile 持久化。** 宿主端在 user-settings seam 中注册 `ui-onboarding` 分节,并存入当前 `$DSH_HOME/settings.yaml`。除非 `welcomeNoticeVersion` 与文案所有者文件中的常量精确相等,否则 loopback 浏览器会显示通知。「继续」会以当前版本执行一次路径变更,并且仅在宿主端提交成功后调用 `complete()`;写入失败时通知保持打开,关闭页面或进程则不会写入任何内容。提升该常量会有意要求每个 profile 对修订后的文案重新确认一次。非 loopback 浏览器不能调用仅限 loopback 的 settings API;它仍显示同一通知,但显式点击「继续」只会在当前浏览器进程中完成该步骤,重新加载或新进程会再次显示通知。 -**并发视图无需陈旧的整体替换即可收敛。** 确认写入有意省略 `expectedRevision`:每个标签页都向同一路径写入相同版本,因此该操作是幂等的,并会保留同级字段,而不是重建整个分节。`settings/document-updated` 会转为 `host/settings-changed`;另一个标签页或外部编辑器提交当前版本后,已挂载的标签页会重新拉取状态并推进。API 网关在可配置提供方 namespace 之外,通过封闭的允许列表暴露这一个产品 namespace,同时不会把它的变更视为模型目录失效事件。 +**并发 loopback 视图无需陈旧的整体替换即可收敛。** 确认写入有意省略 `expectedRevision`:每个 loopback 标签页都向同一路径写入相同版本,因此该操作是幂等的,并会保留同级字段,而不是重建整个分节。`settings/document-updated` 会转为 `host/settings-changed`;另一个标签页或外部编辑器提交当前版本后,已挂载的 loopback 标签页会重新拉取状态并推进。API 网关在可配置提供方 namespace 之外,通过封闭的允许列表暴露这一个产品 namespace,同时不会把它的变更视为模型目录失效事件。 **引导流程会暂时接管视口,形成一个连续阶段。** 纯色产品界面通过挂载到 `body` 的 portal 取代完整的应用视图,并将底层应用根节点标记为 inert;严格符合要求的遮罩仍挂载在该界面后方,并保留 `position:absolute`、left/right/bottom 偏移量为零、`top:80px`、`rgba(0, 0, 0, 0.24)` 和 `backdrop-filter: blur(2px)`。欢迎页和按条件显示的凭据设置页在这一阶段中依次呈现,而不是各自作为独立的模态窗口。两个页面都复用 Web UI 的黑色 `BrandWordmark`。欢迎页在 `内测声明` 标题下逐字保留既定的四段文案;所有段落统一采用 16/28 的正文字号与行高,只有最后一段中指定的行动语句使用较为克制的 500 字重。短暂的错落式透明度与纵向位移动画营造出舒缓节奏,但不会阻碍交互,并会在用户启用减少动态效果时禁用。初始焦点落在标题上,「继续」是唯一按钮,且不存在关闭、Escape 或点击遮罩的退出路径。 ## 曾考虑的替代方案 -**浏览器本地存储**:不予采用,因为确认状态会跟随某个浏览器 profile,而不是 `$DSH_HOME`;全新的 Harness profile 可能错误继承此前的确认状态,外部 profile 编辑也没有权威更新流。 +**浏览器本地存储**:不予采用,因为确认状态会跟随某个浏览器 profile,而不是 `$DSH_HOME`;全新的 Harness profile 可能错误继承此前的确认状态,外部 profile 编辑也没有权威更新流。因此,非 loopback 的回退保持为进程内状态,而不是浏览器 profile 状态。 **在 `ui-settings-general` 中再增加一个独立模态窗口**:不予采用,因为欢迎通知和凭据就绪状态同时为真时,list 注册方仍会堆叠。声明并渲染该 list 的外壳应当持有有序所有权。 @@ -32,4 +32,4 @@ GUI 的凭据引导从 DeepSeek 专用的就绪状态检查开始,但内部测 ## 后果 -全新 profile 始终会在提供方专用引导之前看到欢迎通知;凭据已经配置时,只会跳过后续 DeepSeek 步骤。点击「继续」后重新加载不会再次显示已确认版本,更改文案所有者文件中的版本值会让通知重新出现,而确认前关闭窗口不会改变下次启动。针对性的 store 与 React 测试固化了精确版本比较、写入失败、单一操作、不可关闭路径、协调器顺序、按条件移交 DeepSeek 步骤和 HMR(热模块替换)清理行为。真实 Chromium 场景会使用隔离的 harness 家目录启动随产品提供的 Web 组合,验证遮罩的精确几何尺寸和计算样式,在确认前后分别重新加载,继续进入凭据缺失设置流程,确认凭据已配置时确认版本不匹配仍会使通知重新出现,并检查浏览器控制台。 +全新 profile 始终会在提供方专用引导之前看到欢迎通知;凭据已经配置时,只会跳过后续 DeepSeek 步骤。在 loopback 上,点击「继续」后重新加载不会再次显示已确认版本,更改文案所有者文件中的版本值会让通知重新出现,而确认前关闭窗口不会改变下次启动。在非 loopback 上,「继续」会在不发起受保护 settings 请求的情况下推进当前进程,重新加载则再次显示通知。针对性的 store 与 React 测试固化了两种持久化模式、精确版本比较、写入失败、单一操作、不可关闭路径、协调器顺序、按条件移交 DeepSeek 步骤和 HMR(热模块替换)清理行为。真实 Chromium 场景会使用隔离的 harness 家目录启动随产品提供的 Web 组合,验证遮罩的精确几何尺寸和计算样式,在确认前后分别重新加载,继续进入凭据缺失设置流程,确认凭据已配置时确认版本不匹配仍会使通知重新出现,并检查浏览器控制台。 diff --git a/apps/web/tests/remote-welcome.e2e.ts b/apps/web/tests/remote-welcome.e2e.ts new file mode 100644 index 0000000000..483d6c369c --- /dev/null +++ b/apps/web/tests/remote-welcome.e2e.ts @@ -0,0 +1,53 @@ +// Trusted non-loopback Web access must not wedge on the loopback-only +// settings API while the mandatory product notice owns the viewport. +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { + acknowledgeReloadConnectionLoss, launchWebScaffold, watchConsole, webSnapshotMode, + type WebScaffold, +} from './scaffold.ts' +import { ZH_BROWSER_LOCALE } from './support.ts' +import { WELCOME_NOTICE_COPY } from '@deepseek-ai/dsh-client-ui-settings-general' + +const MODE = webSnapshotMode() + +describe.skipIf(MODE === 'record')('web e2e: remote welcome notice', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({ remoteAuthority: 'remote.localhost', welcomeNoticePending: true }) + browser = await chromium.launch() + page = await browser.newPage({ viewport: { width: 1440, height: 960 }, locale: ZH_BROWSER_LOCALE }) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('#root', { timeout: 30_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('advances process-locally and presents the notice again after reload', async () => { + const welcome = page.getByRole('region', { name: WELCOME_NOTICE_COPY.zh.title }) + await welcome.waitFor({ timeout: 15_000 }) + expect(await page.locator('#root').evaluate(root => (root as HTMLElement).inert)).toBe(true) + + await welcome.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel }).click() + await welcome.waitFor({ state: 'detached', timeout: 15_000 }) + await expect.poll( + () => page.locator('#root').evaluate(root => (root as HTMLElement).inert), + { timeout: 15_000 }, + ).toBe(false) + const reloadWarnings = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + acknowledgeReloadConnectionLoss(tripwire, reloadWarnings) + await welcome.waitFor({ timeout: 15_000 }) + expect(tripwire.warnings).toEqual([]) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) +}) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 1b8afbb247..62a1d31d0d 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -89,7 +89,7 @@ const REPLAY_PROVIDERS = [{ export interface WebScaffold { /** The active snapshot mode this scaffold booted under. */ mode: WebSnapshotMode - /** Browser-facing origin (http://127.0.0.1:). */ + /** Browser-facing origin for the bound test server. */ baseUrl: string /** Settled root context (the in-process barrier seam; headless event subscription is its sanctioned use). */ ctx: Context @@ -166,6 +166,8 @@ export interface LaunchOptions { } /** Leave the current welcome notice unacknowledged; ordinary scenarios publish it as complete before browser boot. */ welcomeNoticePending?: boolean + /** Browse through this trusted non-loopback hostname while the test server stays bound to loopback. */ + remoteAuthority?: string } /** Dispose the booted tree and remove both owned temp roots, reporting every independent cleanup failure. */ @@ -185,6 +187,7 @@ async function cleanupScaffoldWorld(ctx: Context, workspaceCwd: string, persiste export async function launchWebScaffold(options: LaunchOptions = {}): Promise { requireDist() const mode = webSnapshotMode() + const browserHost = options.remoteAuthority ?? '127.0.0.1' if (mode === 'record') { // Both owning vitest configs (web unconditionally, snapshot in record // mode) load the repo-root .env before this file runs. @@ -261,7 +264,13 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise /^\d{1,3}$/.test(part) && Number(part) <= 255) +} + +function welcomePersistence(): 'host' | 'memory' { + return typeof location === 'undefined' || isLoopbackHostname(location.hostname) ? 'host' : 'memory' +} + /** * Required services (cordis fiber inject). The target slots are declared by * ui-settings' apply, whose activation order relative to this one is NOT @@ -61,7 +73,7 @@ export function apply(ctx: ClientContext): void { // locale/change re-registration wiring. const t = ctx.locale.bind(NS) const connection = ctx.get('connection') as ConnectionHandle - const welcomeController = new WelcomeNoticeStore(connection.api) + const welcomeController = new WelcomeNoticeStore(connection.api, welcomePersistence()) const useWelcomeSnapshot = bindSnapshotSelector(welcomeController.store) const welcomeInjected = (): WelcomeNoticeInjected => ({ controller: welcomeController, diff --git a/packages/client/ui-settings-general/src/client/welcome-store.ts b/packages/client/ui-settings-general/src/client/welcome-store.ts index ad0e18305c..fdff28f052 100644 --- a/packages/client/ui-settings-general/src/client/welcome-store.ts +++ b/packages/client/ui-settings-general/src/client/welcome-store.ts @@ -1,4 +1,4 @@ -/** Durable welcome-notice state over the Host settings document. */ +/** Welcome-notice state, durable when the browser may use Host settings. */ import type { IApiClient, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client' import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' @@ -24,7 +24,7 @@ function acknowledgementOf(view: SettingsNamespaceView): string | undefined { return typeof value === 'string' ? value : undefined } -/** Coordinates welcome acknowledgement reads and the sole durable write. */ +/** Coordinates durable Host acknowledgement or a process-local remote fallback. */ export class WelcomeNoticeStore { /** uSES-safe state source shared by the registered welcome step. */ readonly store: SnapshotStore = createSnapshotStore({ @@ -33,12 +33,22 @@ export class WelcomeNoticeStore { private generation = 0 - /** @param api - settings wire face used for durable reads and writes. */ - constructor(private readonly api: Pick) {} + /** + * @param api - settings wire face used for durable reads and writes. + * @param persistence - remote browsers use memory because settings is loopback-only. + */ + constructor( + private readonly api: Pick, + private readonly persistence: 'host' | 'memory' = 'host', + ) {} - /** Load the current acknowledgement from the Host settings document. */ + /** Load the acknowledgement from Host settings or initialize process-local state. */ async load(): Promise { const generation = ++this.generation + if (this.persistence === 'memory') { + this.store.update((state) => { state.status = 'ready'; state.error = null }) + return + } this.store.update((state) => { state.status = 'loading'; state.error = null }) try { const response = await this.api.settings.describe({}) @@ -64,12 +74,20 @@ export class WelcomeNoticeStore { } /** - * Persist this copy version. The path mutation is idempotent across tabs and - * preserves every sibling setting; failure leaves the step unacknowledged. - * @returns true only when the Host committed the acknowledgement. + * Acknowledge this copy version. The Host path mutation is idempotent across + * tabs and preserves sibling settings; remote fallback changes only this store. + * @returns true when the selected persistence mode accepted the acknowledgement. */ async acknowledge(): Promise { const generation = ++this.generation + if (this.persistence === 'memory') { + this.store.update((state) => { + state.status = 'ready' + state.acknowledged = true + state.error = null + }) + return true + } this.store.update((state) => { state.status = 'saving'; state.error = null }) try { const response = await this.api.settings.mutate({ diff --git a/packages/client/ui-settings-general/tests/apply.spec.ts b/packages/client/ui-settings-general/tests/apply.spec.ts index 81a56a9a4a..17e4ced2b2 100644 --- a/packages/client/ui-settings-general/tests/apply.spec.ts +++ b/packages/client/ui-settings-general/tests/apply.spec.ts @@ -1,6 +1,6 @@ /** Ownerless-copy registrations: the four seats, the dictionaries, thunked labels, and HMR recovery. */ import { Context } from 'cordis' -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' @@ -16,6 +16,8 @@ import { WELCOME_NOTICE_SETTINGS_NAMESPACE } from '../src/onboarding-copy.ts' // the shipped Chinese copy, so they state the browser they assume. usePinnedBrowserLanguages('zh-CN') +afterEach(() => { vi.unstubAllGlobals() }) + /** The five seats this plugin fills (slot name → expected component). */ const SEATS = [ ['settings.trigger', TriggerContent], @@ -159,6 +161,20 @@ describe('ui-settings-general apply', () => { await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledTimes(3) }) }) + it('keeps remote welcome acknowledgement process-local', async () => { + vi.stubGlobal('location', { hostname: '192.0.2.20' }) + const b = await bench() + declare(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + const entry = b.slots.entries('settings.onboarding')[0]! + const { controller } = (entry.inject as unknown as () => WelcomeNoticeInjected)() + + await controller.load() + await expect(controller.acknowledge()).resolves.toBe(true) + expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true }) + expect(b.settingsDescribe).not.toHaveBeenCalled() + }) + it('re-registers after an HMR collapse of the declaring chain (stale disposers must not block)', async () => { const b = await bench() const redeclare = declare(b.slots) diff --git a/packages/client/ui-settings-general/tests/welcome-store.spec.ts b/packages/client/ui-settings-general/tests/welcome-store.spec.ts index 28c7b0509c..45e4ca5590 100644 --- a/packages/client/ui-settings-general/tests/welcome-store.spec.ts +++ b/packages/client/ui-settings-general/tests/welcome-store.spec.ts @@ -30,6 +30,21 @@ function deferred() { } describe('WelcomeNoticeStore', () => { + it('acknowledges in memory without calling loopback-only settings APIs', async () => { + const describe = vi.fn() + const mutate = vi.fn() + const controller = new WelcomeNoticeStore({ settings: { describe, mutate } } as never, 'memory') + + await controller.load() + expect(controller.store.getSnapshot()).toEqual({ status: 'ready', acknowledged: false, error: null }) + await expect(controller.acknowledge()).resolves.toBe(true) + expect(controller.store.getSnapshot()).toEqual({ status: 'ready', acknowledged: true, error: null }) + await controller.load() + expect(controller.store.getSnapshot()).toEqual({ status: 'ready', acknowledged: true, error: null }) + expect(describe).not.toHaveBeenCalled() + expect(mutate).not.toHaveBeenCalled() + }) + it('acknowledges only the exact current copy version', async () => { for (const [version, acknowledged] of [ [undefined, false], diff --git a/tsconfig.host.json b/tsconfig.host.json index 6e0860317c..a88257a8ff 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -23,6 +23,7 @@ "apps/web/tests/settings-chrome.e2e.ts", "apps/web/tests/models-settings.e2e.ts", "apps/web/tests/onboarding-deepseek-config.e2e.ts", + "apps/web/tests/remote-welcome.e2e.ts", "apps/web/tests/workspace-management.e2e.ts", "apps/web/tests/replay-round-trip.e2e.ts", "apps/web/tests/hmr-live.e2e.ts", From 7a71ab9a92cbb36f5e5e08fe6629ee245b84ac45 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Sun, 2 Aug 2026 13:24:49 -0700 Subject: [PATCH 080/129] fix(web): share loopback hostname policy --- .../client/connection/src/api-request-trust.ts | 9 +-------- packages/client/connection/src/client/index.ts | 2 ++ .../client/connection/src/loopback-hostname.ts | 12 ++++++++++++ .../connection/tests/loopback-hostname.spec.ts | 18 ++++++++++++++++++ .../ui-settings-general/src/client/index.ts | 10 +--------- 5 files changed, 34 insertions(+), 17 deletions(-) create mode 100644 packages/client/connection/src/loopback-hostname.ts create mode 100644 packages/client/connection/tests/loopback-hostname.spec.ts diff --git a/packages/client/connection/src/api-request-trust.ts b/packages/client/connection/src/api-request-trust.ts index 8c1bddd631..ecb180dca7 100644 --- a/packages/client/connection/src/api-request-trust.ts +++ b/packages/client/connection/src/api-request-trust.ts @@ -14,6 +14,7 @@ */ import type { IncomingHttpHeaders } from 'node:http' +import { isLoopbackHostname } from './loopback-hostname.ts' /** The request facts the fence reads (structural subset of IncomingMessage). */ interface ApiTrustRequest { @@ -25,14 +26,6 @@ function header(headers: IncomingHttpHeaders, name: string): string | undefined return typeof value === 'string' ? value : undefined } -function isLoopbackHostname(hostname: string): boolean { - if (hostname === 'localhost' || hostname === '[::1]') return true - const parts = hostname.split('.') - return parts.length === 4 - && parts[0] === '127' - && parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255) -} - /** Normalized URL of a Host-header authority (hostname lowercased, default port stripped, IPv6 bracketed), or undefined when unparsable. */ function parseAuthority(authority: string): URL | undefined { try { diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index a7ebfbbd86..daa20f1672 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -9,6 +9,8 @@ import { ConnectionController, type ConnectionConfig, type ConnectionSinks, type import { FixtureApiClient } from './fixture.ts' import { WebApiClient } from './web-api-client.ts' +export { isLoopbackHostname } from '../loopback-hostname.ts' + // ---- Contract re-exports (browser-safe apiproxy channels + core types) ---- export type { ApiProxy, SessionsApi, SessionSearchItem, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame, diff --git a/packages/client/connection/src/loopback-hostname.ts b/packages/client/connection/src/loopback-hostname.ts new file mode 100644 index 0000000000..8fd30445bd --- /dev/null +++ b/packages/client/connection/src/loopback-hostname.ts @@ -0,0 +1,12 @@ +/** + * Whether a normalized URL hostname names the local loopback authority. + * @param hostname - WHATWG URL hostname (IPv6 literals retain brackets). + * @returns true for localhost, IPv6 loopback, or any IPv4 address in 127/8. + */ +export function isLoopbackHostname(hostname: string): boolean { + if (hostname === 'localhost' || hostname === '[::1]') return true + const parts = hostname.split('.') + return parts.length === 4 + && parts[0] === '127' + && parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255) +} diff --git a/packages/client/connection/tests/loopback-hostname.spec.ts b/packages/client/connection/tests/loopback-hostname.spec.ts new file mode 100644 index 0000000000..d0eaf3e0c4 --- /dev/null +++ b/packages/client/connection/tests/loopback-hostname.spec.ts @@ -0,0 +1,18 @@ +/** Shared loopback-hostname semantics for the Host fence and browser UI. */ + +import { describe, expect, it } from 'vitest' +import { isLoopbackHostname } from '../src/loopback-hostname.ts' + +describe('isLoopbackHostname', () => { + it('accepts localhost, IPv6 loopback, and the whole IPv4 127/8 block', () => { + for (const hostname of ['localhost', '[::1]', '127.0.0.1', '127.8.9.10', '127.255.255.255']) { + expect(isLoopbackHostname(hostname)).toBe(true) + } + }) + + it('refuses malformed and non-loopback hostnames', () => { + for (const hostname of ['remote.localhost', '::1', '128.0.0.1', '127.0.0', '127.0.0.256', '127.0.0.-1']) { + expect(isLoopbackHostname(hostname)).toBe(false) + } + }) +}) diff --git a/packages/client/ui-settings-general/src/client/index.ts b/packages/client/ui-settings-general/src/client/index.ts index f2449391ce..c0c0fb569f 100644 --- a/packages/client/ui-settings-general/src/client/index.ts +++ b/packages/client/ui-settings-general/src/client/index.ts @@ -7,7 +7,7 @@ */ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots' -import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' +import { isLoopbackHostname, type ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' // Type-only: pulls the shell's SlotMap merges (trigger/header/section/item). import type {} from '@deepseek-ai/dsh-client-ui-settings/client' @@ -41,14 +41,6 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Dictionary namespace owned by this plugin (shell chrome + General copy). */ const NS = 'settings' -function isLoopbackHostname(hostname: string): boolean { - if (hostname === 'localhost' || hostname === '[::1]') return true - const parts = hostname.split('.') - return parts.length === 4 - && parts[0] === '127' - && parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255) -} - function welcomePersistence(): 'host' | 'memory' { return typeof location === 'undefined' || isLoopbackHostname(location.hostname) ? 'host' : 'memory' } From 57343cfeb8eaa3bea4151b4d59a54509a914483a Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Sun, 2 Aug 2026 13:30:53 -0700 Subject: [PATCH 081/129] fix(web): expose loopback policy safely --- packages/client/connection/package.json | 1 + packages/client/connection/src/client/index.ts | 2 -- packages/client/tsdown.client.ts | 2 +- packages/client/ui-settings-general/src/client/index.ts | 3 ++- scripts/client-bundle-purity.spec.ts | 1 + 5 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index d86b2bdf2a..f2842ec061 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -19,6 +19,7 @@ "types": "./lib/types/client/index.d.ts", "default": "./lib/client.js" }, + "./loopback-hostname": "./src/loopback-hostname.ts", "./src/*": "./src/*", "./package.json": "./package.json" }, diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index daa20f1672..a7ebfbbd86 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -9,8 +9,6 @@ import { ConnectionController, type ConnectionConfig, type ConnectionSinks, type import { FixtureApiClient } from './fixture.ts' import { WebApiClient } from './web-api-client.ts' -export { isLoopbackHostname } from '../loopback-hostname.ts' - // ---- Contract re-exports (browser-safe apiproxy channels + core types) ---- export type { ApiProxy, SessionsApi, SessionSearchItem, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame, diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index 2ff1856b3d..fad00fe4f0 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -28,7 +28,7 @@ const CSS_VIRTUAL_SUFFIX = '.mjs' * Everything else under @deepseek-ai/* is either a module-table entry * (external) or a leak the purity gate rejects. */ -export const INLINE_SAFE = /^@deepseek-ai\/dsh-(host-apiproxy|session|llm|tools|brand)(\/|$)/ +export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:host-apiproxy|session|llm|tools|brand)(?:\/|$)|@deepseek-ai\/dsh-client-connection\/loopback-hostname$)/ /** * Documented TEMPORARY exemption, not a platform module (hence not in diff --git a/packages/client/ui-settings-general/src/client/index.ts b/packages/client/ui-settings-general/src/client/index.ts index c0c0fb569f..6a7510d4ac 100644 --- a/packages/client/ui-settings-general/src/client/index.ts +++ b/packages/client/ui-settings-general/src/client/index.ts @@ -7,7 +7,8 @@ */ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots' -import { isLoopbackHostname, type ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' +import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' +import { isLoopbackHostname } from '@deepseek-ai/dsh-client-connection/loopback-hostname' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' // Type-only: pulls the shell's SlotMap merges (trigger/header/section/item). import type {} from '@deepseek-ai/dsh-client-ui-settings/client' diff --git a/scripts/client-bundle-purity.spec.ts b/scripts/client-bundle-purity.spec.ts index d70964bdba..2eef1ba5ef 100644 --- a/scripts/client-bundle-purity.spec.ts +++ b/scripts/client-bundle-purity.spec.ts @@ -53,6 +53,7 @@ describe('client bundle purity gate', () => { expect(resolveId('@deepseek-ai/dsh-host-apiproxy/api')).toBeNull() expect(resolveId('@deepseek-ai/dsh-session/surface')).toBeNull() expect(resolveId('@deepseek-ai/dsh-brand')).toBeNull() + expect(resolveId('@deepseek-ai/dsh-client-connection/loopback-hostname')).toBeNull() }) it('throws on any other @deepseek-ai leak', () => { From b85c0a1851229595eacd73505159473378d8169e Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 3 Aug 2026 10:30:32 +0800 Subject: [PATCH 082/129] fix(ui-workspace): draw Archive session with the archive-box glyph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The session row menu's Archive action carried ic_ds_download_outline_16, a download tray that reads as "save to disk" rather than "put away". Swap it for the design's archive glyph — a lidded box with a label slot — added to the ic_ds_* set as IconArchiveOutline20. The glyph is native 20, the first of that size in the set, so the menu's 16px icon slot asks for it explicitly. The figma export's 0.11px stroke ring around the box contour is dropped: it restates the same contour in the same ink, which currentColor already carries. --- packages/client/ui-primitives/src/icons/index.tsx | 15 +++++++++++++++ packages/client/ui-primitives/src/icons/props.ts | 2 +- .../client/ui-primitives/tests/icons.spec.tsx | 10 ++++++---- .../client/ui-workspace/src/client/rows/Rows.tsx | 5 +++-- 4 files changed, 25 insertions(+), 7 deletions(-) diff --git a/packages/client/ui-primitives/src/icons/index.tsx b/packages/client/ui-primitives/src/icons/index.tsx index 830ff4f642..74e3e757b2 100644 --- a/packages/client/ui-primitives/src/icons/index.tsx +++ b/packages/client/ui-primitives/src/icons/index.tsx @@ -728,3 +728,18 @@ export const IconQuestionOutline14 = ({ size = 14, className }: IconProps) => ( ) + +/** ic_ds_archive_outline_20 (figma extract): lidded box + label slot. The export's + * 0.11px stroke ring around the box contour is dropped — it restates the same + * contour in the same ink, which currentColor already carries. */ +export const IconArchiveOutline20 = ({ size = 20, className }: IconProps) => ( + + + + +) diff --git a/packages/client/ui-primitives/src/icons/props.ts b/packages/client/ui-primitives/src/icons/props.ts index 59b10c8492..d86245b550 100644 --- a/packages/client/ui-primitives/src/icons/props.ts +++ b/packages/client/ui-primitives/src/icons/props.ts @@ -1,6 +1,6 @@ /** Shared props for every ic_ds_* icon component. */ export interface IconProps { - /** Square edge in px; defaults to the glyph's native size (14 or 16). */ + /** Square edge in px; defaults to the glyph's native size (14, 16, or 20). */ size?: number | undefined /** Extra class for layout placement; color rides currentColor. * (`| undefined` for exactOptionalPropertyTypes: callers forward their own optional prop.) */ diff --git a/packages/client/ui-primitives/tests/icons.spec.tsx b/packages/client/ui-primitives/tests/icons.spec.tsx index 536d1b774f..fbc4b71e28 100644 --- a/packages/client/ui-primitives/tests/icons.spec.tsx +++ b/packages/client/ui-primitives/tests/icons.spec.tsx @@ -2,7 +2,7 @@ import { cleanup, render } from '@testing-library/react' import { afterEach, describe, expect, it } from 'vitest' import * as primitives from '@deepseek-ai/dsh-client-ui-primitives' -import { IconApiOutline14, IconFolderClose16, IconSendOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconApiOutline14, IconArchiveOutline20, IconFolderClose16, IconSendOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' afterEach(cleanup) @@ -14,8 +14,8 @@ const icons = Object.fromEntries( const iconNames = Object.keys(icons) describe('ic_ds_ icon set', () => { - it('exports the full P-I set (45 deepsuite + 14 figma extracts + the hand-authored sparkle)', () => { - expect(iconNames.length).toBe(60) + it('exports the full P-I set (45 deepsuite + 15 figma extracts + the hand-authored sparkle)', () => { + expect(iconNames.length).toBe(61) }) it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', (name) => { @@ -36,11 +36,13 @@ describe('ic_ds_ icon set', () => { expect(svg.classList.contains('x')).toBe(true) }) - it('native defaults: 14-glyphs default 14, 16-glyphs default 16', () => { + it('native defaults: 14-glyphs default 14, 16-glyphs default 16, 20-glyphs default 20', () => { const api = render() expect(api.container.querySelector('svg')!.getAttribute('width')).toBe('14') const folder = render() expect(folder.container.querySelector('svg')!.getAttribute('width')).toBe('16') + const archive = render() + expect(archive.container.querySelector('svg')!.getAttribute('width')).toBe('20') }) }) diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index 27a105df30..ab92e671ea 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -8,7 +8,7 @@ import { useState } from 'react' import clsx from 'clsx' import { - HoverCard, IconBranchOutline16, IconDownloadOutline16, IconEditOutline16, + HoverCard, IconArchiveOutline20, IconBranchOutline16, IconEditOutline16, IconEllipsisOutline16, IconFolderClose16, IconFolderOpen16, IconPlusOutline16, IconTrashOutline16, IconTriangleRightFill14, Menu, StateDot, } from '@deepseek-ai/dsh-client-ui-primitives' @@ -273,7 +273,8 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork const sessionMenuItems = [ { id: 'rename', label: t('rename'), icon: }, { id: 'fork', label: t('menu.fork'), icon: }, - { id: 'archive', label: t('menu.archiveSession'), icon: }, + // 20-native glyph in the menu's 16px icon slot (Menu.module.css .itemIcon). + { id: 'archive', label: t('menu.archiveSession'), icon: }, ] // Figma session cell: pad 8, status slot 16, then a 4px title gap. const ownRow = ( From 3f34a72b661782b9e2f35b5182d86a4b00f15445 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 3 Aug 2026 10:42:34 +0800 Subject: [PATCH 083/129] review(ui-primitives): stop the size doc from enumerating native sizes The parenthetical listed 14/16 and this branch added 20, but the set never held to it: IconRightUpOutline14 defaults to 8, IconTreeCorner8x10 to 10, and IconWarningOutline16 to 14. Drop the list rather than maintain one that drifts, and retitle the icons test that carried the same generalization. --- packages/client/ui-primitives/src/icons/props.ts | 2 +- packages/client/ui-primitives/tests/icons.spec.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/client/ui-primitives/src/icons/props.ts b/packages/client/ui-primitives/src/icons/props.ts index d86245b550..27dde23e4d 100644 --- a/packages/client/ui-primitives/src/icons/props.ts +++ b/packages/client/ui-primitives/src/icons/props.ts @@ -1,6 +1,6 @@ /** Shared props for every ic_ds_* icon component. */ export interface IconProps { - /** Square edge in px; defaults to the glyph's native size (14, 16, or 20). */ + /** Square edge in px; defaults to the glyph's own drawn size. */ size?: number | undefined /** Extra class for layout placement; color rides currentColor. * (`| undefined` for exactOptionalPropertyTypes: callers forward their own optional prop.) */ diff --git a/packages/client/ui-primitives/tests/icons.spec.tsx b/packages/client/ui-primitives/tests/icons.spec.tsx index fbc4b71e28..cc5175cba4 100644 --- a/packages/client/ui-primitives/tests/icons.spec.tsx +++ b/packages/client/ui-primitives/tests/icons.spec.tsx @@ -36,7 +36,7 @@ describe('ic_ds_ icon set', () => { expect(svg.classList.contains('x')).toBe(true) }) - it('native defaults: 14-glyphs default 14, 16-glyphs default 16, 20-glyphs default 20', () => { + it('each glyph defaults to its own drawn size, not one set-wide default', () => { const api = render() expect(api.container.querySelector('svg')!.getAttribute('width')).toBe('14') const folder = render() From 79072e356c3396886c6eb1e769b5ebb8fed721e8 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 3 Aug 2026 11:24:35 +0800 Subject: [PATCH 084/129] fix(directory-picker-browse): advertise the path editor and walk the panes with the draft MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Select Workspace Directory dialog hid its one route into typing a path behind an invisible click target, and once the editor opened the panes stayed on whatever level was listed when it opened — so the typed text and the list under it disagreed for the whole edit. The edit zone now carries a pencil glyph at the bar's right edge and lights in the editor's own footprint on hover/focus (the bar keeps one height across the swap). While editing, the panes follow the draft: a directory part no pane lists is scanned after a 250ms rest and lands in place, so typing deeper descends and erasing segments steps back up without leaving the editor, and a final segment nobody matches releases the prefix filter instead of emptying the pane it is being spelled into. The draft-following scan is speculative and silent on failure; Enter still owns the view from submission until landing and remains the only path that surfaces an error. --- ...directory-picker-capability-seam.i18n.yaml | 4 +- ...-07-28-directory-picker-capability-seam.md | 4 + ...-28-directory-picker-capability-seam.zh.md | 4 + .../directory-browser.expected.md | 3 +- apps/web/tests/workspace-management.e2e.ts | 35 +++- .../directory-picker-browse/README.i18n.yaml | 4 +- .../host/directory-picker-browse/README.md | 2 +- .../host/directory-picker-browse/README.zh.md | 2 +- .../src/client/DirectoryBrowser.module.css | 46 ++++- .../src/client/DirectoryBrowser.tsx | 184 +++++++++++++++--- .../tests/directory-browser.spec.tsx | 102 +++++++++- 11 files changed, 345 insertions(+), 45 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml index 6855a0af2b..4342f49f2b 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.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 .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md -2026-07-28-directory-picker-capability-seam.md: 9884385cf9e0d51604bab9e4fd3c4bee77448331 -2026-07-28-directory-picker-capability-seam.zh.md: 8c229b9fb08d5052ba8a512f2153a89a9e5fd455 +2026-07-28-directory-picker-capability-seam.md: 90aa8bc7cfc0dc0fb3d057b9991682c9b531ea23 +2026-07-28-directory-picker-capability-seam.zh.md: 12917c95456bca9cdd5e20ae97847156af81277e diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md index 9884385cf9..90aa8bc7cf 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md @@ -20,6 +20,7 @@ Placement and policy rulings folded into this decision: - **Dependency survey (hand-roll vs adopt).** Node's stdlib *is* the maintained cross-platform OS layer (`readdir(withFileTypes)`, `homedir`, path semantics); surveyed alternatives fail the dependency bar — file-manager packages (`node-file-manager`, `files-and-folders`, Syncfusion's provider) are whole HTTP apps (fit), drive-letter helpers (`drivelist` native addon, `windows-drive-letters` ~7y stale) fail health/proportionality. The browse backend is a thin adapter over stdlib. - **Hidden entries: return-and-flag.** The host stamps `hidden` (POSIX dot convention) and returns everything; the client filters. Display policy stays client-side, and the show-hidden toggle shipped as exactly that client-only change: a fixed-label footer toggle whose state lives in the pressed presentation (`aria-pressed` + check glyph), a dot-led path-draft prefix reveals the hidden entries it names, and the current selection is exempt from both the hidden and the prefix filter (it anchors the two-pane view). Windows' `FILE_ATTRIBUTE_HIDDEN` is not exposed by dirents — documented limitation until a native probe pays for itself. - **Path-editor cancel scope: the dialog card.** The browse client's path editor cancels on Escape and on focus leaving the card, both observed at a card-scope wrapper rather than the input — after Tab parks focus on a filtered row the input is off the event path, yet Escape must collapse the editor (not the dialog) and a later focus departure must still cancel. Non-cancel exemptions: window/tab focus loss, in-card focus moves, and pointer paths (rows and the toggle suppress focus steal on mousedown while editing). Separators for seeding and draft-tail filtering are inferred from `listing.home`; the wire-field alternative below records the deferred authoritative form. Combobox semantics between the editor and the list it filters (`aria-expanded`/`aria-controls`/active-descendant, result announcements) are likewise deferred — today they read to assistive tech as separate widgets. +- **The path editor advertises itself, and the panes follow the draft.** The click-to-edit zone is not invisible: a pencil glyph sits at the bar's right edge and hover/focus lights the zone in the editor's own footprint, so the one route into typing a path is discoverable and the bar does not resize when zone and input swap. While the editor is open the panes track the draft instead of whatever level happened to be listed when it opened — the final segment prefix-filters the level its directory part names, a tail nobody matches releases the filter (a name still being spelled must not empty the pane it is being spelled into), and a directory part no pane lists is scanned after a 250ms rest and lands single-wide in place, so typing deeper descends and erasing segments steps back up without leaving the editor. That scan is speculative — half-typed directories are unreadable most of the time — so a failure keeps the last readable panes and stays silent. Enter remains the authoritative commit: it owns the view from submission until landing (a debounce timer armed by the same keystrokes is held back rather than superseding the navigation, and a rejected submission stays held until the next edit) and it alone surfaces the failure. - **Navigation lands selection-anchored, quiet, and bounded.** Away from the display root (the same collapse the crumb header renders, so crumbs and pane shape never disagree), the landing is two-pane: the target's actual parent-level entry re-selected (platform case folding on Windows), its children on the right, so a crumb jump reads as stepping back one pane rather than collapsing to a single column. Target and parent legs land as **one frame** when the parent leg settles within the 200ms wait bound — the stale view keeps rendering until then, so navigation swaps the panes without an intermediate single-pane flash — and past the bound the target commits alone at once (an Enter-submitted navigation is never held hostage by a stalled parent) with the late parent leg upgrading the landing in place. The parent leg runs under the landing's supersession scope and is aborted on the wire by any newer intent (Escape inside the landing window therefore withdraws the whole navigation); a failed parent leg, or a truncated parent window lacking the target, leaves the single-pane landing — the upgrade must never orphan the selection it exists to anchor. The loading indicator follows the same quiet rule: it floats over the content's bottom-right corner (never a layout-shifting row; the truncated/error rows own the bottom left and keep rendering through a scan) and only once a scan outlives a 300ms silence window, so a local listing swaps with nothing shown at all. Row picks are deliberately exempt from the one-frame rule: a pick's immediate pane split is its selected-state feedback (aria-current, crumbs following), while a navigation has nothing to acknowledge the click but the swap itself. Both timing constants are calibrated for local enumeration; a remote deployment (one RPC per level, commonly 100–400ms) would sit inside the silence window with no pressed state on the crumbs — revisit the window or add pressed feedback when a remote consumer lands. - **Symlinks: follow for enterability.** `stat` probes symlinks (broken/cyclic → skipped); crumbs keep the logical path the operator navigated, and `workspace.create` already canonicalizes via realpath at adoption. - **Listing levels are bounded, and streamed.** One `list` call returns at most `maxEntries` rows (config, default 1000 — GitHub's web-UI directory-listing bound). The level streams via `opendir` into a name-sorted window of `maxEntries + 1` candidates, so memory stays O(maxEntries) and enterability probing touches only windowed candidates; the wire `DirectoryListing` carries a required `truncated` flag so the client states incompleteness instead of silently missing tail entries. A windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated. Window insertion is binary with an O(1) full-window tail rejection (an oversized level must not pay a window scan per dirent), and `list(path, signal)` threads the carrier's request signal so a scan of a stalled network directory cannot outlive a disconnected caller — every await in the scan (open, each read, each symlink probe) races the signal, an aborted exit abandons rather than awaits the close (Node queues close behind in-flight reads), and abandoned settlements are swallowed so cleanup can never surface as an unhandled rejection. An unbounded level is a memory/responsiveness hole for large or adversarial directories. @@ -34,6 +35,9 @@ Placement and policy rulings folded into this decision: - **Adopting a file-manager/drive-enumeration dependency.** Rejected per the survey above; recorded here as the dependency policy requires. - **A flip-label show-hidden toggle ("Hide hidden files").** Rejected: a flipping action label is ambiguous between state and action and doubles the negative; the fixed label with a pressed presentation states both at once. - **Pure relatedTarget blur cancellation (no mousedown suppression).** Rejected: Safari does not focus buttons on pointer down, so a click's focusout carries a null `relatedTarget` and would cancel the editor before the click lands; editing-scoped mousedown suppression plus the card-anchored relatedTarget guard covers pointer and keyboard paths together. +- **A permanently visible path input above the Miller view.** Rejected: the breadcrumb is already the "where am I" reading, and a second always-present field duplicates it while costing a row of a 500px card that the columns need. The glyph plus the hover-lit zone puts the affordance on the bar that already answers the question. +- **Scanning the draft on every keystroke, or only on Enter.** Per keystroke: walking one path segment issues a listing per character, most of them for directories the operator is typing through, not at. Only on Enter (what shipped first): the panes and the typed text disagreed for the whole edit — the complaint this bullet answers. The 250ms rest keeps one scan per directory the typing actually settles on. +- **Emptying a pane on a prefix miss (what shipped first).** Rejected: mid-name the miss is the normal state, so the pane blanked exactly while the operator needed it to confirm the name; releasing the filter keeps the level readable and costs only the transient wideness. - **A wire `separator` field on `DirectoryListing` (host stamps `path.sep`).** Deferred, not rejected: it is the authoritative form — a POSIX home directory containing a backslash defeats the `listing.home` heuristic — but it touches the seam type and every backend; the browse client's `separatorOf` carries a TODO pointing at this alternative until a wire change is next scheduled. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md index 8c229b9fb0..12917c9545 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md @@ -20,6 +20,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick - **依赖调研(手写 vs 引入)。** Node 标准库本身就是维护中的跨平台 OS 层(`readdir(withFileTypes)`、`homedir`、路径语义);调研过的替代品都过不了依赖门槛——文件管理器包(`node-file-manager`、`files-and-folders`、Syncfusion 的 provider)是整套 HTTP 应用(契合度不过),盘符工具(原生插件 `drivelist`、约七年未更的 `windows-drive-letters`)健康度/比例失当。browse 后端是标准库上的薄适配。 - **隐藏条目:返回并打标。** 宿主标注 `hidden`(POSIX 点前缀约定)并返回全部条目;客户端过滤。展示策略留在客户端,"显示隐藏"开关正是作为这一纯客户端改动落地:标签固定的 footer 开关,其状态由按下态呈现承载(`aria-pressed` + 勾选符号);以点开头的路径草稿前缀会显出它所指名的隐藏条目;当前选中项则不受隐藏与前缀两种过滤影响(它锚定着双栏视图)。Windows 的 `FILE_ATTRIBUTE_HIDDEN` 不被 dirent 暴露——记为限制,直到原生探测值回其成本。 - **路径编辑器的取消范围:对话框卡片。** browse 客户端的路径编辑器在按 Escape 与焦点离开卡片时取消,两者都在卡片范围的包装层而非输入框上监听——Tab 把焦点停到某个过滤命中的行之后,输入框已不在事件路径上,但 Escape 仍须收起编辑器(而非对话框),其后的焦点离开也仍须取消。不取消的豁免:窗口/标签页失焦、卡片内焦点移动,以及指针路径(编辑期间行与开关在 mousedown 时抑制焦点夺取)。预填与草稿末段过滤所用的分隔符从 `listing.home` 推断;下文的线上字段替代方案记录了被延期的权威形态。编辑器与其过滤的列表之间的 combobox 语义(`aria-expanded`/`aria-controls`/active-descendant、结果播报)同样被延期——目前二者在辅助技术看来是彼此独立的控件。 +- **路径编辑器自我点明,各栏跟随草稿。** 点击即编辑的区域不再是隐形的:栏右端坐着一枚铅笔图标,悬停/聚焦时该区以编辑器自身的轮廓亮起,于是键入路径这唯一入口可被发现,且区域与输入框互换时栏高不变。编辑器打开期间,各栏跟随草稿,而不是停在它打开那一刻恰好列出的层级——末段对其目录部分所指的层级做前缀过滤,无一匹配的末段解除过滤(还在拼写中的名字不该把正在拼写它的那一栏清空),而任何一栏都未列出的目录部分会在停顿 250ms 后被扫描、以单宽栏就地落地,于是继续键入即下潜、删掉末段即上退,全程不必离开编辑器。该扫描是推测性的——键入到一半的目录多数时候读不出来——因此失败时保留最后一次可读的分栏并保持沉默。Enter 仍是权威提交:自提交至落地由它独占视图(同一批按键武装的防抖计时器会被扣住,而不是顶掉这次导航;提交被拒后仍扣住,直到下一次编辑),也只有它把失败呈现出来。 - **导航以选中项为锚、安静且有界地落地。** 在展示根之外(与 crumb 头部渲染的是同一塌缩,因此 crumb 与分栏形态永不相左),落地即双栏:重新选中目标在父层级中的实际条目(Windows 上按平台惯例折叠大小写),右侧展示其子项,因此 crumb 跳转读作后退一栏,而不是塌缩成单列。父层级这一程在 200ms 等待上限内落定时,目标与父层级两程以**同一帧**落地——在此之前陈旧视图持续渲染,导航换栏时因此没有中间的单栏闪现——超出该上限则目标即刻单独提交(Enter 提交的导航绝不会被滞塞的父层级扣作人质),迟到的父层级这一程再就地升级这次落地。父层级这一程在落地的 supersession 范围下运行,任何较新的意图都会在线上将其中止(因此在落地窗口内按 Escape 即撤回整次导航);父层级这一程失败,或被截断的父窗口缺少目标时,都保留单栏落地——升级的存在正是为了锚定选中项,绝不能反而让它悬空。加载指示器遵循同一安静规则:它浮于内容右下角(绝不是会挪动布局的一行;截断/错误行占据左下角,并在扫描期间持续渲染),且仅在扫描超出 300ms 静默窗口后才出现,因此本地列举切换时什么也不显示。行选取被刻意豁免于同一帧规则:选取后立即分栏本身就是其选中态反馈(aria-current、crumb 跟随),而导航除了换栏本身没有任何东西可确认这次点击。两个时序常量都按本地列举校准;远程部署(每层级一次 RPC,通常 100–400ms)会落在静默窗口之内、crumb 上却没有按下态——待远程消费方落地时,重新审视该窗口或补上按下反馈。 - **符号链接:为可进入性而跟随。** 用 `stat` 探测符号链接(断链/循环→跳过);面包屑保留操作者导航的逻辑路径,`workspace.create` 在接纳时本就做 realpath 规范化。 - **列举层级有上限,且流式处理。** 单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端目录列举的同一上限)。层级经 `opendir` 流入一个按名排序、容量 `maxEntries + 1` 的候选窗口,内存保持 O(maxEntries),可进入性探测只触及窗口内候选;线上 `DirectoryListing` 携带必填的 `truncated` 标志,让客户端明示不完整而不是静默缺尾。窗口内的断链符号链接不从窗口外回填——发生过驱逐本身已把层级标记为截断。窗口插入为二分查找、满窗尾部单次比较即拒绝(超大层级不能为每个 dirent 付出一次全窗扫描),且 `list(path, signal)` 透传载体的请求信号,滞塞网络目录的扫描不会在调用方断连后继续存活——扫描中的每个 await(打开、每次读取、每次符号链接探测)都与信号赛跑,中止路径放弃而非等待 close(Node 会把 close 排在在飞读取之后),被放弃的 settlement 全部吞掉,清理不会以未处理拒绝的形式冒出。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。 @@ -34,6 +35,9 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick - **引入文件管理器/盘符枚举依赖。** 按上文调研否决;依赖政策要求记录于此。 - **动作标签随状态翻转的"显示隐藏"开关("隐藏隐藏文件")。** 否决:会翻转的动作标签在状态与动作之间有歧义,还把否定叠了两层;固定标签加按下态呈现一次说清两者。 - **纯 relatedTarget 失焦取消(不做 mousedown 抑制)。** 否决:Safari 在指针按下时不给按钮聚焦,点击触发的 focusout 因而携带空 `relatedTarget`,会在点击落地前就取消编辑器;编辑期作用的 mousedown 抑制加上锚定卡片的 relatedTarget 守卫才能同时覆盖指针与键盘路径。 +- **在 Miller 视图上方常驻一个路径输入框。** 否决:面包屑本就在回答"我在哪儿",再常驻一个字段是重复回答,还要从 500px 卡片里挪走一行——那是列需要的高度。图标加悬停亮起的区域,把这个入口放在了已经回答该问题的那一栏上。 +- **每敲一个键就扫描草稿,或只在 Enter 时扫描。** 每键扫描:走完一段路径就是每个字符一次列举,其中多数目录操作者只是路过而非停留。只在 Enter 时扫描(最初落地的行为):整个编辑过程中各栏与所键入文本各说各话——正是本条所回应的抱怨。250ms 的停顿把扫描收敛为"键入真正停下来的每个目录一次"。 +- **前缀无一匹配时清空该栏(最初落地的行为)。** 否决:名字敲到一半时"无匹配"才是常态,于是恰恰在操作者需要它确认名字时把栏清空了;解除过滤保住了层级的可读性,代价只是短暂的宽松。 - **在 `DirectoryListing` 上增设线上 `separator` 字段(宿主标注 `path.sep`)。** 延期而非否决:它才是权威形态——含反斜杠的 POSIX 家目录会击穿 `listing.home` 启发式——但它触及 seam 类型与每个后端;browse 客户端的 `separatorOf` 挂着指向本方案的 TODO,直到下次安排线上变更。 ## 后果 diff --git a/apps/web/tests/snapshots/workspace-management/directory-browser.expected.md b/apps/web/tests/snapshots/workspace-management/directory-browser.expected.md index baaaa6f3dc..47957dab82 100644 --- a/apps/web/tests/snapshots/workspace-management/directory-browser.expected.md +++ b/apps/web/tests/snapshots/workspace-management/directory-browser.expected.md @@ -4,7 +4,8 @@ - button "Home" - img - button "browse-golden" - - button "Edit path" + - button "Edit path": + - img - list: - listitem: - button "adopted": diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index 1ffcf6f490..1a5531f59a 100644 --- a/apps/web/tests/workspace-management.e2e.ts +++ b/apps/web/tests/workspace-management.e2e.ts @@ -1,6 +1,7 @@ // Web e2e scenarios: workspace management — adding a workspace through the // composed directory dialog (its own New folder affordance is the product's -// one creation route), same-basename directory adoption, the rename round +// one creation route), the dialog's path editor walking the panes with the +// typed draft, same-basename directory adoption, the rename round // trip over the real wire (workspace.rename RPC + durable registry), the // duplicate-name pre-check, the // flat "In one list" view with its persisted group-by preference, the session @@ -12,7 +13,7 @@ // seeded-history seed reused verbatim — no new recording). import { mkdir, readFile, stat, writeFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' -import { join } from 'node:path' +import { join, sep } from 'node:path' import type { Browser, Locator, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' @@ -403,6 +404,36 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff expect(tripwire.pageErrors).toEqual([]) }, 60_000) + it('walks the panes with the typed path: deeper past a separator, back up on erase, whole on a miss', async () => { + // The panes must track the draft without leaving the editor, so the + // typed text and what is listed under it never disagree. + const staged = join(scaffold.workspaceCwd, 'browse-golden') + await mkdir(join(staged, 'alpha', 'only-under-alpha'), { recursive: true }) + const dialog = await browseTo(staged) + await expect.poll(() => dialog.getByText('alpha', { exact: true }).count(), { timeout: 10_000 }).toBe(1) + await dialog.getByRole('button', { name: 'Edit path' }).click() + const path = dialog.getByLabel('Edit path') + // A directory part no pane lists: the panes follow it and keep the editor. + await path.fill(`${join(staged, 'alpha')}${sep}`) + await expect.poll(() => dialog.getByText('only-under-alpha', { exact: true }).count(), { timeout: 10_000 }).toBe(1) + // The editor is still up with the draft intact: the panes moved under it. + expect(await path.inputValue()).toBe(`${join(staged, 'alpha')}${sep}`) + // Erasing back past the separator steps the panes up, the tail filtering + // the level it returns to. + await path.fill(`${staged}${sep}al`) + await expect.poll(() => dialog.getByText('alpha', { exact: true }).count(), { timeout: 10_000 }).toBe(1) + expect(await dialog.getByText('beta', { exact: true }).count()).toBe(0) + expect(await dialog.getByText('only-under-alpha', { exact: true }).count()).toBe(0) + // A tail nobody matches is a name still being spelled: the level shows + // whole instead of emptying under it. + await path.fill(`${staged}${sep}zzz`) + await expect.poll(() => dialog.getByText('beta', { exact: true }).count(), { timeout: 10_000 }).toBe(1) + expect(await dialog.getByText('alpha', { exact: true }).count()).toBe(1) + await dialog.getByRole('button', { name: 'Cancel' }).click() + await dialog.waitFor({ state: 'hidden', timeout: 10_000 }) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + /** * Expand Ungrouped and return its seeded session row. The only visible child * is the non-blank persisted Session; the blank Session created while diff --git a/packages/host/directory-picker-browse/README.i18n.yaml b/packages/host/directory-picker-browse/README.i18n.yaml index 673d053e3a..4063c7d692 100644 --- a/packages/host/directory-picker-browse/README.i18n.yaml +++ b/packages/host/directory-picker-browse/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/host/directory-picker-browse/README.md -README.md: 52b5fe7e89f915be3b50324628e9d5c48f1ef94c -README.zh.md: 742da39470083887a71ddba4a7c8012f0ce0ea1f +README.md: 7cb0ec785766e954ff4bb39df6825ee7e8c9d821 +README.zh.md: ec71a90bbcd004ec9f9c0d8a9a236882a7487b73 diff --git a/packages/host/directory-picker-browse/README.md b/packages/host/directory-picker-browse/README.md index 52b5fe7e89..7cb0ec7857 100644 --- a/packages/host/directory-picker-browse/README.md +++ b/packages/host/directory-picker-browse/README.md @@ -6,7 +6,7 @@ The **in-app browsing backend** of the [directory-picker seam](../directory-pick Behavior facts: listings return **directories only**, name-sorted, with symlinks-to-directories followed (broken/cyclic links skipped — the probe `stat` failing means "not enterable") and a host-owned `hidden` flag (POSIX dot convention) left for the client to act on; `crumbs` is the root-to-target ancestor chain, the root crumb labeled by its full path (`/`, `C:\`); an absent `list` path means the host account's home directory. `createDirectory` is non-recursive (a missing parent is a real failure, not a level to invent) and validates the name as a single non-blank segment even when called directly, mirroring the wire schema's fence. Both primitives reject an explicit path that is not fully qualified — relative forms, and on Windows the rooted drive-less forms (`\foo`, `/foo`) and incomplete UNC prefixes (`\\`, `\\server`) that `isAbsolute` accepts — with `directory-unreadable`/`directory-create-failed`, instead of letting `resolve` rebase it under the host process cwd or current drive. One `list` call returns at most `maxEntries` rows (config, default 1000 — the bound GitHub's web UI applies to directory listings), and the level streams through a bounded window so memory stays O(maxEntries) no matter how many children the directory holds: a cut level keeps the name-sorted head, counts hidden rows against the bound, probes only windowed candidates, and reports `truncated: true` so the client can say the level is incomplete (a windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated); window insertion is binary with an O(1) full-window tail rejection, and `list` threads the caller's `AbortSignal` so a disconnect or timeout stops the scan instead of letting it outlive the caller. Failures throw the seam's typed `DirectoryPickerError`. Policy rationale: [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md). -**Dual-face package**: the browser half (`./client`) fills [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes with the in-app **Select Workspace Directory** dialog (figma `Harness` 813-23126 family — Miller two-column view whose navigations land selection-anchored and quiet: the previous view keeps rendering while a crumb jump or a submitted path is scanned (a "Loading…" pill floats over it only once the scan outlives a 300ms silence window, never shifting the columns), then target and parent legs land as one two-pane frame with the target re-selected as its actual parent-level entry — so stepping back never collapses and no intermediate frame flashes (a parent leg outliving its 200ms wait bound lands the target alone and upgrades in place; a failed or truncated parent leg keeps the single-pane landing; the display root keeps the single wide level); breadcrumb with a click-to-edit path zone whose editor seeds a trailing separator, prefix-filters the listed level from the draft's final segment while typing (case-insensitively, over the listed — possibly truncated — rows only; Enter still navigates by the exact text), and cancels on Escape or when focus leaves the dialog card (window/tab switches and in-card focus moves keep the draft); a fixed-label show-hidden footer toggle over the host's `hidden` flags, with a dot-led typed prefix revealing its matches and the current selection exempt from both filters; nested New-folder dialog), driving `host.listDirectory`/`host.createDirectory` and registering its own locale namespace (`directory-browser`, zh default / en). One cordis.yml row therefore composes both sides of the browse interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind). +**Dual-face package**: the browser half (`./client`) fills [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes with the in-app **Select Workspace Directory** dialog (figma `Harness` 813-23126 family — Miller two-column view whose navigations land selection-anchored and quiet: the previous view keeps rendering while a crumb jump or a submitted path is scanned (a "Loading…" pill floats over it only once the scan outlives a 300ms silence window, never shifting the columns), then target and parent legs land as one two-pane frame with the target re-selected as its actual parent-level entry — so stepping back never collapses and no intermediate frame flashes (a parent leg outliving its 200ms wait bound lands the target alone and upgrades in place; a failed or truncated parent leg keeps the single-pane landing; the display root keeps the single wide level); breadcrumb with a click-to-edit path zone, advertised by the pencil glyph at the bar's right edge and lit on hover in the editor's own footprint, whose editor seeds a trailing separator and then keeps the panes under the draft: the final segment prefix-filters the level its directory part names (case-insensitively, over the listed — possibly truncated — rows only; a tail nobody matches releases the filter instead of emptying the pane), while a directory part no pane lists is scanned after a 250ms rest and shown in place, so typing deeper descends and erasing segments steps back up without leaving the editor — a speculative scan is silent when it fails, and Enter still navigates by the exact text, owning the view until it lands; the editor cancels on Escape or when focus leaves the dialog card (window/tab switches and in-card focus moves keep the draft); a fixed-label show-hidden footer toggle over the host's `hidden` flags, with a dot-led typed prefix revealing its matches and the current selection exempt from every filter; nested New-folder dialog), driving `host.listDirectory`/`host.createDirectory` and registering its own locale namespace (`directory-browser`, zh default / en). One cordis.yml row therefore composes both sides of the browse interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind). ## Model Experience diff --git a/packages/host/directory-picker-browse/README.zh.md b/packages/host/directory-picker-browse/README.zh.md index 742da39470..ec71a90bbc 100644 --- a/packages/host/directory-picker-browse/README.zh.md +++ b/packages/host/directory-picker-browse/README.zh.md @@ -6,7 +6,7 @@ 行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `stat` 失败即"不可进入"),并携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示决策留给客户端;`crumbs` 是从根到目标的祖先链,根 crumb 以完整路径标注(`/`、`C:\`);`list` 不带路径即列举宿主账户的家目录。`createDirectory` 不递归(父目录缺失是真实失败,不是要补造的层级),且即便被直接调用也把名称校验为单个非空段,与协议 schema 的栅栏一致。两个原语都拒绝非完全限定的显式路径——相对形态,以及 Windows 上 `isAbsolute` 会放行的无盘符有根形态(`\foo`、`/foo`)与不完整的 UNC 前缀(`\\`、`\\server`)——报 `directory-unreadable`/`directory-create-failed`,而不是任由 `resolve` 把它重定位到宿主进程 cwd 或当前盘符之下。单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端对目录列举采用的同一上限),且层级以流式方式经过一个有界窗口,无论目录有多少子项内存都保持 O(maxEntries):被截断的层级保留按名排序的头部、隐藏行计入上限、只探测窗口内候选,并报告 `truncated: true`,供客户端提示层级不完整(窗口内的断链符号链接不会从窗口外回填——发生过驱逐本身已把层级标记为截断);窗口插入为二分查找、满窗尾部单次比较即拒绝,且 `list` 透传调用方的 `AbortSignal`,断连或超时会停止扫描而不是让它在调用方离开后继续。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 -**双面包**:browser half(`./client`)以应用内 **选择工作区目录** 对话框(figma `Harness` 813-23126 家族——Miller 双列视图,其导航以选中项为锚、安静落地:扫描 crumb 跳转或提交的路径期间,先前视图持续渲染("Loading…" 胶囊仅在扫描超出 300ms 静默窗口后才浮于其上,绝不挪动各列),随后目标与父层级两程以单个双栏帧落地,目标被重新选中为其在父层级中的实际条目——因此后退绝不塌缩,也没有中间帧闪现(父层级这一程超出其 200ms 等待上限时,目标单独落地,随后就地升级;父层级这一程失败或被截断时保持单栏落地;展示根保持单个宽层级);带点击即编辑路径区的面包屑,其编辑器预填尾随分隔符、输入时以草稿末段对所列层级做前缀过滤(不区分大小写,且仅作用于已列出、可能被截断的行;Enter 仍按确切文本导航)、按 Escape 或焦点离开对话框卡片即取消(窗口/标签页切换与卡片内焦点移动保留草稿);基于宿主 `hidden` 标志、标签固定的"显示隐藏"footer 开关,键入以点开头的前缀会显出其匹配项,且当前选中项不受这两种过滤影响;嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞,驱动 `host.listDirectory`/`host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`,zh 默认/en)。因此一行 cordis.yml 同时组合浏览交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。 +**双面包**:browser half(`./client`)以应用内 **选择工作区目录** 对话框(figma `Harness` 813-23126 家族——Miller 双列视图,其导航以选中项为锚、安静落地:扫描 crumb 跳转或提交的路径期间,先前视图持续渲染("Loading…" 胶囊仅在扫描超出 300ms 静默窗口后才浮于其上,绝不挪动各列),随后目标与父层级两程以单个双栏帧落地,目标被重新选中为其在父层级中的实际条目——因此后退绝不塌缩,也没有中间帧闪现(父层级这一程超出其 200ms 等待上限时,目标单独落地,随后就地升级;父层级这一程失败或被截断时保持单栏落地;展示根保持单个宽层级);带点击即编辑路径区的面包屑,该区由栏右端的铅笔图标点明、悬停时以编辑器自身的轮廓亮起,其编辑器预填尾随分隔符,随后让下方各栏跟随草稿:末段对其目录部分所指的层级做前缀过滤(不区分大小写,且仅作用于已列出、可能被截断的行;无一匹配的末段会解除过滤,而不是把该栏清空),而任何一栏都未列出的目录部分会在停顿 250ms 后被扫描并就地展示,于是继续键入即下潜、删掉末段即上退,全程不必离开编辑器——推测性扫描失败时保持沉默,而 Enter 仍按确切文本导航,并在落地前独占视图;编辑器按 Escape 或焦点离开对话框卡片即取消(窗口/标签页切换与卡片内焦点移动保留草稿);基于宿主 `hidden` 标志、标签固定的"显示隐藏"footer 开关,键入以点开头的前缀会显出其匹配项,且当前选中项不受任何过滤影响;嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞,驱动 `host.listDirectory`/`host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`,zh 默认/en)。因此一行 cordis.yml 同时组合浏览交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。 ## 模型体验 diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css index 2f207e4195..eaad4d46d8 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css @@ -53,7 +53,8 @@ display: flex; align-items: center; gap: 4px; - min-height: 20px; + /* The path editor's height: crumb mode and edit mode occupy the same bar. */ + min-height: 24px; } /* Deep chains scroll inside the trail (the effect pins the tail into view) @@ -118,17 +119,52 @@ color: var(--dsw-alias-label-tertiary); } -/* The empty remainder of the bar: invisible, but a real click target that - * flips the bar into path-edit mode. */ +/* The empty remainder of the bar: a real click target that flips the bar + * into path-edit mode. The zone itself stays flush with the crumbs; the + * pencil glyph seated at its right edge is the standing affordance, and + * hover/focus lights the zone in the editor's own rounded shape so the + * gesture reads before the click. */ .crumbEditZone { + display: flex; + align-items: center; + justify-content: flex-end; flex: 1 0 34px; min-width: 34px; - align-self: stretch; - border: none; + /* The editor's own height, so hover previews the input's exact footprint + * and the bar does not resize when the two swap. */ + height: 24px; + padding: 0 6px; + border: 1px solid transparent; + border-radius: 8px; background: transparent; cursor: text; } +.crumbEditZone:hover, +.crumbEditZone:focus-visible { + border-color: var(--dsw-alias-border-l2); + outline: none; +} + +.crumbEditGlyph { + flex: none; + color: var(--dsw-alias-label-tertiary); +} + +.crumbEditZone:hover .crumbEditGlyph, +.crumbEditZone:focus-visible .crumbEditGlyph { + color: var(--dsw-alias-label-primary); +} + +.crumbEditZone:disabled { + border-color: transparent; + cursor: default; +} + +.crumbEditZone:disabled .crumbEditGlyph { + color: var(--dsw-alias-label-caption); +} + .pathInput { box-sizing: border-box; flex: 1 1 0; diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index f5510fda7c..1f9903952d 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -18,15 +18,20 @@ * owning flow decides what "Open" means and owns the workspace-creation * error surface. Hidden entries are host-flagged and hidden by default; the * footer's fixed-label "Show hidden files" toggle (aria-pressed, check when - * on) reveals them (client-side only). The path editor opens seeded with a - * trailing separator, and while the draft's directory part names a listed - * level, its final segment prefix-filters that level's rows (a dot-led - * prefix also reveals the hidden entries it names). + * on) reveals them (client-side only). The path editor announces itself with + * a pencil glyph and a hover-lit zone, opens seeded with a trailing + * separator, and keeps the panes under the draft: the final segment + * prefix-filters the level its directory part names (a dot-led prefix also + * reveals the hidden entries it names, and a prefix nobody matches releases + * the filter), while a directory part no pane lists is scanned after a short + * debounce and shown in place — so typing deeper descends and erasing + * segments steps back up without leaving the editor. */ import { useCallback, useEffect, useRef, useState } from 'react' import clsx from 'clsx' import { - Button, IconCheckOutline16, IconChevronRightOutline14, IconFolderClose16, IconFolderOpen16, IconPlusOutline16, Modal, + Button, IconCheckOutline16, IconChevronRightOutline14, IconEditOutline16, IconFolderClose16, IconFolderOpen16, + IconPlusOutline16, Modal, } from '@deepseek-ai/dsh-client-ui-primitives' import type { DirectoryEntry, DirectoryListing } from '@deepseek-ai/dsh-client-runtime/client' import { DirectoryBrowseError } from '@deepseek-ai/dsh-client-runtime/client' @@ -75,6 +80,15 @@ const SLOW_SCAN_DELAY_MS = 300 */ const PARENT_LEG_WAIT_MS = 200 +/** + * How long a typed draft rests before the panes follow it to a directory no + * pane lists. The window absorbs the keystrokes that walk through + * intermediate directory parts (every character of `/usr/lo` past the + * separator would otherwise be its own scan) while staying short enough that + * a pause reads as "the list moved with me". + */ +const DRAFT_PREVIEW_DEBOUNCE_MS = 250 + /** * Breadcrumb rows for display: inside the home subtree the chain starts at a * localized Home crumb; outside it the full ancestry shows, the root labeled @@ -100,21 +114,84 @@ function separatorOf(listing: DirectoryListing): '\\' | '/' { return listing.home.includes('\\') ? '\\' : '/' } +/** The listed level as a directory part: its own path, separator-terminated (the root already is). */ +function levelDirectory(listing: DirectoryListing): string { + const sep = separatorOf(listing) + return listing.path.endsWith(sep) ? listing.path : `${listing.path}${sep}` +} + +/** + * The draft's directory part — everything through its last separator — or + * null while no separator has been typed at all (nothing addresses a + * directory yet). The platform separator comes from `listing`, so the caller + * passes any listing of the host's filesystem. + */ +function draftDirectory(listing: DirectoryListing, draft: string): string | null { + const cut = draft.lastIndexOf(separatorOf(listing)) + return cut === -1 ? null : draft.slice(0, cut + 1) +} + /** * The path draft's final segment, when its directory part is exactly the * level `listing` lists — the segment the level prefix-filters on while the * user types. Any other draft (no separator yet, or naming some other * directory) leaves the level unfiltered. The directory part compares - * exactly (it is the host's own path text, reached by seeding or erasing); - * only the name filter downstream is case-insensitive. + * exactly (it is the host's own path text, reached by seeding, erasing, or a + * draft-following scan); only the name filter downstream is case-insensitive. */ function draftPrefixFor(listing: DirectoryListing, draft: string | null): string | null { if (draft === null) return null - const sep = separatorOf(listing) - const cut = draft.lastIndexOf(sep) - if (cut === -1) return null - const level = listing.path.endsWith(sep) ? listing.path : `${listing.path}${sep}` - return draft.slice(0, cut + 1) === level ? draft.slice(cut + 1) : null + const directory = draftDirectory(listing, draft) + if (directory === null) return null + return directory === levelDirectory(listing) ? draft.slice(directory.length) : null +} + +/** + * The directory a draft addresses that no rendered pane lists — the level the + * editor must scan for the panes to keep following the typed path. Null when + * a pane already lists it (the prefix filter alone answers the draft), when + * no separator has been typed yet, and when no level is listed at all: the + * platform separator is read off a listing, so the editor's + * failed-home-listing recovery path types blind until Enter. + */ +function pendingPreviewDirectory( + parent: DirectoryListing | null, + child: DirectoryListing | null, + draft: string | null, +): string | null { + if (parent === null || draft === null) return null + const directory = draftDirectory(parent, draft) + if (directory === null || directory === levelDirectory(parent)) return null + if (child !== null && directory === levelDirectory(child)) return null + return directory +} + +/** + * The rows one column renders. The selection is exempt from every filter: it + * anchors the two-pane view (crumbs and the child pane point at it), so + * neither the hidden filter after a dot-reveal pick nor a prefix miss may + * orphan it. A prefix narrows the level only while some row matches it — a + * tail nobody matches is a name being spelled, not a demand for an empty + * pane, so the level shows whole (and its hidden rows return to obeying the + * toggle, the dot-led reveal included). + */ +function visibleEntries( + entries: readonly DirectoryEntry[], + selectedPath: string | null, + showHidden: boolean, + filterPrefix: string | null, +): readonly DirectoryEntry[] { + const needle = filterPrefix === null ? '' : filterPrefix.toLowerCase() + const matches = (entry: DirectoryEntry): boolean => entry.name.toLowerCase().startsWith(needle) + const narrowing = needle !== '' && entries.some(matches) + // A dot-led prefix names hidden entries explicitly, so matching ones + // surface even while the toggle keeps the rest hidden. + const revealHidden = narrowing && needle.startsWith('.') + return entries.filter((entry) => { + if (entry.path === selectedPath) return true + if (narrowing && !matches(entry)) return false + return showHidden || !entry.hidden || revealHidden + }) } /** One column of folder rows (the Miller view renders one or two of these). */ @@ -127,16 +204,7 @@ function LevelColumn({ entries, selectedPath, busy, onPick, showHidden, filterPr filterPrefix: string | null pathEditing: boolean }) { - const visible = entries.filter((entry) => { - // The selection is exempt from both filters: it anchors the two-pane - // view (crumbs and the child pane point at it), so neither the hidden - // filter after a dot-reveal pick nor a prefix miss may orphan it. - if (entry.path === selectedPath) return true - if (filterPrefix !== null && !entry.name.toLowerCase().startsWith(filterPrefix.toLowerCase())) return false - // A dot-led prefix names hidden entries explicitly, so matching ones - // surface even while the toggle keeps the rest hidden. - return showHidden || !entry.hidden || filterPrefix?.startsWith('.') === true - }) + const visible = visibleEntries(entries, selectedPath, showHidden, filterPrefix) return (
{visible.map((entry) => { @@ -381,6 +449,41 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, }) }, [launchListing, pathDraft]) + /** + * Enter owns the view from submission until its navigation lands, so the + * debounce timer the same keystrokes armed must not supersede it. Cleared + * by the next edit (and by opening the editor); a failed submission leaves + * it set, so the rejected path is not immediately re-scanned as a preview. + */ + const previewSuspended = useRef(false) + + /** + * List the directory the draft addresses and show it WITHOUT closing the + * editor: the level replaces the panes single-wide (the selection and its + * child preview belonged to the level the draft left), and the draft's + * final segment prefix-filters it from the next render on. Unlike Enter, + * this is speculative — half-typed directories are unreadable most of the + * time — so a failure keeps the last readable panes and stays silent, + * leaving submission to surface the real error. A landing clears a stale + * error for the same reason: it, not the launch, is what makes the message + * obsolete. + */ + const previewDraftLevel = useCallback((directory: string) => { + const { seq, scan } = launchListing(directory) + setLoading(true) + scan.then((level) => { + if (seq !== requestSeq.current) return + setParent(level) + setSelected(null) + setChild(null) + setLoading(false) + setError(null) + }, () => { + if (seq !== requestSeq.current) return + setLoading(false) + }) + }, [launchListing]) + /** Abandon path editing (Escape or clicking away) and restore the crumb view. */ const cancelPathEdit = useCallback(() => { // Cancel also withdraws a navigation the editor already launched: its @@ -499,6 +602,22 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, return () => { window.clearTimeout(timer) } }, [loading, scanWindow]) + // The panes follow the draft: a directory part no pane lists is scanned + // once the typing rests. The dependency is the target STRING, so the + // landing it commits cannot re-arm the timer (a host that answers with a + // differently spelled path leaves the target unchanged, hence unrepeated), + // and every further keystroke replaces the pending timer instead of + // queueing another scan. + const previewDirectory = pendingPreviewDirectory(parent, child, pathDraft) + useEffect(() => { + if (previewDirectory === null) return + const timer = window.setTimeout(() => { + if (previewSuspended.current) return + previewDraftLevel(previewDirectory) + }, DRAFT_PREVIEW_DEBOUNCE_MS) + return () => { window.clearTimeout(timer) } + }, [previewDirectory, previewDraftLevel]) + // After the hooks: a closed dialog renders nothing and evaluates no copy. const crumbSource = child ?? parent const crumbs = crumbSource === null ? [] : displayCrumbs(crumbSource, t('browser.home')) @@ -637,11 +756,17 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, ))} - {/* The empty zone right of the crumbs is the path-edit affordance. */} + {/* The empty zone right of the crumbs is the path-edit + * affordance: the whole remainder of the bar clicks into + * the editor, and the pencil glyph parked at its right + * edge (with the same tooltip) is what says so — an + * invisible target the operator must guess at is the one + * way into typing a path. */} ) : ( @@ -682,6 +810,9 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // repopulate the view with the older path. supersede() setLoading(false) + // A fresh edit releases the submission hold: the panes + // may follow the new text wherever it points. + previewSuspended.current = false setPathDraft(event.target.value) }} {...compositionGuard} @@ -699,6 +830,11 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // focus on the returning crumb edit zone (a failure // keeps the editor, so the flag waits until close). refocusEditZone.current = true + // The submitted path owns the view now: a debounce + // timer still pending from these keystrokes would + // otherwise supersede this navigation and land the + // draft's parent directory instead. + previewSuspended.current = true navigate(pathDraft) } } diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx index ce9f03fb0b..0c7e155add 100644 --- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -11,9 +11,14 @@ const HOME = '/home/u' const DOCS = `${HOME}/Documents` const HARNESS = `${DOCS}/harness` -/** Listing fake over a tiny fixed tree; unknown paths reject like the Host. */ +/** + * Listing fake over a tiny fixed tree; unknown paths reject like the Host. + * A trailing separator is dropped the way the Host's own `resolve` drops it, + * so a directory part typed into the path editor addresses its level. + */ function listingFor(path?: string): DirectoryListing { - const target = path ?? HOME + const asked = path ?? HOME + const target = asked.length > 1 && asked.endsWith('/') ? asked.slice(0, -1) : asked const tree: Record = { [HOME]: { path: HOME, @@ -659,9 +664,14 @@ describe('DirectoryBrowser', () => { // A dot-led prefix names hidden entries, so it reveals the match. fireEvent.change(input, { target: { value: `${HOME}/.co` } }) expect(screen.getByRole('listitem').textContent).toBe('.config') - // A prefix matching nothing empties the level (no stale rows linger). + // A prefix nobody matches releases the filter: the level shows whole + // (hidden rows back under the toggle) instead of emptying under a name + // the operator is still spelling. fireEvent.change(input, { target: { value: `${HOME}/zzz` } }) - expect(screen.queryByRole('listitem')).toBeNull() + expect(screen.getAllByRole('listitem').map(item => item.textContent)).toEqual(['Documents']) + // Its dot-led reveal lapses with it. + fireEvent.change(input, { target: { value: `${HOME}/.zzz` } }) + expect(screen.getAllByRole('listitem').map(item => item.textContent)).toEqual(['Documents']) // A draft naming some other directory (or none) leaves the level whole. fireEvent.change(input, { target: { value: 'no-separator' } }) expect(screen.getByRole('listitem').textContent).toBe('Documents') @@ -679,17 +689,95 @@ describe('DirectoryBrowser', () => { expect(input.value).toBe(`${DOCS}/`) fireEvent.change(input, { target: { value: `${DOCS}/h` } }) expect(within(columns()[1]!).getByText('harness')).toBeTruthy() + // A miss releases the right pane's filter rather than emptying it. fireEvent.change(input, { target: { value: `${DOCS}/zzz` } }) - expect(within(columns()[1]!).queryAllByRole('listitem')).toHaveLength(0) + expect(within(columns()[1]!).getByText('harness')).toBeTruthy() expect(within(columns()[0]!).getByText('Documents')).toBeTruthy() // Erasing back into the parent's own path moves the filter to the LEFT - // pane and releases the right one. The selected row is exempt (it - // anchors the two-pane view), so it alone survives the miss. + // pane and releases the right one — no scan, both levels are on screen. fireEvent.change(input, { target: { value: `${HOME}/zz` } }) expect(within(columns()[0]!).getAllByRole('listitem').map(item => item.textContent)).toEqual(['Documents']) expect(within(columns()[1]!).getByText('harness')).toBeTruthy() }) + it('follows the draft into a directory no pane lists, and back up when segments are erased', async () => { + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + // Typing past a separator addresses a level nobody shows: the panes + // follow it once the typing rests, and the tail filters the arrival. + fireEvent.change(input, { target: { value: `${DOCS}/h` } }) + await waitFor(() => { expect(screen.getByText('harness')).toBeTruthy() }) + expect(b.listDirectory.mock.calls.at(-1)?.[0]).toBe(`${DOCS}/`) + // Still editing: the panes moved under the draft, the editor stayed. + expect(screen.getByLabelText('browser.editPath').value).toBe(`${DOCS}/h`) + // Erasing back past the separator steps the panes up a level again. + fireEvent.change(input, { target: { value: `${HOME}/Do` } }) + await waitFor(() => { expect(screen.getByText('Documents')).toBeTruthy() }) + expect(b.listDirectory.mock.calls.at(-1)?.[0]).toBe(`${HOME}/`) + expect(columns()).toHaveLength(1) + }) + + it('keeps the panes and stays silent when a draft-following scan fails', async () => { + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + fireEvent.change(input, { target: { value: `${HOME}/nope/x` } }) + await waitFor(() => { expect(b.listDirectory).toHaveBeenCalledWith(`${HOME}/nope/`, expect.anything()) }) + // A half-typed directory is unreadable most of the time: the last + // readable level keeps rendering and no error interrupts the typing. + expect(screen.getByText('Documents')).toBeTruthy() + expect(screen.queryByRole('alert')).toBeNull() + }) + + it('holds the draft-following scan while a submitted path is in flight', async () => { + const listDirectory = vi.fn(async (path?: string) => { + // The submitted leg never settles, so the debounce window elapses with + // the navigation still owning the view. + if (path === HARNESS) return await new Promise(() => {}) + return listingFor(path) + }) + mount({ listDirectory }) + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + fireEvent.change(input, { target: { value: HARNESS } }) + fireEvent.keyDown(input, { key: 'Enter' }) + await act(async () => { await new Promise((resolve) => { setTimeout(resolve, 400) }) }) + // Only the initial home listing and the submitted path — the draft's + // directory part was never scanned behind the navigation's back. + expect(listDirectory.mock.calls.map(call => call[0])).toEqual([undefined, HARNESS]) + }) + + it('discards draft-following scans that a newer edit superseded', async () => { + let landDocs = (): void => {} + let failRoot = (): void => {} + const listDirectory = vi.fn(async (path?: string) => { + if (path === `${DOCS}/`) return await new Promise((resolve) => { landDocs = () => { resolve(listingFor(DOCS)) } }) + if (path === '/') { + return await new Promise((_, reject) => { + failRoot = () => { reject(new Error('root unreadable')) } + }) + } + return listingFor(path) + }) + mount({ listDirectory }) + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + fireEvent.change(input, { target: { value: `${DOCS}/h` } }) + await waitFor(() => { expect(listDirectory).toHaveBeenCalledWith(`${DOCS}/`, expect.anything()) }) + fireEvent.change(input, { target: { value: '/x' } }) + await waitFor(() => { expect(listDirectory).toHaveBeenCalledWith('/', expect.anything()) }) + // Back onto the listed level: neither pending scan may still land. + fireEvent.change(input, { target: { value: `${HOME}/D` } }) + await act(async () => { landDocs(); failRoot() }) + expect(screen.getAllByRole('listitem').map(item => item.textContent)).toEqual(['Documents']) + expect(screen.queryByRole('alert')).toBeNull() + }) + it('keeps the draft and filter through window focus loss and in-dialog focus moves', async () => { mount() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) From 0b0de64769a5a57294012ac03c3852be0103e4de Mon Sep 17 00:00:00 2001 From: kingwl Date: Mon, 3 Aug 2026 11:40:33 +0800 Subject: [PATCH 085/129] bound inactive subagent timing to projection cut --- ...07-27-web-subagent-conversations.i18n.yaml | 4 +- .../2026-07-27-web-subagent-conversations.md | 2 +- ...026-07-27-web-subagent-conversations.zh.md | 2 +- .../src/client/SubagentCatalogAction.tsx | 8 +-- .../tests/conversation-ui.spec.tsx | 14 ++++-- packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 2 +- packages/subagent/subagent/README.zh.md | 2 +- .../subagent/subagent/src/projection-types.ts | 9 +++- packages/subagent/subagent/src/projection.ts | 50 ++++++++++++------- .../subagent/tests/timing-projection.spec.ts | 7 ++- 11 files changed, 66 insertions(+), 38 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml index 168d28d13a..08db3986b2 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.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 .agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md -2026-07-27-web-subagent-conversations.md: 859c6c5c17e830ab55c8513d56741966655eaf7a -2026-07-27-web-subagent-conversations.zh.md: 05d5c0f1d59b0bdebdecb33dc360e937af44d7b6 +2026-07-27-web-subagent-conversations.md: 09a444dc6c391f3abdfc5bfe0f3367d4bda430c3 +2026-07-27-web-subagent-conversations.zh.md: 87929e8b35212092587ca4566bd10a1242c84e30 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md index 859c6c5c17..09a444dc6c 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md @@ -41,7 +41,7 @@ The header action is absent only when a complete empty direct-catalog response a `running` means the exact child Agent driver is draining work at the Host sampling boundary; `inactive` means that driver is idle or absent. The UI does not translate either value into success, failure, cancellation, completeness, or resumability. `subagent.list` supplies the current driver-status baseline, `host/session-status` updates known activity in place, request-local replay prevents an older in-flight list response from overwriting a newer transition, and `host/session-removed` returns a known row to `inactive`; reconnect reads a fresh baseline. A `host/session-added` frame for a direct subagent immediately flips any loaded parent row to `hasChildren: true`, and that positive hint survives an older in-flight catalog response; membership, labels, mode, diagnostics, and the authoritative snapshot still require a debounced `subagent.list` refresh while the affected branch is open. A prompt response remains delivery-time authority. -Healthy rows reuse the standard session projections retained in the list mirror. `subagentTiming` resets at every descriptor so an inherited fork seed cannot enter the child's total, accumulates completed `turn/start` → `turn/end` spans, and carries the current turn's `activeSince`. The menu formats whole seconds and advances its local clock only while a known descendant is running; an inactive row uses settled duration, or the summary's last activity to bound an interrupted open turn, so reopening the menu never restarts completed work. The duration does not imply a durable outcome. +Healthy rows reuse the standard session projections retained in the list mirror. `subagentTiming` resets at every descriptor so an inherited fork seed cannot enter the child's total, accumulates completed `turn/start` → `turn/end` spans, and carries same-cut `active.since` and `active.through` bounds for an open turn. The menu formats whole seconds and advances its local clock only while a known descendant is running; an inactive row bounds an interrupted open turn with `active.through`, so a stale projection never borrows newer session metadata and reopening the menu never restarts completed work. The duration does not imply a durable outcome. Selecting a row records its exact address before opening the resident client `Session`. History pagination, event folding, tool render intents, titles, and live mux reconciliation reuse the ordinary conversation machinery. Breadcrumbs use catalog labels, follow parent links only through `origin: 'subagent'` rows, include the first ordinary owner, and keep ordinary forks single-level. Forking an addressed subagent creates an ordinary fork with direct source lineage and attaches it to the nearest workspace-owning ancestor. The catalog is an ARIA tree with lazy ArrowRight/ArrowLeft disclosure, linear ArrowUp/ArrowDown navigation, Home/End, Escape, and focus restoration. diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md index 05d5c0f1d5..87929e8b35 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md @@ -41,7 +41,7 @@ Figma 中的 [subagent 列表](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5 `running` 表示在 Host 采样边界,确切 child Agent driver 正在处理工作;`inactive` 表示该 driver 空闲或不存在。UI 不会把任一值解释为成功、失败、取消、完成状态或可恢复性。`subagent.list` 提供当前 driver 状态基线,`host/session-status` 会就地更新已知活动状态,请求内回放会阻止更早发起但尚未完成的列表响应覆盖较新的状态转换,`host/session-removed` 则会使已知行恢复为 `inactive`;重连时会读取新的基线。直接 subagent 的 `host/session-added` 帧会立即把任何已加载的 parent 行翻转为 `hasChildren: true`,并使这项正向提示不被更早发起但尚未完成的目录响应覆盖;受影响分支打开期间,成员、label、mode、diagnostic 与权威快照仍需要通过去抖动的 `subagent.list` 刷新来更新。消息投递时仍以提示词响应为权威依据。 -健康行会复用列表镜像中保留的标准会话投影。`subagentTiming` 会在每个描述符处重置,使继承的 fork 种子不会计入 child 总量;它会累加已完成的 `turn/start` → `turn/end` 时段,并携带当前轮次的 `activeSince`。菜单会以整秒格式化时间,且仅在有已知后代处于运行状态时才推进其本地时钟;对 inactive 行,菜单使用已结算耗时,或以摘要的最后活动为被中断未结束轮次的上界,因此重新打开菜单绝不会让已完成工作重新计时。该耗时不蕴含持久化结果语义。 +健康行会复用列表镜像中保留的标准会话投影。`subagentTiming` 会在每个描述符处重置,使继承的 fork 种子不会计入 child 总量;它会累加已完成的 `turn/start` → `turn/end` 时段,并携带未结束轮次同一切面的 `active.since` 和 `active.through` 边界。菜单会以整秒格式化时间,且仅在有已知后代处于运行状态时才推进其本地时钟;对 inactive 行,菜单以 `active.through` 为被中断未结束轮次的上界,因此陈旧投影绝不会借用更新的会话元数据,且重新打开菜单绝不会让已完成工作重新计时。该耗时不蕴含持久化结果语义。 选择一行后,系统会先记录其确切地址,再打开常驻客户端 `Session`。历史分页、事件 fold、工具渲染意图、title 与实时 mux 归并都会复用普通对话机制。面包屑导航使用目录 label,只会沿 `origin: 'subagent'` 行的父链接逐级回溯,包含第一个普通 owner,并让普通 fork 保持单层。从已寻址 subagent 创建 fork 时,会生成具有直接源谱系的普通 fork,并将其附加到最近拥有 Workspace 的祖先。目录是一棵 ARIA 树,支持懒加载式 ArrowRight/ArrowLeft 展开与折叠、线性 ArrowUp/ArrowDown 导航、Home/End、Escape 以及焦点恢复。 diff --git a/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx b/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx index d507b30019..eae3addf0e 100644 --- a/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx +++ b/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx @@ -69,9 +69,11 @@ function activityDuration( const timing: SessionProjectionMap['subagentTiming'] | undefined = summary.projectionValues?.subagentTiming if (timing === undefined) return undefined - if (timing.activeSince === undefined) return timing.settledMs - const end = activity === 'running' ? now : summary.updatedAt - return timing.settledMs + Math.max(0, end - timing.activeSince) + if (timing.active === undefined) return timing.settledMs + const end = activity === 'running' + ? now + : timing.active.through + return timing.settledMs + Math.max(0, end - timing.active.since) } /** Format a non-negative duration to seconds without dropping larger units. */ diff --git a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx b/packages/client/ui-subagent/tests/conversation-ui.spec.tsx index 487cd14f35..a6cbb22aeb 100644 --- a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx +++ b/packages/client/ui-subagent/tests/conversation-ui.spec.tsx @@ -245,9 +245,9 @@ describe('SubagentCatalogAction', () => { vi.useFakeTimers() vi.setSystemTime(now) const rows = [ - ['running', 'running', 65_000, now - 5_000, now], - ['finished', 'inactive', 3_723_000, undefined, now - 60_000], - ['interrupted', 'inactive', 2_000, now - 7_000, now - 3_000], + ['running', 'running', 65_000, now - 5_000, now - 1_000, now], + ['finished', 'inactive', 3_723_000, undefined, undefined, now - 60_000], + ['interrupted', 'inactive', 2_000, now - 7_000, now - 3_000, now + 60_000], ] as const const entries = rows.map(([id, activity]) => ({ kind: 'child' as const, @@ -257,7 +257,9 @@ describe('SubagentCatalogAction', () => { activity, hasChildren: false, })) - const summaries = Object.fromEntries(rows.map(([id, activity, settledMs, activeSince, updatedAt]) => { + const summaries = Object.fromEntries(rows.map(([ + id, activity, settledMs, activeSince, activeThrough, updatedAt, + ]) => { const childId = id as SessionId return [id, { ...summary(childId, updatedAt), @@ -267,7 +269,9 @@ describe('SubagentCatalogAction', () => { projectionValues: { subagentTiming: { settledMs, - ...(activeSince === undefined ? {} : { activeSince }), + ...(activeSince === undefined || activeThrough === undefined + ? {} + : { active: { since: activeSince, through: activeThrough } }), }, }, }] diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index 76740d4506..ceac4245a5 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/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/subagent/subagent/README.md -README.md: ec4af55bcd9374b1abb55d7bb098eef568449684 -README.zh.md: 8323853ff0de2a15da6475fc1433a68f0074ee93 +README.md: e54f0b98ec3649cec428a47026e6657a9749608b +README.zh.md: 1624fa59854d9b61770c5ef0f9d89f7882198da4 diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index ec4af55bcd..e54f0b98ec 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -92,7 +92,7 @@ Provider additions and removals also emit `subagent/provider-added` and `subagen Continuable children do not create `SubagentRun` or Tasks. The continuation manager directly owns one process-local Activation and retained `AgentHandle` per resident child Session, uses the Agent inbox as the only FIFO, and cold-resumes from the durable descriptor. Exact live direct-parent identity authorizes parent-to-child delivery. Exact live child identity authorizes reports; the manager derives the recipient from durable `parentSession`, and `MessageSource` remains provenance rather than authority. -When `ctx.sessionProjections` is available, the service registers `subagentTiming`. The projection resets at each descriptor so a fork seed's ancestor work cannot enter the child's total, then accumulates `turn/start` → `turn/end` active time and retains `activeSince` for an open turn. Only descriptors and turn boundaries change the value, so token chunks do not create timing updates. +When `ctx.sessionProjections` is available, the service registers `subagentTiming`. The projection resets at each descriptor so a fork seed's ancestor work cannot enter the child's total, then accumulates `turn/start` → `turn/end` active time and retains same-cut `active.since` and `active.through` bounds for an open turn. While that turn remains open, `active.through` follows the latest folded event, giving an inactive consumer a conservative crash bound without mixing in newer session metadata. `registerContinuableSetup()` lets optional packages add child-scoped capabilities without teaching the continuation manager their names. Contributions install synchronously before Activation publication, roll back with failed setup, and are released with the child scope. New grants wait for the next Activation, while contribution removal revokes every resident installation immediately. diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 8323853ff0..1624fa5985 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -92,7 +92,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 可继续子级不会创建 `SubagentRun` 或 Task。延续管理器为每个驻留子 Session 直接拥有一个仅存在于当前进程的 Activation 和一个留存的 `AgentHandle`,使用 Agent inbox 作为唯一 FIFO,并从持久化描述符冷恢复。父到子投递由准确的实时直接父级身份授权。上报则由准确的实时子级身份授权;管理器根据持久化的 `parentSession` 推导接收方,`MessageSource` 仍只表示来源,不表示权限。 -当 `ctx.sessionProjections` 可用时,服务会注册 `subagentTiming`。该投影会在每个描述符处重置,使 fork 种子中的祖先工作不会计入 child 总量,随后累加 `turn/start` → `turn/end` 活跃时间,并为未结束的轮次保留 `activeSince`。只有描述符和轮次边界会改变该值,因此 token 分片不会产生计时更新。 +当 `ctx.sessionProjections` 可用时,服务会注册 `subagentTiming`。该投影会在每个描述符处重置,使 fork 种子中的祖先工作不会计入 child 总量,随后累加 `turn/start` → `turn/end` 活跃时间,并为未结束的轮次保留同一切面的 `active.since` 和 `active.through` 边界。在该轮次保持未结束期间,`active.through` 会跟随最近折叠的事件,从而为 inactive 消费方提供保守的崩溃上界,又不会混入更新的会话元数据。 `registerContinuableSetup()` 允许可选包添加子级作用域功能,而无需让延续管理器知道这些功能的名称。贡献会在 Activation 发布前同步安装,在设置失败时一并回滚,并随子级作用域释放。新授权须等到下一个 Activation,移除贡献则会立即撤销每个驻留安装项。 diff --git a/packages/subagent/subagent/src/projection-types.ts b/packages/subagent/subagent/src/projection-types.ts index cefaec3727..c5a23b03b8 100644 --- a/packages/subagent/subagent/src/projection-types.ts +++ b/packages/subagent/subagent/src/projection-types.ts @@ -8,8 +8,13 @@ export interface SubagentTimingProjection { /** Milliseconds accumulated across completed turns after the child's own descriptor. */ settledMs: number - /** Start of the currently open turn, when one has not reached `turn/end`. */ - activeSince?: number + /** Same-cut bounds of the currently open turn, when one has not reached `turn/end`. */ + active?: { + /** Start of the open turn. */ + since: number + /** Latest event time folded into this projection cut. */ + through: number + } } declare module '@deepseek-ai/dsh-session-projection/types' { diff --git a/packages/subagent/subagent/src/projection.ts b/packages/subagent/subagent/src/projection.ts index 6b15a66bbf..171ea18eef 100644 --- a/packages/subagent/subagent/src/projection.ts +++ b/packages/subagent/subagent/src/projection.ts @@ -8,16 +8,25 @@ import { z } from 'zod' import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' import type { SubagentTimingProjection } from './projection-types.ts' -interface TimingState extends SubagentTimingProjection { +interface TimingState { + /** Milliseconds accumulated across completed post-descriptor turns. */ + settledMs: number + /** Current open interval kept paired inside the fold. */ + active?: { since: number; through: number } /** Latest pre-descriptor turn start, promoted when the child's own descriptor arrives. */ pendingTurnStart?: number /** Whether the fold has crossed a descriptor in this logical log. */ descriptorSeen: boolean } +// Cast for the optional values: under exactOptionalPropertyTypes zod infers +// `number | undefined` where the interface declares absent-or-number fields. const projectionSchema = z.object({ settledMs: z.number().int().nonnegative(), - activeSince: z.number().int().nonnegative().optional(), + active: z.object({ + since: z.number().int().nonnegative(), + through: z.number().int().nonnegative(), + }).strict().optional(), }).strict() as unknown as z.ZodType /** @@ -36,33 +45,38 @@ ProjectionDefinition<'subagentTiming', TimingState> = { apply: (state, event) => { if (event.type === 'turn/start') { return state.descriptorSeen - ? { ...state, activeSince: event.time } + ? { ...state, active: { since: event.time, through: event.time } } : { ...state, pendingTurnStart: event.time } } if (event.type === 'subagent/descriptor') { - const activeSince = state.activeSince ?? state.pendingTurnStart + const activeSince = state.active?.since ?? state.pendingTurnStart return { descriptorSeen: true, settledMs: 0, - ...(activeSince === undefined ? {} : { activeSince }), + ...(activeSince === undefined + ? {} + : { active: { since: activeSince, through: event.time } }), } } - if (event.type !== 'turn/end') return state - if (!state.descriptorSeen) { - if (state.pendingTurnStart === undefined) return state - const { pendingTurnStart: _closed, ...next } = state - return next - } - if (state.activeSince === undefined) return state - const { activeSince, ...rest } = state - return { - ...rest, - settledMs: state.settledMs + Math.max(0, event.time - activeSince), + if (event.type === 'turn/end') { + if (!state.descriptorSeen) { + if (state.pendingTurnStart === undefined) return state + const { pendingTurnStart: _closed, ...next } = state + return next + } + if (state.active === undefined) return state + const { active, ...rest } = state + return { + ...rest, + settledMs: state.settledMs + Math.max(0, event.time - active.since), + } } + if (state.active === undefined) return state + return { ...state, active: { ...state.active, through: event.time } } }, view: state => ({ settledMs: state.settledMs, - ...(state.activeSince === undefined ? {} : { activeSince: state.activeSince }), + ...(state.active === undefined ? {} : { active: state.active }), }), - stateVersion: 1, + stateVersion: 2, } diff --git a/packages/subagent/subagent/tests/timing-projection.spec.ts b/packages/subagent/subagent/tests/timing-projection.spec.ts index ac3cac9ebb..e9a3be43ea 100644 --- a/packages/subagent/subagent/tests/timing-projection.spec.ts +++ b/packages/subagent/subagent/tests/timing-projection.spec.ts @@ -21,10 +21,13 @@ describe('subagent timing projection', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(SubagentService) + const serviceFiber = await ctx.plugin(SubagentService) expect(ctx.sessionProjections.snapshot(ctx.sessions.create()).values.subagentTiming) .toEqual({ settledMs: 0 }) + await serviceFiber.dispose() + expect(ctx.sessionProjections.snapshot(ctx.sessions.create()).values.subagentTiming) + .toBeUndefined() }) it('resets inherited seed timing at the child descriptor and sums later completed turns', () => { @@ -47,7 +50,7 @@ describe('subagent timing projection', () => { event('turn/end', 2, 900), event('turn/start', 3, 2_000), event('assistant/chunk', 4, 2_500), - ])).toEqual({ settledMs: 0, activeSince: 2_000 }) + ])).toEqual({ settledMs: 0, active: { since: 2_000, through: 2_500 } }) }) it('ignores completed pre-descriptor turns and unrelated events', () => { From fdf83f2c3ebb5fc14689491e24a8737101e5ac36 Mon Sep 17 00:00:00 2001 From: kingwl Date: Mon, 3 Aug 2026 11:44:11 +0800 Subject: [PATCH 086/129] document projection-cut duration bound --- packages/client/ui-subagent/README.i18n.yaml | 4 ++-- packages/client/ui-subagent/README.md | 2 +- packages/client/ui-subagent/README.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/client/ui-subagent/README.i18n.yaml b/packages/client/ui-subagent/README.i18n.yaml index 2b512252f0..01e2412288 100644 --- a/packages/client/ui-subagent/README.i18n.yaml +++ b/packages/client/ui-subagent/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-subagent/README.md -README.md: 16a54484fb53544c71af0806c1497a18f9141002 -README.zh.md: 166a25d095b3e7f10f7239262f39e27972344529 +README.md: 33a2f2899fc34af52cda6b19f473847927da7ef0 +README.zh.md: cab1edb6b81df4b89d05153c801a4d277037ad9c diff --git a/packages/client/ui-subagent/README.md b/packages/client/ui-subagent/README.md index 16a54484fb..33a2f2899f 100644 --- a/packages/client/ui-subagent/README.md +++ b/packages/client/ui-subagent/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Web subagent feature owner: contributes the lazily expandable catalog tree to `conversation.session.header.actions`, reason-specific read-only replacements to the conversation composer chain, and the existing `@` reference source to `ctx.slash`. -The header action reads `subagentsByParent` and session summaries through the standard `useSessions` hook. After a non-empty direct catalog arrives, its trigger counts the complete subagent-only descendant lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. The compact tree remains direct-catalog authoritative: continuable and one-shot rows display mode plus `running`/`inactive` activity, an optional log-backed title, and active-turn duration to the second; an unlabeled one-shot row falls back to its session id, while corrupt, unsupported, or unavailable rows remain readable but disabled. Duration sums completed `subagentTiming` turns, advances once per second only for an open turn on a running child, and freezes after the child becomes inactive; an interrupted open turn is bounded by the session summary's last activity. Each healthy row's `hasChildren` hint determines disclosure before interaction, so known leaves never show an arrow; expanding a branch immediately reserves one disabled loading row per known direct descendant, then lazily replaces them with that child's authoritative catalog. Every visible branch is reported to the runtime so membership frames cause a debounced refresh only where the tree is being consumed. Selecting any depth calls `SessionsService.openSubagent()` with the row's exact `{parentSessionId, childSessionId, mode}` address. Component-local state owns tree visibility, expanded branches, keyboard focus, and the running-duration clock. ArrowRight/ArrowLeft expand and collapse branches; ArrowUp/ArrowDown, Home, End, and Escape navigate or close the tree; closing returns focus to the trigger. Styling uses tokens only. +The header action reads `subagentsByParent` and session summaries through the standard `useSessions` hook. After a non-empty direct catalog arrives, its trigger counts the complete subagent-only descendant lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. The compact tree remains direct-catalog authoritative: continuable and one-shot rows display mode plus `running`/`inactive` activity, an optional log-backed title, and active-turn duration to the second; an unlabeled one-shot row falls back to its session id, while corrupt, unsupported, or unavailable rows remain readable but disabled. Duration sums completed `subagentTiming` turns, advances once per second only for an open turn on a running child, and freezes after the child becomes inactive; an interrupted open turn is bounded by its same-cut `active.through`, never by newer session metadata. Each healthy row's `hasChildren` hint determines disclosure before interaction, so known leaves never show an arrow; expanding a branch immediately reserves one disabled loading row per known direct descendant, then lazily replaces them with that child's authoritative catalog. Every visible branch is reported to the runtime so membership frames cause a debounced refresh only where the tree is being consumed. Selecting any depth calls `SessionsService.openSubagent()` with the row's exact `{parentSessionId, childSessionId, mode}` address. Component-local state owns tree visibility, expanded branches, keyboard focus, and the running-duration clock. ArrowRight/ArrowLeft expand and collapse branches; ArrowUp/ArrowDown, Home, End, and Escape navigate or close the tree; closing returns focus to the trigger. Styling uses tokens only. A one-shot child always elects a read-only composer that identifies the transcript as a completed execution record. A continuable child does so only when its exact parent is unavailable, with copy explaining the recovery path. A continuable child with a live parent keeps the ordinary input chrome, whose Session routes through `subagent.prompt`; running input remains Send because every follow-up joins the child's FIFO inbox, and addressed sessions never expose Stop. This package never receives host context or calls a model-facing tool. The catalog and composer behavior are specified by the [Web subagent conversations Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md). diff --git a/packages/client/ui-subagent/README.zh.md b/packages/client/ui-subagent/README.zh.md index 166a25d095..cab1edb6b8 100644 --- a/packages/client/ui-subagent/README.zh.md +++ b/packages/client/ui-subagent/README.zh.md @@ -4,7 +4,7 @@ Web subagent 功能 owner:向 `conversation.session.header.actions` 贡献可懒加载展开的目录树,向会话编辑器链贡献按原因区分的只读替代呈现,并保留注册到 `ctx.slash` 的既有 `@` 引用 source。 -页头操作通过标准 `useSessions` 钩子读取 `subagentsByParent` 与会话摘要。非空直接目录到达后,其触发器会统计仅含 subagent 的完整后代谱系,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。紧凑树仍以直接目录为权威依据:可继续和 one-shot 行会显示 mode、`running`/`inactive` 活动状态、由日志支撑的可选 title,以及精确到秒的活跃轮次耗时;没有 label 的 one-shot 行会回退到其会话 id,而损坏、不受支持或不可用的行仍保持可读但禁用。耗时会累加已完成的 `subagentTiming` 轮次,仅在运行中 child 存在未结束轮次时每秒递增一次,并在 child 变为 inactive 后冻结;被中断的未结束轮次以会话摘要中的最后活动为上界。每个健康行的 `hasChildren` 提示会在交互前决定是否显示展开控件,因此已知叶子节点从不显示箭头;展开分支时,会立即为每个已知直接后代预留一行禁用的加载行,随后再用该 child 的权威目录懒加载结果替换这些占位行。每个可见分支都会上报给运行时,使成员帧只在树正被消费的位置触发去抖动刷新。选择任意深度的条目都会使用该行的确切地址 `{parentSessionId, childSessionId, mode}` 调用 `SessionsService.openSubagent()`。组件局部状态负责树的可见性、已展开分支、键盘焦点与运行中耗时时钟。ArrowRight/ArrowLeft 展开和折叠分支;ArrowUp/ArrowDown、Home、End 与 Escape 用于导航或关闭树;关闭后焦点返回触发器。样式只使用 token。 +页头操作通过标准 `useSessions` 钩子读取 `subagentsByParent` 与会话摘要。非空直接目录到达后,其触发器会统计仅含 subagent 的完整后代谱系,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。紧凑树仍以直接目录为权威依据:可继续和 one-shot 行会显示 mode、`running`/`inactive` 活动状态、由日志支撑的可选 title,以及精确到秒的活跃轮次耗时;没有 label 的 one-shot 行会回退到其会话 id,而损坏、不受支持或不可用的行仍保持可读但禁用。耗时会累加已完成的 `subagentTiming` 轮次,仅在运行中 child 存在未结束轮次时每秒递增一次,并在 child 变为 inactive 后冻结;被中断的未结束轮次以其同一切面的 `active.through` 为上界,绝不使用更新的会话元数据。每个健康行的 `hasChildren` 提示会在交互前决定是否显示展开控件,因此已知叶子节点从不显示箭头;展开分支时,会立即为每个已知直接后代预留一行禁用的加载行,随后再用该 child 的权威目录懒加载结果替换这些占位行。每个可见分支都会上报给运行时,使成员帧只在树正被消费的位置触发去抖动刷新。选择任意深度的条目都会使用该行的确切地址 `{parentSessionId, childSessionId, mode}` 调用 `SessionsService.openSubagent()`。组件局部状态负责树的可见性、已展开分支、键盘焦点与运行中耗时时钟。ArrowRight/ArrowLeft 展开和折叠分支;ArrowUp/ArrowDown、Home、End 与 Escape 用于导航或关闭树;关闭后焦点返回触发器。样式只使用 token。 one-shot child 始终选用只读编辑器,并将 transcript(文本记录)说明为已完成的执行记录。可继续 child 仅在其确切 parent 不可用时选用只读编辑器,并以文案说明恢复路径。确切 parent 存活时,可继续 child 保留普通输入 chrome,其 Session 会通过 `subagent.prompt` 路由;child 运行期间,输入操作仍为 Send,因为每条后续消息都会进入 child 的 FIFO inbox,且已寻址会话绝不公开 Stop。本包绝不接收宿主 context,也不调用面向模型的工具。目录与编辑器行为由 [Web subagent 对话 Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md)规定。 From c183fae6f9827e20e1a5e09ea38bc91b3f0b5a76 Mon Sep 17 00:00:00 2001 From: kingwl Date: Mon, 3 Aug 2026 12:04:44 +0800 Subject: [PATCH 087/129] clarify timing projection schema cast --- packages/subagent/subagent/src/projection.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/subagent/subagent/src/projection.ts b/packages/subagent/subagent/src/projection.ts index 171ea18eef..ffdcb4fd09 100644 --- a/packages/subagent/subagent/src/projection.ts +++ b/packages/subagent/subagent/src/projection.ts @@ -19,8 +19,8 @@ interface TimingState { descriptorSeen: boolean } -// Cast for the optional values: under exactOptionalPropertyTypes zod infers -// `number | undefined` where the interface declares absent-or-number fields. +// Zod's optional output includes explicit `undefined`; with +// exactOptionalPropertyTypes the public interface permits omission only. const projectionSchema = z.object({ settledMs: z.number().int().nonnegative(), active: z.object({ From 30f442d42e36ab989596a6120a04236802a0902f Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 3 Aug 2026 12:11:11 +0800 Subject: [PATCH 088/129] review(directory-picker-browse): re-arm the draft-following wait per keystroke ds-review-bot round one. Keying the debounce on the directory part the draft named left two states with no recovery until the operator crossed a separator: a keystroke that superseded an in-flight scan never re-armed one, and an edit after a rejected submission released the hold with no timer left to release. The wait is now keyed on the draft itself and decides its target when it fires, reading the panes through a ref so a landing cannot re-arm it (a host answering with a differently spelled path would otherwise scan forever). A landed scan that unmounts the row a keyboard operator Tabbed onto re-parks focus on the still-open editor; the Modal has no focus trap. That a walked-to level survives closing the editor is now stated in the README, the Agent Note, and the module contract. The new e2e stages its own beta directory so running it alone sees the tree its assertions describe. --- ...directory-picker-capability-seam.i18n.yaml | 4 +- ...-07-28-directory-picker-capability-seam.md | 2 +- ...-28-directory-picker-capability-seam.zh.md | 2 +- apps/web/tests/workspace-management.e2e.ts | 3 + .../directory-picker-browse/README.i18n.yaml | 4 +- .../host/directory-picker-browse/README.md | 2 +- .../host/directory-picker-browse/README.zh.md | 2 +- .../src/client/DirectoryBrowser.tsx | 54 ++++++++++---- .../tests/directory-browser.spec.tsx | 74 ++++++++++++++++++- 9 files changed, 124 insertions(+), 23 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml index 4342f49f2b..9afbc7ebcd 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.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 .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md -2026-07-28-directory-picker-capability-seam.md: 90aa8bc7cfc0dc0fb3d057b9991682c9b531ea23 -2026-07-28-directory-picker-capability-seam.zh.md: 12917c95456bca9cdd5e20ae97847156af81277e +2026-07-28-directory-picker-capability-seam.md: c7833eaee691618bb76e40bf34c2114dda030315 +2026-07-28-directory-picker-capability-seam.zh.md: 9a6d52a3e1e3629ae54dbcf46ee8e785536294cd diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md index 90aa8bc7cf..c7833eaee6 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md @@ -20,7 +20,7 @@ Placement and policy rulings folded into this decision: - **Dependency survey (hand-roll vs adopt).** Node's stdlib *is* the maintained cross-platform OS layer (`readdir(withFileTypes)`, `homedir`, path semantics); surveyed alternatives fail the dependency bar — file-manager packages (`node-file-manager`, `files-and-folders`, Syncfusion's provider) are whole HTTP apps (fit), drive-letter helpers (`drivelist` native addon, `windows-drive-letters` ~7y stale) fail health/proportionality. The browse backend is a thin adapter over stdlib. - **Hidden entries: return-and-flag.** The host stamps `hidden` (POSIX dot convention) and returns everything; the client filters. Display policy stays client-side, and the show-hidden toggle shipped as exactly that client-only change: a fixed-label footer toggle whose state lives in the pressed presentation (`aria-pressed` + check glyph), a dot-led path-draft prefix reveals the hidden entries it names, and the current selection is exempt from both the hidden and the prefix filter (it anchors the two-pane view). Windows' `FILE_ATTRIBUTE_HIDDEN` is not exposed by dirents — documented limitation until a native probe pays for itself. - **Path-editor cancel scope: the dialog card.** The browse client's path editor cancels on Escape and on focus leaving the card, both observed at a card-scope wrapper rather than the input — after Tab parks focus on a filtered row the input is off the event path, yet Escape must collapse the editor (not the dialog) and a later focus departure must still cancel. Non-cancel exemptions: window/tab focus loss, in-card focus moves, and pointer paths (rows and the toggle suppress focus steal on mousedown while editing). Separators for seeding and draft-tail filtering are inferred from `listing.home`; the wire-field alternative below records the deferred authoritative form. Combobox semantics between the editor and the list it filters (`aria-expanded`/`aria-controls`/active-descendant, result announcements) are likewise deferred — today they read to assistive tech as separate widgets. -- **The path editor advertises itself, and the panes follow the draft.** The click-to-edit zone is not invisible: a pencil glyph sits at the bar's right edge and hover/focus lights the zone in the editor's own footprint, so the one route into typing a path is discoverable and the bar does not resize when zone and input swap. While the editor is open the panes track the draft instead of whatever level happened to be listed when it opened — the final segment prefix-filters the level its directory part names, a tail nobody matches releases the filter (a name still being spelled must not empty the pane it is being spelled into), and a directory part no pane lists is scanned after a 250ms rest and lands single-wide in place, so typing deeper descends and erasing segments steps back up without leaving the editor. That scan is speculative — half-typed directories are unreadable most of the time — so a failure keeps the last readable panes and stays silent. Enter remains the authoritative commit: it owns the view from submission until landing (a debounce timer armed by the same keystrokes is held back rather than superseding the navigation, and a rejected submission stays held until the next edit) and it alone surfaces the failure. +- **The path editor advertises itself, and the panes follow the draft.** The click-to-edit zone is not invisible: a pencil glyph sits at the bar's right edge and hover/focus lights the zone in the editor's own footprint, so the one route into typing a path is discoverable and the bar does not resize when zone and input swap. While the editor is open the panes track the draft instead of whatever level happened to be listed when it opened — the final segment prefix-filters the level its directory part names, a tail nobody matches releases the filter (a name still being spelled must not empty the pane it is being spelled into), and a directory part no pane lists is scanned after a 250ms rest and lands single-wide in place, so typing deeper descends and erasing segments steps back up without leaving the editor. That scan is speculative — half-typed directories are unreadable most of the time — so a failure keeps the last readable panes and stays silent. Enter remains the authoritative commit: it owns the view from submission until landing (a debounce timer armed by the same keystrokes is held back rather than superseding the navigation, and a rejected submission stays held until the next edit) and it alone surfaces the failure. Two consequences are deliberate. The wait is keyed on the draft, not on the directory part it names, so a keystroke that superseded an in-flight scan re-arms one and an edit after a rejected submission releases the hold; the panes it reads are a ref rather than a dependency, or the landing would re-arm the wait and a host answering with a differently spelled path would scan forever. And a walk is not rewound: closing the editor — cancellation included — leaves the panes where the draft took them, named by the crumbs and followed by Open's fallback target, because the operator watched them move. A landing that unmounts the row a keyboard operator Tabbed onto re-parks focus on the editor, since the Modal has no focus trap. - **Navigation lands selection-anchored, quiet, and bounded.** Away from the display root (the same collapse the crumb header renders, so crumbs and pane shape never disagree), the landing is two-pane: the target's actual parent-level entry re-selected (platform case folding on Windows), its children on the right, so a crumb jump reads as stepping back one pane rather than collapsing to a single column. Target and parent legs land as **one frame** when the parent leg settles within the 200ms wait bound — the stale view keeps rendering until then, so navigation swaps the panes without an intermediate single-pane flash — and past the bound the target commits alone at once (an Enter-submitted navigation is never held hostage by a stalled parent) with the late parent leg upgrading the landing in place. The parent leg runs under the landing's supersession scope and is aborted on the wire by any newer intent (Escape inside the landing window therefore withdraws the whole navigation); a failed parent leg, or a truncated parent window lacking the target, leaves the single-pane landing — the upgrade must never orphan the selection it exists to anchor. The loading indicator follows the same quiet rule: it floats over the content's bottom-right corner (never a layout-shifting row; the truncated/error rows own the bottom left and keep rendering through a scan) and only once a scan outlives a 300ms silence window, so a local listing swaps with nothing shown at all. Row picks are deliberately exempt from the one-frame rule: a pick's immediate pane split is its selected-state feedback (aria-current, crumbs following), while a navigation has nothing to acknowledge the click but the swap itself. Both timing constants are calibrated for local enumeration; a remote deployment (one RPC per level, commonly 100–400ms) would sit inside the silence window with no pressed state on the crumbs — revisit the window or add pressed feedback when a remote consumer lands. - **Symlinks: follow for enterability.** `stat` probes symlinks (broken/cyclic → skipped); crumbs keep the logical path the operator navigated, and `workspace.create` already canonicalizes via realpath at adoption. - **Listing levels are bounded, and streamed.** One `list` call returns at most `maxEntries` rows (config, default 1000 — GitHub's web-UI directory-listing bound). The level streams via `opendir` into a name-sorted window of `maxEntries + 1` candidates, so memory stays O(maxEntries) and enterability probing touches only windowed candidates; the wire `DirectoryListing` carries a required `truncated` flag so the client states incompleteness instead of silently missing tail entries. A windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated. Window insertion is binary with an O(1) full-window tail rejection (an oversized level must not pay a window scan per dirent), and `list(path, signal)` threads the carrier's request signal so a scan of a stalled network directory cannot outlive a disconnected caller — every await in the scan (open, each read, each symlink probe) races the signal, an aborted exit abandons rather than awaits the close (Node queues close behind in-flight reads), and abandoned settlements are swallowed so cleanup can never surface as an unhandled rejection. An unbounded level is a memory/responsiveness hole for large or adversarial directories. diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md index 12917c9545..9a6d52a3e1 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md @@ -20,7 +20,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick - **依赖调研(手写 vs 引入)。** Node 标准库本身就是维护中的跨平台 OS 层(`readdir(withFileTypes)`、`homedir`、路径语义);调研过的替代品都过不了依赖门槛——文件管理器包(`node-file-manager`、`files-and-folders`、Syncfusion 的 provider)是整套 HTTP 应用(契合度不过),盘符工具(原生插件 `drivelist`、约七年未更的 `windows-drive-letters`)健康度/比例失当。browse 后端是标准库上的薄适配。 - **隐藏条目:返回并打标。** 宿主标注 `hidden`(POSIX 点前缀约定)并返回全部条目;客户端过滤。展示策略留在客户端,"显示隐藏"开关正是作为这一纯客户端改动落地:标签固定的 footer 开关,其状态由按下态呈现承载(`aria-pressed` + 勾选符号);以点开头的路径草稿前缀会显出它所指名的隐藏条目;当前选中项则不受隐藏与前缀两种过滤影响(它锚定着双栏视图)。Windows 的 `FILE_ATTRIBUTE_HIDDEN` 不被 dirent 暴露——记为限制,直到原生探测值回其成本。 - **路径编辑器的取消范围:对话框卡片。** browse 客户端的路径编辑器在按 Escape 与焦点离开卡片时取消,两者都在卡片范围的包装层而非输入框上监听——Tab 把焦点停到某个过滤命中的行之后,输入框已不在事件路径上,但 Escape 仍须收起编辑器(而非对话框),其后的焦点离开也仍须取消。不取消的豁免:窗口/标签页失焦、卡片内焦点移动,以及指针路径(编辑期间行与开关在 mousedown 时抑制焦点夺取)。预填与草稿末段过滤所用的分隔符从 `listing.home` 推断;下文的线上字段替代方案记录了被延期的权威形态。编辑器与其过滤的列表之间的 combobox 语义(`aria-expanded`/`aria-controls`/active-descendant、结果播报)同样被延期——目前二者在辅助技术看来是彼此独立的控件。 -- **路径编辑器自我点明,各栏跟随草稿。** 点击即编辑的区域不再是隐形的:栏右端坐着一枚铅笔图标,悬停/聚焦时该区以编辑器自身的轮廓亮起,于是键入路径这唯一入口可被发现,且区域与输入框互换时栏高不变。编辑器打开期间,各栏跟随草稿,而不是停在它打开那一刻恰好列出的层级——末段对其目录部分所指的层级做前缀过滤,无一匹配的末段解除过滤(还在拼写中的名字不该把正在拼写它的那一栏清空),而任何一栏都未列出的目录部分会在停顿 250ms 后被扫描、以单宽栏就地落地,于是继续键入即下潜、删掉末段即上退,全程不必离开编辑器。该扫描是推测性的——键入到一半的目录多数时候读不出来——因此失败时保留最后一次可读的分栏并保持沉默。Enter 仍是权威提交:自提交至落地由它独占视图(同一批按键武装的防抖计时器会被扣住,而不是顶掉这次导航;提交被拒后仍扣住,直到下一次编辑),也只有它把失败呈现出来。 +- **路径编辑器自我点明,各栏跟随草稿。** 点击即编辑的区域不再是隐形的:栏右端坐着一枚铅笔图标,悬停/聚焦时该区以编辑器自身的轮廓亮起,于是键入路径这唯一入口可被发现,且区域与输入框互换时栏高不变。编辑器打开期间,各栏跟随草稿,而不是停在它打开那一刻恰好列出的层级——末段对其目录部分所指的层级做前缀过滤,无一匹配的末段解除过滤(还在拼写中的名字不该把正在拼写它的那一栏清空),而任何一栏都未列出的目录部分会在停顿 250ms 后被扫描、以单宽栏就地落地,于是继续键入即下潜、删掉末段即上退,全程不必离开编辑器。该扫描是推测性的——键入到一半的目录多数时候读不出来——因此失败时保留最后一次可读的分栏并保持沉默。Enter 仍是权威提交:自提交至落地由它独占视图(同一批按键武装的防抖计时器会被扣住,而不是顶掉这次导航;提交被拒后仍扣住,直到下一次编辑),也只有它把失败呈现出来。有两点是刻意为之。等待以草稿为键,而非以它指名的目录部分为键,于是顶掉在飞扫描的那次按键会重新武装等待,被拒提交之后的编辑也能释放那道扣留;而它读取的分栏是 ref 而非依赖,否则落地会重新武装等待,遇到以不同拼写作答的宿主便会永远扫描下去。以及,走过的路不回退:关闭编辑器——包括取消——都把分栏留在草稿带到的地方,由面包屑指明、Open 的兜底目标随之而动,因为操作者亲眼看着它们移动。若落地卸载了键盘操作者 Tab 停留的那一行,焦点会被重新停回编辑器——Modal 并没有焦点陷阱。 - **导航以选中项为锚、安静且有界地落地。** 在展示根之外(与 crumb 头部渲染的是同一塌缩,因此 crumb 与分栏形态永不相左),落地即双栏:重新选中目标在父层级中的实际条目(Windows 上按平台惯例折叠大小写),右侧展示其子项,因此 crumb 跳转读作后退一栏,而不是塌缩成单列。父层级这一程在 200ms 等待上限内落定时,目标与父层级两程以**同一帧**落地——在此之前陈旧视图持续渲染,导航换栏时因此没有中间的单栏闪现——超出该上限则目标即刻单独提交(Enter 提交的导航绝不会被滞塞的父层级扣作人质),迟到的父层级这一程再就地升级这次落地。父层级这一程在落地的 supersession 范围下运行,任何较新的意图都会在线上将其中止(因此在落地窗口内按 Escape 即撤回整次导航);父层级这一程失败,或被截断的父窗口缺少目标时,都保留单栏落地——升级的存在正是为了锚定选中项,绝不能反而让它悬空。加载指示器遵循同一安静规则:它浮于内容右下角(绝不是会挪动布局的一行;截断/错误行占据左下角,并在扫描期间持续渲染),且仅在扫描超出 300ms 静默窗口后才出现,因此本地列举切换时什么也不显示。行选取被刻意豁免于同一帧规则:选取后立即分栏本身就是其选中态反馈(aria-current、crumb 跟随),而导航除了换栏本身没有任何东西可确认这次点击。两个时序常量都按本地列举校准;远程部署(每层级一次 RPC,通常 100–400ms)会落在静默窗口之内、crumb 上却没有按下态——待远程消费方落地时,重新审视该窗口或补上按下反馈。 - **符号链接:为可进入性而跟随。** 用 `stat` 探测符号链接(断链/循环→跳过);面包屑保留操作者导航的逻辑路径,`workspace.create` 在接纳时本就做 realpath 规范化。 - **列举层级有上限,且流式处理。** 单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端目录列举的同一上限)。层级经 `opendir` 流入一个按名排序、容量 `maxEntries + 1` 的候选窗口,内存保持 O(maxEntries),可进入性探测只触及窗口内候选;线上 `DirectoryListing` 携带必填的 `truncated` 标志,让客户端明示不完整而不是静默缺尾。窗口内的断链符号链接不从窗口外回填——发生过驱逐本身已把层级标记为截断。窗口插入为二分查找、满窗尾部单次比较即拒绝(超大层级不能为每个 dirent 付出一次全窗扫描),且 `list(path, signal)` 透传载体的请求信号,滞塞网络目录的扫描不会在调用方断连后继续存活——扫描中的每个 await(打开、每次读取、每次符号链接探测)都与信号赛跑,中止路径放弃而非等待 close(Node 会把 close 排在在飞读取之后),被放弃的 settlement 全部吞掉,清理不会以未处理拒绝的形式冒出。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。 diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index 1a5531f59a..8eec0bc31c 100644 --- a/apps/web/tests/workspace-management.e2e.ts +++ b/apps/web/tests/workspace-management.e2e.ts @@ -407,8 +407,11 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff it('walks the panes with the typed path: deeper past a separator, back up on erase, whole on a miss', async () => { // The panes must track the draft without leaving the editor, so the // typed text and what is listed under it never disagree. + // Staged by this scenario itself (mkdir is recursive and idempotent), so + // running it alone through -t sees the same tree the assertions describe. const staged = join(scaffold.workspaceCwd, 'browse-golden') await mkdir(join(staged, 'alpha', 'only-under-alpha'), { recursive: true }) + await mkdir(join(staged, 'beta'), { recursive: true }) const dialog = await browseTo(staged) await expect.poll(() => dialog.getByText('alpha', { exact: true }).count(), { timeout: 10_000 }).toBe(1) await dialog.getByRole('button', { name: 'Edit path' }).click() diff --git a/packages/host/directory-picker-browse/README.i18n.yaml b/packages/host/directory-picker-browse/README.i18n.yaml index 4063c7d692..06979f1226 100644 --- a/packages/host/directory-picker-browse/README.i18n.yaml +++ b/packages/host/directory-picker-browse/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/host/directory-picker-browse/README.md -README.md: 7cb0ec785766e954ff4bb39df6825ee7e8c9d821 -README.zh.md: ec71a90bbcd004ec9f9c0d8a9a236882a7487b73 +README.md: a559f7f23b74f694c25fb43e005cbaccd4024308 +README.zh.md: 321e27d7a1dc9eb9bf0d1dab37c1af42e8ad6519 diff --git a/packages/host/directory-picker-browse/README.md b/packages/host/directory-picker-browse/README.md index 7cb0ec7857..a559f7f23b 100644 --- a/packages/host/directory-picker-browse/README.md +++ b/packages/host/directory-picker-browse/README.md @@ -6,7 +6,7 @@ The **in-app browsing backend** of the [directory-picker seam](../directory-pick Behavior facts: listings return **directories only**, name-sorted, with symlinks-to-directories followed (broken/cyclic links skipped — the probe `stat` failing means "not enterable") and a host-owned `hidden` flag (POSIX dot convention) left for the client to act on; `crumbs` is the root-to-target ancestor chain, the root crumb labeled by its full path (`/`, `C:\`); an absent `list` path means the host account's home directory. `createDirectory` is non-recursive (a missing parent is a real failure, not a level to invent) and validates the name as a single non-blank segment even when called directly, mirroring the wire schema's fence. Both primitives reject an explicit path that is not fully qualified — relative forms, and on Windows the rooted drive-less forms (`\foo`, `/foo`) and incomplete UNC prefixes (`\\`, `\\server`) that `isAbsolute` accepts — with `directory-unreadable`/`directory-create-failed`, instead of letting `resolve` rebase it under the host process cwd or current drive. One `list` call returns at most `maxEntries` rows (config, default 1000 — the bound GitHub's web UI applies to directory listings), and the level streams through a bounded window so memory stays O(maxEntries) no matter how many children the directory holds: a cut level keeps the name-sorted head, counts hidden rows against the bound, probes only windowed candidates, and reports `truncated: true` so the client can say the level is incomplete (a windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated); window insertion is binary with an O(1) full-window tail rejection, and `list` threads the caller's `AbortSignal` so a disconnect or timeout stops the scan instead of letting it outlive the caller. Failures throw the seam's typed `DirectoryPickerError`. Policy rationale: [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md). -**Dual-face package**: the browser half (`./client`) fills [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes with the in-app **Select Workspace Directory** dialog (figma `Harness` 813-23126 family — Miller two-column view whose navigations land selection-anchored and quiet: the previous view keeps rendering while a crumb jump or a submitted path is scanned (a "Loading…" pill floats over it only once the scan outlives a 300ms silence window, never shifting the columns), then target and parent legs land as one two-pane frame with the target re-selected as its actual parent-level entry — so stepping back never collapses and no intermediate frame flashes (a parent leg outliving its 200ms wait bound lands the target alone and upgrades in place; a failed or truncated parent leg keeps the single-pane landing; the display root keeps the single wide level); breadcrumb with a click-to-edit path zone, advertised by the pencil glyph at the bar's right edge and lit on hover in the editor's own footprint, whose editor seeds a trailing separator and then keeps the panes under the draft: the final segment prefix-filters the level its directory part names (case-insensitively, over the listed — possibly truncated — rows only; a tail nobody matches releases the filter instead of emptying the pane), while a directory part no pane lists is scanned after a 250ms rest and shown in place, so typing deeper descends and erasing segments steps back up without leaving the editor — a speculative scan is silent when it fails, and Enter still navigates by the exact text, owning the view until it lands; the editor cancels on Escape or when focus leaves the dialog card (window/tab switches and in-card focus moves keep the draft); a fixed-label show-hidden footer toggle over the host's `hidden` flags, with a dot-led typed prefix revealing its matches and the current selection exempt from every filter; nested New-folder dialog), driving `host.listDirectory`/`host.createDirectory` and registering its own locale namespace (`directory-browser`, zh default / en). One cordis.yml row therefore composes both sides of the browse interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind). +**Dual-face package**: the browser half (`./client`) fills [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes with the in-app **Select Workspace Directory** dialog (figma `Harness` 813-23126 family — Miller two-column view whose navigations land selection-anchored and quiet: the previous view keeps rendering while a crumb jump or a submitted path is scanned (a "Loading…" pill floats over it only once the scan outlives a 300ms silence window, never shifting the columns), then target and parent legs land as one two-pane frame with the target re-selected as its actual parent-level entry — so stepping back never collapses and no intermediate frame flashes (a parent leg outliving its 200ms wait bound lands the target alone and upgrades in place; a failed or truncated parent leg keeps the single-pane landing; the display root keeps the single wide level); breadcrumb with a click-to-edit path zone, advertised by the pencil glyph at the bar's right edge and lit on hover in the editor's own footprint, whose editor seeds a trailing separator and then keeps the panes under the draft: the final segment prefix-filters the level its directory part names (case-insensitively, over the listed — possibly truncated — rows only; a tail nobody matches releases the filter instead of emptying the pane), while a directory part no pane lists is scanned after a 250ms rest and shown in place, so typing deeper descends and erasing segments steps back up without leaving the editor — a speculative scan is silent when it fails, and Enter still navigates by the exact text, owning the view until it lands; the editor cancels on Escape or when focus leaves the dialog card (window/tab switches and in-card focus moves keep the draft), and panes the draft walked to stay where the walk ended — the crumbs name that level and Open's fallback target follows them, so cancelling closes the editor rather than rewinding the walk; a fixed-label show-hidden footer toggle over the host's `hidden` flags, with a dot-led typed prefix revealing its matches and the current selection exempt from every filter; nested New-folder dialog), driving `host.listDirectory`/`host.createDirectory` and registering its own locale namespace (`directory-browser`, zh default / en). One cordis.yml row therefore composes both sides of the browse interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind). ## Model Experience diff --git a/packages/host/directory-picker-browse/README.zh.md b/packages/host/directory-picker-browse/README.zh.md index ec71a90bbc..321e27d7a1 100644 --- a/packages/host/directory-picker-browse/README.zh.md +++ b/packages/host/directory-picker-browse/README.zh.md @@ -6,7 +6,7 @@ 行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `stat` 失败即"不可进入"),并携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示决策留给客户端;`crumbs` 是从根到目标的祖先链,根 crumb 以完整路径标注(`/`、`C:\`);`list` 不带路径即列举宿主账户的家目录。`createDirectory` 不递归(父目录缺失是真实失败,不是要补造的层级),且即便被直接调用也把名称校验为单个非空段,与协议 schema 的栅栏一致。两个原语都拒绝非完全限定的显式路径——相对形态,以及 Windows 上 `isAbsolute` 会放行的无盘符有根形态(`\foo`、`/foo`)与不完整的 UNC 前缀(`\\`、`\\server`)——报 `directory-unreadable`/`directory-create-failed`,而不是任由 `resolve` 把它重定位到宿主进程 cwd 或当前盘符之下。单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端对目录列举采用的同一上限),且层级以流式方式经过一个有界窗口,无论目录有多少子项内存都保持 O(maxEntries):被截断的层级保留按名排序的头部、隐藏行计入上限、只探测窗口内候选,并报告 `truncated: true`,供客户端提示层级不完整(窗口内的断链符号链接不会从窗口外回填——发生过驱逐本身已把层级标记为截断);窗口插入为二分查找、满窗尾部单次比较即拒绝,且 `list` 透传调用方的 `AbortSignal`,断连或超时会停止扫描而不是让它在调用方离开后继续。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 -**双面包**:browser half(`./client`)以应用内 **选择工作区目录** 对话框(figma `Harness` 813-23126 家族——Miller 双列视图,其导航以选中项为锚、安静落地:扫描 crumb 跳转或提交的路径期间,先前视图持续渲染("Loading…" 胶囊仅在扫描超出 300ms 静默窗口后才浮于其上,绝不挪动各列),随后目标与父层级两程以单个双栏帧落地,目标被重新选中为其在父层级中的实际条目——因此后退绝不塌缩,也没有中间帧闪现(父层级这一程超出其 200ms 等待上限时,目标单独落地,随后就地升级;父层级这一程失败或被截断时保持单栏落地;展示根保持单个宽层级);带点击即编辑路径区的面包屑,该区由栏右端的铅笔图标点明、悬停时以编辑器自身的轮廓亮起,其编辑器预填尾随分隔符,随后让下方各栏跟随草稿:末段对其目录部分所指的层级做前缀过滤(不区分大小写,且仅作用于已列出、可能被截断的行;无一匹配的末段会解除过滤,而不是把该栏清空),而任何一栏都未列出的目录部分会在停顿 250ms 后被扫描并就地展示,于是继续键入即下潜、删掉末段即上退,全程不必离开编辑器——推测性扫描失败时保持沉默,而 Enter 仍按确切文本导航,并在落地前独占视图;编辑器按 Escape 或焦点离开对话框卡片即取消(窗口/标签页切换与卡片内焦点移动保留草稿);基于宿主 `hidden` 标志、标签固定的"显示隐藏"footer 开关,键入以点开头的前缀会显出其匹配项,且当前选中项不受任何过滤影响;嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞,驱动 `host.listDirectory`/`host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`,zh 默认/en)。因此一行 cordis.yml 同时组合浏览交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。 +**双面包**:browser half(`./client`)以应用内 **选择工作区目录** 对话框(figma `Harness` 813-23126 家族——Miller 双列视图,其导航以选中项为锚、安静落地:扫描 crumb 跳转或提交的路径期间,先前视图持续渲染("Loading…" 胶囊仅在扫描超出 300ms 静默窗口后才浮于其上,绝不挪动各列),随后目标与父层级两程以单个双栏帧落地,目标被重新选中为其在父层级中的实际条目——因此后退绝不塌缩,也没有中间帧闪现(父层级这一程超出其 200ms 等待上限时,目标单独落地,随后就地升级;父层级这一程失败或被截断时保持单栏落地;展示根保持单个宽层级);带点击即编辑路径区的面包屑,该区由栏右端的铅笔图标点明、悬停时以编辑器自身的轮廓亮起,其编辑器预填尾随分隔符,随后让下方各栏跟随草稿:末段对其目录部分所指的层级做前缀过滤(不区分大小写,且仅作用于已列出、可能被截断的行;无一匹配的末段会解除过滤,而不是把该栏清空),而任何一栏都未列出的目录部分会在停顿 250ms 后被扫描并就地展示,于是继续键入即下潜、删掉末段即上退,全程不必离开编辑器——推测性扫描失败时保持沉默,而 Enter 仍按确切文本导航,并在落地前独占视图;编辑器按 Escape 或焦点离开对话框卡片即取消(窗口/标签页切换与卡片内焦点移动保留草稿),而草稿走到的层级会留在原地——面包屑指明该层级、Open 的兜底目标随之而动,因此取消只是关闭编辑器,并不回退这段行走;基于宿主 `hidden` 标志、标签固定的"显示隐藏"footer 开关,键入以点开头的前缀会显出其匹配项,且当前选中项不受任何过滤影响;嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞,驱动 `host.listDirectory`/`host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`,zh 默认/en)。因此一行 cordis.yml 同时组合浏览交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。 ## 模型体验 diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index 1f9903952d..e3a13cc54d 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -25,7 +25,9 @@ * reveals the hidden entries it names, and a prefix nobody matches releases * the filter), while a directory part no pane lists is scanned after a short * debounce and shown in place — so typing deeper descends and erasing - * segments steps back up without leaving the editor. + * segments steps back up without leaving the editor. Panes the draft walked + * to stay put when the editor closes (cancellation included): the crumbs name + * where the walk ended, and Open's fallback target follows them. */ import { useCallback, useEffect, useRef, useState } from 'react' import clsx from 'clsx' @@ -157,9 +159,9 @@ function draftPrefixFor(listing: DirectoryListing, draft: string | null): string function pendingPreviewDirectory( parent: DirectoryListing | null, child: DirectoryListing | null, - draft: string | null, + draft: string, ): string | null { - if (parent === null || draft === null) return null + if (parent === null) return null const directory = draftDirectory(parent, draft) if (directory === null || directory === levelDirectory(parent)) return null if (child !== null && directory === levelDirectory(child)) return null @@ -453,10 +455,23 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, * Enter owns the view from submission until its navigation lands, so the * debounce timer the same keystrokes armed must not supersede it. Cleared * by the next edit (and by opening the editor); a failed submission leaves - * it set, so the rejected path is not immediately re-scanned as a preview. + * it set until the operator edits again, so the rejected path is not + * immediately re-scanned as a preview. */ const previewSuspended = useRef(false) + // The panes as the draft-following scan must read them when its wait + // fires: current, but NOT a dependency of the wait (see the effect below). + const viewRef = useRef<{ parent: DirectoryListing | null; child: DirectoryListing | null }>({ parent: null, child: null }) + useEffect(() => { viewRef.current = { parent, child } }, [parent, child]) + + /** + * A landed preview replaced the pane a keyboard operator may have Tabbed + * onto, so the focus it drops is re-parked on the still-open editor (the + * Modal has no focus trap). Consumed by the refocus effect below. + */ + const refocusPathInput = useRef(false) + /** * List the directory the draft addresses and show it WITHOUT closing the * editor: the level replaces the panes single-wide (the selection and its @@ -478,6 +493,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, setChild(null) setLoading(false) setError(null) + refocusPathInput.current = true }, () => { if (seq !== requestSeq.current) return setLoading(false) @@ -602,21 +618,25 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, return () => { window.clearTimeout(timer) } }, [loading, scanWindow]) - // The panes follow the draft: a directory part no pane lists is scanned - // once the typing rests. The dependency is the target STRING, so the - // landing it commits cannot re-arm the timer (a host that answers with a - // differently spelled path leaves the target unchanged, hence unrepeated), - // and every further keystroke replaces the pending timer instead of - // queueing another scan. - const previewDirectory = pendingPreviewDirectory(parent, child, pathDraft) + // The panes follow the draft: EVERY keystroke replaces the pending timer, + // and the target is decided when it fires, off the panes as they stand + // then. Keying the wait on the draft (not on the directory part it names) + // is what makes a keystroke that superseded an in-flight scan re-arm one, + // and what lets an edit after a rejected submission release the hold the + // submission took. The panes are read through a ref for the converse + // reason: were they dependencies, the landing this commits would re-arm the + // wait, and a host answering with a differently spelled path would scan + // forever. useEffect(() => { - if (previewDirectory === null) return + if (pathDraft === null) return const timer = window.setTimeout(() => { if (previewSuspended.current) return - previewDraftLevel(previewDirectory) + const directory = pendingPreviewDirectory(viewRef.current.parent, viewRef.current.child, pathDraft) + if (directory === null) return + previewDraftLevel(directory) }, DRAFT_PREVIEW_DEBOUNCE_MS) return () => { window.clearTimeout(timer) } - }, [previewDirectory, previewDraftLevel]) + }, [pathDraft, previewDraftLevel]) // After the hooks: a closed dialog renders nothing and evaluates no copy. const crumbSource = child ?? parent @@ -642,6 +662,12 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // replacing the picked button's column — while Enter and an input-focused // Escape land on the crumb edit zone that replaces the input. useEffect(() => { + if (refocusPathInput.current) { + refocusPathInput.current = false + // Only when the swap actually dropped focus to body: focus the operator + // still holds (the input itself, a surviving row) stays theirs. + if (document.activeElement === document.body) pathInputRef.current?.focus() + } if (pathDraft !== null) return if (refocusPick.current) { refocusPick.current = false diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx index 0c7e155add..d94029e3bf 100644 --- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -678,7 +678,7 @@ describe('DirectoryBrowser', () => { }) it('filters the child pane in two-pane mode and follows the draft back up a level', async () => { - mount() + const b = mount() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) fireEvent.click(rowButton(screen.getByRole('listitem'))) await waitFor(() => { expect(columns()).toHaveLength(2) }) @@ -689,6 +689,12 @@ describe('DirectoryBrowser', () => { expect(input.value).toBe(`${DOCS}/`) fireEvent.change(input, { target: { value: `${DOCS}/h` } }) expect(within(columns()[1]!).getByText('harness')).toBeTruthy() + // The child pane already lists that directory: no scan follows, and both + // panes stay. + const settled = b.listDirectory.mock.calls.length + await act(async () => { await new Promise((resolve) => { setTimeout(resolve, 400) }) }) + expect(b.listDirectory.mock.calls).toHaveLength(settled) + expect(columns()).toHaveLength(2) // A miss releases the right pane's filter rather than emptying it. fireEvent.change(input, { target: { value: `${DOCS}/zzz` } }) expect(within(columns()[1]!).getByText('harness')).toBeTruthy() @@ -712,6 +718,12 @@ describe('DirectoryBrowser', () => { expect(b.listDirectory.mock.calls.at(-1)?.[0]).toBe(`${DOCS}/`) // Still editing: the panes moved under the draft, the editor stayed. expect(screen.getByLabelText('browser.editPath').value).toBe(`${DOCS}/h`) + // Typing on inside the level the panes now list costs no scan at all: + // the prefix filter alone answers the draft. + const settled = b.listDirectory.mock.calls.length + fireEvent.change(input, { target: { value: `${DOCS}/ha` } }) + await act(async () => { await new Promise((resolve) => { setTimeout(resolve, 400) }) }) + expect(b.listDirectory.mock.calls).toHaveLength(settled) // Erasing back past the separator steps the panes up a level again. fireEvent.change(input, { target: { value: `${HOME}/Do` } }) await waitFor(() => { expect(screen.getByText('Documents')).toBeTruthy() }) @@ -719,6 +731,62 @@ describe('DirectoryBrowser', () => { expect(columns()).toHaveLength(1) }) + it('re-arms the draft-following scan after a keystroke superseded one in flight', async () => { + let started = 0 + const listDirectory = vi.fn(async (path?: string) => { + if (path !== `${DOCS}/`) return listingFor(path) + started += 1 + // The first scan never settles: the next keystroke aborts it, and only + // a re-armed wait can still land the level the draft names. + if (started === 1) return await new Promise(() => {}) + return listingFor(path) + }) + mount({ listDirectory }) + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + fireEvent.change(input, { target: { value: `${DOCS}/h` } }) + await waitFor(() => { expect(started).toBe(1) }) + // A further tail keystroke supersedes the in-flight scan; the panes must + // still follow, not sit on the stale level until a separator is typed. + fireEvent.change(input, { target: { value: `${DOCS}/ha` } }) + await waitFor(() => { expect(screen.getByText('harness')).toBeTruthy() }) + }) + + it('follows the draft again after an edit releases a failed submission hold', async () => { + const listDirectory = vi.fn(async (path?: string) => { + if (path === HARNESS) throw new Error('target unreadable') + return listingFor(path) + }) + mount({ listDirectory }) + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + // Submitting inside the debounce window holds the pending scan back. + fireEvent.change(input, { target: { value: HARNESS } }) + fireEvent.keyDown(input, { key: 'Enter' }) + await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('target unreadable') }) + // Correcting only the final segment leaves the directory part unchanged; + // the edit must still release the hold and re-arm the wait. + fireEvent.change(input, { target: { value: `${HARNESS}x` } }) + await waitFor(() => { expect(listDirectory).toHaveBeenCalledWith(`${DOCS}/`, expect.anything()) }) + await waitFor(() => { expect(screen.getByText('harness')).toBeTruthy() }) + }) + + it('re-parks focus on the editor when a landed scan unmounts the focused row', async () => { + mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + fireEvent.change(input, { target: { value: `${DOCS}/h` } }) + // The keyboard path: focus Tabbed onto a row of the level about to be + // replaced. Without a re-park it would fall to body, outside a Modal that + // has no focus trap. + rowButton(screen.getByRole('listitem')).focus() + await waitFor(() => { expect(screen.getByText('harness')).toBeTruthy() }) + expect(document.activeElement).toBe(screen.getByLabelText('browser.editPath')) + }) + it('keeps the panes and stays silent when a draft-following scan fails', async () => { const b = mount() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) @@ -1065,6 +1133,10 @@ describe('DirectoryBrowser', () => { fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) const input = screen.getByLabelText('browser.editPath') fireEvent.change(input, { target: { value: DOCS } }) + // With no level listed there is no platform separator to read, so the + // draft-following wait resolves to nothing and the editor types blind. + await act(async () => { await new Promise((resolve) => { setTimeout(resolve, 400) }) }) + expect(listDirectory).toHaveBeenCalledTimes(1) listDirectory.mockImplementation(async (path?: string) => listingFor(path)) fireEvent.keyDown(input, { key: 'Enter' }) await waitFor(() => { expect(screen.getByText('harness')).toBeTruthy() }) From c58b07833e2c59cece49a90383f3bf8ba4f2a49f Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 3 Aug 2026 13:08:31 +0800 Subject: [PATCH 089/129] fix(directory-picker-browse): land the draft-following walk two-pane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The draft-following scan replaced the panes with one wide level, so typing a path collapsed the dialog's Miller view — the thing the dialog is. It now lands through the same selection-anchored landing every navigation uses: target and parent legs as one frame, the target re-selected in its parent level, its children on the right. Typing a path moves the Miller view exactly as a crumb jump does. One landing shape, two callers: `land(path, {closeEditor, announce})` is what `navigate` and the draft-following scan share. A submitted path closes the editor and announces failures; the speculative scan keeps both to itself and re-parks the focus its swap dropped. A level a pane already lists still needs no scan at all — the filter alone answers the draft — so erasing back into the parent's own path keeps both panes and only moves the filter. --- ...directory-picker-capability-seam.i18n.yaml | 4 +- ...-07-28-directory-picker-capability-seam.md | 2 +- ...-28-directory-picker-capability-seam.zh.md | 2 +- apps/web/tests/workspace-management.e2e.ts | 15 +- .../directory-picker-browse/README.i18n.yaml | 4 +- .../host/directory-picker-browse/README.md | 2 +- .../host/directory-picker-browse/README.zh.md | 2 +- .../src/client/DirectoryBrowser.tsx | 138 +++++++++--------- .../tests/directory-browser.spec.tsx | 59 ++++++-- 9 files changed, 131 insertions(+), 97 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml index 9afbc7ebcd..1690808ee0 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.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 .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md -2026-07-28-directory-picker-capability-seam.md: c7833eaee691618bb76e40bf34c2114dda030315 -2026-07-28-directory-picker-capability-seam.zh.md: 9a6d52a3e1e3629ae54dbcf46ee8e785536294cd +2026-07-28-directory-picker-capability-seam.md: 15d0a6ad3fc1e92e0487024c0d7f611380382e2d +2026-07-28-directory-picker-capability-seam.zh.md: 54042cea3bf4a4888855a60765ccc19977e6a061 diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md index c7833eaee6..15d0a6ad3f 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md @@ -20,7 +20,7 @@ Placement and policy rulings folded into this decision: - **Dependency survey (hand-roll vs adopt).** Node's stdlib *is* the maintained cross-platform OS layer (`readdir(withFileTypes)`, `homedir`, path semantics); surveyed alternatives fail the dependency bar — file-manager packages (`node-file-manager`, `files-and-folders`, Syncfusion's provider) are whole HTTP apps (fit), drive-letter helpers (`drivelist` native addon, `windows-drive-letters` ~7y stale) fail health/proportionality. The browse backend is a thin adapter over stdlib. - **Hidden entries: return-and-flag.** The host stamps `hidden` (POSIX dot convention) and returns everything; the client filters. Display policy stays client-side, and the show-hidden toggle shipped as exactly that client-only change: a fixed-label footer toggle whose state lives in the pressed presentation (`aria-pressed` + check glyph), a dot-led path-draft prefix reveals the hidden entries it names, and the current selection is exempt from both the hidden and the prefix filter (it anchors the two-pane view). Windows' `FILE_ATTRIBUTE_HIDDEN` is not exposed by dirents — documented limitation until a native probe pays for itself. - **Path-editor cancel scope: the dialog card.** The browse client's path editor cancels on Escape and on focus leaving the card, both observed at a card-scope wrapper rather than the input — after Tab parks focus on a filtered row the input is off the event path, yet Escape must collapse the editor (not the dialog) and a later focus departure must still cancel. Non-cancel exemptions: window/tab focus loss, in-card focus moves, and pointer paths (rows and the toggle suppress focus steal on mousedown while editing). Separators for seeding and draft-tail filtering are inferred from `listing.home`; the wire-field alternative below records the deferred authoritative form. Combobox semantics between the editor and the list it filters (`aria-expanded`/`aria-controls`/active-descendant, result announcements) are likewise deferred — today they read to assistive tech as separate widgets. -- **The path editor advertises itself, and the panes follow the draft.** The click-to-edit zone is not invisible: a pencil glyph sits at the bar's right edge and hover/focus lights the zone in the editor's own footprint, so the one route into typing a path is discoverable and the bar does not resize when zone and input swap. While the editor is open the panes track the draft instead of whatever level happened to be listed when it opened — the final segment prefix-filters the level its directory part names, a tail nobody matches releases the filter (a name still being spelled must not empty the pane it is being spelled into), and a directory part no pane lists is scanned after a 250ms rest and lands single-wide in place, so typing deeper descends and erasing segments steps back up without leaving the editor. That scan is speculative — half-typed directories are unreadable most of the time — so a failure keeps the last readable panes and stays silent. Enter remains the authoritative commit: it owns the view from submission until landing (a debounce timer armed by the same keystrokes is held back rather than superseding the navigation, and a rejected submission stays held until the next edit) and it alone surfaces the failure. Two consequences are deliberate. The wait is keyed on the draft, not on the directory part it names, so a keystroke that superseded an in-flight scan re-arms one and an edit after a rejected submission releases the hold; the panes it reads are a ref rather than a dependency, or the landing would re-arm the wait and a host answering with a differently spelled path would scan forever. And a walk is not rewound: closing the editor — cancellation included — leaves the panes where the draft took them, named by the crumbs and followed by Open's fallback target, because the operator watched them move. A landing that unmounts the row a keyboard operator Tabbed onto re-parks focus on the editor, since the Modal has no focus trap. +- **The path editor advertises itself, and the panes follow the draft.** The click-to-edit zone is not invisible: a pencil glyph sits at the bar's right edge and hover/focus lights the zone in the editor's own footprint, so the one route into typing a path is discoverable and the bar does not resize when zone and input swap. While the editor is open the panes track the draft instead of whatever level happened to be listed when it opened — the final segment prefix-filters the level its directory part names, a tail nobody matches releases the filter (a name still being spelled must not empty the pane it is being spelled into), and a directory part no pane lists is scanned after a 250ms rest and lands through the same selection-anchored, two-pane landing every navigation uses, so typing a path moves the Miller view exactly as a crumb jump does — typing deeper descends, erasing segments walks back up — without leaving the editor. One landing shape, two callers: a submitted path closes the editor and announces failures, the draft-following scan keeps both to itself. That scan is speculative — half-typed directories are unreadable most of the time — so a failure keeps the last readable panes and stays silent. Enter remains the authoritative commit: it owns the view from submission until landing (a debounce timer armed by the same keystrokes is held back rather than superseding the navigation, and a rejected submission stays held until the next edit) and it alone surfaces the failure. Two consequences are deliberate. The wait is keyed on the draft, not on the directory part it names, so a keystroke that superseded an in-flight scan re-arms one and an edit after a rejected submission releases the hold; the panes it reads are a ref rather than a dependency, or the landing would re-arm the wait and a host answering with a differently spelled path would scan forever. And a walk is not rewound: closing the editor — cancellation included — leaves the panes where the draft took them, named by the crumbs and followed by Open's fallback target, because the operator watched them move. A landing that unmounts the row a keyboard operator Tabbed onto re-parks focus on the editor, since the Modal has no focus trap. - **Navigation lands selection-anchored, quiet, and bounded.** Away from the display root (the same collapse the crumb header renders, so crumbs and pane shape never disagree), the landing is two-pane: the target's actual parent-level entry re-selected (platform case folding on Windows), its children on the right, so a crumb jump reads as stepping back one pane rather than collapsing to a single column. Target and parent legs land as **one frame** when the parent leg settles within the 200ms wait bound — the stale view keeps rendering until then, so navigation swaps the panes without an intermediate single-pane flash — and past the bound the target commits alone at once (an Enter-submitted navigation is never held hostage by a stalled parent) with the late parent leg upgrading the landing in place. The parent leg runs under the landing's supersession scope and is aborted on the wire by any newer intent (Escape inside the landing window therefore withdraws the whole navigation); a failed parent leg, or a truncated parent window lacking the target, leaves the single-pane landing — the upgrade must never orphan the selection it exists to anchor. The loading indicator follows the same quiet rule: it floats over the content's bottom-right corner (never a layout-shifting row; the truncated/error rows own the bottom left and keep rendering through a scan) and only once a scan outlives a 300ms silence window, so a local listing swaps with nothing shown at all. Row picks are deliberately exempt from the one-frame rule: a pick's immediate pane split is its selected-state feedback (aria-current, crumbs following), while a navigation has nothing to acknowledge the click but the swap itself. Both timing constants are calibrated for local enumeration; a remote deployment (one RPC per level, commonly 100–400ms) would sit inside the silence window with no pressed state on the crumbs — revisit the window or add pressed feedback when a remote consumer lands. - **Symlinks: follow for enterability.** `stat` probes symlinks (broken/cyclic → skipped); crumbs keep the logical path the operator navigated, and `workspace.create` already canonicalizes via realpath at adoption. - **Listing levels are bounded, and streamed.** One `list` call returns at most `maxEntries` rows (config, default 1000 — GitHub's web-UI directory-listing bound). The level streams via `opendir` into a name-sorted window of `maxEntries + 1` candidates, so memory stays O(maxEntries) and enterability probing touches only windowed candidates; the wire `DirectoryListing` carries a required `truncated` flag so the client states incompleteness instead of silently missing tail entries. A windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated. Window insertion is binary with an O(1) full-window tail rejection (an oversized level must not pay a window scan per dirent), and `list(path, signal)` threads the carrier's request signal so a scan of a stalled network directory cannot outlive a disconnected caller — every await in the scan (open, each read, each symlink probe) races the signal, an aborted exit abandons rather than awaits the close (Node queues close behind in-flight reads), and abandoned settlements are swallowed so cleanup can never surface as an unhandled rejection. An unbounded level is a memory/responsiveness hole for large or adversarial directories. diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md index 9a6d52a3e1..54042cea3b 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md @@ -20,7 +20,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick - **依赖调研(手写 vs 引入)。** Node 标准库本身就是维护中的跨平台 OS 层(`readdir(withFileTypes)`、`homedir`、路径语义);调研过的替代品都过不了依赖门槛——文件管理器包(`node-file-manager`、`files-and-folders`、Syncfusion 的 provider)是整套 HTTP 应用(契合度不过),盘符工具(原生插件 `drivelist`、约七年未更的 `windows-drive-letters`)健康度/比例失当。browse 后端是标准库上的薄适配。 - **隐藏条目:返回并打标。** 宿主标注 `hidden`(POSIX 点前缀约定)并返回全部条目;客户端过滤。展示策略留在客户端,"显示隐藏"开关正是作为这一纯客户端改动落地:标签固定的 footer 开关,其状态由按下态呈现承载(`aria-pressed` + 勾选符号);以点开头的路径草稿前缀会显出它所指名的隐藏条目;当前选中项则不受隐藏与前缀两种过滤影响(它锚定着双栏视图)。Windows 的 `FILE_ATTRIBUTE_HIDDEN` 不被 dirent 暴露——记为限制,直到原生探测值回其成本。 - **路径编辑器的取消范围:对话框卡片。** browse 客户端的路径编辑器在按 Escape 与焦点离开卡片时取消,两者都在卡片范围的包装层而非输入框上监听——Tab 把焦点停到某个过滤命中的行之后,输入框已不在事件路径上,但 Escape 仍须收起编辑器(而非对话框),其后的焦点离开也仍须取消。不取消的豁免:窗口/标签页失焦、卡片内焦点移动,以及指针路径(编辑期间行与开关在 mousedown 时抑制焦点夺取)。预填与草稿末段过滤所用的分隔符从 `listing.home` 推断;下文的线上字段替代方案记录了被延期的权威形态。编辑器与其过滤的列表之间的 combobox 语义(`aria-expanded`/`aria-controls`/active-descendant、结果播报)同样被延期——目前二者在辅助技术看来是彼此独立的控件。 -- **路径编辑器自我点明,各栏跟随草稿。** 点击即编辑的区域不再是隐形的:栏右端坐着一枚铅笔图标,悬停/聚焦时该区以编辑器自身的轮廓亮起,于是键入路径这唯一入口可被发现,且区域与输入框互换时栏高不变。编辑器打开期间,各栏跟随草稿,而不是停在它打开那一刻恰好列出的层级——末段对其目录部分所指的层级做前缀过滤,无一匹配的末段解除过滤(还在拼写中的名字不该把正在拼写它的那一栏清空),而任何一栏都未列出的目录部分会在停顿 250ms 后被扫描、以单宽栏就地落地,于是继续键入即下潜、删掉末段即上退,全程不必离开编辑器。该扫描是推测性的——键入到一半的目录多数时候读不出来——因此失败时保留最后一次可读的分栏并保持沉默。Enter 仍是权威提交:自提交至落地由它独占视图(同一批按键武装的防抖计时器会被扣住,而不是顶掉这次导航;提交被拒后仍扣住,直到下一次编辑),也只有它把失败呈现出来。有两点是刻意为之。等待以草稿为键,而非以它指名的目录部分为键,于是顶掉在飞扫描的那次按键会重新武装等待,被拒提交之后的编辑也能释放那道扣留;而它读取的分栏是 ref 而非依赖,否则落地会重新武装等待,遇到以不同拼写作答的宿主便会永远扫描下去。以及,走过的路不回退:关闭编辑器——包括取消——都把分栏留在草稿带到的地方,由面包屑指明、Open 的兜底目标随之而动,因为操作者亲眼看着它们移动。若落地卸载了键盘操作者 Tab 停留的那一行,焦点会被重新停回编辑器——Modal 并没有焦点陷阱。 +- **路径编辑器自我点明,各栏跟随草稿。** 点击即编辑的区域不再是隐形的:栏右端坐着一枚铅笔图标,悬停/聚焦时该区以编辑器自身的轮廓亮起,于是键入路径这唯一入口可被发现,且区域与输入框互换时栏高不变。编辑器打开期间,各栏跟随草稿,而不是停在它打开那一刻恰好列出的层级——末段对其目录部分所指的层级做前缀过滤,无一匹配的末段解除过滤(还在拼写中的名字不该把正在拼写它的那一栏清空),而任何一栏都未列出的目录部分会在停顿 250ms 后被扫描,并经由每次导航共用的那套以选中项为锚的双栏落地落定,于是键入路径移动 Miller 视图的方式与 crumb 跳转完全一致——继续键入即下潜、删掉末段即上退——全程不必离开编辑器。一种落地形态、两个调用方:提交的路径关闭编辑器并呈现失败,草稿跟随扫描则两者都不做。该扫描是推测性的——键入到一半的目录多数时候读不出来——因此失败时保留最后一次可读的分栏并保持沉默。Enter 仍是权威提交:自提交至落地由它独占视图(同一批按键武装的防抖计时器会被扣住,而不是顶掉这次导航;提交被拒后仍扣住,直到下一次编辑),也只有它把失败呈现出来。有两点是刻意为之。等待以草稿为键,而非以它指名的目录部分为键,于是顶掉在飞扫描的那次按键会重新武装等待,被拒提交之后的编辑也能释放那道扣留;而它读取的分栏是 ref 而非依赖,否则落地会重新武装等待,遇到以不同拼写作答的宿主便会永远扫描下去。以及,走过的路不回退:关闭编辑器——包括取消——都把分栏留在草稿带到的地方,由面包屑指明、Open 的兜底目标随之而动,因为操作者亲眼看着它们移动。若落地卸载了键盘操作者 Tab 停留的那一行,焦点会被重新停回编辑器——Modal 并没有焦点陷阱。 - **导航以选中项为锚、安静且有界地落地。** 在展示根之外(与 crumb 头部渲染的是同一塌缩,因此 crumb 与分栏形态永不相左),落地即双栏:重新选中目标在父层级中的实际条目(Windows 上按平台惯例折叠大小写),右侧展示其子项,因此 crumb 跳转读作后退一栏,而不是塌缩成单列。父层级这一程在 200ms 等待上限内落定时,目标与父层级两程以**同一帧**落地——在此之前陈旧视图持续渲染,导航换栏时因此没有中间的单栏闪现——超出该上限则目标即刻单独提交(Enter 提交的导航绝不会被滞塞的父层级扣作人质),迟到的父层级这一程再就地升级这次落地。父层级这一程在落地的 supersession 范围下运行,任何较新的意图都会在线上将其中止(因此在落地窗口内按 Escape 即撤回整次导航);父层级这一程失败,或被截断的父窗口缺少目标时,都保留单栏落地——升级的存在正是为了锚定选中项,绝不能反而让它悬空。加载指示器遵循同一安静规则:它浮于内容右下角(绝不是会挪动布局的一行;截断/错误行占据左下角,并在扫描期间持续渲染),且仅在扫描超出 300ms 静默窗口后才出现,因此本地列举切换时什么也不显示。行选取被刻意豁免于同一帧规则:选取后立即分栏本身就是其选中态反馈(aria-current、crumb 跟随),而导航除了换栏本身没有任何东西可确认这次点击。两个时序常量都按本地列举校准;远程部署(每层级一次 RPC,通常 100–400ms)会落在静默窗口之内、crumb 上却没有按下态——待远程消费方落地时,重新审视该窗口或补上按下反馈。 - **符号链接:为可进入性而跟随。** 用 `stat` 探测符号链接(断链/循环→跳过);面包屑保留操作者导航的逻辑路径,`workspace.create` 在接纳时本就做 realpath 规范化。 - **列举层级有上限,且流式处理。** 单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端目录列举的同一上限)。层级经 `opendir` 流入一个按名排序、容量 `maxEntries + 1` 的候选窗口,内存保持 O(maxEntries),可进入性探测只触及窗口内候选;线上 `DirectoryListing` 携带必填的 `truncated` 标志,让客户端明示不完整而不是静默缺尾。窗口内的断链符号链接不从窗口外回填——发生过驱逐本身已把层级标记为截断。窗口插入为二分查找、满窗尾部单次比较即拒绝(超大层级不能为每个 dirent 付出一次全窗扫描),且 `list(path, signal)` 透传载体的请求信号,滞塞网络目录的扫描不会在调用方断连后继续存活——扫描中的每个 await(打开、每次读取、每次符号链接探测)都与信号赛跑,中止路径放弃而非等待 close(Node 会把 close 排在在飞读取之后),被放弃的 settlement 全部吞掉,清理不会以未处理拒绝的形式冒出。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。 diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index 8eec0bc31c..b16e45184a 100644 --- a/apps/web/tests/workspace-management.e2e.ts +++ b/apps/web/tests/workspace-management.e2e.ts @@ -416,17 +416,18 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff await expect.poll(() => dialog.getByText('alpha', { exact: true }).count(), { timeout: 10_000 }).toBe(1) await dialog.getByRole('button', { name: 'Edit path' }).click() const path = dialog.getByLabel('Edit path') - // A directory part no pane lists: the panes follow it and keep the editor. + // A directory part no pane lists: the panes walk to it, landing the + // ordinary two-pane Miller view (level | its children) with the editor + // still up and the draft intact. await path.fill(`${join(staged, 'alpha')}${sep}`) await expect.poll(() => dialog.getByText('only-under-alpha', { exact: true }).count(), { timeout: 10_000 }).toBe(1) - // The editor is still up with the draft intact: the panes moved under it. + expect(await dialog.getByRole('list').count()).toBe(2) expect(await path.inputValue()).toBe(`${join(staged, 'alpha')}${sep}`) - // Erasing back past the separator steps the panes up, the tail filtering - // the level it returns to. + // Erasing back past the separator returns to a level already on screen: + // the tail filters it, no scan needed, both panes stay. await path.fill(`${staged}${sep}al`) - await expect.poll(() => dialog.getByText('alpha', { exact: true }).count(), { timeout: 10_000 }).toBe(1) - expect(await dialog.getByText('beta', { exact: true }).count()).toBe(0) - expect(await dialog.getByText('only-under-alpha', { exact: true }).count()).toBe(0) + await expect.poll(() => dialog.getByText('beta', { exact: true }).count(), { timeout: 10_000 }).toBe(0) + expect(await dialog.getByText('alpha', { exact: true }).count()).toBe(1) // A tail nobody matches is a name still being spelled: the level shows // whole instead of emptying under it. await path.fill(`${staged}${sep}zzz`) diff --git a/packages/host/directory-picker-browse/README.i18n.yaml b/packages/host/directory-picker-browse/README.i18n.yaml index 06979f1226..2d66b6593b 100644 --- a/packages/host/directory-picker-browse/README.i18n.yaml +++ b/packages/host/directory-picker-browse/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/host/directory-picker-browse/README.md -README.md: a559f7f23b74f694c25fb43e005cbaccd4024308 -README.zh.md: 321e27d7a1dc9eb9bf0d1dab37c1af42e8ad6519 +README.md: c0375331e0e82fd6864b2027e7e36e0c6cb9986a +README.zh.md: 91d35de8821414c095db2a7834309864b5df0cd6 diff --git a/packages/host/directory-picker-browse/README.md b/packages/host/directory-picker-browse/README.md index a559f7f23b..c0375331e0 100644 --- a/packages/host/directory-picker-browse/README.md +++ b/packages/host/directory-picker-browse/README.md @@ -6,7 +6,7 @@ The **in-app browsing backend** of the [directory-picker seam](../directory-pick Behavior facts: listings return **directories only**, name-sorted, with symlinks-to-directories followed (broken/cyclic links skipped — the probe `stat` failing means "not enterable") and a host-owned `hidden` flag (POSIX dot convention) left for the client to act on; `crumbs` is the root-to-target ancestor chain, the root crumb labeled by its full path (`/`, `C:\`); an absent `list` path means the host account's home directory. `createDirectory` is non-recursive (a missing parent is a real failure, not a level to invent) and validates the name as a single non-blank segment even when called directly, mirroring the wire schema's fence. Both primitives reject an explicit path that is not fully qualified — relative forms, and on Windows the rooted drive-less forms (`\foo`, `/foo`) and incomplete UNC prefixes (`\\`, `\\server`) that `isAbsolute` accepts — with `directory-unreadable`/`directory-create-failed`, instead of letting `resolve` rebase it under the host process cwd or current drive. One `list` call returns at most `maxEntries` rows (config, default 1000 — the bound GitHub's web UI applies to directory listings), and the level streams through a bounded window so memory stays O(maxEntries) no matter how many children the directory holds: a cut level keeps the name-sorted head, counts hidden rows against the bound, probes only windowed candidates, and reports `truncated: true` so the client can say the level is incomplete (a windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated); window insertion is binary with an O(1) full-window tail rejection, and `list` threads the caller's `AbortSignal` so a disconnect or timeout stops the scan instead of letting it outlive the caller. Failures throw the seam's typed `DirectoryPickerError`. Policy rationale: [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md). -**Dual-face package**: the browser half (`./client`) fills [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes with the in-app **Select Workspace Directory** dialog (figma `Harness` 813-23126 family — Miller two-column view whose navigations land selection-anchored and quiet: the previous view keeps rendering while a crumb jump or a submitted path is scanned (a "Loading…" pill floats over it only once the scan outlives a 300ms silence window, never shifting the columns), then target and parent legs land as one two-pane frame with the target re-selected as its actual parent-level entry — so stepping back never collapses and no intermediate frame flashes (a parent leg outliving its 200ms wait bound lands the target alone and upgrades in place; a failed or truncated parent leg keeps the single-pane landing; the display root keeps the single wide level); breadcrumb with a click-to-edit path zone, advertised by the pencil glyph at the bar's right edge and lit on hover in the editor's own footprint, whose editor seeds a trailing separator and then keeps the panes under the draft: the final segment prefix-filters the level its directory part names (case-insensitively, over the listed — possibly truncated — rows only; a tail nobody matches releases the filter instead of emptying the pane), while a directory part no pane lists is scanned after a 250ms rest and shown in place, so typing deeper descends and erasing segments steps back up without leaving the editor — a speculative scan is silent when it fails, and Enter still navigates by the exact text, owning the view until it lands; the editor cancels on Escape or when focus leaves the dialog card (window/tab switches and in-card focus moves keep the draft), and panes the draft walked to stay where the walk ended — the crumbs name that level and Open's fallback target follows them, so cancelling closes the editor rather than rewinding the walk; a fixed-label show-hidden footer toggle over the host's `hidden` flags, with a dot-led typed prefix revealing its matches and the current selection exempt from every filter; nested New-folder dialog), driving `host.listDirectory`/`host.createDirectory` and registering its own locale namespace (`directory-browser`, zh default / en). One cordis.yml row therefore composes both sides of the browse interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind). +**Dual-face package**: the browser half (`./client`) fills [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes with the in-app **Select Workspace Directory** dialog (figma `Harness` 813-23126 family — Miller two-column view whose navigations land selection-anchored and quiet: the previous view keeps rendering while a crumb jump or a submitted path is scanned (a "Loading…" pill floats over it only once the scan outlives a 300ms silence window, never shifting the columns), then target and parent legs land as one two-pane frame with the target re-selected as its actual parent-level entry — so stepping back never collapses and no intermediate frame flashes (a parent leg outliving its 200ms wait bound lands the target alone and upgrades in place; a failed or truncated parent leg keeps the single-pane landing; the display root keeps the single wide level); breadcrumb with a click-to-edit path zone, advertised by the pencil glyph at the bar's right edge and lit on hover in the editor's own footprint, whose editor seeds a trailing separator and then keeps the panes under the draft: the final segment prefix-filters the level its directory part names (case-insensitively, over the listed — possibly truncated — rows only; a tail nobody matches releases the filter instead of emptying the pane), while a directory part no pane lists is scanned after a 250ms rest and lands like any other navigation — selection-anchored, two-pane away from the display root — so typing deeper descends and erasing segments walks back up, moving the Miller view without leaving the editor (a level a pane already shows needs no scan at all: the filter alone answers the draft) — a speculative scan is silent when it fails, and Enter still navigates by the exact text, owning the view until it lands; the editor cancels on Escape or when focus leaves the dialog card (window/tab switches and in-card focus moves keep the draft), and panes the draft walked to stay where the walk ended — the crumbs name that level and Open's fallback target follows them, so cancelling closes the editor rather than rewinding the walk; a fixed-label show-hidden footer toggle over the host's `hidden` flags, with a dot-led typed prefix revealing its matches and the current selection exempt from every filter; nested New-folder dialog), driving `host.listDirectory`/`host.createDirectory` and registering its own locale namespace (`directory-browser`, zh default / en). One cordis.yml row therefore composes both sides of the browse interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind). ## Model Experience diff --git a/packages/host/directory-picker-browse/README.zh.md b/packages/host/directory-picker-browse/README.zh.md index 321e27d7a1..91d35de882 100644 --- a/packages/host/directory-picker-browse/README.zh.md +++ b/packages/host/directory-picker-browse/README.zh.md @@ -6,7 +6,7 @@ 行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `stat` 失败即"不可进入"),并携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示决策留给客户端;`crumbs` 是从根到目标的祖先链,根 crumb 以完整路径标注(`/`、`C:\`);`list` 不带路径即列举宿主账户的家目录。`createDirectory` 不递归(父目录缺失是真实失败,不是要补造的层级),且即便被直接调用也把名称校验为单个非空段,与协议 schema 的栅栏一致。两个原语都拒绝非完全限定的显式路径——相对形态,以及 Windows 上 `isAbsolute` 会放行的无盘符有根形态(`\foo`、`/foo`)与不完整的 UNC 前缀(`\\`、`\\server`)——报 `directory-unreadable`/`directory-create-failed`,而不是任由 `resolve` 把它重定位到宿主进程 cwd 或当前盘符之下。单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端对目录列举采用的同一上限),且层级以流式方式经过一个有界窗口,无论目录有多少子项内存都保持 O(maxEntries):被截断的层级保留按名排序的头部、隐藏行计入上限、只探测窗口内候选,并报告 `truncated: true`,供客户端提示层级不完整(窗口内的断链符号链接不会从窗口外回填——发生过驱逐本身已把层级标记为截断);窗口插入为二分查找、满窗尾部单次比较即拒绝,且 `list` 透传调用方的 `AbortSignal`,断连或超时会停止扫描而不是让它在调用方离开后继续。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 -**双面包**:browser half(`./client`)以应用内 **选择工作区目录** 对话框(figma `Harness` 813-23126 家族——Miller 双列视图,其导航以选中项为锚、安静落地:扫描 crumb 跳转或提交的路径期间,先前视图持续渲染("Loading…" 胶囊仅在扫描超出 300ms 静默窗口后才浮于其上,绝不挪动各列),随后目标与父层级两程以单个双栏帧落地,目标被重新选中为其在父层级中的实际条目——因此后退绝不塌缩,也没有中间帧闪现(父层级这一程超出其 200ms 等待上限时,目标单独落地,随后就地升级;父层级这一程失败或被截断时保持单栏落地;展示根保持单个宽层级);带点击即编辑路径区的面包屑,该区由栏右端的铅笔图标点明、悬停时以编辑器自身的轮廓亮起,其编辑器预填尾随分隔符,随后让下方各栏跟随草稿:末段对其目录部分所指的层级做前缀过滤(不区分大小写,且仅作用于已列出、可能被截断的行;无一匹配的末段会解除过滤,而不是把该栏清空),而任何一栏都未列出的目录部分会在停顿 250ms 后被扫描并就地展示,于是继续键入即下潜、删掉末段即上退,全程不必离开编辑器——推测性扫描失败时保持沉默,而 Enter 仍按确切文本导航,并在落地前独占视图;编辑器按 Escape 或焦点离开对话框卡片即取消(窗口/标签页切换与卡片内焦点移动保留草稿),而草稿走到的层级会留在原地——面包屑指明该层级、Open 的兜底目标随之而动,因此取消只是关闭编辑器,并不回退这段行走;基于宿主 `hidden` 标志、标签固定的"显示隐藏"footer 开关,键入以点开头的前缀会显出其匹配项,且当前选中项不受任何过滤影响;嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞,驱动 `host.listDirectory`/`host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`,zh 默认/en)。因此一行 cordis.yml 同时组合浏览交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。 +**双面包**:browser half(`./client`)以应用内 **选择工作区目录** 对话框(figma `Harness` 813-23126 家族——Miller 双列视图,其导航以选中项为锚、安静落地:扫描 crumb 跳转或提交的路径期间,先前视图持续渲染("Loading…" 胶囊仅在扫描超出 300ms 静默窗口后才浮于其上,绝不挪动各列),随后目标与父层级两程以单个双栏帧落地,目标被重新选中为其在父层级中的实际条目——因此后退绝不塌缩,也没有中间帧闪现(父层级这一程超出其 200ms 等待上限时,目标单独落地,随后就地升级;父层级这一程失败或被截断时保持单栏落地;展示根保持单个宽层级);带点击即编辑路径区的面包屑,该区由栏右端的铅笔图标点明、悬停时以编辑器自身的轮廓亮起,其编辑器预填尾随分隔符,随后让下方各栏跟随草稿:末段对其目录部分所指的层级做前缀过滤(不区分大小写,且仅作用于已列出、可能被截断的行;无一匹配的末段会解除过滤,而不是把该栏清空),而任何一栏都未列出的目录部分会在停顿 250ms 后被扫描,并像其他任何一次导航那样落地——以选中项为锚,在展示根之外即双栏——于是继续键入即下潜、删掉末段即上退,Miller 视图随之移动而不必离开编辑器(某一栏已经展示的层级则根本不需要扫描:过滤本身就答复了草稿)——推测性扫描失败时保持沉默,而 Enter 仍按确切文本导航,并在落地前独占视图;编辑器按 Escape 或焦点离开对话框卡片即取消(窗口/标签页切换与卡片内焦点移动保留草稿),而草稿走到的层级会留在原地——面包屑指明该层级、Open 的兜底目标随之而动,因此取消只是关闭编辑器,并不回退这段行走;基于宿主 `hidden` 标志、标签固定的"显示隐藏"footer 开关,键入以点开头的前缀会显出其匹配项,且当前选中项不受任何过滤影响;嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞,驱动 `host.listDirectory`/`host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`,zh 默认/en)。因此一行 cordis.yml 同时组合浏览交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。 ## 模型体验 diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index e3a13cc54d..395793268c 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -24,10 +24,12 @@ * prefix-filters the level its directory part names (a dot-led prefix also * reveals the hidden entries it names, and a prefix nobody matches releases * the filter), while a directory part no pane lists is scanned after a short - * debounce and shown in place — so typing deeper descends and erasing - * segments steps back up without leaving the editor. Panes the draft walked - * to stay put when the editor closes (cancellation included): the crumbs name - * where the walk ended, and Open's fallback target follows them. + * debounce and lands like any other navigation — selection-anchored and + * two-pane away from the display root — so typing deeper descends and + * erasing segments walks back up, moving the Miller view without leaving the + * editor. Panes the draft walked to stay put when the editor closes + * (cancellation included): the crumbs name where the walk ended, and Open's + * fallback target follows them. */ import { useCallback, useEffect, useRef, useState } from 'react' import clsx from 'clsx' @@ -334,25 +336,65 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, }, [restartSlowScanWindow, listDirectory]) /** - * Replace the whole view with a freshly navigated level. Away from the + * Enter owns the view from submission until its navigation lands, so the + * debounce timer the same keystrokes armed must not supersede it. Cleared + * by the next edit (and by opening the editor); a failed submission leaves + * it set until the operator edits again, so the rejected path is not + * immediately re-scanned as a preview. + */ + const previewSuspended = useRef(false) + + // The panes as the draft-following scan must read them when its wait + // fires: current, but NOT a dependency of the wait (see the effect below). + const viewRef = useRef<{ parent: DirectoryListing | null; child: DirectoryListing | null }>({ parent: null, child: null }) + useEffect(() => { viewRef.current = { parent, child } }, [parent, child]) + + /** + * A landed preview replaced the pane a keyboard operator may have Tabbed + * onto, so the focus it drops is re-parked on the still-open editor (the + * Modal has no focus trap). Consumed by the refocus effect below. + */ + const refocusPathInput = useRef(false) + + /** + * Replace the whole view with a freshly scanned level. Away from the * display root — the same collapse the crumb header renders, so crumbs and * pane shape never disagree — the landing is two-pane: the target's ACTUAL * parent-level entry re-selected (left pane = parent, right pane = the * target), so a crumb jump reads as stepping back one pane. Both legs land * as one frame when the parent leg settles within * {@link PARENT_LEG_WAIT_MS}; past that bound (or at the display root) the - * target commits alone — single wide level, the editor closes, loading - * ends — and a late parent leg still upgrades the landing in place. A - * failed parent leg, or a truncated parent window that lacks the target, - * leaves the single-pane landing — the upgrade must never orphan the - * selection it exists to anchor. Until whichever commit comes first, the - * previous view keeps rendering: navigation swaps the panes, it never - * blanks them. + * target commits alone — single wide level, loading ends — and a late + * parent leg still upgrades the landing in place. A failed parent leg, or a + * truncated parent window that lacks the target, leaves the single-pane + * landing — the upgrade must never orphan the selection it exists to + * anchor. Until whichever commit comes first, the previous view keeps + * rendering: a landing swaps the panes, it never blanks them. + * + * Two callers, one landing shape. A submitted path (Enter, a crumb) closes + * the editor on arrival and announces its failure; the editor's own + * draft-following scan keeps both to itself — it is speculative, so a + * failure leaves the last readable panes standing and says nothing, while + * an arrival clears the stale message and re-parks focus the swap dropped. + * @param path - the level to list; absent lists the Host home directory. + * @param options - `closeEditor` retires the path draft on arrival; + * `announce` surfaces a failure as the dialog's alert. */ - const navigate = useCallback((path?: string) => { + const land = useCallback((path: string | undefined, options: { closeEditor: boolean; announce: boolean }) => { const { seq, scan } = launchListing(path) setLoading(true) - setError(null) + if (options.announce) setError(null) + // What every landing does once its panes are committed, whichever shape + // committed them. + const settle = (): void => { + setLoading(false) + if (options.closeEditor) { + setPathDraft(null) + return + } + setError(null) + refocusPathInput.current = true + } scan.then((target) => { if (seq !== requestSeq.current) return // The single-pane landing; `landed` makes it first-commit-only, while @@ -364,8 +406,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, setParent(target) setSelected(null) setChild(null) - setLoading(false) - setPathDraft(null) + settle() } // Arity is label-independent: only the collapsed chain's depth decides. if (displayCrumbs(target, '').length < 2) { landSingle(); return } @@ -386,10 +427,8 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, setChild(target) // Idempotent on a late upgrade of a timed-out landing: reopening the // editor or starting a newer scan supersedes this seq, so reaching - // here means the draft is closed and the loading flag is this - // navigation's own. - setLoading(false) - setPathDraft(null) + // here means the settlement is still this landing's own. + settle() }, () => { // The parent-leg failure (its abort included) never surfaces: the // target listed fine, and nobody asked to see the parent level. @@ -399,10 +438,15 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, }, (reason: unknown) => { if (seq !== requestSeq.current) return setLoading(false) - setError(failureText(reason)) + if (options.announce) setError(failureText(reason)) }) }, [launchListing, continueScan]) + /** Commit a submitted path (Enter, a crumb, the initial home listing): the editor closes, failures surface. */ + const navigate = useCallback((path?: string) => { + land(path, { closeEditor: true, announce: true }) + }, [land]) + // Editor-close focus parking (consumed by the refocus effect below the // miller-row ref): a pick parks on the selection's row, Enter and an // input-focused Escape park on the crumb edit zone that replaces the @@ -452,53 +496,15 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, }, [launchListing, pathDraft]) /** - * Enter owns the view from submission until its navigation lands, so the - * debounce timer the same keystrokes armed must not supersede it. Cleared - * by the next edit (and by opening the editor); a failed submission leaves - * it set until the operator edits again, so the rejected path is not - * immediately re-scanned as a preview. - */ - const previewSuspended = useRef(false) - - // The panes as the draft-following scan must read them when its wait - // fires: current, but NOT a dependency of the wait (see the effect below). - const viewRef = useRef<{ parent: DirectoryListing | null; child: DirectoryListing | null }>({ parent: null, child: null }) - useEffect(() => { viewRef.current = { parent, child } }, [parent, child]) - - /** - * A landed preview replaced the pane a keyboard operator may have Tabbed - * onto, so the focus it drops is re-parked on the still-open editor (the - * Modal has no focus trap). Consumed by the refocus effect below. - */ - const refocusPathInput = useRef(false) - - /** - * List the directory the draft addresses and show it WITHOUT closing the - * editor: the level replaces the panes single-wide (the selection and its - * child preview belonged to the level the draft left), and the draft's - * final segment prefix-filters it from the next render on. Unlike Enter, - * this is speculative — half-typed directories are unreadable most of the - * time — so a failure keeps the last readable panes and stays silent, - * leaving submission to surface the real error. A landing clears a stale - * error for the same reason: it, not the launch, is what makes the message - * obsolete. + * Walk the panes to the directory the draft addresses, WITHOUT closing the + * editor. The landing is an ordinary one — selection-anchored and two-pane + * away from the display root — so typing a path moves the Miller view + * exactly as a crumb jump does, and the draft's final segment + * prefix-filters the arrival from the next render on. */ const previewDraftLevel = useCallback((directory: string) => { - const { seq, scan } = launchListing(directory) - setLoading(true) - scan.then((level) => { - if (seq !== requestSeq.current) return - setParent(level) - setSelected(null) - setChild(null) - setLoading(false) - setError(null) - refocusPathInput.current = true - }, () => { - if (seq !== requestSeq.current) return - setLoading(false) - }) - }, [launchListing]) + land(directory, { closeEditor: false, announce: false }) + }, [land]) /** Abandon path editing (Escape or clicking away) and restore the crumb view. */ const cancelPathEdit = useCallback(() => { diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx index d94029e3bf..d6d6a3903c 100644 --- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -652,7 +652,7 @@ describe('DirectoryBrowser', () => { }) it('prefix-filters the listed level from the draft tail, dot revealing hidden matches', async () => { - mount() + const b = mount() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) const input = screen.getByLabelText('browser.editPath') @@ -672,9 +672,17 @@ describe('DirectoryBrowser', () => { // Its dot-led reveal lapses with it. fireEvent.change(input, { target: { value: `${HOME}/.zzz` } }) expect(screen.getAllByRole('listitem').map(item => item.textContent)).toEqual(['Documents']) - // A draft naming some other directory (or none) leaves the level whole. + // A tail inside the listed level names no level to walk to: the wait + // fires and finds nothing to scan. + const settled = b.listDirectory.mock.calls.length + await act(async () => { await new Promise((resolve) => { setTimeout(resolve, 400) }) }) + expect(b.listDirectory.mock.calls).toHaveLength(settled) + // A draft naming some other directory (or none) leaves the level whole — + // and a draft with no separator at all addresses no directory either. fireEvent.change(input, { target: { value: 'no-separator' } }) expect(screen.getByRole('listitem').textContent).toBe('Documents') + await act(async () => { await new Promise((resolve) => { setTimeout(resolve, 400) }) }) + expect(b.listDirectory.mock.calls).toHaveLength(settled) }) it('filters the child pane in two-pane mode and follows the draft back up a level', async () => { @@ -706,29 +714,46 @@ describe('DirectoryBrowser', () => { expect(within(columns()[1]!).getByText('harness')).toBeTruthy() }) - it('follows the draft into a directory no pane lists, and back up when segments are erased', async () => { + it('follows the draft into a directory no pane lists, landing the two-pane Miller view', async () => { const b = mount() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + expect(columns()).toHaveLength(1) fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) const input = screen.getByLabelText('browser.editPath') - // Typing past a separator addresses a level nobody shows: the panes - // follow it once the typing rests, and the tail filters the arrival. + // Typing past a separator addresses a level nobody shows: the panes walk + // to it once the typing rests, landing the ordinary selection-anchored + // two-pane view (level | its children) with the tail filtering the right + // pane — a typed path moves the Miller view exactly as a crumb jump does. fireEvent.change(input, { target: { value: `${DOCS}/h` } }) - await waitFor(() => { expect(screen.getByText('harness')).toBeTruthy() }) - expect(b.listDirectory.mock.calls.at(-1)?.[0]).toBe(`${DOCS}/`) + await waitFor(() => { expect(columns()).toHaveLength(2) }) + expect(b.listDirectory).toHaveBeenCalledWith(`${DOCS}/`, expect.anything()) + expect(within(columns()[0]!).getByText('Documents')).toBeTruthy() + expect(within(columns()[1]!).getByText('harness')).toBeTruthy() // Still editing: the panes moved under the draft, the editor stayed. expect(screen.getByLabelText('browser.editPath').value).toBe(`${DOCS}/h`) - // Typing on inside the level the panes now list costs no scan at all: - // the prefix filter alone answers the draft. + // Typing on inside a level the panes already list costs no scan at all: + // the prefix filter alone answers the draft, both panes stay. const settled = b.listDirectory.mock.calls.length fireEvent.change(input, { target: { value: `${DOCS}/ha` } }) await act(async () => { await new Promise((resolve) => { setTimeout(resolve, 400) }) }) expect(b.listDirectory.mock.calls).toHaveLength(settled) - // Erasing back past the separator steps the panes up a level again. - fireEvent.change(input, { target: { value: `${HOME}/Do` } }) - await waitFor(() => { expect(screen.getByText('Documents')).toBeTruthy() }) - expect(b.listDirectory.mock.calls.at(-1)?.[0]).toBe(`${HOME}/`) - expect(columns()).toHaveLength(1) + expect(columns()).toHaveLength(2) + }) + + it('walks the panes back up when erased segments leave the listed levels', async () => { + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + fireEvent.change(input, { target: { value: `${DOCS}/h` } }) + await waitFor(() => { expect(columns()).toHaveLength(2) }) + // Erasing back to a directory neither pane lists walks up to it; the + // filesystem root is the display root, so it lands the single wide level + // with the tail filtering it. + fireEvent.change(input, { target: { value: '/ho' } }) + await waitFor(() => { expect(columns()).toHaveLength(1) }) + expect(b.listDirectory).toHaveBeenCalledWith('/', expect.anything()) + expect(screen.getAllByRole('listitem').map(item => item.textContent)).toEqual(['home']) }) it('re-arms the draft-following scan after a keystroke superseded one in flight', async () => { @@ -778,12 +803,14 @@ describe('DirectoryBrowser', () => { await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) const input = screen.getByLabelText('browser.editPath') - fireEvent.change(input, { target: { value: `${DOCS}/h` } }) + // Two levels down, so the walk replaces the LEFT pane the focused row + // lives in (a landing that re-lists the same level reuses its rows). + fireEvent.change(input, { target: { value: `${HARNESS}/` } }) // The keyboard path: focus Tabbed onto a row of the level about to be // replaced. Without a re-park it would fall to body, outside a Modal that // has no focus trap. rowButton(screen.getByRole('listitem')).focus() - await waitFor(() => { expect(screen.getByText('harness')).toBeTruthy() }) + await waitFor(() => { expect(within(columns()[0]!).getByText('harness')).toBeTruthy() }) expect(document.activeElement).toBe(screen.getByLabelText('browser.editPath')) }) From 255cd90c6e04e3fd14ec54df4764106fbc1a1673 Mon Sep 17 00:00:00 2001 From: kingwl Date: Mon, 3 Aug 2026 13:23:40 +0800 Subject: [PATCH 090/129] compact long subagent durations --- ...07-27-web-subagent-conversations.i18n.yaml | 4 +- .../2026-07-27-web-subagent-conversations.md | 6 +- ...026-07-27-web-subagent-conversations.zh.md | 6 +- apps/web/tests/scaffold.ts | 9 ++- .../subagent-conversation/tree.expected.md | 2 +- apps/web/tests/subagent-conversation.e2e.ts | 16 ++-- packages/client/ui-subagent/README.i18n.yaml | 4 +- packages/client/ui-subagent/README.md | 2 +- packages/client/ui-subagent/README.zh.md | 2 +- .../src/client/SubagentCatalogAction.tsx | 81 ++++++++++++++++--- .../client/ui-subagent/src/client/locales.ts | 16 ++++ .../tests/conversation-ui.spec.tsx | 19 ++++- 12 files changed, 136 insertions(+), 31 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml index 08db3986b2..e0814009e3 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.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 .agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md -2026-07-27-web-subagent-conversations.md: 09a444dc6c391f3abdfc5bfe0f3367d4bda430c3 -2026-07-27-web-subagent-conversations.zh.md: 87929e8b35212092587ca4566bd10a1242c84e30 +2026-07-27-web-subagent-conversations.md: a8c7c4716fe797f94193b9ee7560394b0e0ff54a +2026-07-27-web-subagent-conversations.zh.md: 09980b30acf84379ae99e8d30122fcd69f57af75 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md index 09a444dc6c..a8c7c4716f 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md @@ -33,7 +33,7 @@ The Figma [subagent list](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Ha | The session header opens a compact child list. | The trigger aggregates the complete subagent-only descendant lineage; the tree shows every direct catalog entry in service order, including disabled diagnostics. | | Selecting a row reuses the conversation UI. | Addressed history never activates the child; only a continuable row with a live parent retains the ordinary composer. | | Nested agents expand progressively. | Each row carries a one-level `hasChildren` snapshot; disclosure reserves known direct-descendant rows immediately, then loads only that row's direct catalog and retains its own parent address. | -| Rows show labels, state, and active duration without duplicating sidebar rows. | Mode and `running`/`inactive` activity are textual as well as visual; optional title and exact active-turn duration come from the list's retained projection values. `SessionHeader.origin` removes duplicate navigation rows but grants no capability. | +| Rows show labels, state, and active duration without duplicating sidebar rows. | Mode and `running`/`inactive` activity are textual as well as visual; optional title and active-turn duration come from the list's retained projection values. Compact duration loses smaller units above one day, while hover and accessible naming retain exact whole seconds. `SessionHeader.origin` removes duplicate navigation rows but grants no capability. | ## Product contract @@ -41,7 +41,7 @@ The header action is absent only when a complete empty direct-catalog response a `running` means the exact child Agent driver is draining work at the Host sampling boundary; `inactive` means that driver is idle or absent. The UI does not translate either value into success, failure, cancellation, completeness, or resumability. `subagent.list` supplies the current driver-status baseline, `host/session-status` updates known activity in place, request-local replay prevents an older in-flight list response from overwriting a newer transition, and `host/session-removed` returns a known row to `inactive`; reconnect reads a fresh baseline. A `host/session-added` frame for a direct subagent immediately flips any loaded parent row to `hasChildren: true`, and that positive hint survives an older in-flight catalog response; membership, labels, mode, diagnostics, and the authoritative snapshot still require a debounced `subagent.list` refresh while the affected branch is open. A prompt response remains delivery-time authority. -Healthy rows reuse the standard session projections retained in the list mirror. `subagentTiming` resets at every descriptor so an inherited fork seed cannot enter the child's total, accumulates completed `turn/start` → `turn/end` spans, and carries same-cut `active.since` and `active.through` bounds for an open turn. The menu formats whole seconds and advances its local clock only while a known descendant is running; an inactive row bounds an interrupted open turn with `active.through`, so a stale projection never borrows newer session metadata and reopening the menu never restarts completed work. The duration does not imply a durable outcome. +Healthy rows reuse the standard session projections retained in the list mirror. `subagentTiming` resets at every descriptor so an inherited fork seed cannot enter the child's total, accumulates completed `turn/start` → `turn/end` spans, and carries same-cut `active.since` and `active.through` bounds for an open turn. Below one day the menu formats whole seconds; longer visual values retain at most two adjacent units, using approximate 30-day months and 365-day years, while hover and accessible naming preserve the exact day/hour/minute/second duration. The menu advances its local clock only while a known descendant is running; an inactive row bounds an interrupted open turn with `active.through`, so a stale projection never borrows newer session metadata and reopening the menu never restarts completed work. The duration does not imply a durable outcome. Selecting a row records its exact address before opening the resident client `Session`. History pagination, event folding, tool render intents, titles, and live mux reconciliation reuse the ordinary conversation machinery. Breadcrumbs use catalog labels, follow parent links only through `origin: 'subagent'` rows, include the first ordinary owner, and keep ordinary forks single-level. Forking an addressed subagent creates an ordinary fork with direct source lineage and attaches it to the nearest workspace-owning ancestor. The catalog is an ARIA tree with lazy ArrowRight/ArrowLeft disclosure, linear ArrowUp/ArrowDown navigation, Home/End, Escape, and focus restoration. @@ -104,7 +104,7 @@ The shipped Web composition mounts SQLite session query beside JSONL persistence - Host protocol tests pin schemas including required boolean expandability, id echoing, mode verification, non-activating history, exact-parent enforcement, FIFO admission receipts, cancellation, and sanitized failure mapping. - Generic Host tests pin attached and cold history and forks without Agent publication, cold projection folding, descriptor/origin/runtime-owner denial, explicit-id adoption denial, and the direct queue-control fence. - Client object tests pin retained and restored addresses, one-shot read-only rejection, history routing, continuable prompt routing, no addressed cancellation, suppression of Agent-bound model controls, live activity flips including in-flight response replay and detach fallback, subagent-parent expandability flips, and membership refresh. -- jsdom tests pin the aggregate descendant count and activity, second-precision running and frozen inactive durations, the summary-backed root action across absent and stale-empty catalogs, known loading-row shape, mixed-mode rows, pre-click leaf disclosure, diagnostics, lazy descendant disclosure, direct-parent addresses, keyboard behavior, and both read-only reasons. +- jsdom tests pin the aggregate descendant count and activity, second-precision running and frozen inactive durations, adaptive long-duration units with exact accessible text, the summary-backed root action across absent and stale-empty catalogs, known loading-row shape, mixed-mode rows, pre-click leaf disclosure, diagnostics, lazy descendant disclosure, direct-parent addresses, keyboard behavior, and both read-only reasons. - The keyless assembled Web snapshot contains an inactive continuable child, an inactive one-shot sibling, and a persisted grandchild; it pins the three-descendant trigger across a stale empty catalog response, timing rows, and aggregate running transition, expands without activation, opens persisted history, admits a human FIFO follow-up, reconciles child mux events, and proves one-shot history remains read-only. - Navigation tests pin subagent-only breadcrumbs, workspace placement for forks created from subagents, and `origin: 'subagent'` sidebar filtering without hiding ordinary forks. diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md index 87929e8b35..09980b30ac 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md @@ -33,7 +33,7 @@ Figma 中的 [subagent 列表](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5 | 会话页头可打开紧凑的 child 列表。 | 触发器会汇总仅含 subagent 的完整后代谱系;树按服务顺序显示每个直接目录条目,包括已禁用的 diagnostic。 | | 选择一行会复用对话 UI。 | 已寻址历史绝不激活 child;只有 parent 存活的可继续行才保留普通输入框。 | | 嵌套 agent 会逐层展开。 | 每行携带一层 `hasChildren` 快照;展开时会立即预留已知直接后代行,随后仍只加载该行的直接目录,并保留其自身的 parent 地址。 | -| 条目显示 label、状态与活跃耗时,同时避免侧边栏条目重复。 | mode 与 `running`/`inactive` 活动状态会同时以文字和视觉呈现;可选 title 与精确的活跃轮次耗时来自列表保留的投影值。`SessionHeader.origin` 会移除重复的导航条目,但不授予任何功能权限。 | +| 条目显示 label、状态与活跃耗时,同时避免侧边栏条目重复。 | mode 与 `running`/`inactive` 活动状态会同时以文字和视觉呈现;可选 title 与活跃轮次耗时来自列表保留的投影值。紧凑耗时从一天起省略更小的单位,而悬停和无障碍名称仍保留精确的整秒数。`SessionHeader.origin` 会移除重复的导航条目,但不授予任何功能权限。 | ## 产品契约 @@ -41,7 +41,7 @@ Figma 中的 [subagent 列表](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5 `running` 表示在 Host 采样边界,确切 child Agent driver 正在处理工作;`inactive` 表示该 driver 空闲或不存在。UI 不会把任一值解释为成功、失败、取消、完成状态或可恢复性。`subagent.list` 提供当前 driver 状态基线,`host/session-status` 会就地更新已知活动状态,请求内回放会阻止更早发起但尚未完成的列表响应覆盖较新的状态转换,`host/session-removed` 则会使已知行恢复为 `inactive`;重连时会读取新的基线。直接 subagent 的 `host/session-added` 帧会立即把任何已加载的 parent 行翻转为 `hasChildren: true`,并使这项正向提示不被更早发起但尚未完成的目录响应覆盖;受影响分支打开期间,成员、label、mode、diagnostic 与权威快照仍需要通过去抖动的 `subagent.list` 刷新来更新。消息投递时仍以提示词响应为权威依据。 -健康行会复用列表镜像中保留的标准会话投影。`subagentTiming` 会在每个描述符处重置,使继承的 fork 种子不会计入 child 总量;它会累加已完成的 `turn/start` → `turn/end` 时段,并携带未结束轮次同一切面的 `active.since` 和 `active.through` 边界。菜单会以整秒格式化时间,且仅在有已知后代处于运行状态时才推进其本地时钟;对 inactive 行,菜单以 `active.through` 为被中断未结束轮次的上界,因此陈旧投影绝不会借用更新的会话元数据,且重新打开菜单绝不会让已完成工作重新计时。该耗时不蕴含持久化结果语义。 +健康行会复用列表镜像中保留的标准会话投影。`subagentTiming` 会在每个描述符处重置,使继承的 fork 种子不会计入 child 总量;它会累加已完成的 `turn/start` → `turn/end` 时段,并携带未结束轮次同一切面的 `active.since` 和 `active.through` 边界。不足一天时,菜单会以整秒格式化时间;达到一天后的视觉值最多保留两个相邻单位,其中月份按近似 30 天计算,年份按近似 365 天计算,而悬停信息与无障碍名称会保留精确的天/小时/分钟/秒耗时。菜单仅在有已知后代处于运行状态时才推进其本地时钟;对 inactive 行,菜单以 `active.through` 为被中断未结束轮次的上界,因此陈旧投影绝不会借用更新的会话元数据,且重新打开菜单绝不会让已完成工作重新计时。该耗时不蕴含持久化结果语义。 选择一行后,系统会先记录其确切地址,再打开常驻客户端 `Session`。历史分页、事件 fold、工具渲染意图、title 与实时 mux 归并都会复用普通对话机制。面包屑导航使用目录 label,只会沿 `origin: 'subagent'` 行的父链接逐级回溯,包含第一个普通 owner,并让普通 fork 保持单层。从已寻址 subagent 创建 fork 时,会生成具有直接源谱系的普通 fork,并将其附加到最近拥有 Workspace 的祖先。目录是一棵 ARIA 树,支持懒加载式 ArrowRight/ArrowLeft 展开与折叠、线性 ArrowUp/ArrowDown 导航、Home/End、Escape 以及焦点恢复。 @@ -104,7 +104,7 @@ one-shot 行始终会用文案替代输入框,说明执行记录为只读。 - 宿主协议测试固定 schema(包括必需的布尔可展开性)、id 回显、mode 校验、非激活式历史、确切 parent 强制要求、FIFO 准入回执、取消与脱敏后的失败映射。 - 通用 Host 测试固定在不发布 Agent 的情况下读取已附加与冷态历史及执行 fork、冷态投影归并、按描述符/origin/运行时 owner 拒绝、拒绝显式 id 接纳,以及直接队列控制栅栏。 - 客户端对象测试固定已保留与已恢复的地址、one-shot 只读拒绝、历史路由、可继续提示词路由、已寻址对话不提供取消、屏蔽绑定到 agent 的模型控件、实时活动状态翻转(包括在途响应回放与 detach 回退)、subagent parent 可展开性翻转与成员刷新。 -- jsdom 测试固定后代聚合计数与活动状态、精确到秒的运行中耗时与冻结后 inactive 耗时、目录缺失或为陈旧空目录时由摘要支撑的根操作、已知加载行的形态、混合 mode 行、点击前的叶子展开控件、diagnostic、后代懒加载展开、直接 parent 地址、键盘行为与两种只读原因。 +- jsdom 测试固定后代聚合计数与活动状态、精确到秒的运行中耗时与冻结后 inactive 耗时、采用自适应单位的长耗时及其精确无障碍文本、目录缺失或为陈旧空目录时由摘要支撑的根操作、已知加载行的形态、混合 mode 行、点击前的叶子展开控件、diagnostic、后代懒加载展开、直接 parent 地址、键盘行为与两种只读原因。 - 无密钥的组装 Web 快照包含一个 inactive 的可继续 child、一个 inactive 的 one-shot sibling 和一个持久化 grandchild;它会固定触发器在一次陈旧的空目录响应后仍显示三个后代,并固定计时行以及聚合 `running` 状态转换,在不激活的情况下展开、打开持久化历史、准入一条用户 FIFO 后续消息、归并 child mux 事件,并证明 one-shot 历史仍然只读。 - 导航测试固定仅含 subagent 的面包屑导航、从 subagent 创建 fork 时的 Workspace 归属,以及 `origin: 'subagent'` 侧边栏过滤,同时不隐藏普通 fork。 diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 1b8afbb247..11e834ad0d 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -511,7 +511,14 @@ function normalizeAria(snapshot: string, workspaceCwd: string): string { .split(workspaceCwd).join('{{cwd}}') .split(base).join('{{workspace}}') .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '{{uuid}}') - .replace(/\b\d+(?:\.\d+)?(?:ms|s|秒)\b/g, '{{duration}}') + .replace( + /~\d+(?:y(?: \d+mo)?|mo(?: \d+d)?)|\b(?:\d+d(?: \d+h(?: \d+m \d+s)?)?|\d+h \d+m \d+s|\d+m \d+s|\d+s|\d+(?:\.\d+)?ms)\b/g, + duration => duration.startsWith('~') ? duration : '{{duration}}', + ) + .replace( + /约\d+(?:年(?:\d+个月)?|个月(?:\d+天)?)|\d+(?:天(?:\d+小时(?:\d+分\d+秒)?)?|小时\d+分\d+秒|分\d+秒|秒)/g, + duration => duration.startsWith('约') ? duration : '{{duration}}', + ) // Message IconActions clocks widen by calendar day/year; collapse every // shape so goldens stay stable across midnight and year boundaries. .replace(/\d{4}年\d{1,2}月\d{1,2}日 \d{2}:\d{2}/g, '{{clock}}') diff --git a/apps/web/tests/snapshots/subagent-conversation/tree.expected.md b/apps/web/tests/snapshots/subagent-conversation/tree.expected.md index c1174c042e..ccbfa608bb 100644 --- a/apps/web/tests/snapshots/subagent-conversation/tree.expected.md +++ b/apps/web/tests/snapshots/subagent-conversation/tree.expected.md @@ -1,8 +1,8 @@ - tree "Subagent sessions": + - treeitem "event-sourcing reviewer one-shot · not running {{duration}}" [level=1]: event-sourcing reviewer one-shot · not running ~6mo 12d - treeitem "event-sourcing researcher Explain event sourcing in one · continuable · not running {{duration}}" [expanded] [level=1]: - button "Collapse event-sourcing researcher descendants": - img - text: event-sourcing researcher Explain event sourcing in one · continuable · not running {{duration}} - group: - treeitem "example editor continuable · not running {{duration}}" [level=2] - - treeitem "event-sourcing reviewer one-shot · not running {{duration}}" [level=1] diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index b719037867..ac2e4fe1f2 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -108,7 +108,8 @@ describe('web e2e: persisted subagent conversation and human continuation', () = childId = started.childId await waitForAgentToSettle(scaffold, childId) oneShotId = sessionId('recorded-one-shot') - const oneShotAt = Date.now() + const oneShotDurationMs = 192 * 24 * 60 * 60 * 1_000 + const oneShotAt = Date.now() - oneShotDurationMs await scaffold.ctx.sessionPersistence.create({ version: SESSION_FORMAT_VERSION, id: oneShotId, @@ -146,7 +147,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = { type: 'turn/end', seq: 3, - time: oneShotAt + 3, + time: oneShotAt + oneShotDurationMs, data: { turn: 1, reason: { kind: 'completed' } }, }, ] as SessionEvent[]) @@ -199,14 +200,14 @@ describe('web e2e: persisted subagent conversation and human continuation', () = expect(scaffold.ctx.agents.get(oneShotId)).toBeUndefined() expect(scaffold.ctx.agents.get(grandchildId)).toBeUndefined() await expect(scaffold.ctx.subagents.listChildren(parent.id)).resolves.toMatchObject([ - { - kind: 'child', id: childId, mode: 'continuable', label: LABEL, - activity: 'inactive', hasChildren: true, - }, { kind: 'child', id: oneShotId, mode: 'one-shot', label: ONE_SHOT_LABEL, activity: 'inactive', hasChildren: false, }, + { + kind: 'child', id: childId, mode: 'continuable', label: LABEL, + activity: 'inactive', hasChildren: true, + }, ]) await expect(scaffold.ctx.subagents.listChildren(childId)).resolves.toMatchObject([ { @@ -301,6 +302,9 @@ describe('web e2e: persisted subagent conversation and human continuation', () = expect(await page.getByRole('button', { name: `Expand ${ONE_SHOT_LABEL} descendants`, }).count()).toBe(0) + const oneShotRow = page.getByRole('treeitem', { name: new RegExp(ONE_SHOT_LABEL) }) + expect(await oneShotRow.getByText('~6mo 12d', { exact: true }).count()).toBe(1) + expect(await oneShotRow.getAttribute('aria-label')).toContain('192d 00h 00m 00s') await page.getByRole('button', { name: `Expand ${LABEL} descendants` }).click() const childRow = page.getByRole('treeitem', { name: new RegExp(LABEL) }) const childLabel = await childRow.getAttribute('aria-label') diff --git a/packages/client/ui-subagent/README.i18n.yaml b/packages/client/ui-subagent/README.i18n.yaml index 01e2412288..ea1012bc62 100644 --- a/packages/client/ui-subagent/README.i18n.yaml +++ b/packages/client/ui-subagent/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-subagent/README.md -README.md: 33a2f2899fc34af52cda6b19f473847927da7ef0 -README.zh.md: cab1edb6b81df4b89d05153c801a4d277037ad9c +README.md: c2a005a9c9716d246bb4299ab1654314da21643a +README.zh.md: 4fb42b39fd7810f5d60e1b8a00bd9ad7d8752feb diff --git a/packages/client/ui-subagent/README.md b/packages/client/ui-subagent/README.md index 33a2f2899f..c2a005a9c9 100644 --- a/packages/client/ui-subagent/README.md +++ b/packages/client/ui-subagent/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Web subagent feature owner: contributes the lazily expandable catalog tree to `conversation.session.header.actions`, reason-specific read-only replacements to the conversation composer chain, and the existing `@` reference source to `ctx.slash`. -The header action reads `subagentsByParent` and session summaries through the standard `useSessions` hook. After a non-empty direct catalog arrives, its trigger counts the complete subagent-only descendant lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. The compact tree remains direct-catalog authoritative: continuable and one-shot rows display mode plus `running`/`inactive` activity, an optional log-backed title, and active-turn duration to the second; an unlabeled one-shot row falls back to its session id, while corrupt, unsupported, or unavailable rows remain readable but disabled. Duration sums completed `subagentTiming` turns, advances once per second only for an open turn on a running child, and freezes after the child becomes inactive; an interrupted open turn is bounded by its same-cut `active.through`, never by newer session metadata. Each healthy row's `hasChildren` hint determines disclosure before interaction, so known leaves never show an arrow; expanding a branch immediately reserves one disabled loading row per known direct descendant, then lazily replaces them with that child's authoritative catalog. Every visible branch is reported to the runtime so membership frames cause a debounced refresh only where the tree is being consumed. Selecting any depth calls `SessionsService.openSubagent()` with the row's exact `{parentSessionId, childSessionId, mode}` address. Component-local state owns tree visibility, expanded branches, keyboard focus, and the running-duration clock. ArrowRight/ArrowLeft expand and collapse branches; ArrowUp/ArrowDown, Home, End, and Escape navigate or close the tree; closing returns focus to the trigger. Styling uses tokens only. +The header action reads `subagentsByParent` and session summaries through the standard `useSessions` hook. After a non-empty direct catalog arrives, its trigger counts the complete subagent-only descendant lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. The compact tree remains direct-catalog authoritative: continuable and one-shot rows display mode plus `running`/`inactive` activity, an optional log-backed title, and active-turn duration. Visual duration stays exact to the second below one day, then uses at most two adjacent units—days/hours, approximate months/days, or approximate years/months—while hover and the accessible name retain the exact day/hour/minute/second value. An unlabeled one-shot row falls back to its session id, while corrupt, unsupported, or unavailable rows remain readable but disabled. Duration sums completed `subagentTiming` turns, advances once per second only for an open turn on a running child, and freezes after the child becomes inactive; an interrupted open turn is bounded by its same-cut `active.through`, never by newer session metadata. Each healthy row's `hasChildren` hint determines disclosure before interaction, so known leaves never show an arrow; expanding a branch immediately reserves one disabled loading row per known direct descendant, then lazily replaces them with that child's authoritative catalog. Every visible branch is reported to the runtime so membership frames cause a debounced refresh only where the tree is being consumed. Selecting any depth calls `SessionsService.openSubagent()` with the row's exact `{parentSessionId, childSessionId, mode}` address. Component-local state owns tree visibility, expanded branches, keyboard focus, and the running-duration clock. ArrowRight/ArrowLeft expand and collapse branches; ArrowUp/ArrowDown, Home, End, and Escape navigate or close the tree; closing returns focus to the trigger. Styling uses tokens only. A one-shot child always elects a read-only composer that identifies the transcript as a completed execution record. A continuable child does so only when its exact parent is unavailable, with copy explaining the recovery path. A continuable child with a live parent keeps the ordinary input chrome, whose Session routes through `subagent.prompt`; running input remains Send because every follow-up joins the child's FIFO inbox, and addressed sessions never expose Stop. This package never receives host context or calls a model-facing tool. The catalog and composer behavior are specified by the [Web subagent conversations Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md). diff --git a/packages/client/ui-subagent/README.zh.md b/packages/client/ui-subagent/README.zh.md index cab1edb6b8..4fb42b39fd 100644 --- a/packages/client/ui-subagent/README.zh.md +++ b/packages/client/ui-subagent/README.zh.md @@ -4,7 +4,7 @@ Web subagent 功能 owner:向 `conversation.session.header.actions` 贡献可懒加载展开的目录树,向会话编辑器链贡献按原因区分的只读替代呈现,并保留注册到 `ctx.slash` 的既有 `@` 引用 source。 -页头操作通过标准 `useSessions` 钩子读取 `subagentsByParent` 与会话摘要。非空直接目录到达后,其触发器会统计仅含 subagent 的完整后代谱系,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。紧凑树仍以直接目录为权威依据:可继续和 one-shot 行会显示 mode、`running`/`inactive` 活动状态、由日志支撑的可选 title,以及精确到秒的活跃轮次耗时;没有 label 的 one-shot 行会回退到其会话 id,而损坏、不受支持或不可用的行仍保持可读但禁用。耗时会累加已完成的 `subagentTiming` 轮次,仅在运行中 child 存在未结束轮次时每秒递增一次,并在 child 变为 inactive 后冻结;被中断的未结束轮次以其同一切面的 `active.through` 为上界,绝不使用更新的会话元数据。每个健康行的 `hasChildren` 提示会在交互前决定是否显示展开控件,因此已知叶子节点从不显示箭头;展开分支时,会立即为每个已知直接后代预留一行禁用的加载行,随后再用该 child 的权威目录懒加载结果替换这些占位行。每个可见分支都会上报给运行时,使成员帧只在树正被消费的位置触发去抖动刷新。选择任意深度的条目都会使用该行的确切地址 `{parentSessionId, childSessionId, mode}` 调用 `SessionsService.openSubagent()`。组件局部状态负责树的可见性、已展开分支、键盘焦点与运行中耗时时钟。ArrowRight/ArrowLeft 展开和折叠分支;ArrowUp/ArrowDown、Home、End 与 Escape 用于导航或关闭树;关闭后焦点返回触发器。样式只使用 token。 +页头操作通过标准 `useSessions` 钩子读取 `subagentsByParent` 与会话摘要。非空直接目录到达后,其触发器会统计仅含 subagent 的完整后代谱系,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。紧凑树仍以直接目录为权威依据:可继续和 one-shot 行会显示 mode、`running`/`inactive` 活动状态、由日志支撑的可选 title,以及活跃轮次耗时。视觉耗时在不足一天时精确到秒,达到一天后则最多使用两个相邻单位——天/小时、近似月份/天或近似年份/月份——而悬停信息与无障碍名称会保留精确的天/小时/分钟/秒数值。没有 label 的 one-shot 行会回退到其会话 id,而损坏、不受支持或不可用的行仍保持可读但禁用。耗时会累加已完成的 `subagentTiming` 轮次,仅在运行中 child 存在未结束轮次时每秒递增一次,并在 child 变为 inactive 后冻结;被中断的未结束轮次以其同一切面的 `active.through` 为上界,绝不使用更新的会话元数据。每个健康行的 `hasChildren` 提示会在交互前决定是否显示展开控件,因此已知叶子节点从不显示箭头;展开分支时,会立即为每个已知直接后代预留一行禁用的加载行,随后再用该 child 的权威目录懒加载结果替换这些占位行。每个可见分支都会上报给运行时,使成员帧只在树正被消费的位置触发去抖动刷新。选择任意深度的条目都会使用该行的确切地址 `{parentSessionId, childSessionId, mode}` 调用 `SessionsService.openSubagent()`。组件局部状态负责树的可见性、已展开分支、键盘焦点与运行中耗时时钟。ArrowRight/ArrowLeft 展开和折叠分支;ArrowUp/ArrowDown、Home、End 与 Escape 用于导航或关闭树;关闭后焦点返回触发器。样式只使用 token。 one-shot child 始终选用只读编辑器,并将 transcript(文本记录)说明为已完成的执行记录。可继续 child 仅在其确切 parent 不可用时选用只读编辑器,并以文案说明恢复路径。确切 parent 存活时,可继续 child 保留普通输入 chrome,其 Session 会通过 `subagent.prompt` 路由;child 运行期间,输入操作仍为 Send,因为每条后续消息都会进入 child 的 FIFO inbox,且已寻址会话绝不公开 Stop。本包绝不接收宿主 context,也不调用面向模型的工具。目录与编辑器行为由 [Web subagent 对话 Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md)规定。 diff --git a/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx b/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx index eae3addf0e..42241245af 100644 --- a/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx +++ b/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx @@ -76,16 +76,54 @@ function activityDuration( return timing.settledMs + Math.max(0, end - timing.active.since) } -/** Format a non-negative duration to seconds without dropping larger units. */ -function formatDuration(ms: number, t: TranslateNS): string { +interface DurationParts { + seconds: number + minutes: number + hours: number + days: number + totalMinutes: number + totalHours: number +} + +function splitDuration(ms: number): DurationParts { const totalSeconds = Math.floor(Math.max(0, ms) / 1_000) - const seconds = totalSeconds % 60 const totalMinutes = Math.floor(totalSeconds / 60) - const minutes = totalMinutes % 60 - const hours = Math.floor(totalMinutes / 60) - if (hours > 0) { + const totalHours = Math.floor(totalMinutes / 60) + return { + seconds: totalSeconds % 60, + minutes: totalMinutes % 60, + hours: totalHours % 24, + days: Math.floor(totalHours / 24), + totalMinutes, + totalHours, + } +} + +/** Format a duration with decreasing visual precision at larger scales. */ +function formatDuration(ms: number, t: TranslateNS): string { + const { seconds, minutes, hours, days, totalMinutes, totalHours } = splitDuration(ms) + if (days >= 365) { + const years = Math.floor(days / 365) + const months = Math.floor((days % 365) / 30) + return months === 0 + ? t('duration.years', { years }) + : t('duration.yearsMonths', { years, months }) + } + if (days >= 30) { + const months = Math.floor(days / 30) + const remainingDays = days % 30 + return remainingDays === 0 + ? t('duration.months', { months }) + : t('duration.monthsDays', { months, days: remainingDays }) + } + if (days > 0) { + return hours === 0 + ? t('duration.days', { days }) + : t('duration.daysHours', { days, hours }) + } + if (totalHours > 0) { return t('duration.hours', { - hours, + hours: totalHours, minutes: String(minutes).padStart(2, '0'), seconds: String(seconds).padStart(2, '0'), }) @@ -99,6 +137,19 @@ function formatDuration(ms: number, t: TranslateNS): string { return t('duration.seconds', { seconds }) } +/** Preserve exact whole seconds for hover and accessible naming. */ +function formatExactDuration(ms: number, t: TranslateNS): string { + const { seconds, minutes, hours, days } = splitDuration(ms) + return days === 0 + ? formatDuration(ms, t) + : t('duration.exactDays', { + days, + hours: String(hours).padStart(2, '0'), + minutes: String(minutes).padStart(2, '0'), + seconds: String(seconds).padStart(2, '0'), + }) +} + /** Aggregate the complete subagent-only descendant subtree from flat summaries. */ function summarizeDescendants( sessionId: SessionId, @@ -231,7 +282,10 @@ function CatalogRows({ ) const duration = durationMs === undefined ? undefined - : formatDuration(durationMs, t) + : { + compact: formatDuration(durationMs, t), + exact: formatExactDuration(durationMs, t), + } const open = (): void => { openChild({ parentSessionId, childSessionId: entry.id, mode: entry.mode }) @@ -263,7 +317,7 @@ function CatalogRows({ role="treeitem" tabIndex={0} aria-level={level} - aria-label={[label, secondary, duration] + aria-label={[label, secondary, duration?.exact] .filter(value => value !== undefined) .join(' ')} {...knownLeaf ? {} : { 'aria-expanded': isExpanded }} @@ -290,7 +344,14 @@ function CatalogRows({ {label} {secondary} - {duration !== undefined && {duration}} + {duration !== undefined && ( + + {duration.compact} + + )}
{isExpanded && !knownLeaf && ( diff --git a/packages/client/ui-subagent/src/client/locales.ts b/packages/client/ui-subagent/src/client/locales.ts index 5eaee7b0f1..b3897d7216 100644 --- a/packages/client/ui-subagent/src/client/locales.ts +++ b/packages/client/ui-subagent/src/client/locales.ts @@ -11,6 +11,14 @@ export const zh = { 'duration.seconds': '{seconds}秒', 'duration.minutes': '{minutes}分{seconds}秒', 'duration.hours': '{hours}小时{minutes}分{seconds}秒', + 'duration.days': '{days}天', + 'duration.daysHours': '{days}天{hours}小时', + 'duration.months': '约{months}个月', + 'duration.monthsDays': '约{months}个月{days}天', + 'duration.years': '约{years}年', + 'duration.yearsMonths': '约{years}年{months}个月', + 'duration.exactDays': '{days}天{hours}小时{minutes}分{seconds}秒', + 'duration.exactTitle': '总活跃耗时:{duration}', 'loading.label': '正在加载子代理…', 'loading.aria': '正在加载子代理', 'load.error': '无法加载子代理', @@ -40,6 +48,14 @@ export const en: Record = { 'duration.seconds': '{seconds}s', 'duration.minutes': '{minutes}m {seconds}s', 'duration.hours': '{hours}h {minutes}m {seconds}s', + 'duration.days': '{days}d', + 'duration.daysHours': '{days}d {hours}h', + 'duration.months': '~{months}mo', + 'duration.monthsDays': '~{months}mo {days}d', + 'duration.years': '~{years}y', + 'duration.yearsMonths': '~{years}y {months}mo', + 'duration.exactDays': '{days}d {hours}h {minutes}m {seconds}s', + 'duration.exactTitle': 'Total active duration: {duration}', 'loading.label': 'Loading subagents…', 'loading.aria': 'Loading subagents', 'load.error': 'Unable to load subagents', diff --git a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx b/packages/client/ui-subagent/tests/conversation-ui.spec.tsx index a6cbb22aeb..15fd71dbd9 100644 --- a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx +++ b/packages/client/ui-subagent/tests/conversation-ui.spec.tsx @@ -242,12 +242,21 @@ describe('SubagentCatalogAction', () => { it('ticks active duration by seconds and freezes inactive rows', async () => { const now = 2_000_000_000_000 + const minute = 60_000 + const hour = 60 * minute + const day = 24 * hour vi.useFakeTimers() vi.setSystemTime(now) const rows = [ ['running', 'running', 65_000, now - 5_000, now - 1_000, now], ['finished', 'inactive', 3_723_000, undefined, undefined, now - 60_000], ['interrupted', 'inactive', 2_000, now - 7_000, now - 3_000, now + 60_000], + ['days', 'inactive', 12 * day + 5 * hour + 6 * minute + 7_000, undefined, undefined, now], + ['whole-day', 'inactive', day, undefined, undefined, now], + ['months', 'inactive', 192 * day, undefined, undefined, now], + ['whole-month', 'inactive', 30 * day, undefined, undefined, now], + ['years', 'inactive', 832 * day, undefined, undefined, now], + ['whole-year', 'inactive', 365 * day, undefined, undefined, now], ] as const const entries = rows.map(([id, activity]) => ({ kind: 'child' as const, @@ -278,11 +287,19 @@ describe('SubagentCatalogAction', () => { })) as Record const input = props(catalog({ entries }), {}, summaries) render() - fireEvent.click(screen.getByRole('button', { name: /3 个子代理/ })) + fireEvent.click(screen.getByRole('button', { name: /9 个子代理/ })) expect(screen.getByRole('treeitem', { name: /running.*1分10秒/ })).toBeTruthy() expect(screen.getByRole('treeitem', { name: /finished.*1小时02分03秒/ })).toBeTruthy() expect(screen.getByRole('treeitem', { name: /interrupted.*6秒/ })).toBeTruthy() + expect(screen.getByRole('treeitem', { name: /days.*12天05小时06分07秒/ })).toBeTruthy() + expect(screen.getByText('12天5小时').getAttribute('title')) + .toBe('总活跃耗时:12天05小时06分07秒') + expect(screen.getByText('1天')).toBeTruthy() + expect(screen.getByText('约6个月12天')).toBeTruthy() + expect(screen.getByText('约1个月')).toBeTruthy() + expect(screen.getByText('约2年3个月')).toBeTruthy() + expect(screen.getByText('约1年')).toBeTruthy() await vi.advanceTimersByTimeAsync(1_000) expect(screen.getByRole('treeitem', { name: /running.*1分11秒/ })).toBeTruthy() From 2ceed380dd1ecf60bf8317fa9f58dbfbb96bb053 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 3 Aug 2026 13:27:45 +0800 Subject: [PATCH 091/129] fix(directory-picker-browse): keep the typed level in the last pane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skipping the draft-following scan whenever ANY pane happened to list the directory was the cheaper rule and the wrong one: erasing a segment left the level being typed on the LEFT, with its own child pane still standing to its right, so the two panes stopped reading as "where I am, and where I came from". The pane arity is now the invariant the editor maintains — the last pane lists the level the path names, its parent sits beside it, and only a display root lists alone. Only that last pane's own tail costs no scan; every other directory part re-lands. --- ...directory-picker-capability-seam.i18n.yaml | 4 +-- ...-07-28-directory-picker-capability-seam.md | 2 +- ...-28-directory-picker-capability-seam.zh.md | 2 +- apps/web/tests/workspace-management.e2e.ts | 9 +++-- .../directory-picker-browse/README.i18n.yaml | 4 +-- .../host/directory-picker-browse/README.md | 2 +- .../host/directory-picker-browse/README.zh.md | 2 +- .../src/client/DirectoryBrowser.tsx | 34 +++++++++++-------- .../tests/directory-browser.spec.tsx | 30 +++++++++++++--- 9 files changed, 59 insertions(+), 30 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml index 1690808ee0..a05ed7b37d 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.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 .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md -2026-07-28-directory-picker-capability-seam.md: 15d0a6ad3fc1e92e0487024c0d7f611380382e2d -2026-07-28-directory-picker-capability-seam.zh.md: 54042cea3bf4a4888855a60765ccc19977e6a061 +2026-07-28-directory-picker-capability-seam.md: bd1a0bc2f840416f4c939474d1d90c51701c7fac +2026-07-28-directory-picker-capability-seam.zh.md: f836c00443ad460d9589deba859401b305d0d902 diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md index 15d0a6ad3f..bd1a0bc2f8 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md @@ -20,7 +20,7 @@ Placement and policy rulings folded into this decision: - **Dependency survey (hand-roll vs adopt).** Node's stdlib *is* the maintained cross-platform OS layer (`readdir(withFileTypes)`, `homedir`, path semantics); surveyed alternatives fail the dependency bar — file-manager packages (`node-file-manager`, `files-and-folders`, Syncfusion's provider) are whole HTTP apps (fit), drive-letter helpers (`drivelist` native addon, `windows-drive-letters` ~7y stale) fail health/proportionality. The browse backend is a thin adapter over stdlib. - **Hidden entries: return-and-flag.** The host stamps `hidden` (POSIX dot convention) and returns everything; the client filters. Display policy stays client-side, and the show-hidden toggle shipped as exactly that client-only change: a fixed-label footer toggle whose state lives in the pressed presentation (`aria-pressed` + check glyph), a dot-led path-draft prefix reveals the hidden entries it names, and the current selection is exempt from both the hidden and the prefix filter (it anchors the two-pane view). Windows' `FILE_ATTRIBUTE_HIDDEN` is not exposed by dirents — documented limitation until a native probe pays for itself. - **Path-editor cancel scope: the dialog card.** The browse client's path editor cancels on Escape and on focus leaving the card, both observed at a card-scope wrapper rather than the input — after Tab parks focus on a filtered row the input is off the event path, yet Escape must collapse the editor (not the dialog) and a later focus departure must still cancel. Non-cancel exemptions: window/tab focus loss, in-card focus moves, and pointer paths (rows and the toggle suppress focus steal on mousedown while editing). Separators for seeding and draft-tail filtering are inferred from `listing.home`; the wire-field alternative below records the deferred authoritative form. Combobox semantics between the editor and the list it filters (`aria-expanded`/`aria-controls`/active-descendant, result announcements) are likewise deferred — today they read to assistive tech as separate widgets. -- **The path editor advertises itself, and the panes follow the draft.** The click-to-edit zone is not invisible: a pencil glyph sits at the bar's right edge and hover/focus lights the zone in the editor's own footprint, so the one route into typing a path is discoverable and the bar does not resize when zone and input swap. While the editor is open the panes track the draft instead of whatever level happened to be listed when it opened — the final segment prefix-filters the level its directory part names, a tail nobody matches releases the filter (a name still being spelled must not empty the pane it is being spelled into), and a directory part no pane lists is scanned after a 250ms rest and lands through the same selection-anchored, two-pane landing every navigation uses, so typing a path moves the Miller view exactly as a crumb jump does — typing deeper descends, erasing segments walks back up — without leaving the editor. One landing shape, two callers: a submitted path closes the editor and announces failures, the draft-following scan keeps both to itself. That scan is speculative — half-typed directories are unreadable most of the time — so a failure keeps the last readable panes and stays silent. Enter remains the authoritative commit: it owns the view from submission until landing (a debounce timer armed by the same keystrokes is held back rather than superseding the navigation, and a rejected submission stays held until the next edit) and it alone surfaces the failure. Two consequences are deliberate. The wait is keyed on the draft, not on the directory part it names, so a keystroke that superseded an in-flight scan re-arms one and an edit after a rejected submission releases the hold; the panes it reads are a ref rather than a dependency, or the landing would re-arm the wait and a host answering with a differently spelled path would scan forever. And a walk is not rewound: closing the editor — cancellation included — leaves the panes where the draft took them, named by the crumbs and followed by Open's fallback target, because the operator watched them move. A landing that unmounts the row a keyboard operator Tabbed onto re-parks focus on the editor, since the Modal has no focus trap. +- **The path editor advertises itself, and the panes follow the draft.** The click-to-edit zone is not invisible: a pencil glyph sits at the bar's right edge and hover/focus lights the zone in the editor's own footprint, so the one route into typing a path is discoverable and the bar does not resize when zone and input swap. While the editor is open the panes track the draft instead of whatever level happened to be listed when it opened — the final segment prefix-filters the level its directory part names, a tail nobody matches releases the filter (a name still being spelled must not empty the pane it is being spelled into), and any other directory part is scanned after a 250ms rest and lands through the same selection-anchored, two-pane landing every navigation uses, so typing a path moves the Miller view exactly as a crumb jump does — typing deeper descends, erasing segments walks back up — without leaving the editor. **The pane arity is the invariant**: the last pane always lists the level the path names, with its parent beside it and nothing but a display root listing alone. Skipping the scan whenever *any* pane happened to list the directory was the cheaper rule and the wrong one — erasing a segment then left the level being typed on the left with its own child pane still standing to its right, so the panes stopped reading as "where I am, and where I came from". Only the last pane's own tail costs no scan. One landing shape, two callers: a submitted path closes the editor and announces failures, the draft-following scan keeps both to itself. That scan is speculative — half-typed directories are unreadable most of the time — so a failure keeps the last readable panes and stays silent. Enter remains the authoritative commit: it owns the view from submission until landing (a debounce timer armed by the same keystrokes is held back rather than superseding the navigation, and a rejected submission stays held until the next edit) and it alone surfaces the failure. Two consequences are deliberate. The wait is keyed on the draft, not on the directory part it names, so a keystroke that superseded an in-flight scan re-arms one and an edit after a rejected submission releases the hold; the panes it reads are a ref rather than a dependency, or the landing would re-arm the wait and a host answering with a differently spelled path would scan forever. And a walk is not rewound: closing the editor — cancellation included — leaves the panes where the draft took them, named by the crumbs and followed by Open's fallback target, because the operator watched them move. A landing that unmounts the row a keyboard operator Tabbed onto re-parks focus on the editor, since the Modal has no focus trap. - **Navigation lands selection-anchored, quiet, and bounded.** Away from the display root (the same collapse the crumb header renders, so crumbs and pane shape never disagree), the landing is two-pane: the target's actual parent-level entry re-selected (platform case folding on Windows), its children on the right, so a crumb jump reads as stepping back one pane rather than collapsing to a single column. Target and parent legs land as **one frame** when the parent leg settles within the 200ms wait bound — the stale view keeps rendering until then, so navigation swaps the panes without an intermediate single-pane flash — and past the bound the target commits alone at once (an Enter-submitted navigation is never held hostage by a stalled parent) with the late parent leg upgrading the landing in place. The parent leg runs under the landing's supersession scope and is aborted on the wire by any newer intent (Escape inside the landing window therefore withdraws the whole navigation); a failed parent leg, or a truncated parent window lacking the target, leaves the single-pane landing — the upgrade must never orphan the selection it exists to anchor. The loading indicator follows the same quiet rule: it floats over the content's bottom-right corner (never a layout-shifting row; the truncated/error rows own the bottom left and keep rendering through a scan) and only once a scan outlives a 300ms silence window, so a local listing swaps with nothing shown at all. Row picks are deliberately exempt from the one-frame rule: a pick's immediate pane split is its selected-state feedback (aria-current, crumbs following), while a navigation has nothing to acknowledge the click but the swap itself. Both timing constants are calibrated for local enumeration; a remote deployment (one RPC per level, commonly 100–400ms) would sit inside the silence window with no pressed state on the crumbs — revisit the window or add pressed feedback when a remote consumer lands. - **Symlinks: follow for enterability.** `stat` probes symlinks (broken/cyclic → skipped); crumbs keep the logical path the operator navigated, and `workspace.create` already canonicalizes via realpath at adoption. - **Listing levels are bounded, and streamed.** One `list` call returns at most `maxEntries` rows (config, default 1000 — GitHub's web-UI directory-listing bound). The level streams via `opendir` into a name-sorted window of `maxEntries + 1` candidates, so memory stays O(maxEntries) and enterability probing touches only windowed candidates; the wire `DirectoryListing` carries a required `truncated` flag so the client states incompleteness instead of silently missing tail entries. A windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated. Window insertion is binary with an O(1) full-window tail rejection (an oversized level must not pay a window scan per dirent), and `list(path, signal)` threads the carrier's request signal so a scan of a stalled network directory cannot outlive a disconnected caller — every await in the scan (open, each read, each symlink probe) races the signal, an aborted exit abandons rather than awaits the close (Node queues close behind in-flight reads), and abandoned settlements are swallowed so cleanup can never surface as an unhandled rejection. An unbounded level is a memory/responsiveness hole for large or adversarial directories. diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md index 54042cea3b..f836c00443 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md @@ -20,7 +20,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick - **依赖调研(手写 vs 引入)。** Node 标准库本身就是维护中的跨平台 OS 层(`readdir(withFileTypes)`、`homedir`、路径语义);调研过的替代品都过不了依赖门槛——文件管理器包(`node-file-manager`、`files-and-folders`、Syncfusion 的 provider)是整套 HTTP 应用(契合度不过),盘符工具(原生插件 `drivelist`、约七年未更的 `windows-drive-letters`)健康度/比例失当。browse 后端是标准库上的薄适配。 - **隐藏条目:返回并打标。** 宿主标注 `hidden`(POSIX 点前缀约定)并返回全部条目;客户端过滤。展示策略留在客户端,"显示隐藏"开关正是作为这一纯客户端改动落地:标签固定的 footer 开关,其状态由按下态呈现承载(`aria-pressed` + 勾选符号);以点开头的路径草稿前缀会显出它所指名的隐藏条目;当前选中项则不受隐藏与前缀两种过滤影响(它锚定着双栏视图)。Windows 的 `FILE_ATTRIBUTE_HIDDEN` 不被 dirent 暴露——记为限制,直到原生探测值回其成本。 - **路径编辑器的取消范围:对话框卡片。** browse 客户端的路径编辑器在按 Escape 与焦点离开卡片时取消,两者都在卡片范围的包装层而非输入框上监听——Tab 把焦点停到某个过滤命中的行之后,输入框已不在事件路径上,但 Escape 仍须收起编辑器(而非对话框),其后的焦点离开也仍须取消。不取消的豁免:窗口/标签页失焦、卡片内焦点移动,以及指针路径(编辑期间行与开关在 mousedown 时抑制焦点夺取)。预填与草稿末段过滤所用的分隔符从 `listing.home` 推断;下文的线上字段替代方案记录了被延期的权威形态。编辑器与其过滤的列表之间的 combobox 语义(`aria-expanded`/`aria-controls`/active-descendant、结果播报)同样被延期——目前二者在辅助技术看来是彼此独立的控件。 -- **路径编辑器自我点明,各栏跟随草稿。** 点击即编辑的区域不再是隐形的:栏右端坐着一枚铅笔图标,悬停/聚焦时该区以编辑器自身的轮廓亮起,于是键入路径这唯一入口可被发现,且区域与输入框互换时栏高不变。编辑器打开期间,各栏跟随草稿,而不是停在它打开那一刻恰好列出的层级——末段对其目录部分所指的层级做前缀过滤,无一匹配的末段解除过滤(还在拼写中的名字不该把正在拼写它的那一栏清空),而任何一栏都未列出的目录部分会在停顿 250ms 后被扫描,并经由每次导航共用的那套以选中项为锚的双栏落地落定,于是键入路径移动 Miller 视图的方式与 crumb 跳转完全一致——继续键入即下潜、删掉末段即上退——全程不必离开编辑器。一种落地形态、两个调用方:提交的路径关闭编辑器并呈现失败,草稿跟随扫描则两者都不做。该扫描是推测性的——键入到一半的目录多数时候读不出来——因此失败时保留最后一次可读的分栏并保持沉默。Enter 仍是权威提交:自提交至落地由它独占视图(同一批按键武装的防抖计时器会被扣住,而不是顶掉这次导航;提交被拒后仍扣住,直到下一次编辑),也只有它把失败呈现出来。有两点是刻意为之。等待以草稿为键,而非以它指名的目录部分为键,于是顶掉在飞扫描的那次按键会重新武装等待,被拒提交之后的编辑也能释放那道扣留;而它读取的分栏是 ref 而非依赖,否则落地会重新武装等待,遇到以不同拼写作答的宿主便会永远扫描下去。以及,走过的路不回退:关闭编辑器——包括取消——都把分栏留在草稿带到的地方,由面包屑指明、Open 的兜底目标随之而动,因为操作者亲眼看着它们移动。若落地卸载了键盘操作者 Tab 停留的那一行,焦点会被重新停回编辑器——Modal 并没有焦点陷阱。 +- **路径编辑器自我点明,各栏跟随草稿。** 点击即编辑的区域不再是隐形的:栏右端坐着一枚铅笔图标,悬停/聚焦时该区以编辑器自身的轮廓亮起,于是键入路径这唯一入口可被发现,且区域与输入框互换时栏高不变。编辑器打开期间,各栏跟随草稿,而不是停在它打开那一刻恰好列出的层级——末段对其目录部分所指的层级做前缀过滤,无一匹配的末段解除过滤(还在拼写中的名字不该把正在拼写它的那一栏清空),而其余任何目录部分都会在停顿 250ms 后被扫描,并经由每次导航共用的那套以选中项为锚的双栏落地落定,于是键入路径移动 Miller 视图的方式与 crumb 跳转完全一致——继续键入即下潜、删掉末段即上退——全程不必离开编辑器。**分栏个数才是不变量**:最后一栏永远是路径所指的那一层,其上一层在它旁边,只有展示根会独占一栏。"只要任意一栏碰巧列出了该目录就跳过扫描"是更省事、也是错的规则——删掉一段之后,正在键入的那一层会留在左栏,而它自己的子栏仍立在右边,于是两栏不再读作"我在哪儿、我从哪儿来"。只有最后一栏自己的末段不需要扫描。一种落地形态、两个调用方:提交的路径关闭编辑器并呈现失败,草稿跟随扫描则两者都不做。该扫描是推测性的——键入到一半的目录多数时候读不出来——因此失败时保留最后一次可读的分栏并保持沉默。Enter 仍是权威提交:自提交至落地由它独占视图(同一批按键武装的防抖计时器会被扣住,而不是顶掉这次导航;提交被拒后仍扣住,直到下一次编辑),也只有它把失败呈现出来。有两点是刻意为之。等待以草稿为键,而非以它指名的目录部分为键,于是顶掉在飞扫描的那次按键会重新武装等待,被拒提交之后的编辑也能释放那道扣留;而它读取的分栏是 ref 而非依赖,否则落地会重新武装等待,遇到以不同拼写作答的宿主便会永远扫描下去。以及,走过的路不回退:关闭编辑器——包括取消——都把分栏留在草稿带到的地方,由面包屑指明、Open 的兜底目标随之而动,因为操作者亲眼看着它们移动。若落地卸载了键盘操作者 Tab 停留的那一行,焦点会被重新停回编辑器——Modal 并没有焦点陷阱。 - **导航以选中项为锚、安静且有界地落地。** 在展示根之外(与 crumb 头部渲染的是同一塌缩,因此 crumb 与分栏形态永不相左),落地即双栏:重新选中目标在父层级中的实际条目(Windows 上按平台惯例折叠大小写),右侧展示其子项,因此 crumb 跳转读作后退一栏,而不是塌缩成单列。父层级这一程在 200ms 等待上限内落定时,目标与父层级两程以**同一帧**落地——在此之前陈旧视图持续渲染,导航换栏时因此没有中间的单栏闪现——超出该上限则目标即刻单独提交(Enter 提交的导航绝不会被滞塞的父层级扣作人质),迟到的父层级这一程再就地升级这次落地。父层级这一程在落地的 supersession 范围下运行,任何较新的意图都会在线上将其中止(因此在落地窗口内按 Escape 即撤回整次导航);父层级这一程失败,或被截断的父窗口缺少目标时,都保留单栏落地——升级的存在正是为了锚定选中项,绝不能反而让它悬空。加载指示器遵循同一安静规则:它浮于内容右下角(绝不是会挪动布局的一行;截断/错误行占据左下角,并在扫描期间持续渲染),且仅在扫描超出 300ms 静默窗口后才出现,因此本地列举切换时什么也不显示。行选取被刻意豁免于同一帧规则:选取后立即分栏本身就是其选中态反馈(aria-current、crumb 跟随),而导航除了换栏本身没有任何东西可确认这次点击。两个时序常量都按本地列举校准;远程部署(每层级一次 RPC,通常 100–400ms)会落在静默窗口之内、crumb 上却没有按下态——待远程消费方落地时,重新审视该窗口或补上按下反馈。 - **符号链接:为可进入性而跟随。** 用 `stat` 探测符号链接(断链/循环→跳过);面包屑保留操作者导航的逻辑路径,`workspace.create` 在接纳时本就做 realpath 规范化。 - **列举层级有上限,且流式处理。** 单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端目录列举的同一上限)。层级经 `opendir` 流入一个按名排序、容量 `maxEntries + 1` 的候选窗口,内存保持 O(maxEntries),可进入性探测只触及窗口内候选;线上 `DirectoryListing` 携带必填的 `truncated` 标志,让客户端明示不完整而不是静默缺尾。窗口内的断链符号链接不从窗口外回填——发生过驱逐本身已把层级标记为截断。窗口插入为二分查找、满窗尾部单次比较即拒绝(超大层级不能为每个 dirent 付出一次全窗扫描),且 `list(path, signal)` 透传载体的请求信号,滞塞网络目录的扫描不会在调用方断连后继续存活——扫描中的每个 await(打开、每次读取、每次符号链接探测)都与信号赛跑,中止路径放弃而非等待 close(Node 会把 close 排在在飞读取之后),被放弃的 settlement 全部吞掉,清理不会以未处理拒绝的形式冒出。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。 diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index b16e45184a..075f5687ef 100644 --- a/apps/web/tests/workspace-management.e2e.ts +++ b/apps/web/tests/workspace-management.e2e.ts @@ -423,11 +423,14 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff await expect.poll(() => dialog.getByText('only-under-alpha', { exact: true }).count(), { timeout: 10_000 }).toBe(1) expect(await dialog.getByRole('list').count()).toBe(2) expect(await path.inputValue()).toBe(`${join(staged, 'alpha')}${sep}`) - // Erasing back past the separator returns to a level already on screen: - // the tail filters it, no scan needed, both panes stay. + // Erasing back past the separator walks the panes up, so the level being + // typed is the last pane again (its children no longer stand to its + // right) and the tail filters it. await path.fill(`${staged}${sep}al`) - await expect.poll(() => dialog.getByText('beta', { exact: true }).count(), { timeout: 10_000 }).toBe(0) + await expect.poll(() => dialog.getByText('only-under-alpha', { exact: true }).count(), { timeout: 10_000 }).toBe(0) expect(await dialog.getByText('alpha', { exact: true }).count()).toBe(1) + expect(await dialog.getByText('beta', { exact: true }).count()).toBe(0) + expect(await dialog.getByRole('list').count()).toBe(2) // A tail nobody matches is a name still being spelled: the level shows // whole instead of emptying under it. await path.fill(`${staged}${sep}zzz`) diff --git a/packages/host/directory-picker-browse/README.i18n.yaml b/packages/host/directory-picker-browse/README.i18n.yaml index 2d66b6593b..4b4f10ee23 100644 --- a/packages/host/directory-picker-browse/README.i18n.yaml +++ b/packages/host/directory-picker-browse/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/host/directory-picker-browse/README.md -README.md: c0375331e0e82fd6864b2027e7e36e0c6cb9986a -README.zh.md: 91d35de8821414c095db2a7834309864b5df0cd6 +README.md: 04d97adf71ae4a3ea8d89b24098f27cbdee20961 +README.zh.md: 603afaed7e4b4c77e699e009fc612e73c06d73aa diff --git a/packages/host/directory-picker-browse/README.md b/packages/host/directory-picker-browse/README.md index c0375331e0..04d97adf71 100644 --- a/packages/host/directory-picker-browse/README.md +++ b/packages/host/directory-picker-browse/README.md @@ -6,7 +6,7 @@ The **in-app browsing backend** of the [directory-picker seam](../directory-pick Behavior facts: listings return **directories only**, name-sorted, with symlinks-to-directories followed (broken/cyclic links skipped — the probe `stat` failing means "not enterable") and a host-owned `hidden` flag (POSIX dot convention) left for the client to act on; `crumbs` is the root-to-target ancestor chain, the root crumb labeled by its full path (`/`, `C:\`); an absent `list` path means the host account's home directory. `createDirectory` is non-recursive (a missing parent is a real failure, not a level to invent) and validates the name as a single non-blank segment even when called directly, mirroring the wire schema's fence. Both primitives reject an explicit path that is not fully qualified — relative forms, and on Windows the rooted drive-less forms (`\foo`, `/foo`) and incomplete UNC prefixes (`\\`, `\\server`) that `isAbsolute` accepts — with `directory-unreadable`/`directory-create-failed`, instead of letting `resolve` rebase it under the host process cwd or current drive. One `list` call returns at most `maxEntries` rows (config, default 1000 — the bound GitHub's web UI applies to directory listings), and the level streams through a bounded window so memory stays O(maxEntries) no matter how many children the directory holds: a cut level keeps the name-sorted head, counts hidden rows against the bound, probes only windowed candidates, and reports `truncated: true` so the client can say the level is incomplete (a windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated); window insertion is binary with an O(1) full-window tail rejection, and `list` threads the caller's `AbortSignal` so a disconnect or timeout stops the scan instead of letting it outlive the caller. Failures throw the seam's typed `DirectoryPickerError`. Policy rationale: [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md). -**Dual-face package**: the browser half (`./client`) fills [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes with the in-app **Select Workspace Directory** dialog (figma `Harness` 813-23126 family — Miller two-column view whose navigations land selection-anchored and quiet: the previous view keeps rendering while a crumb jump or a submitted path is scanned (a "Loading…" pill floats over it only once the scan outlives a 300ms silence window, never shifting the columns), then target and parent legs land as one two-pane frame with the target re-selected as its actual parent-level entry — so stepping back never collapses and no intermediate frame flashes (a parent leg outliving its 200ms wait bound lands the target alone and upgrades in place; a failed or truncated parent leg keeps the single-pane landing; the display root keeps the single wide level); breadcrumb with a click-to-edit path zone, advertised by the pencil glyph at the bar's right edge and lit on hover in the editor's own footprint, whose editor seeds a trailing separator and then keeps the panes under the draft: the final segment prefix-filters the level its directory part names (case-insensitively, over the listed — possibly truncated — rows only; a tail nobody matches releases the filter instead of emptying the pane), while a directory part no pane lists is scanned after a 250ms rest and lands like any other navigation — selection-anchored, two-pane away from the display root — so typing deeper descends and erasing segments walks back up, moving the Miller view without leaving the editor (a level a pane already shows needs no scan at all: the filter alone answers the draft) — a speculative scan is silent when it fails, and Enter still navigates by the exact text, owning the view until it lands; the editor cancels on Escape or when focus leaves the dialog card (window/tab switches and in-card focus moves keep the draft), and panes the draft walked to stay where the walk ended — the crumbs name that level and Open's fallback target follows them, so cancelling closes the editor rather than rewinding the walk; a fixed-label show-hidden footer toggle over the host's `hidden` flags, with a dot-led typed prefix revealing its matches and the current selection exempt from every filter; nested New-folder dialog), driving `host.listDirectory`/`host.createDirectory` and registering its own locale namespace (`directory-browser`, zh default / en). One cordis.yml row therefore composes both sides of the browse interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind). +**Dual-face package**: the browser half (`./client`) fills [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes with the in-app **Select Workspace Directory** dialog (figma `Harness` 813-23126 family — Miller two-column view whose navigations land selection-anchored and quiet: the previous view keeps rendering while a crumb jump or a submitted path is scanned (a "Loading…" pill floats over it only once the scan outlives a 300ms silence window, never shifting the columns), then target and parent legs land as one two-pane frame with the target re-selected as its actual parent-level entry — so stepping back never collapses and no intermediate frame flashes (a parent leg outliving its 200ms wait bound lands the target alone and upgrades in place; a failed or truncated parent leg keeps the single-pane landing; the display root keeps the single wide level); breadcrumb with a click-to-edit path zone, advertised by the pencil glyph at the bar's right edge and lit on hover in the editor's own footprint, whose editor seeds a trailing separator and then keeps the panes under the draft: the final segment prefix-filters the level its directory part names (case-insensitively, over the listed — possibly truncated — rows only; a tail nobody matches releases the filter instead of emptying the pane), while any other directory part is scanned after a 250ms rest and lands like any other navigation — selection-anchored, two-pane away from the display root — so typing deeper descends and erasing segments walks back up, moving the Miller view without leaving the editor; the pane arity is the invariant, the last pane always listing the level the path names with its parent beside it (only that level's own tail costs no scan, and only a display root lists alone) — a speculative scan is silent when it fails, and Enter still navigates by the exact text, owning the view until it lands; the editor cancels on Escape or when focus leaves the dialog card (window/tab switches and in-card focus moves keep the draft), and panes the draft walked to stay where the walk ended — the crumbs name that level and Open's fallback target follows them, so cancelling closes the editor rather than rewinding the walk; a fixed-label show-hidden footer toggle over the host's `hidden` flags, with a dot-led typed prefix revealing its matches and the current selection exempt from every filter; nested New-folder dialog), driving `host.listDirectory`/`host.createDirectory` and registering its own locale namespace (`directory-browser`, zh default / en). One cordis.yml row therefore composes both sides of the browse interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind). ## Model Experience diff --git a/packages/host/directory-picker-browse/README.zh.md b/packages/host/directory-picker-browse/README.zh.md index 91d35de882..603afaed7e 100644 --- a/packages/host/directory-picker-browse/README.zh.md +++ b/packages/host/directory-picker-browse/README.zh.md @@ -6,7 +6,7 @@ 行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `stat` 失败即"不可进入"),并携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示决策留给客户端;`crumbs` 是从根到目标的祖先链,根 crumb 以完整路径标注(`/`、`C:\`);`list` 不带路径即列举宿主账户的家目录。`createDirectory` 不递归(父目录缺失是真实失败,不是要补造的层级),且即便被直接调用也把名称校验为单个非空段,与协议 schema 的栅栏一致。两个原语都拒绝非完全限定的显式路径——相对形态,以及 Windows 上 `isAbsolute` 会放行的无盘符有根形态(`\foo`、`/foo`)与不完整的 UNC 前缀(`\\`、`\\server`)——报 `directory-unreadable`/`directory-create-failed`,而不是任由 `resolve` 把它重定位到宿主进程 cwd 或当前盘符之下。单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端对目录列举采用的同一上限),且层级以流式方式经过一个有界窗口,无论目录有多少子项内存都保持 O(maxEntries):被截断的层级保留按名排序的头部、隐藏行计入上限、只探测窗口内候选,并报告 `truncated: true`,供客户端提示层级不完整(窗口内的断链符号链接不会从窗口外回填——发生过驱逐本身已把层级标记为截断);窗口插入为二分查找、满窗尾部单次比较即拒绝,且 `list` 透传调用方的 `AbortSignal`,断连或超时会停止扫描而不是让它在调用方离开后继续。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 -**双面包**:browser half(`./client`)以应用内 **选择工作区目录** 对话框(figma `Harness` 813-23126 家族——Miller 双列视图,其导航以选中项为锚、安静落地:扫描 crumb 跳转或提交的路径期间,先前视图持续渲染("Loading…" 胶囊仅在扫描超出 300ms 静默窗口后才浮于其上,绝不挪动各列),随后目标与父层级两程以单个双栏帧落地,目标被重新选中为其在父层级中的实际条目——因此后退绝不塌缩,也没有中间帧闪现(父层级这一程超出其 200ms 等待上限时,目标单独落地,随后就地升级;父层级这一程失败或被截断时保持单栏落地;展示根保持单个宽层级);带点击即编辑路径区的面包屑,该区由栏右端的铅笔图标点明、悬停时以编辑器自身的轮廓亮起,其编辑器预填尾随分隔符,随后让下方各栏跟随草稿:末段对其目录部分所指的层级做前缀过滤(不区分大小写,且仅作用于已列出、可能被截断的行;无一匹配的末段会解除过滤,而不是把该栏清空),而任何一栏都未列出的目录部分会在停顿 250ms 后被扫描,并像其他任何一次导航那样落地——以选中项为锚,在展示根之外即双栏——于是继续键入即下潜、删掉末段即上退,Miller 视图随之移动而不必离开编辑器(某一栏已经展示的层级则根本不需要扫描:过滤本身就答复了草稿)——推测性扫描失败时保持沉默,而 Enter 仍按确切文本导航,并在落地前独占视图;编辑器按 Escape 或焦点离开对话框卡片即取消(窗口/标签页切换与卡片内焦点移动保留草稿),而草稿走到的层级会留在原地——面包屑指明该层级、Open 的兜底目标随之而动,因此取消只是关闭编辑器,并不回退这段行走;基于宿主 `hidden` 标志、标签固定的"显示隐藏"footer 开关,键入以点开头的前缀会显出其匹配项,且当前选中项不受任何过滤影响;嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞,驱动 `host.listDirectory`/`host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`,zh 默认/en)。因此一行 cordis.yml 同时组合浏览交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。 +**双面包**:browser half(`./client`)以应用内 **选择工作区目录** 对话框(figma `Harness` 813-23126 家族——Miller 双列视图,其导航以选中项为锚、安静落地:扫描 crumb 跳转或提交的路径期间,先前视图持续渲染("Loading…" 胶囊仅在扫描超出 300ms 静默窗口后才浮于其上,绝不挪动各列),随后目标与父层级两程以单个双栏帧落地,目标被重新选中为其在父层级中的实际条目——因此后退绝不塌缩,也没有中间帧闪现(父层级这一程超出其 200ms 等待上限时,目标单独落地,随后就地升级;父层级这一程失败或被截断时保持单栏落地;展示根保持单个宽层级);带点击即编辑路径区的面包屑,该区由栏右端的铅笔图标点明、悬停时以编辑器自身的轮廓亮起,其编辑器预填尾随分隔符,随后让下方各栏跟随草稿:末段对其目录部分所指的层级做前缀过滤(不区分大小写,且仅作用于已列出、可能被截断的行;无一匹配的末段会解除过滤,而不是把该栏清空),而其余任何目录部分都会在停顿 250ms 后被扫描,并像其他任何一次导航那样落地——以选中项为锚,在展示根之外即双栏——于是继续键入即下潜、删掉末段即上退,Miller 视图随之移动而不必离开编辑器;分栏个数是这里的不变量:最后一栏永远是路径所指的那一层,其上一层在它旁边(只有这一层自己的末段不触发扫描,也只有展示根会独占一栏)——推测性扫描失败时保持沉默,而 Enter 仍按确切文本导航,并在落地前独占视图;编辑器按 Escape 或焦点离开对话框卡片即取消(窗口/标签页切换与卡片内焦点移动保留草稿),而草稿走到的层级会留在原地——面包屑指明该层级、Open 的兜底目标随之而动,因此取消只是关闭编辑器,并不回退这段行走;基于宿主 `hidden` 标志、标签固定的"显示隐藏"footer 开关,键入以点开头的前缀会显出其匹配项,且当前选中项不受任何过滤影响;嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞,驱动 `host.listDirectory`/`host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`,zh 默认/en)。因此一行 cordis.yml 同时组合浏览交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。 ## 模型体验 diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index 395793268c..eaada21965 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -23,13 +23,14 @@ * separator, and keeps the panes under the draft: the final segment * prefix-filters the level its directory part names (a dot-led prefix also * reveals the hidden entries it names, and a prefix nobody matches releases - * the filter), while a directory part no pane lists is scanned after a short + * the filter), while any other directory part is scanned after a short * debounce and lands like any other navigation — selection-anchored and - * two-pane away from the display root — so typing deeper descends and - * erasing segments walks back up, moving the Miller view without leaving the - * editor. Panes the draft walked to stay put when the editor closes - * (cancellation included): the crumbs name where the walk ended, and Open's - * fallback target follows them. + * two-pane away from the display root. The pane arity holds throughout: the + * last pane is the level the path names and the one beside it is its parent, + * so typing deeper descends and erasing segments walks back up, moving the + * Miller view without leaving the editor. Panes the draft walked to stay put + * when the editor closes (cancellation included): the crumbs name where the + * walk ended, and Open's fallback target follows them. */ import { useCallback, useEffect, useRef, useState } from 'react' import clsx from 'clsx' @@ -151,12 +152,16 @@ function draftPrefixFor(listing: DirectoryListing, draft: string | null): string } /** - * The directory a draft addresses that no rendered pane lists — the level the - * editor must scan for the panes to keep following the typed path. Null when - * a pane already lists it (the prefix filter alone answers the draft), when - * no separator has been typed yet, and when no level is listed at all: the - * platform separator is read off a listing, so the editor's - * failed-home-listing recovery path types blind until Enter. + * The directory a draft addresses that the panes are not already presenting + * as the current level — what the editor must scan to keep the view under the + * typed path. The pane arity is the invariant this preserves: the LAST pane + * always lists the level the path names, with its parent beside it (a display + * root lists alone), so a draft naming any other level re-lands rather than + * leaving a deeper level standing to the right of the one being typed. Null + * when that level is already the last pane, when no separator has been typed + * yet, and when no level is listed at all: the platform separator is read off + * a listing, so the editor's failed-home-listing recovery path types blind + * until Enter. */ function pendingPreviewDirectory( parent: DirectoryListing | null, @@ -165,9 +170,8 @@ function pendingPreviewDirectory( ): string | null { if (parent === null) return null const directory = draftDirectory(parent, draft) - if (directory === null || directory === levelDirectory(parent)) return null - if (child !== null && directory === levelDirectory(child)) return null - return directory + if (directory === null) return null + return directory === levelDirectory(child ?? parent) ? null : directory } /** diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx index d6d6a3903c..69c61751eb 100644 --- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -707,11 +707,13 @@ describe('DirectoryBrowser', () => { fireEvent.change(input, { target: { value: `${DOCS}/zzz` } }) expect(within(columns()[1]!).getByText('harness')).toBeTruthy() expect(within(columns()[0]!).getByText('Documents')).toBeTruthy() - // Erasing back into the parent's own path moves the filter to the LEFT - // pane and releases the right one — no scan, both levels are on screen. + // Erasing back into the parent's own path re-lands on it rather than + // filtering the LEFT pane: the level being typed is always the last pane, + // never a pane with a deeper level standing to its right. Home is the + // display root, so it lands alone. fireEvent.change(input, { target: { value: `${HOME}/zz` } }) - expect(within(columns()[0]!).getAllByRole('listitem').map(item => item.textContent)).toEqual(['Documents']) - expect(within(columns()[1]!).getByText('harness')).toBeTruthy() + await waitFor(() => { expect(columns()).toHaveLength(1) }) + expect(screen.getAllByRole('listitem').map(item => item.textContent)).toEqual(['Documents']) }) it('follows the draft into a directory no pane lists, landing the two-pane Miller view', async () => { @@ -740,6 +742,26 @@ describe('DirectoryBrowser', () => { expect(columns()).toHaveLength(2) }) + it('keeps the typed level in the last pane, its parent beside it, as the draft walks', async () => { + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + // Two levels down: the typed level on the right, its parent on the left. + fireEvent.change(input, { target: { value: `${HARNESS}/` } }) + await waitFor(() => { expect(within(columns()[0]!).getByText('harness')).toBeTruthy() }) + expect(columns()).toHaveLength(2) + expect(within(columns()[1]!).queryAllByRole('listitem')).toHaveLength(0) + // Erasing back to the parent's own path re-lands on it: the level being + // typed moves BACK into the last pane instead of staying on the left with + // its own child pane still to the right. + fireEvent.change(input, { target: { value: `${DOCS}/ha` } }) + await waitFor(() => { expect(within(columns()[0]!).getByText('Documents')).toBeTruthy() }) + expect(columns()).toHaveLength(2) + expect(within(columns()[1]!).getAllByRole('listitem').map(item => item.textContent)).toEqual(['harness']) + expect(b.listDirectory).toHaveBeenCalledWith(`${DOCS}/`, expect.anything()) + }) + it('walks the panes back up when erased segments leave the listed levels', async () => { const b = mount() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) From 6c0ce22f59bc818cc9eebd609e8883b87083ad63 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 3 Aug 2026 13:45:30 +0800 Subject: [PATCH 092/129] fix(directory-picker-browse): light the whole bar, and move the view once per keystroke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hover affordance framed only the strip right of the crumbs. The bar itself now carries the outline and the padding in both modes, so hovering previews exactly the field the click produces and nothing resizes when the two swap. One keystroke moved the view twice: deleting a separator first narrowed the pane the draft had just walked away from, then replaced it with its landing. The tail now filters only the LAST pane — the one whose level the path names — so a pane on its way out holds still until its landing arrives. Also from the review round: the walk waits both legs out instead of taking the submitted-navigation bound (a speculative scan has nothing waiting on it, and a tail keystroke aborting a slow parent leg would otherwise strand the two-pane view); a level keeps answering the directory text that produced it, so `..` segments and Windows forward slashes filter and stop rescanning; the release-on-miss rule counts displayable rows, so it survives `hidden` ever meaning more than dot-prefixed; and the editor's 250ms rest joins the other two constants on the remote-recalibration list. --- ...directory-picker-capability-seam.i18n.yaml | 4 +- ...-07-28-directory-picker-capability-seam.md | 4 +- ...-28-directory-picker-capability-seam.zh.md | 4 +- .../directory-picker-browse/README.i18n.yaml | 4 +- .../host/directory-picker-browse/README.md | 2 +- .../host/directory-picker-browse/README.zh.md | 2 +- .../src/client/DirectoryBrowser.module.css | 53 +++--- .../src/client/DirectoryBrowser.tsx | 171 +++++++++++------- .../tests/directory-browser.spec.tsx | 114 +++++++++++- 9 files changed, 256 insertions(+), 102 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml index a05ed7b37d..048536527a 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.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 .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md -2026-07-28-directory-picker-capability-seam.md: bd1a0bc2f840416f4c939474d1d90c51701c7fac -2026-07-28-directory-picker-capability-seam.zh.md: f836c00443ad460d9589deba859401b305d0d902 +2026-07-28-directory-picker-capability-seam.md: 01968990db81852dbf965a90fc151bab357ecb55 +2026-07-28-directory-picker-capability-seam.zh.md: ffbb939eabcca3e16711a4cadcadad50660a9e04 diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md index bd1a0bc2f8..01968990db 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md @@ -20,8 +20,8 @@ Placement and policy rulings folded into this decision: - **Dependency survey (hand-roll vs adopt).** Node's stdlib *is* the maintained cross-platform OS layer (`readdir(withFileTypes)`, `homedir`, path semantics); surveyed alternatives fail the dependency bar — file-manager packages (`node-file-manager`, `files-and-folders`, Syncfusion's provider) are whole HTTP apps (fit), drive-letter helpers (`drivelist` native addon, `windows-drive-letters` ~7y stale) fail health/proportionality. The browse backend is a thin adapter over stdlib. - **Hidden entries: return-and-flag.** The host stamps `hidden` (POSIX dot convention) and returns everything; the client filters. Display policy stays client-side, and the show-hidden toggle shipped as exactly that client-only change: a fixed-label footer toggle whose state lives in the pressed presentation (`aria-pressed` + check glyph), a dot-led path-draft prefix reveals the hidden entries it names, and the current selection is exempt from both the hidden and the prefix filter (it anchors the two-pane view). Windows' `FILE_ATTRIBUTE_HIDDEN` is not exposed by dirents — documented limitation until a native probe pays for itself. - **Path-editor cancel scope: the dialog card.** The browse client's path editor cancels on Escape and on focus leaving the card, both observed at a card-scope wrapper rather than the input — after Tab parks focus on a filtered row the input is off the event path, yet Escape must collapse the editor (not the dialog) and a later focus departure must still cancel. Non-cancel exemptions: window/tab focus loss, in-card focus moves, and pointer paths (rows and the toggle suppress focus steal on mousedown while editing). Separators for seeding and draft-tail filtering are inferred from `listing.home`; the wire-field alternative below records the deferred authoritative form. Combobox semantics between the editor and the list it filters (`aria-expanded`/`aria-controls`/active-descendant, result announcements) are likewise deferred — today they read to assistive tech as separate widgets. -- **The path editor advertises itself, and the panes follow the draft.** The click-to-edit zone is not invisible: a pencil glyph sits at the bar's right edge and hover/focus lights the zone in the editor's own footprint, so the one route into typing a path is discoverable and the bar does not resize when zone and input swap. While the editor is open the panes track the draft instead of whatever level happened to be listed when it opened — the final segment prefix-filters the level its directory part names, a tail nobody matches releases the filter (a name still being spelled must not empty the pane it is being spelled into), and any other directory part is scanned after a 250ms rest and lands through the same selection-anchored, two-pane landing every navigation uses, so typing a path moves the Miller view exactly as a crumb jump does — typing deeper descends, erasing segments walks back up — without leaving the editor. **The pane arity is the invariant**: the last pane always lists the level the path names, with its parent beside it and nothing but a display root listing alone. Skipping the scan whenever *any* pane happened to list the directory was the cheaper rule and the wrong one — erasing a segment then left the level being typed on the left with its own child pane still standing to its right, so the panes stopped reading as "where I am, and where I came from". Only the last pane's own tail costs no scan. One landing shape, two callers: a submitted path closes the editor and announces failures, the draft-following scan keeps both to itself. That scan is speculative — half-typed directories are unreadable most of the time — so a failure keeps the last readable panes and stays silent. Enter remains the authoritative commit: it owns the view from submission until landing (a debounce timer armed by the same keystrokes is held back rather than superseding the navigation, and a rejected submission stays held until the next edit) and it alone surfaces the failure. Two consequences are deliberate. The wait is keyed on the draft, not on the directory part it names, so a keystroke that superseded an in-flight scan re-arms one and an edit after a rejected submission releases the hold; the panes it reads are a ref rather than a dependency, or the landing would re-arm the wait and a host answering with a differently spelled path would scan forever. And a walk is not rewound: closing the editor — cancellation included — leaves the panes where the draft took them, named by the crumbs and followed by Open's fallback target, because the operator watched them move. A landing that unmounts the row a keyboard operator Tabbed onto re-parks focus on the editor, since the Modal has no focus trap. -- **Navigation lands selection-anchored, quiet, and bounded.** Away from the display root (the same collapse the crumb header renders, so crumbs and pane shape never disagree), the landing is two-pane: the target's actual parent-level entry re-selected (platform case folding on Windows), its children on the right, so a crumb jump reads as stepping back one pane rather than collapsing to a single column. Target and parent legs land as **one frame** when the parent leg settles within the 200ms wait bound — the stale view keeps rendering until then, so navigation swaps the panes without an intermediate single-pane flash — and past the bound the target commits alone at once (an Enter-submitted navigation is never held hostage by a stalled parent) with the late parent leg upgrading the landing in place. The parent leg runs under the landing's supersession scope and is aborted on the wire by any newer intent (Escape inside the landing window therefore withdraws the whole navigation); a failed parent leg, or a truncated parent window lacking the target, leaves the single-pane landing — the upgrade must never orphan the selection it exists to anchor. The loading indicator follows the same quiet rule: it floats over the content's bottom-right corner (never a layout-shifting row; the truncated/error rows own the bottom left and keep rendering through a scan) and only once a scan outlives a 300ms silence window, so a local listing swaps with nothing shown at all. Row picks are deliberately exempt from the one-frame rule: a pick's immediate pane split is its selected-state feedback (aria-current, crumbs following), while a navigation has nothing to acknowledge the click but the swap itself. Both timing constants are calibrated for local enumeration; a remote deployment (one RPC per level, commonly 100–400ms) would sit inside the silence window with no pressed state on the crumbs — revisit the window or add pressed feedback when a remote consumer lands. +- **The path editor advertises itself, and the panes follow the draft.** The click-to-edit zone is not invisible: a pencil glyph sits at the bar's right edge and hover/focus lights the WHOLE bar in the editor's own box — the bar carries the outline and padding in both modes, so the hover previews exactly the field the click produces and nothing resizes when zone and input swap. While the editor is open the panes track the draft instead of whatever level happened to be listed when it opened — the final segment prefix-filters the level its directory part names, a tail nobody matches releases the filter (a name still being spelled must not empty the pane it is being spelled into), and any other directory part is scanned after a 250ms rest and lands through the same selection-anchored, two-pane landing every navigation uses, so typing a path moves the Miller view exactly as a crumb jump does — typing deeper descends, erasing segments walks back up — without leaving the editor. **The pane arity is the invariant**: the last pane always lists the level the path names, with its parent beside it and nothing but a display root listing alone. Skipping the scan whenever *any* pane happened to list the directory was the cheaper rule and the wrong one — erasing a segment then left the level being typed on the left with its own child pane still standing to its right, so the panes stopped reading as "where I am, and where I came from". Only the last pane's own tail costs no scan. One landing shape, two callers: a submitted path closes the editor and announces failures, the draft-following scan keeps both to itself. That scan is speculative — half-typed directories are unreadable most of the time — so a failure keeps the last readable panes and stays silent. Enter remains the authoritative commit: it owns the view from submission until landing (a debounce timer armed by the same keystrokes is held back rather than superseding the navigation, and a rejected submission stays held until the next edit) and it alone surfaces the failure. Two consequences are deliberate. The wait is keyed on the draft, not on the directory part it names, so a keystroke that superseded an in-flight scan re-arms one and an edit after a rejected submission releases the hold; the panes it reads are a ref rather than a dependency, or the landing would re-arm the wait and a host answering with a differently spelled path would scan forever. And a walk is not rewound: closing the editor — cancellation included — leaves the panes where the draft took them, named by the crumbs and followed by Open's fallback target, because the operator watched them move. A landing that unmounts the row a keyboard operator Tabbed onto re-parks focus on the editor, since the Modal has no focus trap. Two further rules keep one keystroke to one movement: the walk waits BOTH legs out rather than taking the submitted-navigation wait bound (nothing waits on a speculative scan, so landing single-pane and upgrading would be the very flash this exists to avoid, and it would strand the two-pane view whenever a tail keystroke aborted a slow parent leg), and the tail filters only the LAST pane — narrowing a pane the draft has walked away from would move the view once as it narrows and again as its landing replaces it. A level also keeps answering the directory text that produced it (`scanned`), because the Host resolves what it is given: `..` segments and, on Windows, forward slashes reach a level whose own path spells the request differently, and without the memo those drafts would rescan on every keystroke and never filter. +- **Navigation lands selection-anchored, quiet, and bounded.** Away from the display root (the same collapse the crumb header renders, so crumbs and pane shape never disagree), the landing is two-pane: the target's actual parent-level entry re-selected (platform case folding on Windows), its children on the right, so a crumb jump reads as stepping back one pane rather than collapsing to a single column. Target and parent legs land as **one frame** when the parent leg settles within the 200ms wait bound — the stale view keeps rendering until then, so navigation swaps the panes without an intermediate single-pane flash — and past the bound the target commits alone at once (an Enter-submitted navigation is never held hostage by a stalled parent) with the late parent leg upgrading the landing in place. The parent leg runs under the landing's supersession scope and is aborted on the wire by any newer intent (Escape inside the landing window therefore withdraws the whole navigation); a failed parent leg, or a truncated parent window lacking the target, leaves the single-pane landing — the upgrade must never orphan the selection it exists to anchor. The loading indicator follows the same quiet rule: it floats over the content's bottom-right corner (never a layout-shifting row; the truncated/error rows own the bottom left and keep rendering through a scan) and only once a scan outlives a 300ms silence window, so a local listing swaps with nothing shown at all. Row picks are deliberately exempt from the one-frame rule: a pick's immediate pane split is its selected-state feedback (aria-current, crumbs following), while a navigation has nothing to acknowledge the click but the swap itself. All three timing constants — the 200ms parent-leg bound, the 300ms silence window, and the editor's 250ms draft rest — are calibrated for local enumeration; a remote deployment (one RPC per level, commonly 100–400ms) would sit inside the silence window with no pressed state on the crumbs, and would pay rest plus RPC before the panes follow a typed path — revisit all three together when a remote consumer lands. - **Symlinks: follow for enterability.** `stat` probes symlinks (broken/cyclic → skipped); crumbs keep the logical path the operator navigated, and `workspace.create` already canonicalizes via realpath at adoption. - **Listing levels are bounded, and streamed.** One `list` call returns at most `maxEntries` rows (config, default 1000 — GitHub's web-UI directory-listing bound). The level streams via `opendir` into a name-sorted window of `maxEntries + 1` candidates, so memory stays O(maxEntries) and enterability probing touches only windowed candidates; the wire `DirectoryListing` carries a required `truncated` flag so the client states incompleteness instead of silently missing tail entries. A windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated. Window insertion is binary with an O(1) full-window tail rejection (an oversized level must not pay a window scan per dirent), and `list(path, signal)` threads the carrier's request signal so a scan of a stalled network directory cannot outlive a disconnected caller — every await in the scan (open, each read, each symlink probe) races the signal, an aborted exit abandons rather than awaits the close (Node queues close behind in-flight reads), and abandoned settlements are swallowed so cleanup can never surface as an unhandled rejection. An unbounded level is a memory/responsiveness hole for large or adversarial directories. - **Whole-filesystem scope, no roots config.** `workspace.create` accepts arbitrary paths and the API serves bash-driving methods, so a browse root would be UX scoping, not a boundary; configurability without a consumer fails the evidence bar. Deferred until a deployment needs it. diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md index f836c00443..ffbb939eab 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md @@ -20,8 +20,8 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick - **依赖调研(手写 vs 引入)。** Node 标准库本身就是维护中的跨平台 OS 层(`readdir(withFileTypes)`、`homedir`、路径语义);调研过的替代品都过不了依赖门槛——文件管理器包(`node-file-manager`、`files-and-folders`、Syncfusion 的 provider)是整套 HTTP 应用(契合度不过),盘符工具(原生插件 `drivelist`、约七年未更的 `windows-drive-letters`)健康度/比例失当。browse 后端是标准库上的薄适配。 - **隐藏条目:返回并打标。** 宿主标注 `hidden`(POSIX 点前缀约定)并返回全部条目;客户端过滤。展示策略留在客户端,"显示隐藏"开关正是作为这一纯客户端改动落地:标签固定的 footer 开关,其状态由按下态呈现承载(`aria-pressed` + 勾选符号);以点开头的路径草稿前缀会显出它所指名的隐藏条目;当前选中项则不受隐藏与前缀两种过滤影响(它锚定着双栏视图)。Windows 的 `FILE_ATTRIBUTE_HIDDEN` 不被 dirent 暴露——记为限制,直到原生探测值回其成本。 - **路径编辑器的取消范围:对话框卡片。** browse 客户端的路径编辑器在按 Escape 与焦点离开卡片时取消,两者都在卡片范围的包装层而非输入框上监听——Tab 把焦点停到某个过滤命中的行之后,输入框已不在事件路径上,但 Escape 仍须收起编辑器(而非对话框),其后的焦点离开也仍须取消。不取消的豁免:窗口/标签页失焦、卡片内焦点移动,以及指针路径(编辑期间行与开关在 mousedown 时抑制焦点夺取)。预填与草稿末段过滤所用的分隔符从 `listing.home` 推断;下文的线上字段替代方案记录了被延期的权威形态。编辑器与其过滤的列表之间的 combobox 语义(`aria-expanded`/`aria-controls`/active-descendant、结果播报)同样被延期——目前二者在辅助技术看来是彼此独立的控件。 -- **路径编辑器自我点明,各栏跟随草稿。** 点击即编辑的区域不再是隐形的:栏右端坐着一枚铅笔图标,悬停/聚焦时该区以编辑器自身的轮廓亮起,于是键入路径这唯一入口可被发现,且区域与输入框互换时栏高不变。编辑器打开期间,各栏跟随草稿,而不是停在它打开那一刻恰好列出的层级——末段对其目录部分所指的层级做前缀过滤,无一匹配的末段解除过滤(还在拼写中的名字不该把正在拼写它的那一栏清空),而其余任何目录部分都会在停顿 250ms 后被扫描,并经由每次导航共用的那套以选中项为锚的双栏落地落定,于是键入路径移动 Miller 视图的方式与 crumb 跳转完全一致——继续键入即下潜、删掉末段即上退——全程不必离开编辑器。**分栏个数才是不变量**:最后一栏永远是路径所指的那一层,其上一层在它旁边,只有展示根会独占一栏。"只要任意一栏碰巧列出了该目录就跳过扫描"是更省事、也是错的规则——删掉一段之后,正在键入的那一层会留在左栏,而它自己的子栏仍立在右边,于是两栏不再读作"我在哪儿、我从哪儿来"。只有最后一栏自己的末段不需要扫描。一种落地形态、两个调用方:提交的路径关闭编辑器并呈现失败,草稿跟随扫描则两者都不做。该扫描是推测性的——键入到一半的目录多数时候读不出来——因此失败时保留最后一次可读的分栏并保持沉默。Enter 仍是权威提交:自提交至落地由它独占视图(同一批按键武装的防抖计时器会被扣住,而不是顶掉这次导航;提交被拒后仍扣住,直到下一次编辑),也只有它把失败呈现出来。有两点是刻意为之。等待以草稿为键,而非以它指名的目录部分为键,于是顶掉在飞扫描的那次按键会重新武装等待,被拒提交之后的编辑也能释放那道扣留;而它读取的分栏是 ref 而非依赖,否则落地会重新武装等待,遇到以不同拼写作答的宿主便会永远扫描下去。以及,走过的路不回退:关闭编辑器——包括取消——都把分栏留在草稿带到的地方,由面包屑指明、Open 的兜底目标随之而动,因为操作者亲眼看着它们移动。若落地卸载了键盘操作者 Tab 停留的那一行,焦点会被重新停回编辑器——Modal 并没有焦点陷阱。 -- **导航以选中项为锚、安静且有界地落地。** 在展示根之外(与 crumb 头部渲染的是同一塌缩,因此 crumb 与分栏形态永不相左),落地即双栏:重新选中目标在父层级中的实际条目(Windows 上按平台惯例折叠大小写),右侧展示其子项,因此 crumb 跳转读作后退一栏,而不是塌缩成单列。父层级这一程在 200ms 等待上限内落定时,目标与父层级两程以**同一帧**落地——在此之前陈旧视图持续渲染,导航换栏时因此没有中间的单栏闪现——超出该上限则目标即刻单独提交(Enter 提交的导航绝不会被滞塞的父层级扣作人质),迟到的父层级这一程再就地升级这次落地。父层级这一程在落地的 supersession 范围下运行,任何较新的意图都会在线上将其中止(因此在落地窗口内按 Escape 即撤回整次导航);父层级这一程失败,或被截断的父窗口缺少目标时,都保留单栏落地——升级的存在正是为了锚定选中项,绝不能反而让它悬空。加载指示器遵循同一安静规则:它浮于内容右下角(绝不是会挪动布局的一行;截断/错误行占据左下角,并在扫描期间持续渲染),且仅在扫描超出 300ms 静默窗口后才出现,因此本地列举切换时什么也不显示。行选取被刻意豁免于同一帧规则:选取后立即分栏本身就是其选中态反馈(aria-current、crumb 跟随),而导航除了换栏本身没有任何东西可确认这次点击。两个时序常量都按本地列举校准;远程部署(每层级一次 RPC,通常 100–400ms)会落在静默窗口之内、crumb 上却没有按下态——待远程消费方落地时,重新审视该窗口或补上按下反馈。 +- **路径编辑器自我点明,各栏跟随草稿。** 点击即编辑的区域不再是隐形的:栏右端坐着一枚铅笔图标,悬停/聚焦时**整条栏**以编辑器自身的那只框亮起——轮廓与内边距在两种模式下都由栏承载,于是悬停预览的正是点击后出现的那只输入框,区域与输入框互换时也没有任何尺寸变化。编辑器打开期间,各栏跟随草稿,而不是停在它打开那一刻恰好列出的层级——末段对其目录部分所指的层级做前缀过滤,无一匹配的末段解除过滤(还在拼写中的名字不该把正在拼写它的那一栏清空),而其余任何目录部分都会在停顿 250ms 后被扫描,并经由每次导航共用的那套以选中项为锚的双栏落地落定,于是键入路径移动 Miller 视图的方式与 crumb 跳转完全一致——继续键入即下潜、删掉末段即上退——全程不必离开编辑器。**分栏个数才是不变量**:最后一栏永远是路径所指的那一层,其上一层在它旁边,只有展示根会独占一栏。"只要任意一栏碰巧列出了该目录就跳过扫描"是更省事、也是错的规则——删掉一段之后,正在键入的那一层会留在左栏,而它自己的子栏仍立在右边,于是两栏不再读作"我在哪儿、我从哪儿来"。只有最后一栏自己的末段不需要扫描。一种落地形态、两个调用方:提交的路径关闭编辑器并呈现失败,草稿跟随扫描则两者都不做。该扫描是推测性的——键入到一半的目录多数时候读不出来——因此失败时保留最后一次可读的分栏并保持沉默。Enter 仍是权威提交:自提交至落地由它独占视图(同一批按键武装的防抖计时器会被扣住,而不是顶掉这次导航;提交被拒后仍扣住,直到下一次编辑),也只有它把失败呈现出来。有两点是刻意为之。等待以草稿为键,而非以它指名的目录部分为键,于是顶掉在飞扫描的那次按键会重新武装等待,被拒提交之后的编辑也能释放那道扣留;而它读取的分栏是 ref 而非依赖,否则落地会重新武装等待,遇到以不同拼写作答的宿主便会永远扫描下去。以及,走过的路不回退:关闭编辑器——包括取消——都把分栏留在草稿带到的地方,由面包屑指明、Open 的兜底目标随之而动,因为操作者亲眼看着它们移动。若落地卸载了键盘操作者 Tab 停留的那一行,焦点会被重新停回编辑器——Modal 并没有焦点陷阱。另有两条规则保证一次按键只让视图移动一次:这段行走会**等齐两程**,而不套用提交导航的等待上限(推测性扫描没有任何东西在等它,先落单栏再升级恰恰就是它要避免的那次闪动,而且一旦末段按键中止了缓慢的父层级这一程,双栏视图就会永久丢失);末段也只过滤**最后一栏**——去收窄一个草稿已经走开的分栏,会让视图先因收窄动一次、再因它自己的落地动一次。此外,层级会持续应答产生它的那段目录文本(`scanned`),因为宿主会规范化它收到的东西:`..` 段与 Windows 的正斜杠都会抵达一个自身路径拼写不同的层级;没有这份记忆,这类草稿会每敲一键就重扫一次,而且永远过滤不了。 +- **导航以选中项为锚、安静且有界地落地。** 在展示根之外(与 crumb 头部渲染的是同一塌缩,因此 crumb 与分栏形态永不相左),落地即双栏:重新选中目标在父层级中的实际条目(Windows 上按平台惯例折叠大小写),右侧展示其子项,因此 crumb 跳转读作后退一栏,而不是塌缩成单列。父层级这一程在 200ms 等待上限内落定时,目标与父层级两程以**同一帧**落地——在此之前陈旧视图持续渲染,导航换栏时因此没有中间的单栏闪现——超出该上限则目标即刻单独提交(Enter 提交的导航绝不会被滞塞的父层级扣作人质),迟到的父层级这一程再就地升级这次落地。父层级这一程在落地的 supersession 范围下运行,任何较新的意图都会在线上将其中止(因此在落地窗口内按 Escape 即撤回整次导航);父层级这一程失败,或被截断的父窗口缺少目标时,都保留单栏落地——升级的存在正是为了锚定选中项,绝不能反而让它悬空。加载指示器遵循同一安静规则:它浮于内容右下角(绝不是会挪动布局的一行;截断/错误行占据左下角,并在扫描期间持续渲染),且仅在扫描超出 300ms 静默窗口后才出现,因此本地列举切换时什么也不显示。行选取被刻意豁免于同一帧规则:选取后立即分栏本身就是其选中态反馈(aria-current、crumb 跟随),而导航除了换栏本身没有任何东西可确认这次点击。三个时序常量——200ms 父层级上限、300ms 静默窗口,以及编辑器的 250ms 草稿停顿——都按本地列举校准;远程部署(每层级一次 RPC,通常 100–400ms)会落在静默窗口之内、crumb 上却没有按下态,而且要先付停顿再付 RPC 分栏才跟上——待远程消费方落地时,三者一并重新审视。 - **符号链接:为可进入性而跟随。** 用 `stat` 探测符号链接(断链/循环→跳过);面包屑保留操作者导航的逻辑路径,`workspace.create` 在接纳时本就做 realpath 规范化。 - **列举层级有上限,且流式处理。** 单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端目录列举的同一上限)。层级经 `opendir` 流入一个按名排序、容量 `maxEntries + 1` 的候选窗口,内存保持 O(maxEntries),可进入性探测只触及窗口内候选;线上 `DirectoryListing` 携带必填的 `truncated` 标志,让客户端明示不完整而不是静默缺尾。窗口内的断链符号链接不从窗口外回填——发生过驱逐本身已把层级标记为截断。窗口插入为二分查找、满窗尾部单次比较即拒绝(超大层级不能为每个 dirent 付出一次全窗扫描),且 `list(path, signal)` 透传载体的请求信号,滞塞网络目录的扫描不会在调用方断连后继续存活——扫描中的每个 await(打开、每次读取、每次符号链接探测)都与信号赛跑,中止路径放弃而非等待 close(Node 会把 close 排在在飞读取之后),被放弃的 settlement 全部吞掉,清理不会以未处理拒绝的形式冒出。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。 - **全盘可浏览,不做 roots 配置。** `workspace.create` 接受任意路径且 API 本就提供驱动 bash 的方法,浏览根只会是 UX 范围而非边界;没有消费方的可配置性过不了证据门槛。等到有部署需要再做。 diff --git a/packages/host/directory-picker-browse/README.i18n.yaml b/packages/host/directory-picker-browse/README.i18n.yaml index 4b4f10ee23..7c2df43ab2 100644 --- a/packages/host/directory-picker-browse/README.i18n.yaml +++ b/packages/host/directory-picker-browse/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/host/directory-picker-browse/README.md -README.md: 04d97adf71ae4a3ea8d89b24098f27cbdee20961 -README.zh.md: 603afaed7e4b4c77e699e009fc612e73c06d73aa +README.md: 62384cc0b0e5756e56c1d608252721c506a0915f +README.zh.md: 8f495e1e4d87486d0565eadcbf7df694494c7096 diff --git a/packages/host/directory-picker-browse/README.md b/packages/host/directory-picker-browse/README.md index 04d97adf71..62384cc0b0 100644 --- a/packages/host/directory-picker-browse/README.md +++ b/packages/host/directory-picker-browse/README.md @@ -6,7 +6,7 @@ The **in-app browsing backend** of the [directory-picker seam](../directory-pick Behavior facts: listings return **directories only**, name-sorted, with symlinks-to-directories followed (broken/cyclic links skipped — the probe `stat` failing means "not enterable") and a host-owned `hidden` flag (POSIX dot convention) left for the client to act on; `crumbs` is the root-to-target ancestor chain, the root crumb labeled by its full path (`/`, `C:\`); an absent `list` path means the host account's home directory. `createDirectory` is non-recursive (a missing parent is a real failure, not a level to invent) and validates the name as a single non-blank segment even when called directly, mirroring the wire schema's fence. Both primitives reject an explicit path that is not fully qualified — relative forms, and on Windows the rooted drive-less forms (`\foo`, `/foo`) and incomplete UNC prefixes (`\\`, `\\server`) that `isAbsolute` accepts — with `directory-unreadable`/`directory-create-failed`, instead of letting `resolve` rebase it under the host process cwd or current drive. One `list` call returns at most `maxEntries` rows (config, default 1000 — the bound GitHub's web UI applies to directory listings), and the level streams through a bounded window so memory stays O(maxEntries) no matter how many children the directory holds: a cut level keeps the name-sorted head, counts hidden rows against the bound, probes only windowed candidates, and reports `truncated: true` so the client can say the level is incomplete (a windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated); window insertion is binary with an O(1) full-window tail rejection, and `list` threads the caller's `AbortSignal` so a disconnect or timeout stops the scan instead of letting it outlive the caller. Failures throw the seam's typed `DirectoryPickerError`. Policy rationale: [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md). -**Dual-face package**: the browser half (`./client`) fills [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes with the in-app **Select Workspace Directory** dialog (figma `Harness` 813-23126 family — Miller two-column view whose navigations land selection-anchored and quiet: the previous view keeps rendering while a crumb jump or a submitted path is scanned (a "Loading…" pill floats over it only once the scan outlives a 300ms silence window, never shifting the columns), then target and parent legs land as one two-pane frame with the target re-selected as its actual parent-level entry — so stepping back never collapses and no intermediate frame flashes (a parent leg outliving its 200ms wait bound lands the target alone and upgrades in place; a failed or truncated parent leg keeps the single-pane landing; the display root keeps the single wide level); breadcrumb with a click-to-edit path zone, advertised by the pencil glyph at the bar's right edge and lit on hover in the editor's own footprint, whose editor seeds a trailing separator and then keeps the panes under the draft: the final segment prefix-filters the level its directory part names (case-insensitively, over the listed — possibly truncated — rows only; a tail nobody matches releases the filter instead of emptying the pane), while any other directory part is scanned after a 250ms rest and lands like any other navigation — selection-anchored, two-pane away from the display root — so typing deeper descends and erasing segments walks back up, moving the Miller view without leaving the editor; the pane arity is the invariant, the last pane always listing the level the path names with its parent beside it (only that level's own tail costs no scan, and only a display root lists alone) — a speculative scan is silent when it fails, and Enter still navigates by the exact text, owning the view until it lands; the editor cancels on Escape or when focus leaves the dialog card (window/tab switches and in-card focus moves keep the draft), and panes the draft walked to stay where the walk ended — the crumbs name that level and Open's fallback target follows them, so cancelling closes the editor rather than rewinding the walk; a fixed-label show-hidden footer toggle over the host's `hidden` flags, with a dot-led typed prefix revealing its matches and the current selection exempt from every filter; nested New-folder dialog), driving `host.listDirectory`/`host.createDirectory` and registering its own locale namespace (`directory-browser`, zh default / en). One cordis.yml row therefore composes both sides of the browse interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind). +**Dual-face package**: the browser half (`./client`) fills [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes with the in-app **Select Workspace Directory** dialog (figma `Harness` 813-23126 family — Miller two-column view whose navigations land selection-anchored and quiet: the previous view keeps rendering while a crumb jump or a submitted path is scanned (a "Loading…" pill floats over it only once the scan outlives a 300ms silence window, never shifting the columns), then target and parent legs land as one two-pane frame with the target re-selected as its actual parent-level entry — so stepping back never collapses and no intermediate frame flashes (a parent leg outliving its 200ms wait bound lands the target alone and upgrades in place; a failed or truncated parent leg keeps the single-pane landing; the display root keeps the single wide level); breadcrumb with a click-to-edit path zone, advertised by the pencil glyph at the bar's right edge and lighting the whole bar — the editor's own box — on hover, whose editor seeds a trailing separator and then keeps the panes under the draft: the final segment prefix-filters the LAST pane while that pane lists the level the directory part names (case-insensitively, over the listed — possibly truncated — rows only; a tail nobody matches releases the filter instead of emptying the pane), while any other directory part is scanned after a 250ms rest and lands like any other navigation — selection-anchored, two-pane away from the display root, both legs waited out so one keystroke moves the view once — so typing deeper descends and erasing segments walks back up without leaving the editor; the pane arity is the invariant, the last pane always listing the level the path names with its parent beside it (only that level's own tail costs no scan, and only a display root lists alone), and a level still answers the text that produced it after the Host resolved it (`..` segments, Windows forward slashes) — a speculative scan is silent when it fails, and Enter still navigates by the exact text, owning the view until it lands; the editor cancels on Escape or when focus leaves the dialog card (window/tab switches and in-card focus moves keep the draft), and panes the draft walked to stay where the walk ended — the crumbs name that level and Open's fallback target follows them, so cancelling closes the editor rather than rewinding the walk; a fixed-label show-hidden footer toggle over the host's `hidden` flags, with a dot-led typed prefix revealing its matches and the current selection exempt from every filter; nested New-folder dialog), driving `host.listDirectory`/`host.createDirectory` and registering its own locale namespace (`directory-browser`, zh default / en). One cordis.yml row therefore composes both sides of the browse interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind). ## Model Experience diff --git a/packages/host/directory-picker-browse/README.zh.md b/packages/host/directory-picker-browse/README.zh.md index 603afaed7e..8f495e1e4d 100644 --- a/packages/host/directory-picker-browse/README.zh.md +++ b/packages/host/directory-picker-browse/README.zh.md @@ -6,7 +6,7 @@ 行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `stat` 失败即"不可进入"),并携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示决策留给客户端;`crumbs` 是从根到目标的祖先链,根 crumb 以完整路径标注(`/`、`C:\`);`list` 不带路径即列举宿主账户的家目录。`createDirectory` 不递归(父目录缺失是真实失败,不是要补造的层级),且即便被直接调用也把名称校验为单个非空段,与协议 schema 的栅栏一致。两个原语都拒绝非完全限定的显式路径——相对形态,以及 Windows 上 `isAbsolute` 会放行的无盘符有根形态(`\foo`、`/foo`)与不完整的 UNC 前缀(`\\`、`\\server`)——报 `directory-unreadable`/`directory-create-failed`,而不是任由 `resolve` 把它重定位到宿主进程 cwd 或当前盘符之下。单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端对目录列举采用的同一上限),且层级以流式方式经过一个有界窗口,无论目录有多少子项内存都保持 O(maxEntries):被截断的层级保留按名排序的头部、隐藏行计入上限、只探测窗口内候选,并报告 `truncated: true`,供客户端提示层级不完整(窗口内的断链符号链接不会从窗口外回填——发生过驱逐本身已把层级标记为截断);窗口插入为二分查找、满窗尾部单次比较即拒绝,且 `list` 透传调用方的 `AbortSignal`,断连或超时会停止扫描而不是让它在调用方离开后继续。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 -**双面包**:browser half(`./client`)以应用内 **选择工作区目录** 对话框(figma `Harness` 813-23126 家族——Miller 双列视图,其导航以选中项为锚、安静落地:扫描 crumb 跳转或提交的路径期间,先前视图持续渲染("Loading…" 胶囊仅在扫描超出 300ms 静默窗口后才浮于其上,绝不挪动各列),随后目标与父层级两程以单个双栏帧落地,目标被重新选中为其在父层级中的实际条目——因此后退绝不塌缩,也没有中间帧闪现(父层级这一程超出其 200ms 等待上限时,目标单独落地,随后就地升级;父层级这一程失败或被截断时保持单栏落地;展示根保持单个宽层级);带点击即编辑路径区的面包屑,该区由栏右端的铅笔图标点明、悬停时以编辑器自身的轮廓亮起,其编辑器预填尾随分隔符,随后让下方各栏跟随草稿:末段对其目录部分所指的层级做前缀过滤(不区分大小写,且仅作用于已列出、可能被截断的行;无一匹配的末段会解除过滤,而不是把该栏清空),而其余任何目录部分都会在停顿 250ms 后被扫描,并像其他任何一次导航那样落地——以选中项为锚,在展示根之外即双栏——于是继续键入即下潜、删掉末段即上退,Miller 视图随之移动而不必离开编辑器;分栏个数是这里的不变量:最后一栏永远是路径所指的那一层,其上一层在它旁边(只有这一层自己的末段不触发扫描,也只有展示根会独占一栏)——推测性扫描失败时保持沉默,而 Enter 仍按确切文本导航,并在落地前独占视图;编辑器按 Escape 或焦点离开对话框卡片即取消(窗口/标签页切换与卡片内焦点移动保留草稿),而草稿走到的层级会留在原地——面包屑指明该层级、Open 的兜底目标随之而动,因此取消只是关闭编辑器,并不回退这段行走;基于宿主 `hidden` 标志、标签固定的"显示隐藏"footer 开关,键入以点开头的前缀会显出其匹配项,且当前选中项不受任何过滤影响;嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞,驱动 `host.listDirectory`/`host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`,zh 默认/en)。因此一行 cordis.yml 同时组合浏览交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。 +**双面包**:browser half(`./client`)以应用内 **选择工作区目录** 对话框(figma `Harness` 813-23126 家族——Miller 双列视图,其导航以选中项为锚、安静落地:扫描 crumb 跳转或提交的路径期间,先前视图持续渲染("Loading…" 胶囊仅在扫描超出 300ms 静默窗口后才浮于其上,绝不挪动各列),随后目标与父层级两程以单个双栏帧落地,目标被重新选中为其在父层级中的实际条目——因此后退绝不塌缩,也没有中间帧闪现(父层级这一程超出其 200ms 等待上限时,目标单独落地,随后就地升级;父层级这一程失败或被截断时保持单栏落地;展示根保持单个宽层级);带点击即编辑路径区的面包屑,该区由栏右端的铅笔图标点明,悬停时整条栏——也就是编辑器自身的那只框——亮起,其编辑器预填尾随分隔符,随后让下方各栏跟随草稿:当最后一栏正是目录部分所指的层级时,末段对这一栏做前缀过滤(不区分大小写,且仅作用于已列出、可能被截断的行;无一匹配的末段会解除过滤,而不是把该栏清空),而其余任何目录部分都会在停顿 250ms 后被扫描,并像其他任何一次导航那样落地——以选中项为锚,在展示根之外即双栏,且两程都等齐,于是一次按键只让视图移动一次——继续键入即下潜、删掉末段即上退,全程不必离开编辑器;分栏个数是这里的不变量:最后一栏永远是路径所指的那一层,其上一层在它旁边(只有这一层自己的末段不触发扫描,也只有展示根会独占一栏),而宿主规范化过路径之后(`..` 段、Windows 的正斜杠),该层级仍然应答产生它的那段文本——推测性扫描失败时保持沉默,而 Enter 仍按确切文本导航,并在落地前独占视图;编辑器按 Escape 或焦点离开对话框卡片即取消(窗口/标签页切换与卡片内焦点移动保留草稿),而草稿走到的层级会留在原地——面包屑指明该层级、Open 的兜底目标随之而动,因此取消只是关闭编辑器,并不回退这段行走;基于宿主 `hidden` 标志、标签固定的"显示隐藏"footer 开关,键入以点开头的前缀会显出其匹配项,且当前选中项不受任何过滤影响;嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞,驱动 `host.listDirectory`/`host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`,zh 默认/en)。因此一行 cordis.yml 同时组合浏览交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。 ## 模型体验 diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css index eaad4d46d8..b5fbb611a4 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css @@ -49,12 +49,29 @@ color: var(--dsw-alias-label-primary); } +/* The bar IS the editor's box in both modes: it carries the rounded outline + * and the inner padding, the crumbs and the input sit inside it, and hovering + * the edit zone lights the whole row rather than the remainder right of the + * crumbs. The negative left margin pays back the border and padding, so the + * crumb (and input) text keeps the column the title sits in. */ .crumbBar { display: flex; align-items: center; gap: 4px; - /* The path editor's height: crumb mode and edit mode occupy the same bar. */ + box-sizing: border-box; min-height: 24px; + margin-left: -9px; + padding: 0 8px; + border: 1px solid transparent; + border-radius: 8px; +} + +/* Lit by the affordance the row belongs to, never by a crumb: a crumb's hover + * offers navigation, not path entry. Editing keeps the outline standing. */ +.crumbBar:has(.crumbEditZone:enabled:hover), +.crumbBar:has(.crumbEditZone:focus-visible), +.crumbBar:has(.pathInput) { + border-color: var(--dsw-alias-border-l2); } /* Deep chains scroll inside the trail (the effect pins the tail into view) @@ -119,30 +136,21 @@ color: var(--dsw-alias-label-tertiary); } -/* The empty remainder of the bar: a real click target that flips the bar - * into path-edit mode. The zone itself stays flush with the crumbs; the - * pencil glyph seated at its right edge is the standing affordance, and - * hover/focus lights the zone in the editor's own rounded shape so the - * gesture reads before the click. */ +/* The empty remainder of the bar: a real click target that flips the bar into + * path-edit mode. The pencil glyph seated at its right edge is the standing + * affordance; the outline the gesture lights belongs to the bar, so the whole + * row reads as the box the input will occupy. */ .crumbEditZone { display: flex; align-items: center; justify-content: flex-end; flex: 1 0 34px; min-width: 34px; - /* The editor's own height, so hover previews the input's exact footprint - * and the bar does not resize when the two swap. */ - height: 24px; - padding: 0 6px; - border: 1px solid transparent; - border-radius: 8px; + height: 22px; + padding: 0; + border: none; background: transparent; cursor: text; -} - -.crumbEditZone:hover, -.crumbEditZone:focus-visible { - border-color: var(--dsw-alias-border-l2); outline: none; } @@ -151,13 +159,12 @@ color: var(--dsw-alias-label-tertiary); } -.crumbEditZone:hover .crumbEditGlyph, +.crumbEditZone:enabled:hover .crumbEditGlyph, .crumbEditZone:focus-visible .crumbEditGlyph { color: var(--dsw-alias-label-primary); } .crumbEditZone:disabled { - border-color: transparent; cursor: default; } @@ -165,14 +172,14 @@ color: var(--dsw-alias-label-caption); } +/* Chrome-free: the bar around it draws the box (border, radius, padding). */ .pathInput { box-sizing: border-box; flex: 1 1 0; min-width: 0; - height: 24px; - padding: 0 8px; - border: 1px solid var(--dsw-alias-border-l2); - border-radius: 8px; + height: 22px; + padding: 0; + border: none; outline: none; background: transparent; font-size: 13px; diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index eaada21965..fc6dde82f1 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -19,18 +19,20 @@ * error surface. Hidden entries are host-flagged and hidden by default; the * footer's fixed-label "Show hidden files" toggle (aria-pressed, check when * on) reveals them (client-side only). The path editor announces itself with - * a pencil glyph and a hover-lit zone, opens seeded with a trailing - * separator, and keeps the panes under the draft: the final segment - * prefix-filters the level its directory part names (a dot-led prefix also - * reveals the hidden entries it names, and a prefix nobody matches releases - * the filter), while any other directory part is scanned after a short - * debounce and lands like any other navigation — selection-anchored and - * two-pane away from the display root. The pane arity holds throughout: the - * last pane is the level the path names and the one beside it is its parent, - * so typing deeper descends and erasing segments walks back up, moving the - * Miller view without leaving the editor. Panes the draft walked to stay put - * when the editor closes (cancellation included): the crumbs name where the - * walk ended, and Open's fallback target follows them. + * a pencil glyph and a bar-wide hover-lit outline, opens seeded with a + * trailing separator, and keeps the panes under the draft: the final segment + * prefix-filters the LAST pane while that pane's level is the one the draft's + * directory part names (a dot-led prefix also reveals the hidden entries it + * names, and a prefix nobody matches releases the filter), while any other + * directory part is scanned after a short debounce and lands like any other + * navigation — selection-anchored and two-pane away from the display root, + * both legs waited out so one keystroke moves the view once. The pane arity + * holds throughout: the last pane is the level the path names and the one + * beside it is its parent, so typing deeper descends and erasing segments + * walks back up, moving the Miller view without leaving the editor. Panes the + * draft walked to stay put when the editor closes (cancellation included): + * the crumbs name where the walk ended, and Open's fallback target follows + * them. */ import { useCallback, useEffect, useRef, useState } from 'react' import clsx from 'clsx' @@ -125,63 +127,65 @@ function levelDirectory(listing: DirectoryListing): string { return listing.path.endsWith(sep) ? listing.path : `${listing.path}${sep}` } +/** The directory text a draft-following scan last sent, with the level path the host answered it with. */ +interface ScannedDirectory { + /** The draft's directory part, verbatim as it went to the host. */ + readonly directory: string + /** `path` of the listing that came back. */ + readonly landed: string +} + /** * The draft's directory part — everything through its last separator — or * null while no separator has been typed at all (nothing addresses a - * directory yet). The platform separator comes from `listing`, so the caller - * passes any listing of the host's filesystem. + * directory yet). The platform comes from `listing`: on Windows a forward + * slash separates too (the host's `resolve` accepts either), while on POSIX a + * backslash is a legal name character and never separates. */ function draftDirectory(listing: DirectoryListing, draft: string): string | null { - const cut = draft.lastIndexOf(separatorOf(listing)) + const cut = separatorOf(listing) === '\\' + ? Math.max(draft.lastIndexOf('\\'), draft.lastIndexOf('/')) + : draft.lastIndexOf('/') return cut === -1 ? null : draft.slice(0, cut + 1) } /** - * The path draft's final segment, when its directory part is exactly the - * level `listing` lists — the segment the level prefix-filters on while the - * user types. Any other draft (no separator yet, or naming some other - * directory) leaves the level unfiltered. The directory part compares - * exactly (it is the host's own path text, reached by seeding, erasing, or a - * draft-following scan); only the name filter downstream is case-insensitive. + * How the draft reads against one level: the directory part it names, and — + * when `listing` is the level that directory part addresses — the final + * segment that prefix-filters it while the user types (case-insensitively, + * downstream). A level answers a directory part when its own path is that + * part, or when it is the level that very text just produced (`scanned`): the + * host resolves what it is given, so `..` segments and Windows forward + * slashes reach a level whose path spells the request differently. + * @param listing - the level to read the draft against. + * @param draft - the current path draft. + * @param scanned - the last draft-following scan's directory and landing. + * @returns the draft's directory part (null with no separator typed) and its + * filtering tail (null when this level does not answer that directory). */ -function draftPrefixFor(listing: DirectoryListing, draft: string | null): string | null { - if (draft === null) return null - const directory = draftDirectory(listing, draft) - if (directory === null) return null - return directory === levelDirectory(listing) ? draft.slice(directory.length) : null -} - -/** - * The directory a draft addresses that the panes are not already presenting - * as the current level — what the editor must scan to keep the view under the - * typed path. The pane arity is the invariant this preserves: the LAST pane - * always lists the level the path names, with its parent beside it (a display - * root lists alone), so a draft naming any other level re-lands rather than - * leaving a deeper level standing to the right of the one being typed. Null - * when that level is already the last pane, when no separator has been typed - * yet, and when no level is listed at all: the platform separator is read off - * a listing, so the editor's failed-home-listing recovery path types blind - * until Enter. - */ -function pendingPreviewDirectory( - parent: DirectoryListing | null, - child: DirectoryListing | null, +function readDraft( + listing: DirectoryListing, draft: string, -): string | null { - if (parent === null) return null - const directory = draftDirectory(parent, draft) - if (directory === null) return null - return directory === levelDirectory(child ?? parent) ? null : directory + scanned: ScannedDirectory | null, +): { directory: string | null; tail: string | null } { + const directory = draftDirectory(listing, draft) + if (directory === null) return { directory: null, tail: null } + const answers = directory === levelDirectory(listing) + || (scanned !== null && scanned.directory === directory && scanned.landed === listing.path) + return { directory, tail: answers ? draft.slice(directory.length) : null } } /** * The rows one column renders. The selection is exempt from every filter: it * anchors the two-pane view (crumbs and the child pane point at it), so * neither the hidden filter after a dot-reveal pick nor a prefix miss may - * orphan it. A prefix narrows the level only while some row matches it — a - * tail nobody matches is a name being spelled, not a demand for an empty - * pane, so the level shows whole (and its hidden rows return to obeying the - * toggle, the dot-led reveal included). + * orphan it. A prefix narrows the level only while some row it would actually + * show matches — a tail nobody matches is a name being spelled, not a demand + * for an empty pane, so the level shows whole and its hidden rows return to + * obeying the toggle. Counting only displayable rows is what keeps that true: + * were a hidden row ever to match a prefix that does not reveal it (today + * `hidden` means dot-prefixed, so it cannot), the level would narrow to + * nothing. */ function visibleEntries( entries: readonly DirectoryEntry[], @@ -190,15 +194,15 @@ function visibleEntries( filterPrefix: string | null, ): readonly DirectoryEntry[] { const needle = filterPrefix === null ? '' : filterPrefix.toLowerCase() - const matches = (entry: DirectoryEntry): boolean => entry.name.toLowerCase().startsWith(needle) - const narrowing = needle !== '' && entries.some(matches) // A dot-led prefix names hidden entries explicitly, so matching ones // surface even while the toggle keeps the rest hidden. - const revealHidden = narrowing && needle.startsWith('.') + const displayable = (entry: DirectoryEntry): boolean => showHidden || !entry.hidden || needle.startsWith('.') + const matches = (entry: DirectoryEntry): boolean => displayable(entry) && entry.name.toLowerCase().startsWith(needle) + const narrowing = needle !== '' && entries.some(matches) return entries.filter((entry) => { if (entry.path === selectedPath) return true - if (narrowing && !matches(entry)) return false - return showHidden || !entry.hidden || revealHidden + if (narrowing) return matches(entry) + return showHidden || !entry.hidden }) } @@ -353,6 +357,12 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, const viewRef = useRef<{ parent: DirectoryListing | null; child: DirectoryListing | null }>({ parent: null, child: null }) useEffect(() => { viewRef.current = { parent, child } }, [parent, child]) + // What the last draft-following scan asked for and what came back, so a + // level still answers the text that produced it after the host respelled + // it. Stale entries are harmless: a match needs both the directory text and + // that level's own path, which together already mean the same directory. + const scanned = useRef(null) + /** * A landed preview replaced the pane a keyboard operator may have Tabbed * onto, so the focus it drops is re-parked on the still-open editor (the @@ -376,13 +386,18 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, * rendering: a landing swaps the panes, it never blanks them. * * Two callers, one landing shape. A submitted path (Enter, a crumb) closes - * the editor on arrival and announces its failure; the editor's own - * draft-following scan keeps both to itself — it is speculative, so a - * failure leaves the last readable panes standing and says nothing, while - * an arrival clears the stale message and re-parks focus the swap dropped. + * the editor on arrival, announces its failure, and takes the wait bound — + * it is answering a gesture, so it may not hang on a stalled parent. The + * editor's own draft-following scan keeps all three to itself: it is + * speculative, nothing waits on it, and the stale view keeps rendering, so + * it waits for BOTH legs rather than flashing a single pane it would then + * upgrade — one keystroke must move the view once. A failure leaves the + * last readable panes standing and says nothing, while an arrival clears + * the stale message and re-parks focus the swap dropped. * @param path - the level to list; absent lists the Host home directory. - * @param options - `closeEditor` retires the path draft on arrival; - * `announce` surfaces a failure as the dialog's alert. + * @param options - `closeEditor` retires the path draft on arrival and + * bounds the wait for the parent leg; `announce` surfaces a failure as the + * dialog's alert. */ const land = useCallback((path: string | undefined, options: { closeEditor: boolean; announce: boolean }) => { const { seq, scan } = launchListing(path) @@ -401,6 +416,11 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, } scan.then((target) => { if (seq !== requestSeq.current) return + // The level the panes will present as current answers this exact + // directory text, however the host respelled it (`..`, a Windows + // forward slash): the tail filters, and the same text asks for no + // second scan. + if (!options.closeEditor && path !== undefined) scanned.current = { directory: path, landed: target.path } // The single-pane landing; `landed` makes it first-commit-only, while // the two-pane commit below may still upgrade an already-landed view. let landed = false @@ -438,7 +458,10 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // target listed fine, and nobody asked to see the parent level. landSingle() }) - window.setTimeout(landSingle, PARENT_LEG_WAIT_MS) + // Only a submitted navigation is bounded: the walk waits both legs out + // (see the contract above), and a keystroke aborts it if the operator + // moves on first. + if (options.closeEditor) window.setTimeout(landSingle, PARENT_LEG_WAIT_MS) }, (reason: unknown) => { if (seq !== requestSeq.current) return setLoading(false) @@ -641,8 +664,12 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, if (pathDraft === null) return const timer = window.setTimeout(() => { if (previewSuspended.current) return - const directory = pendingPreviewDirectory(viewRef.current.parent, viewRef.current.child, pathDraft) - if (directory === null) return + // The level the panes present as current: it alone may answer the + // draft, so anything else it names is a level to walk to. + const current = viewRef.current.child ?? viewRef.current.parent + if (current === null) return + const { directory, tail } = readDraft(current, pathDraft, scanned.current) + if (directory === null || tail !== null) return previewDraftLevel(directory) }, DRAFT_PREVIEW_DEBOUNCE_MS) return () => { window.clearTimeout(timer) } @@ -650,6 +677,14 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // After the hooks: a closed dialog renders nothing and evaluates no copy. const crumbSource = child ?? parent + // The draft's tail filters the level it names, which by the pane invariant + // is the LAST pane — never a pane the draft has already walked away from. + // Narrowing that stale pane would move the view twice for one keystroke: + // once as it narrows, again as its landing replaces it. It holds still + // instead, and the filter arrives with the level it belongs to. + const typedPrefix = crumbSource === null || pathDraft === null + ? null + : readDraft(crumbSource, pathDraft, scanned.current).tail const crumbs = crumbSource === null ? [] : displayCrumbs(crumbSource, t('browser.home')) const crumbTail = crumbs.at(-1)?.path useEffect(() => { @@ -888,7 +923,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, busy={parentInert} onPick={select} showHidden={showHidden} - filterPrefix={draftPrefixFor(parent, pathDraft)} + filterPrefix={child === null ? typedPrefix : null} pathEditing={draftPending} /> )} @@ -900,7 +935,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, busy={parentInert} onPick={advance} showHidden={showHidden} - filterPrefix={draftPrefixFor(child, pathDraft)} + filterPrefix={typedPrefix} pathEditing={draftPending} /> )} diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx index 69c61751eb..6329887add 100644 --- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -762,6 +762,105 @@ describe('DirectoryBrowser', () => { expect(b.listDirectory).toHaveBeenCalledWith(`${DOCS}/`, expect.anything()) }) + it('holds a stale pane still until its landing, instead of narrowing it first', async () => { + // Own three-level tree: the level that goes stale needs two rows for the + // narrowing this pins against to be visible at all. + const ROOT = '/u' + const MID = `${ROOT}/mid` + const LEAF = `${MID}/leaf` + const chain = [{ name: '/', path: '/', hidden: false }, { name: 'u', path: ROOT, hidden: false }] + const tree: Record = { + [ROOT]: { + path: ROOT, + home: ROOT, + crumbs: chain, + entries: [{ name: 'mid', path: MID, hidden: false }, { name: 'other', path: `${ROOT}/other`, hidden: false }], + truncated: false, + }, + [MID]: { + path: MID, + home: ROOT, + crumbs: [...chain, { name: 'mid', path: MID, hidden: false }], + entries: [{ name: 'leaf', path: LEAF, hidden: false }, { name: 'sibling', path: `${MID}/sibling`, hidden: false }], + truncated: false, + }, + [LEAF]: { + path: LEAF, + home: ROOT, + crumbs: [...chain, { name: 'mid', path: MID, hidden: false }, { name: 'leaf', path: LEAF, hidden: false }], + entries: [], + truncated: false, + }, + } + mount({ + listDirectory: vi.fn(async (path?: string) => { + const asked = path ?? ROOT + const found = tree[asked.length > 1 && asked.endsWith('/') ? asked.slice(0, -1) : asked] + if (found === undefined) throw new Error(`cannot list ${asked}`) + return found + }), + }) + await waitFor(() => { expect(screen.getByText('mid')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + fireEvent.change(input, { target: { value: `${LEAF}/` } }) + await waitFor(() => { expect(columns()).toHaveLength(2) }) + expect(within(columns()[0]!).getAllByRole('listitem').map(item => item.textContent)).toEqual(['leaf', 'sibling']) + // Deleting the separator names the level the LEFT pane lists. That pane + // is stale — its landing will move it right — so it must not narrow to + // the tail first: one deletion, one movement. + fireEvent.change(input, { target: { value: LEAF } }) + expect(within(columns()[0]!).getAllByRole('listitem').map(item => item.textContent)).toEqual(['leaf', 'sibling']) + await waitFor(() => { expect(within(columns()[0]!).getByText('other')).toBeTruthy() }) + expect(within(columns()[1]!).getAllByRole('listitem').map(item => item.textContent)).toEqual(['leaf']) + }) + + it('keeps the walked-to panes when the editor is cancelled, Open adopting where the walk ended', async () => { + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + fireEvent.change(input, { target: { value: `${DOCS}/h` } }) + await waitFor(() => { expect(columns()).toHaveLength(2) }) + fireEvent.keyDown(input, { key: 'Escape' }) + // Cancel closes the editor; it does not rewind the walk. The operator + // watched the panes move, so the crumbs, the panes, and Open's target all + // stay where the walk ended. + expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull() + expect(columns()).toHaveLength(2) + expect(within(columns()[0]!).getByText('Documents')).toBeTruthy() + expect(within(columns()[1]!).getByText('harness')).toBeTruthy() + expect(screen.getByRole('navigation').textContent).toContain('Documents') + const open = screen.getByRole('button', { name: 'browser.open' }) + expect(open.disabled).toBe(false) + fireEvent.click(open) + expect(b.onOpen).toHaveBeenCalledWith(DOCS) + }) + + it('waits both legs out for a walk: one keystroke never flashes a single pane', async () => { + let landParent = (): void => {} + const listDirectory = vi.fn(async (path?: string) => { + // The parent leg outlives the submitted-navigation wait bound; a walk + // has nothing waiting on it, so it holds the stale view instead of + // landing single-pane and upgrading. + if (path === HOME) return await new Promise((resolve) => { landParent = () => { resolve(listingFor(HOME)) } }) + return listingFor(path) + }) + mount({ listDirectory }) + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + fireEvent.change(input, { target: { value: `${DOCS}/h` } }) + await waitFor(() => { expect(listDirectory).toHaveBeenCalledWith(HOME, expect.anything()) }) + await act(async () => { await new Promise((resolve) => { setTimeout(resolve, 400) }) }) + // Well past the submitted-navigation bound: still the pre-walk view. + expect(columns()).toHaveLength(1) + expect(screen.getByText('Documents')).toBeTruthy() + await act(async () => { landParent() }) + await waitFor(() => { expect(columns()).toHaveLength(2) }) + expect(within(columns()[1]!).getByText('harness')).toBeTruthy() + }) + it('walks the panes back up when erased segments leave the listed levels', async () => { const b = mount() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) @@ -1018,7 +1117,8 @@ describe('DirectoryBrowser', () => { ], truncated: false, } - mount({ listDirectory: vi.fn(async () => windowsListing) }) + const listDirectory = vi.fn(async () => windowsListing) + mount({ listDirectory }) await waitFor(() => { expect(screen.getAllByRole('listitem')).toHaveLength(2) }) fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) const input = screen.getByLabelText('browser.editPath') @@ -1026,6 +1126,18 @@ describe('DirectoryBrowser', () => { expect(input.value).toBe(ROOT) fireEvent.change(input, { target: { value: `${ROOT}u` } }) expect(screen.getByRole('listitem').textContent).toBe('Users') + // Windows separates on a forward slash too (so does the Host's resolve), + // so a path typed that way names its directory; the level the Host + // answers with spells it back with a backslash, and once that scan lands + // the level answers the typed spelling — the tail filters it. + fireEvent.change(input, { target: { value: 'C:/p' } }) + await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('Program Files') }) + // And the same spelling asks for no second scan. + const settled = listDirectory.mock.calls.length + fireEvent.change(input, { target: { value: 'C:/pr' } }) + await act(async () => { await new Promise((resolve) => { setTimeout(resolve, 400) }) }) + expect(listDirectory.mock.calls).toHaveLength(settled) + expect(screen.getByRole('listitem').textContent).toBe('Program Files') }) it('clicking away from the path editor cancels it back to the crumb view', async () => { From 94dabcb7eda4843e14095af1d8e3e13482dc3544 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 3 Aug 2026 13:47:37 +0800 Subject: [PATCH 093/129] feat(ui-primitives): support delayed multiline tooltips --- .../ui-primitives/src/Tooltip.module.css | 2 +- packages/client/ui-primitives/src/Tooltip.tsx | 40 +++++++++++++++---- .../ui-primitives/tests/tooltip.spec.tsx | 28 ++++++++++++- 3 files changed, 60 insertions(+), 10 deletions(-) diff --git a/packages/client/ui-primitives/src/Tooltip.module.css b/packages/client/ui-primitives/src/Tooltip.module.css index 5853531bd4..4da0eebc2d 100644 --- a/packages/client/ui-primitives/src/Tooltip.module.css +++ b/packages/client/ui-primitives/src/Tooltip.module.css @@ -14,7 +14,7 @@ color: var(--dsw-static-neutral-bluish-00); font-size: 14px; line-height: 22px; - white-space: nowrap; + white-space: pre-line; pointer-events: none; animation: tooltip-in 150ms var(--ds-ease-in-out); } diff --git a/packages/client/ui-primitives/src/Tooltip.tsx b/packages/client/ui-primitives/src/Tooltip.tsx index 30e0c5fe0e..e85583a50c 100644 --- a/packages/client/ui-primitives/src/Tooltip.tsx +++ b/packages/client/ui-primitives/src/Tooltip.tsx @@ -1,6 +1,6 @@ // Hover/focus label bubble (figma tooltip pill: dark plate, white text). -// TODO: interaction is a placeholder (no show delay, no flip on viewport -// collision, no arrow) — visuals and behavior get a proper pass later. +// TODO: interaction is a placeholder (no flip on viewport collision or +// arrow) — visuals and behavior get a proper pass later. // The anchor is the child element itself (cloneElement, no wrapper node), so // attaching a tooltip never changes the anchor's layout context. The bubble is // position:fixed and coordinates come from the anchor's rect at show time, so @@ -27,12 +27,13 @@ interface AnchorProps { * Attach a hover/focus tooltip to an anchor element. * @param props.label - bubble text. * @param props.side - placement relative to the anchor (default 'right'). + * @param props.delayMs - hover delay in milliseconds; keyboard focus remains immediate. * @param props.disabled - suppress the bubble while true; the anchor renders identically so * toggling never remounts it (which would cut its CSS transitions). * @param props.children - a single anchor element; its own ref (callback or object) is forwarded alongside the tooltip's. * @returns the cloned anchor plus a fixed-position bubble while hovered/focused. */ -export function Tooltip({ label, side = 'right', disabled = false, children }: { label: string; side?: TooltipSide; disabled?: boolean; children: ReactElement }) { +export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false, children }: { label: string; side?: TooltipSide; delayMs?: number; disabled?: boolean; children: ReactElement }) { const anchor = useRef(null) // React 18 keeps the element's ref outside props; forward it so wrapping an // anchor in Tooltip never silently severs the owner's ref. @@ -43,15 +44,26 @@ export function Tooltip({ label, side = 'right', disabled = false, children }: { else if (childRef != null) (childRef as MutableRefObject).current = el }, [childRef]) const [pos, setPos] = useState<{ x: number; y: number } | null>(null) + const showTimer = useRef | null>(null) // Hover and focus are independent triggers: the bubble hides only after // BOTH clear (hovering away from a focused anchor must not drop it). const triggers = useRef({ hover: false, focus: false }) // Disabling mid-hover (e.g. clicking a rail control expands the sidebar) // must drop an already-visible bubble: no mouseleave fires. + const cancelShow = useCallback(() => { + if (showTimer.current === null) return + clearTimeout(showTimer.current) + showTimer.current = null + }, []) useEffect(() => { - if (disabled) { triggers.current = { hover: false, focus: false }; setPos(null) } - }, [disabled]) + if (disabled) { + cancelShow() + triggers.current = { hover: false, focus: false } + setPos(null) + } + return cancelShow + }, [cancelShow, disabled]) const show = () => { if (disabled) return @@ -63,7 +75,19 @@ export function Tooltip({ label, side = 'right', disabled = false, children }: { ? { x: r.right + 10, y: r.top + r.height / 2 } : { x: r.left + r.width / 2, y: r.bottom + 8 }) } + const showAfterHoverDelay = () => { + cancelShow() + if (delayMs <= 0) { + show() + return + } + showTimer.current = setTimeout(() => { + showTimer.current = null + show() + }, delayMs) + } const hide = () => { + cancelShow() if (!triggers.current.hover && !triggers.current.focus) setPos(null) } @@ -71,9 +95,9 @@ export function Tooltip({ label, side = 'right', disabled = false, children }: { <> {cloneElement(children, { ref: mergedRef, - onMouseEnter: (e) => { children.props.onMouseEnter?.(e); triggers.current.hover = true; show() }, - onMouseLeave: (e) => { children.props.onMouseLeave?.(e); triggers.current.hover = false; setPos(null) }, - onFocus: (e) => { children.props.onFocus?.(e); triggers.current.focus = true; show() }, + onMouseEnter: (e) => { children.props.onMouseEnter?.(e); triggers.current.hover = true; showAfterHoverDelay() }, + onMouseLeave: (e) => { children.props.onMouseLeave?.(e); triggers.current.hover = false; cancelShow(); setPos(null) }, + onFocus: (e) => { children.props.onFocus?.(e); triggers.current.focus = true; cancelShow(); show() }, onBlur: (e) => { children.props.onBlur?.(e); triggers.current.focus = false; hide() }, })} {pos !== null && ( diff --git a/packages/client/ui-primitives/tests/tooltip.spec.tsx b/packages/client/ui-primitives/tests/tooltip.spec.tsx index 591a5eb67a..5dc9a1a378 100644 --- a/packages/client/ui-primitives/tests/tooltip.spec.tsx +++ b/packages/client/ui-primitives/tests/tooltip.spec.tsx @@ -1,11 +1,37 @@ // @vitest-environment jsdom -import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' afterEach(cleanup) describe('Tooltip', () => { + it('can delay pointer hover without delaying keyboard focus', () => { + vi.useFakeTimers() + try { + render( + + + , + ) + const anchor = screen.getByText('anchor') + fireEvent.mouseEnter(anchor) + act(() => { vi.advanceTimersByTime(499) }) + expect(screen.queryByRole('tooltip')).toBeNull() + fireEvent.mouseLeave(anchor) + act(() => { vi.advanceTimersByTime(1) }) + expect(screen.queryByRole('tooltip')).toBeNull() + fireEvent.mouseEnter(anchor) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.getByRole('tooltip').textContent).toBe('Timing details') + fireEvent.mouseLeave(anchor) + fireEvent.focus(anchor) + expect(screen.getByRole('tooltip').textContent).toBe('Timing details') + } finally { + vi.useRealTimers() + } + }) + it('shows the bubble to the right on hover and hides it on leave', () => { render( From a8478fc03126fbec3c6cf580817e7fedfb497e66 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 3 Aug 2026 13:47:51 +0800 Subject: [PATCH 094/129] fix(ui-trajectory): animate responsive ledger layout --- .../src/client/TrajectoryTable.module.css | 83 +++++++++++++++++-- 1 file changed, 74 insertions(+), 9 deletions(-) diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css index 86cc8dfd8b..9e606ca5c6 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css @@ -277,7 +277,7 @@ z-index: 3; top: 0; left: 0; - display: inline-flex; + display: inline-grid; flex: none; align-items: center; box-sizing: border-box; @@ -292,8 +292,18 @@ white-space: nowrap; } +.turnLabelFull, .turnLabelCompact { - display: none; + grid-area: 1 / 1; + max-width: 64px; + overflow: hidden; + opacity: 1; + white-space: nowrap; +} + +.turnLabelCompact { + max-width: 0; + opacity: 0; } .turnLabelActive { @@ -350,15 +360,23 @@ } .kindTagIcon { - display: none; + display: inline-flex; + flex: none; align-items: center; justify-content: center; - width: 13px; + width: 0; height: 13px; + overflow: hidden; + opacity: 0; + transform: scale(0.8); } .kindTagLabel { - display: inline; + display: inline-block; + max-width: 72px; + overflow: hidden; + opacity: 1; + white-space: nowrap; } .table .kindSlot .message { @@ -393,19 +411,66 @@ } .kindTagIcon { - display: inline-flex; + width: 13px; + opacity: 1; + transform: scale(1); } .kindTagLabel { - display: none; + max-width: 0; + opacity: 0; } .turnLabelFull { - display: none; + max-width: 0; + opacity: 0; } .turnLabelCompact { - display: inline; + max-width: 64px; + opacity: 1; + } +} + +@media (prefers-reduced-motion: no-preference) { + .eventColumn, + .event, + .requestBoundaryControl, + .kindSlot, + .kindTag, + .kindTagIcon, + .kindTagLabel, + .turnLabelFull, + .turnLabelCompact { + transition-duration: 180ms; + transition-timing-function: var(--ds-ease-in-out); + } + + .eventColumn, + .kindSlot { + transition-property: width; + } + + .event { + transition-property: padding-right, padding-left; + } + + .requestBoundaryControl { + transition-property: left; + } + + .kindTag { + transition-property: padding-right, padding-left; + } + + .kindTagIcon { + transition-property: width, opacity, transform; + } + + .kindTagLabel, + .turnLabelFull, + .turnLabelCompact { + transition-property: max-width, opacity; } } From c8ece8325c34a7376f753ef74d46ee18867c73b9 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 3 Aug 2026 13:47:55 +0800 Subject: [PATCH 095/129] fix(ui-trajectory): follow live ledger tail --- .../src/client/TrajectoryTable.tsx | 26 +++++++++- .../client/ui-trajectory/tests/table.spec.tsx | 47 +++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index 4f88bf0bca..7973649cf5 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -1,6 +1,6 @@ /** Turn-aware trajectory event ledger with a local record inspector. */ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import type { CSSProperties, ReactNode } from 'react' import { IconChevronRightOutline14, @@ -22,6 +22,8 @@ import { formatElapsedSeconds } from './trajectory-record.ts' import { trajectoryPreviewText, type TrajectoryTurnModel } from './layout.ts' import css from './TrajectoryTable.module.css' +const BOTTOM_FOLLOW_THRESHOLD_PX = 2 + const KIND_LABEL: Record = { system: 'SYSTEM', user: 'USER', @@ -1711,6 +1713,9 @@ export function TrajectoryTable({ // ledger has rendered. Not-found leaves the request pending (`turns` in the // deps retries as history pages in); the ack clears the store field. const rootRef = useRef(null) + const tablePaneRef = useRef(null) + const followsTableTail = useRef(false) + const tableScrollInitialized = useRef(false) const pendingScrollIndex = useRef(null) const openRecordSummaryRef = useRef(openRecordSummary) openRecordSummaryRef.current = openRecordSummary @@ -1734,11 +1739,30 @@ export function TrajectoryTable({ row.scrollIntoView({ behavior: 'smooth', block: 'center' }) } }) + useLayoutEffect(() => { + const pane = tablePaneRef.current + if (pane === null) return + if (!tableScrollInitialized.current) { + tableScrollInitialized.current = true + followsTableTail.current = + pane.scrollHeight - pane.clientHeight - pane.scrollTop + <= BOTTOM_FOLLOW_THRESHOLD_PX + return + } + if (followsTableTail.current) pane.scrollTop = pane.scrollHeight + }, [turns]) return (
{ + const pane = event.currentTarget + followsTableTail.current = + pane.scrollHeight - pane.clientHeight - pane.scrollTop + <= BOTTOM_FOLLOW_THRESHOLD_PX + }} onClick={(event) => { if (event.target === event.currentTarget) clearAllSelections() }} diff --git a/packages/client/ui-trajectory/tests/table.spec.tsx b/packages/client/ui-trajectory/tests/table.spec.tsx index f02eb6e0cc..65c3da3255 100644 --- a/packages/client/ui-trajectory/tests/table.spec.tsx +++ b/packages/client/ui-trajectory/tests/table.spec.tsx @@ -160,6 +160,53 @@ describe('TrajectoryTable', () => { expect(onClearSelection).toHaveBeenCalledOnce() }) + it('follows appended records only while the ledger is already at the bottom', () => { + const view = render() + const tablePane = screen.getByRole('table').parentElement as HTMLElement + let scrollHeight = 200 + Object.defineProperties(tablePane, { + clientHeight: { configurable: true, get: () => 100 }, + scrollHeight: { configurable: true, get: () => scrollHeight }, + }) + tablePane.scrollTop = 100 + fireEvent.scroll(tablePane) + + scrollHeight = 260 + view.rerender( + , + ) + expect(tablePane.scrollTop).toBe(260) + + tablePane.scrollTop = 20 + fireEvent.scroll(tablePane) + scrollHeight = 320 + view.rerender( + , + ) + expect(tablePane.scrollTop).toBe(20) + }) + it('keeps running and failure semantics distinct from record roles', () => { const view = render() expect(view.container.querySelector('tr[data-kind="tool"][data-running="true"]')).toBeTruthy() From 74cd2e4bd9538aa5e0bb10a5cfedd8d0466edd7b Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 3 Aug 2026 13:48:03 +0800 Subject: [PATCH 096/129] feat(ui-trajectory): enrich timeline timing interactions --- apps/web/tests/navigation-panes.e2e.ts | 11 + .../navigation-panes/trajectory.expected.md | 3 +- .../src/client/TrajectoryTimeline.module.css | 55 +++-- .../src/client/TrajectoryTimeline.tsx | 226 +++++++++++++++--- .../client/ui-trajectory/tests/views.spec.tsx | 148 +++++++++++- 5 files changed, 388 insertions(+), 55 deletions(-) diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index 35f77b96ec..af4799a759 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -162,6 +162,17 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { await page.evaluate(() => { document.body.removeAttribute('data-ds-dark-theme') }) await page.getByRole('tab', { name: 'Result' }).click() await expect.poll(() => page.getByText('NAVIGATION_OK', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1) + const assistantSpan = page.locator('[data-timeline-span="message"][data-assistant-timing="true"]').first() + await assistantSpan.hover() + const timingTooltip = page.getByRole('tooltip') + await timingTooltip.waitFor({ timeout: 5_000 }) + await expect.poll(() => timingTooltip.textContent(), { timeout: 5_000 }).toMatch(/TTFT .* Decoding/) + const assistantTimingStyle = await assistantSpan.evaluate(node => ({ + background: getComputedStyle(node).backgroundImage, + ttft: getComputedStyle(node).getPropertyValue('--trajectory-assistant-ttft'), + })) + expect(assistantTimingStyle.background).toContain('linear-gradient') + expect(assistantTimingStyle.ttft).toMatch(/%$/) const snapshot = (await captureStableAria(page, '[class*="viewArea"]', scaffold.workspaceCwd)) .split(SEED_ID).join('{{seededId}}') await compareOrRefreshGolden(TRAJECTORY_EXPECTED, snapshot, MODE) diff --git a/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md b/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md index 788b8a3233..704190b9f2 100644 --- a/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md +++ b/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md @@ -4,7 +4,8 @@ - button "Collapse calls": Calls - img - searchbox "Search trajectory" -- region "Trajectory timeline" +- region "Trajectory timeline": + - tooltip "ASSISTANT {{clock}}:40.549 AM → {{clock}}:42.091 AM Total 1.5 s · TTFT 368 ms · Decoding 1.2 s" - table: - rowgroup: - row "SYSTEM, Initial System Prompt": diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css index 1ca5ab2627..4d548e64d8 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css @@ -7,6 +7,10 @@ user-select: none; } +.root :global([role='tooltip']) { + font: var(--dsw-font-xxxs-11); +} + .plot { display: grid; grid-template-columns: 44px minmax(0, 1fr); @@ -53,6 +57,10 @@ touch-action: none; } +.track[data-panning='true'] { + cursor: grabbing; +} + .empty { position: absolute; top: 50%; @@ -105,8 +113,15 @@ .span { position: absolute; top: calc(var(--trajectory-span-lane) * 14px); - left: calc(var(--trajectory-span-left) + 1px); - width: max(2px, calc(var(--trajectory-span-width) - 2px)); + left: calc(var(--trajectory-span-left) + var(--trajectory-span-gap)); + width: max( + 2px, + calc( + var(--trajectory-span-width) + - var(--trajectory-span-gap) + - var(--trajectory-span-gap) + ) + ); height: 8px; min-width: 2px; border-radius: 1px; @@ -127,23 +142,35 @@ } .span[data-timeline-span='message'] { - background: color-mix( + --trajectory-assistant-decoding-color: color-mix( in srgb, var(--dsw-alias-brand-primary-new-colorprimary-new-color) 60%, var(--dsw-alias-state-error-secondary) ); -} - -.span[data-timeline-span='tool'] { - background: var(--dsw-alias-state-warn-label); -} - -.span[data-timeline-span='subtool'] { - background: color-mix( + --trajectory-assistant-ttft-color: color-mix( in srgb, - var(--dsw-alias-state-warn-label) 62%, - var(--dsw-alias-label-tertiary) + var(--trajectory-assistant-decoding-color) 54%, + var(--dsw-alias-bg-layer-2) ); + + background: var(--trajectory-assistant-decoding-color); + opacity: 1; +} + +.span[data-timeline-span='message'][data-assistant-timing='true'] { + background: linear-gradient( + to right, + var(--trajectory-assistant-ttft-color) 0, + var(--trajectory-assistant-ttft-color) var(--trajectory-assistant-ttft), + var(--trajectory-assistant-decoding-color) var(--trajectory-assistant-ttft), + var(--trajectory-assistant-decoding-color) 100% + ); +} + +.span[data-timeline-span='tool'], +.span[data-timeline-span='subtool'] { + background: var(--dsw-alias-state-warn-label); + opacity: 1; } .span[data-error='true'] { @@ -161,7 +188,7 @@ .span[data-hovered='true']:not([data-current='true']) { z-index: 1; - opacity: 0.78; + opacity: 1; box-shadow: 0 0 0 1px var(--dsw-alias-bg-layer-2), 0 0 0 2px color-mix( diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx index 0fd0825f95..87d7cdcd34 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx @@ -4,7 +4,9 @@ import { memo, useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent, } from 'react' +import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' import type { TrajectoryTurnModel } from './layout.ts' +import type { AssistantMetricDetail, TrajectoryCellKind, TrajectoryCellProps } from './trajectory-record.ts' import { deriveTrajectoryTimeline, formatTimelineOffset, @@ -18,6 +20,14 @@ const MINIMUM_ZOOM_OPERATIONS = 4 const EDGE_PAN_ZONE_FRACTION = 0.08 const EDGE_PAN_STEP_FRACTION = 0.025 const MAXIMUM_EDGE_PAN_PX = 32 +const TIMELINE_TOOLTIP_DELAY_MS = 500 + +interface TimelineRecordDetail { + decodingMs?: number + durationMs?: number + startedAt?: number + ttftMs?: number +} interface FractionRange { start: number @@ -29,6 +39,94 @@ interface HoverPoint { recordIndex: number | null } +interface PanGesture { + anchorClientX: number + anchorStart: number + moved: boolean + pannable: boolean + pointerId: number +} + +function assistantTimingDetail( + metrics: AssistantMetricDetail | undefined, +): Pick { + const start = metrics?.stepStartTime + const first = metrics?.firstTokenTime + const completed = metrics?.completedTime + if ( + metrics?.timingRecorded !== true + || typeof start !== 'number' + || typeof first !== 'number' + || typeof completed !== 'number' + || !Number.isFinite(start) + || !Number.isFinite(first) + || !Number.isFinite(completed) + || first < start + || completed < first + ) return {} + return { ttftMs: first - start, decodingMs: completed - first } +} + +function timelineRecordDetail(cell: TrajectoryCellProps): TimelineRecordDetail { + const durationMs = cell.timeSeconds === null || !Number.isFinite(cell.timeSeconds) + ? undefined + : Math.max(0, cell.timeSeconds * 1_000) + const startedAt = cell.startedAt === null || !Number.isFinite(cell.startedAt) + ? undefined + : cell.startedAt + return { + ...(durationMs === undefined ? {} : { durationMs }), + ...(startedAt === undefined ? {} : { startedAt }), + ...assistantTimingDetail(cell.assistantMetrics), + } +} + +function timelineKindLabel(kind: TrajectoryCellKind): string { + switch (kind) { + case 'system': return 'SYSTEM' + case 'user': return 'USER' + case 'context': return 'CONTEXT' + case 'compacted': return 'COMPACTED' + case 'message': return 'ASSISTANT' + case 'tool': return 'TOOL' + case 'subtool': return 'SUBTOOL' + } +} + +function formatRecordedTime(timestamp: number): string { + return new Date(timestamp).toLocaleTimeString(undefined, { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + fractionalSecondDigits: 3, + }) +} + +function timelineTooltipLabel( + kind: TrajectoryCellKind, + detail: TimelineRecordDetail | undefined, +): string { + const heading = timelineKindLabel(kind) + if (detail === undefined) return heading + const duration = detail.durationMs === undefined + ? null + : `Total ${formatTimelineOffset(detail.durationMs)}` + const range = detail.startedAt === undefined + ? null + : detail.durationMs === undefined + ? `Started ${formatRecordedTime(detail.startedAt)}` + : `${formatRecordedTime(detail.startedAt)} → ${formatRecordedTime( + detail.startedAt + detail.durationMs, + )}` + const segments = detail.ttftMs === undefined || detail.decodingMs === undefined + ? null + : `TTFT ${formatTimelineOffset(detail.ttftMs)} · Decoding ${formatTimelineOffset( + detail.decodingMs, + )}` + const timing = [duration, segments].filter(value => value !== null).join(' · ') + return [heading, range, timing].filter(value => value !== null && value !== '').join('\n') +} + /** Props for the fixed full-domain overview above the trajectory ledger. */ export interface TrajectoryTimelineProps { turns: readonly TrajectoryTurnModel[] @@ -105,14 +203,10 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ onRecordFocus, }: TrajectoryTimelineProps) { const model = useMemo(() => deriveTrajectoryTimeline(turns, mode), [mode, turns]) - const durationByIndex = useMemo( + const detailByIndex = useMemo( () => new Map(turns.flatMap(turn => turn.groups.flatMap(group => - group.cells.flatMap(cell => - cell.timeSeconds === null || !Number.isFinite(cell.timeSeconds) - ? [] - : [[cell.index, Math.max(0, cell.timeSeconds * 1_000)] as const], - ), + group.cells.map(cell => [cell.index, timelineRecordDetail(cell)] as const), ), )), [turns], @@ -123,10 +217,12 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ anchorClientX: number recordIndex: number | null } | null>(null) + const panRef = useRef(null) const rootRef = useRef(null) const trackRef = useRef(null) const [draft, setDraft] = useState(null) const [hover, setHover] = useState(null) + const [panning, setPanning] = useState(false) const [viewport, setViewport] = useState(null) const [animateViewport, setAnimateViewport] = useState(false) useEffect(() => { @@ -267,6 +363,21 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ } const onPointerDown = (event: PointerEvent) => { + if (event.button === 2) { + panRef.current = { + anchorClientX: event.clientX, + anchorStart: domainStart, + moved: false, + pannable: viewport !== null, + pointerId: event.pointerId, + } + if (viewport !== null) setAnimateViewport(false) + setPanning(true) + if (typeof event.currentTarget.setPointerCapture === 'function') { + event.currentTarget.setPointerCapture(event.pointerId) + } + return + } if (event.button !== 0) return const anchor = fractionAt(event) const anchorTime = domainStart + anchor * domainDuration @@ -285,10 +396,24 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ } const onPointerMove = (event: PointerEvent) => { - const drag = dragRef.current const rect = event.currentTarget.getBoundingClientRect() const fraction = fractionAt(event) setHover({ fraction, recordIndex: recordIndexAt(event) }) + const pan = panRef.current + if (pan !== null && pan.pointerId === event.pointerId) { + if (Math.abs(event.clientX - pan.anchorClientX) >= MINIMUM_DRAG_PX) { + pan.moved = true + } + if (!pan.pannable) return + const delta = (event.clientX - pan.anchorClientX) / Math.max(1, rect.width) + const nextStart = Math.min( + Math.max(pan.anchorStart - delta * domainDuration, model.start), + model.end - domainDuration, + ) + setViewport({ start: nextStart, end: nextStart + domainDuration }) + return + } + const drag = dragRef.current if (drag === null || drag.pointerId !== event.pointerId) return let nextDomainStart = domainStart if (viewport !== null) { @@ -326,6 +451,15 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ } const onPointerEnd = (event: PointerEvent) => { + const pan = panRef.current + if (pan !== null && pan.pointerId === event.pointerId) { + const moved = pan.moved + || Math.abs(event.clientX - pan.anchorClientX) >= MINIMUM_DRAG_PX + panRef.current = null + setPanning(false) + if (!moved) onRangeChange(null) + return + } const drag = dragRef.current if (drag === null || drag.pointerId !== event.pointerId) return const pointFraction = fractionAt(event) @@ -375,8 +509,10 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ const onPointerCancel = () => { dragRef.current = null + panRef.current = null setDraft(null) setHover(null) + setPanning(false) } return ( @@ -386,6 +522,7 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
{ - if (dragRef.current === null) setHover(null) + if (dragRef.current === null && panRef.current === null) setHover(null) }} onDoubleClick={(event) => { event.preventDefault() @@ -402,9 +539,6 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ }} onContextMenu={(event) => { event.preventDefault() - setAnimateViewport(false) - onRangeChange(null) - setViewport(null) }} > {hover !== null && hover.recordIndex === null && draft === null && ( @@ -466,7 +600,6 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ className={css.lanes} data-animate-viewport={animateViewport || undefined} data-timeline-domain - aria-hidden="true" style={projectedDomainStyle} > {model.spans @@ -476,34 +609,51 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ .map((span) => { const left = (span.start - model.start) / fullDuration const width = (span.end - span.start) / fullDuration - const durationMs = durationByIndex.get(span.index) + const widthPercent = Math.max(width * 100, 0.35) + const detail = detailByIndex.get(span.index) + const ttftMs = detail?.ttftMs + const decodingMs = detail?.decodingMs + const ttftFraction = ttftMs === undefined + || decodingMs === undefined + || ttftMs + decodingMs <= 0 + ? null + : ttftMs / (ttftMs + decodingMs) return ( - = activeRange.start - ? 'true' - : 'false'} + + label={timelineTooltipLabel(span.kind, detail)} + side="bottom" + delayMs={TIMELINE_TOOLTIP_DELAY_MS} + > + ) })}
diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index fca6608950..6b7be01ec3 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -9,7 +9,7 @@ */ import { Context } from 'cordis' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { createElement, type ComponentProps, type FC, type ReactNode } from 'react' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' @@ -445,7 +445,7 @@ describe('tab switching in ConversationRoot', () => { .toBe('outside') fireEvent.contextMenu(plot) expect(screen.getByRole('row', { name: /USER/ }).getAttribute('data-timeline-focus')) - .toBeNull() + .toBe('outside') }) it('clicking a timeline block clears the range, selects the record, and opens its inspector', async () => { @@ -528,6 +528,57 @@ describe('timeline projection', () => { }], }] satisfies readonly TrajectoryTurnModel[] + it('splits assistant time into recorded TTFT and decoding proportions with a delayed tooltip', () => { + vi.useFakeTimers() + try { + const view = render( + , + ) + const span = view.container.querySelector( + '[data-timeline-span="message"]', + ) + expect(span?.getAttribute('title')).toBeNull() + expect(span?.getAttribute('data-assistant-timing')).toBe('true') + expect(span?.style.getPropertyValue('--trajectory-assistant-ttft')).toBe('25%') + + fireEvent.mouseEnter(span as HTMLElement) + act(() => { vi.advanceTimersByTime(499) }) + expect(view.container.querySelector('[role="tooltip"]')).toBeNull() + act(() => { vi.advanceTimersByTime(1) }) + const tooltip = view.container.querySelector('[role="tooltip"]') + expect(tooltip?.textContent).toContain('Total 2.0 s') + expect(tooltip?.textContent).toContain('TTFT 500 ms') + expect(tooltip?.textContent).toContain('Decoding 1.5 s') + } finally { + vi.useRealTimers() + } + }) + it('cancels native scrolling across the timeline while zooming', () => { render( { })).toBe(false) }) + it('scales sequence gutters with narrow operation spans', () => { + const view = render( + , + ) + const span = view.container.querySelector('[data-timeline-span]') + expect(span?.style.getPropertyValue('--trajectory-span-width')).toBe('10%') + expect(span?.style.getPropertyValue('--trajectory-span-gap')) + .toBe('clamp(0.25px, 0.8%, 1px)') + }) + + it('clears the selection without changing zoom on a zoomed right click', () => { + const onRangeChange = vi.fn() + const view = render( + , + ) + const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events') + vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({ + x: 0, y: 0, left: 0, top: 0, right: 100, bottom: 72, width: 100, height: 72, + toJSON: () => ({}), + }) + fireEvent.wheel(plot, { clientX: 50, deltaY: -1_000 }) + const domain = view.container.querySelector('[data-timeline-domain]') + const domainWidth = domain?.style.getPropertyValue('--trajectory-domain-width') + expect(domainWidth).not.toBe('100%') + + fireEvent.pointerDown(plot, { button: 2, clientX: 50, pointerId: 1 }) + expect(fireEvent.contextMenu(plot)).toBe(false) + fireEvent.pointerUp(plot, { button: 2, clientX: 50, pointerId: 1 }) + + expect(onRangeChange).toHaveBeenCalledOnce() + expect(onRangeChange).toHaveBeenCalledWith(null) + expect(domain?.style.getPropertyValue('--trajectory-domain-width')).toBe(domainWidth) + }) + + it('clears the selection and suppresses the context menu at full zoom', () => { + const onRangeChange = vi.fn() + render( + , + ) + const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events') + + fireEvent.pointerDown(plot, { button: 2, clientX: 50, pointerId: 1 }) + expect(fireEvent.contextMenu(plot)).toBe(false) + fireEvent.pointerUp(plot, { button: 2, clientX: 50, pointerId: 1 }) + expect(onRangeChange).toHaveBeenCalledOnce() + expect(onRangeChange).toHaveBeenCalledWith(null) + }) + + it('pans the zoomed viewport with a right-button drag without changing the selection', () => { + const onRangeChange = vi.fn() + const view = render( + , + ) + const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events') + vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({ + x: 0, y: 0, left: 0, top: 0, right: 100, bottom: 72, width: 100, height: 72, + toJSON: () => ({}), + }) + fireEvent.wheel(plot, { clientX: 50, deltaY: -1_000 }) + const domain = view.container.querySelector('[data-timeline-domain]') + const before = domain?.style.getPropertyValue('--trajectory-domain-left') + + fireEvent.pointerDown(plot, { button: 2, clientX: 50, pointerId: 1 }) + expect(plot.getAttribute('data-panning')).toBe('true') + expect(fireEvent.contextMenu(plot)).toBe(false) + fireEvent.pointerMove(plot, { buttons: 2, clientX: 75, pointerId: 1 }) + fireEvent.pointerUp(plot, { button: 2, clientX: 75, pointerId: 1 }) + + expect(domain?.style.getPropertyValue('--trajectory-domain-left')).not.toBe(before) + expect(onRangeChange).not.toHaveBeenCalled() + expect(plot.getAttribute('data-panning')).toBeNull() + }) + it('pans the zoomed viewport only far enough to reveal a newly selected record', async () => { const onRangeChange = vi.fn() const view = render( From 68e79ed9833978d170ddb45a1b10554269c4b31a Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 3 Aug 2026 13:48:11 +0800 Subject: [PATCH 097/129] docs(ui-trajectory): record timeline interaction contract --- .../2026-07-27-trajectory-inspection-ledger.i18n.yaml | 4 ++-- .../feature/2026-07-27-trajectory-inspection-ledger.md | 5 +++-- .../feature/2026-07-27-trajectory-inspection-ledger.zh.md | 5 +++-- packages/client/ui-trajectory/README.i18n.yaml | 4 ++-- packages/client/ui-trajectory/README.md | 2 +- packages/client/ui-trajectory/README.zh.md | 2 +- 6 files changed, 12 insertions(+), 10 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml index 6e9974fdce..17b8219739 100644 --- a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.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 .agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md -2026-07-27-trajectory-inspection-ledger.md: 8c2a7c42b7898776de5d42459b09c0fb1737ec0b -2026-07-27-trajectory-inspection-ledger.zh.md: 6c5733046dc2cfd3fd2bd005f4cf5c2d2bd110af +2026-07-27-trajectory-inspection-ledger.md: fcdbbb30b065b0b128a8e375cd2e8ed5b2702dac +2026-07-27-trajectory-inspection-ledger.zh.md: ea31389d2fb0970caae271e7d64e4ecff215e93c diff --git a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md index 8c2a7c42b7..fcdbbb30b0 100644 --- a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md +++ b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md @@ -21,7 +21,8 @@ Trajectory has to make prose, machine payloads, token usage, timing, and nested - Call schemas come from the active recorded Request header. Keyless snapshot fixtures deliberately replace that catalog with the non-array `{{tools}}` token, which the durable inspection boundary treats as unavailable instead of attempting to project or fabricate schemas. - Selecting a record or Request opens an inspector inside Trajectory. Tabs and Summary sections follow the selected entity: Markdown messages expose rendered, source, provenance, and hierarchy views; tools add JSON payload/result and schema views; Requests add options, usage, timing, and result navigation. Images render as media rather than serialized data. - Turn folding removes all rows after its first record and replaces them with a compact step/tool-call count; Assistant folding applies the same interaction to its tool-call descendants. Global controls fold or expand both levels. -- The separate Waterfall tab is removed. A fixed Overview above the ledger projects every record with known `startedAt` onto three semantic timing lanes using its own duration. Dragging left or right commits an inclusive interval filter: any record whose active interval overlaps either boundary remains visible, records without known timing leave the focused ledger, and clearing the selection restores the full branch. The Overview keeps the full time domain while focused so the selection can be resized or cleared without losing orientation. +- The separate Waterfall tab is removed. A fixed Overview above the ledger projects every record with known `startedAt` onto three semantic timing lanes using its own duration. Finalized Assistant spans divide the recorded interval at the first non-empty token delta, so distinct TTFT and decoding colors retain their actual ratio; incomplete timing falls back to one Assistant color. Hovering for 500 ms exposes exact start/end, total duration, TTFT, and decoding time without relying on the browser's native tooltip delay. Dragging left or right commits an inclusive interval filter: any record whose active interval overlaps either boundary remains visible, records without known timing leave the focused ledger, and clearing the selection restores the full branch. Wheel gestures zoom the time domain. A right-button click clears the interval selection; dragging instead pans an already zoomed viewport without mutating it. The Overview keeps the full time domain while focused so the selection can be resized or cleared without losing orientation. +- Live history updates retain the ledger's bottom position only while the user is already following its tail. Scrolling upward clears that follow state, so streamed chunks and newly appended records do not interrupt inspection of earlier rows. - This local inspector remains independent from the conversation-wide Chat details column. At narrow widths it overlays the ledger and remains dismissible by keyboard or pointer. ## Alternatives considered @@ -40,4 +41,4 @@ Trajectory has to make prose, machine payloads, token usage, timing, and nested ## Consequences -Trajectory shows more useful records per viewport while retaining Turn and Request orientation. Context rewrites and compactions remain inline with their surrounding history, while a rewind begins a successor branch that inherits only the retained prefix. The main ledger omits token usage and duration so content receives the available width; the local inspector exposes those facts together with full payloads, provenance, schemas, and request timing. The Overview uses recorded start/duration facts without fabricating live elapsed time, and its inclusive focus behavior matches the interaction users already know from Chrome DevTools Network. Focused component tests pin projection, folding, record and interval selection, entity-specific tabs, and running/error semantics; the assembled Web snapshot pins the ledger, Overview, and inspector through the real client composition. +Trajectory shows more useful records per viewport while retaining Turn and Request orientation. Context rewrites and compactions remain inline with their surrounding history, while a rewind begins a successor branch that inherits only the retained prefix. The main ledger omits token usage and duration so content receives the available width; the local inspector exposes those facts together with full payloads, provenance, schemas, and request timing. The Overview uses recorded start/duration and token-boundary facts without fabricating live elapsed time, and its inclusive focus behavior matches the interaction users already know from Chrome DevTools Network. Focused component tests pin tail following, timing projection, delayed detail disclosure, folding, record and interval selection, entity-specific tabs, and running/error semantics; the assembled Web snapshot pins the ledger, Overview timing details, and inspector through the real client composition. diff --git a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md index 6c5733046d..ea31389d2f 100644 --- a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md @@ -21,7 +21,8 @@ Status: implemented - 调用 schema 来自当前生效且已记录的请求头。无密钥快照 fixture(测试前置数据)有意将该目录替换为非数组 token `{{tools}}`,持久化检查边界会将其视为不可用,而不是尝试投影或虚构 schema。 - 选择记录或请求后,轨迹视图内部会打开检查器,其标签页和概览区域随实体类型变化:Markdown 消息提供渲染、源码、来源和层级视图;工具提供 JSON 载荷/结果和 schema 视图;请求提供选项、用量、计时和结果跳转。图片以媒体形式渲染,而不是显示为序列化数据。 - 折叠轮次时保留其第一条记录,将后续行替换为紧凑的步骤和工具调用数量;折叠助手时对其工具调用后代应用相同交互。全局控件可以分别折叠或展开这两个层级。 -- 移除独立的 waterfall(瀑布式事件)标签页。固定在记录表上方的 Overview 区域将所有 `startedAt` 已知的记录按各自耗时投影到三条语义计时轨道。向左或向右拖动会提交包含边界的区间筛选:任何活动区间与所选区间相交的记录都会保留,计时未知的记录会从聚焦后的记录表中移除,清除选择则恢复完整分支。聚焦后,Overview 区域仍保留完整时间范围,以便在不失去方位的情况下调整或清除选择。 +- 移除独立的 waterfall(瀑布式事件)标签页。固定在记录表上方的 Overview 区域将所有 `startedAt` 已知的记录按各自耗时投影到三条语义计时轨道。已完成的助手时间条以首个非空 token 增量为分界,用不同颜色按真实比例表示 TTFT 与解码时间;计时不完整时退化为单一助手色。悬停 500 ms 后会显示精确起止时刻、总耗时、TTFT 和解码时间,而不依赖浏览器原生 tooltip 的延迟。向左或向右拖动会提交包含边界的区间筛选:任何活动区间与所选区间相交的记录都会保留,计时未知的记录会从聚焦后的记录表中移除,清除选择则恢复完整分支。滚轮手势用于缩放时间域。右键单击会清除区间选择;右键拖动则只会平移已放大的 viewport,不会改变该选区。聚焦后,Overview 区域仍保留完整时间范围,以便在不失去方位的情况下调整或清除选择。 +- 实时历史更新仅在用户已经跟随记录表末尾时保留底部位置。向上滚动会清除跟随状态,因此流式分块和新追加的记录不会打断对旧记录的检查。 - 此局部检查器与会话级 Chat 详情栏相互独立。在窄屏下,检查器会覆盖记录表,并且仍可通过键盘或指针关闭。 ## 曾考虑的替代方案 @@ -40,4 +41,4 @@ Status: implemented ## 后果 -轨迹视图在保留轮次与请求定位的同时,每个视口可以显示更多有效记录。上下文 `rewrite` 与压缩保持在周边历史中的原始位置,`rewind` 则建立仅继承保留前缀的后继分支。主记录表省略 token 用量和耗时,让内容获得可用宽度;局部检查器展示这些数据以及完整载荷、来源、schema 和请求计时。Overview 区域使用记录的开始时间与耗时数据,而不虚构实时流逝时间,其包含边界的聚焦行为与用户熟悉的 Chrome DevTools Network 交互一致。针对性组件测试锁定投影、折叠、记录与区间选择、实体特定标签页和运行/错误语义;组装后的 Web 快照则通过真实客户端组合锁定记录表、Overview 区域与检查器。 +轨迹视图在保留轮次与请求定位的同时,每个视口可以显示更多有效记录。上下文 `rewrite` 与压缩保持在周边历史中的原始位置,`rewind` 则建立仅继承保留前缀的后继分支。主记录表省略 token 用量和耗时,让内容获得可用宽度;局部检查器展示这些数据以及完整载荷、来源、schema 和请求计时。Overview 区域使用记录的开始时间、耗时与 token 边界数据,而不虚构实时流逝时间,其包含边界的聚焦行为与用户熟悉的 Chrome DevTools Network 交互一致。针对性组件测试锁定末尾跟随、计时投影、延迟展示详情、折叠、记录与区间选择、实体特定标签页和运行/错误语义;组装后的 Web 快照则通过真实客户端组合锁定记录表、Overview 计时详情与检查器。 diff --git a/packages/client/ui-trajectory/README.i18n.yaml b/packages/client/ui-trajectory/README.i18n.yaml index 41c954adc0..a62816aad2 100644 --- a/packages/client/ui-trajectory/README.i18n.yaml +++ b/packages/client/ui-trajectory/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-trajectory/README.md -README.md: a65c11aed9dd74f9b0b60795441f876c1d64b3ad -README.zh.md: 6e25d24c6b65673b3d003e624b6e0727be60c0e1 +README.md: 7136532bff6f6b3fb79eb29a5bbb667fe44a6b74 +README.zh.md: 1edc81b2f05acca9aaa21995e2e9db812cbf4f62 diff --git a/packages/client/ui-trajectory/README.md b/packages/client/ui-trajectory/README.md index a65c11aed9..7136532bff 100644 --- a/packages/client/ui-trajectory/README.md +++ b/packages/client/ui-trajectory/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. A fixed Overview above the ledger projects real record start/duration timing from left to right; dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full branch. The runtime's independent history source supplies raw context lineage and projects cancellation-frozen Assistant and Tool records, so Trajectory neither reads nor changes the Chat conversation snapshot. The package remains a pure-consumer plugin (registers one view tab into the conversation's `'conversation.view'` slot ring, provides no service, declares no Context merge). Contract: api-contracts v3 §8. +Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. A fixed Overview above the ledger projects real record start/duration timing from left to right; Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full branch. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. Streaming updates keep the ledger pinned only when it was already at the bottom, so reading earlier records suspends tail following. The runtime's independent history source supplies raw context lineage and projects cancellation-frozen Assistant and Tool records, so Trajectory neither reads nor changes the Chat conversation snapshot. The package remains a pure-consumer plugin (registers one view tab into the conversation's `'conversation.view'` slot ring, provides no service, declares no Context merge). Contract: api-contracts v3 §8. ## Model Experience diff --git a/packages/client/ui-trajectory/README.zh.md b/packages/client/ui-trajectory/README.zh.md index 6e25d24c6b..1edc81b2f0 100644 --- a/packages/client/ui-trajectory/README.zh.md +++ b/packages/client/ui-trajectory/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带数值所有者的压缩仍位于其所属轮次内。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整分支。运行时的独立历史数据源提供原始上下文谱系,并投影因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包(package)保持为纯消费方插件(向会话的 `'conversation.view'` slot 环注册一个视图标签页,不提供服务,也不声明 Context 合并)。契约:api-contracts v3 §8。 +Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带数值所有者的压缩仍位于其所属轮次内。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整分支。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。仅当记录表在流式更新前已经位于底部时,更新才会保持贴底;向上阅读旧记录会暂停跟随。运行时的独立历史数据源提供原始上下文谱系,并投影因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包(package)保持为纯消费方插件(向会话的 `'conversation.view'` slot 环注册一个视图标签页,不提供服务,也不声明 Context 合并)。契约:api-contracts v3 §8。 ## 模型体验 From 44304a458c577d9adc274896892aa82b8fd2a54b Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 3 Aug 2026 13:53:50 +0800 Subject: [PATCH 098/129] fix(ui-trajectory): separate consecutive request markers --- .../ui-trajectory/src/client/TrajectoryTable.module.css | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css index 9e606ca5c6..ca8e55be68 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css @@ -104,6 +104,11 @@ border-bottom: 0; } +.table tbody tr[data-request-only='true']:has(+ tr[data-request-only='true']) td { + /* Keep consecutive boundary markers from painting their halos over one another. */ + height: 9px; +} + .table tbody tr[data-request-only='true']:last-child td { /* Retain the lower half of the 16px boundary marker at the table's end. */ height: 9px; From 062a1507f1bc521f62a149de5e74435efd5e7186 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Fri, 31 Jul 2026 15:28:22 +0800 Subject: [PATCH 099/129] feat(ui-trajectory): float the composer over the ledger like chat The trajectory host kept the composer as a fixed flex sibling, so the ledger never reached the viewport bottom. Anchor the composer seat absolutely over the ledger (reusing chat's fade treatment) and have the internal scroll panes reserve the composer's live height plus a 16px gap so end rows and detail bodies scroll clear of the overlay. (cherry picked from commit 0058a1f4b6e8c2e23bfde7c827a84838cc5ebffc) --- .../src/client/TrajectoryTable.module.css | 4 +++- .../ui-trajectory/src/client/views.module.css | 20 +++++++++++++++++-- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css index ca8e55be68..c494c0b862 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css @@ -16,6 +16,7 @@ flex: 1; min-width: 0; overflow: auto; + padding-bottom: var(--dsh-trajectory-bottom-clearance, 0px); container: trajectory-table / inline-size; } @@ -905,6 +906,7 @@ flex: 1; min-height: 0; overflow: auto; + padding-bottom: var(--dsh-trajectory-bottom-clearance, 0px); scrollbar-gutter: stable; } @@ -912,7 +914,7 @@ display: flex; box-sizing: border-box; flex-direction: column; - padding-bottom: 12px; + padding-bottom: calc(12px + var(--dsh-trajectory-bottom-clearance, 0px)); overflow: hidden; } diff --git a/packages/client/ui-trajectory/src/client/views.module.css b/packages/client/ui-trajectory/src/client/views.module.css index 326ac41a99..f23ab48b4d 100644 --- a/packages/client/ui-trajectory/src/client/views.module.css +++ b/packages/client/ui-trajectory/src/client/views.module.css @@ -14,9 +14,11 @@ } /* Trajectory keeps the ledger and details panel inside the remaining - * conversation height. Only the ledger pane scrolls; the composer remains - * the fixed flex sibling below this view. */ + * conversation height; only the internal panes scroll. The composer floats + * over the ledger like chat's sticky seat — absolute, not sticky, because + * this host does not scroll. */ :global([data-conversation-scroll]):has(.root) { + position: relative; overflow: hidden; } @@ -26,6 +28,15 @@ overflow: hidden; } +/* div qualifier outranks ConversationRoot's active-phase sticky rule (equal + * specificity otherwise, and cross-module source order is bundler-defined). */ +:global([data-conversation-scroll]):has(.root) > :global(div[data-composer-seat]) { + position: absolute; + right: 0; + bottom: 0; + left: 0; +} + .ledger { position: relative; z-index: 0; @@ -35,4 +46,9 @@ min-height: 0; min-width: 0; overflow: hidden; + + /* Internal panes reserve the floating composer's live height plus a 16px + * breathing gap so end rows and detail bodies can scroll clear of the + * overlay. */ + --dsh-trajectory-bottom-clearance: calc(var(--dsh-composer-height, 152px) + 16px); } From 95826603cbd18b1f58b3c4c4ed1da7e1ff471ad1 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 3 Aug 2026 13:57:20 +0800 Subject: [PATCH 100/129] review(web): poll the pane-arity assertions in the path-editor e2e A bare count can observe a landing mid-commit on a loaded runner, and the arity is the invariant this scenario exists to pin. --- apps/web/tests/workspace-management.e2e.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index 075f5687ef..b25320f488 100644 --- a/apps/web/tests/workspace-management.e2e.ts +++ b/apps/web/tests/workspace-management.e2e.ts @@ -421,7 +421,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff // still up and the draft intact. await path.fill(`${join(staged, 'alpha')}${sep}`) await expect.poll(() => dialog.getByText('only-under-alpha', { exact: true }).count(), { timeout: 10_000 }).toBe(1) - expect(await dialog.getByRole('list').count()).toBe(2) + await expect.poll(() => dialog.getByRole('list').count(), { timeout: 10_000 }).toBe(2) expect(await path.inputValue()).toBe(`${join(staged, 'alpha')}${sep}`) // Erasing back past the separator walks the panes up, so the level being // typed is the last pane again (its children no longer stand to its @@ -430,7 +430,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff await expect.poll(() => dialog.getByText('only-under-alpha', { exact: true }).count(), { timeout: 10_000 }).toBe(0) expect(await dialog.getByText('alpha', { exact: true }).count()).toBe(1) expect(await dialog.getByText('beta', { exact: true }).count()).toBe(0) - expect(await dialog.getByRole('list').count()).toBe(2) + await expect.poll(() => dialog.getByRole('list').count(), { timeout: 10_000 }).toBe(2) // A tail nobody matches is a name still being spelled: the level shows // whole instead of emptying under it. await path.fill(`${staged}${sep}zzz`) From 5534431d423c3bc47765be1b8d399892590d1e7a Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 3 Aug 2026 14:02:40 +0800 Subject: [PATCH 101/129] fix(ui-trajectory): own composer overlay geometry --- apps/web/tests/navigation-panes.e2e.ts | 19 +++++++++++++ .../skeleton/ConversationRoot.module.css | 20 +++++++++++++ .../src/client/TrajectoryTable.module.css | 8 ++++-- .../src/client/TrajectoryView.tsx | 2 +- .../ui-trajectory/src/client/views.module.css | 28 +------------------ .../client/ui-trajectory/tests/views.spec.tsx | 1 + 6 files changed, 47 insertions(+), 31 deletions(-) diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index af4799a759..4f13ea21cf 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -138,6 +138,23 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-trajectory')) await page.getByRole('tab', { name: 'Trajectory' }).click() await page.waitForTimeout(100) + const overlayLayout = await page.getByRole('table').evaluate((table) => { + const host = table.closest('[data-conversation-scroll]') + const seat = host?.querySelector('[data-composer-seat]') ?? null + const pane = table.parentElement + return { + hostPosition: host === null ? null : getComputedStyle(host).position, + paneOverflowX: pane === null ? null : getComputedStyle(pane).overflowX, + paneScrollableWidth: pane === null ? null : pane.scrollWidth - pane.clientWidth, + seatPosition: seat === null ? null : getComputedStyle(seat).position, + } + }) + expect(overlayLayout).toEqual({ + hostPosition: 'relative', + paneOverflowX: 'hidden', + paneScrollableWidth: 0, + seatPosition: 'absolute', + }) expect({ pageErrors: tripwire.pageErrors, slotErrors, @@ -153,6 +170,8 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { await page.locator('tr[data-kind="tool"]').first().click() const details = page.getByRole('complementary', { name: 'Event details' }) await expect.poll(() => details.count(), { timeout: 10_000 }).toBe(1) + expect(await details.getByRole('tabpanel').evaluate(panel => getComputedStyle(panel).overflowX)) + .toBe('hidden') await page.evaluate(() => { document.body.setAttribute('data-ds-dark-theme', '') }) const darkSummarySurfaces = await details.getByRole('heading', { name: 'Payload' }).evaluate(heading => ({ heading: getComputedStyle(heading).backgroundColor, diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index be480eaea2..1a83efb551 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -208,6 +208,26 @@ ); } +/* Views may opt into a composer overlay while ConversationRoot retains + ownership of the seat geometry and its active-phase precedence. */ +.scrollBody:has([data-conversation-composer-overlay]) { + position: relative; + overflow: hidden; +} + +.scrollBody:has([data-conversation-composer-overlay]) > .viewArea { + flex: 1 1 0; + min-height: 0; + overflow: hidden; +} + +.scrollBody:has([data-conversation-composer-overlay]) > .composerSeat { + position: absolute; + right: 0; + bottom: 0; + left: 0; +} + /* Hero phase: the composer stack (hero chrome + workspace row + card) is flex-centered in the column; composer phase docks it at the bottom. Flex, NOT absolute+transform: a transform would make this box the containing diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css index c494c0b862..0b1cbf8030 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css @@ -15,7 +15,8 @@ .tablePane { flex: 1; min-width: 0; - overflow: auto; + overflow-x: hidden; + overflow-y: auto; padding-bottom: var(--dsh-trajectory-bottom-clearance, 0px); container: trajectory-table / inline-size; } @@ -28,7 +29,7 @@ ); width: 100%; - min-width: 480px; + min-width: 0; border-spacing: 0; table-layout: fixed; color: var(--dsw-alias-label-primary); @@ -905,7 +906,8 @@ .detailBody { flex: 1; min-height: 0; - overflow: auto; + overflow-x: hidden; + overflow-y: auto; padding-bottom: var(--dsh-trajectory-bottom-clearance, 0px); scrollbar-gutter: stable; } diff --git a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx index d62074a26c..9ba75abaac 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx @@ -459,7 +459,7 @@ export function TrajectoryView({ } return ( -
+
{ diff --git a/packages/client/ui-trajectory/src/client/views.module.css b/packages/client/ui-trajectory/src/client/views.module.css index f23ab48b4d..687f4a4657 100644 --- a/packages/client/ui-trajectory/src/client/views.module.css +++ b/packages/client/ui-trajectory/src/client/views.module.css @@ -13,30 +13,6 @@ background: var(--dsw-alias-bg-layer-1); } -/* Trajectory keeps the ledger and details panel inside the remaining - * conversation height; only the internal panes scroll. The composer floats - * over the ledger like chat's sticky seat — absolute, not sticky, because - * this host does not scroll. */ -:global([data-conversation-scroll]):has(.root) { - position: relative; - overflow: hidden; -} - -:global([data-conversation-scroll]):has(.root) > :first-child { - flex: 1 1 0; - min-height: 0; - overflow: hidden; -} - -/* div qualifier outranks ConversationRoot's active-phase sticky rule (equal - * specificity otherwise, and cross-module source order is bundler-defined). */ -:global([data-conversation-scroll]):has(.root) > :global(div[data-composer-seat]) { - position: absolute; - right: 0; - bottom: 0; - left: 0; -} - .ledger { position: relative; z-index: 0; @@ -47,8 +23,6 @@ min-width: 0; overflow: hidden; - /* Internal panes reserve the floating composer's live height plus a 16px - * breathing gap so end rows and detail bodies can scroll clear of the - * overlay. */ + /* ConversationRoot publishes the floating composer's live height. */ --dsh-trajectory-bottom-clearance: calc(var(--dsh-composer-height, 152px) + 16px); } diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index 6b7be01ec3..0650e28746 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -288,6 +288,7 @@ describe('tab switching in ConversationRoot', () => { expect(screen.queryByRole('columnheader')).toBeNull() expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy() expect(screen.getByRole('region', { name: 'Trajectory timeline' })).toBeTruthy() + expect(view.container.querySelector('[data-conversation-composer-overlay]')).toBeTruthy() fireEvent.click(screen.getByRole('button', { name: 'Collapse turns' })) expect(view.container.querySelector('[data-collapsed-summary="turn"]')).toBeTruthy() fireEvent.click(screen.getByRole('button', { name: 'Expand turns' })) From 667960182a2b9bc8694ef98e5b6ced0f62b6dcdb Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 3 Aug 2026 14:02:46 +0800 Subject: [PATCH 102/129] test(web): normalize detailed local clocks --- apps/web/tests/scaffold.ts | 1 + .../web/tests/snapshots/navigation-panes/trajectory.expected.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 1b8afbb247..7a48c4d8ac 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -516,6 +516,7 @@ function normalizeAria(snapshot: string, workspaceCwd: string): string { // shape so goldens stay stable across midnight and year boundaries. .replace(/\d{4}年\d{1,2}月\d{1,2}日 \d{2}:\d{2}/g, '{{clock}}') .replace(/\d{1,2}月\d{1,2}日 \d{2}:\d{2}/g, '{{clock}}') + .replace(/(? Date: Mon, 3 Aug 2026 14:02:54 +0800 Subject: [PATCH 103/129] docs(ui-trajectory): record composer overlay contract --- .../2026-07-27-trajectory-inspection-ledger.i18n.yaml | 4 ++-- .../feature/2026-07-27-trajectory-inspection-ledger.md | 5 ++++- .../feature/2026-07-27-trajectory-inspection-ledger.zh.md | 5 ++++- packages/client/ui-trajectory/README.i18n.yaml | 4 ++-- packages/client/ui-trajectory/README.md | 2 +- packages/client/ui-trajectory/README.zh.md | 2 +- 6 files changed, 14 insertions(+), 8 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml index 17b8219739..d7e0f75f2d 100644 --- a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.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 .agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md -2026-07-27-trajectory-inspection-ledger.md: fcdbbb30b065b0b128a8e375cd2e8ed5b2702dac -2026-07-27-trajectory-inspection-ledger.zh.md: ea31389d2fb0970caae271e7d64e4ecff215e93c +2026-07-27-trajectory-inspection-ledger.md: cdeaa30ea64f47b0e0110baf566f747a4591a384 +2026-07-27-trajectory-inspection-ledger.zh.md: df2a3d266161a7c1c4444863971f3d177533af8c diff --git a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md index fcdbbb30b0..cdeaa30ea6 100644 --- a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md +++ b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md @@ -23,6 +23,7 @@ Trajectory has to make prose, machine payloads, token usage, timing, and nested - Turn folding removes all rows after its first record and replaces them with a compact step/tool-call count; Assistant folding applies the same interaction to its tool-call descendants. Global controls fold or expand both levels. - The separate Waterfall tab is removed. A fixed Overview above the ledger projects every record with known `startedAt` onto three semantic timing lanes using its own duration. Finalized Assistant spans divide the recorded interval at the first non-empty token delta, so distinct TTFT and decoding colors retain their actual ratio; incomplete timing falls back to one Assistant color. Hovering for 500 ms exposes exact start/end, total duration, TTFT, and decoding time without relying on the browser's native tooltip delay. Dragging left or right commits an inclusive interval filter: any record whose active interval overlaps either boundary remains visible, records without known timing leave the focused ledger, and clearing the selection restores the full branch. Wheel gestures zoom the time domain. A right-button click clears the interval selection; dragging instead pans an already zoomed viewport without mutating it. The Overview keeps the full time domain while focused so the selection can be resized or cleared without losing orientation. - Live history updates retain the ledger's bottom position only while the user is already following its tail. Scrolling upward clears that follow state, so streamed chunks and newly appended records do not interrupt inspection of earlier rows. +- Trajectory opts into a conversation-owned composer overlay through `data-conversation-composer-overlay`. `ConversationRoot` positions the composer seat and publishes its live height; Trajectory keeps the ledger at full height and reserves that height plus 16 px inside its vertical table and inspector scrollers. Those panes adapt to the available width instead of exposing horizontal scrollbars beneath the overlay. - This local inspector remains independent from the conversation-wide Chat details column. At narrow widths it overlays the ledger and remains dismissible by keyboard or pointer. ## Alternatives considered @@ -35,10 +36,12 @@ Trajectory has to make prose, machine payloads, token usage, timing, and nested **Reuse the global Chat details column.** Rejected: it would couple local inspection to conversation navigation and make a row click unexpectedly change another view's state. +**Override the composer seat from Trajectory CSS.** Rejected: a cross-package selector would depend on generated class specificity and stylesheet order. An explicit view marker keeps seat geometry and active-phase precedence in `ConversationRoot`, while Trajectory owns only its internal clearance. + **Keep timing in a separate Waterfall tab.** Rejected: the placeholder summarized node counts rather than record timing and forced users to switch away from the rows they wanted to focus. A full-domain Overview keeps timing and filtered records in one visual context. **Change global theme tokens to match the reference.** Rejected: the existing theme already provides paired light and dark semantic layers, and a local redesign does not justify changing unrelated surfaces. ## Consequences -Trajectory shows more useful records per viewport while retaining Turn and Request orientation. Context rewrites and compactions remain inline with their surrounding history, while a rewind begins a successor branch that inherits only the retained prefix. The main ledger omits token usage and duration so content receives the available width; the local inspector exposes those facts together with full payloads, provenance, schemas, and request timing. The Overview uses recorded start/duration and token-boundary facts without fabricating live elapsed time, and its inclusive focus behavior matches the interaction users already know from Chrome DevTools Network. Focused component tests pin tail following, timing projection, delayed detail disclosure, folding, record and interval selection, entity-specific tabs, and running/error semantics; the assembled Web snapshot pins the ledger, Overview timing details, and inspector through the real client composition. +Trajectory shows more useful records per viewport while retaining Turn and Request orientation. Context rewrites and compactions remain inline with their surrounding history, while a rewind begins a successor branch that inherits only the retained prefix. The floating composer leaves the ledger visible to the viewport edge without covering its final rows or hiding horizontal controls. The main ledger omits token usage and duration so content receives the available width; the local inspector exposes those facts together with full payloads, provenance, schemas, and request timing. The Overview uses recorded start/duration and token-boundary facts without fabricating live elapsed time, and its inclusive focus behavior matches the interaction users already know from Chrome DevTools Network. Focused component tests pin tail following, timing projection, delayed detail disclosure, folding, record and interval selection, entity-specific tabs, and running/error semantics; the assembled Web snapshot pins the ledger, Overview timing details, composer overlay geometry, and inspector through the real client composition. diff --git a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md index ea31389d2f..df2a3d2661 100644 --- a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md @@ -23,6 +23,7 @@ Status: implemented - 折叠轮次时保留其第一条记录,将后续行替换为紧凑的步骤和工具调用数量;折叠助手时对其工具调用后代应用相同交互。全局控件可以分别折叠或展开这两个层级。 - 移除独立的 waterfall(瀑布式事件)标签页。固定在记录表上方的 Overview 区域将所有 `startedAt` 已知的记录按各自耗时投影到三条语义计时轨道。已完成的助手时间条以首个非空 token 增量为分界,用不同颜色按真实比例表示 TTFT 与解码时间;计时不完整时退化为单一助手色。悬停 500 ms 后会显示精确起止时刻、总耗时、TTFT 和解码时间,而不依赖浏览器原生 tooltip 的延迟。向左或向右拖动会提交包含边界的区间筛选:任何活动区间与所选区间相交的记录都会保留,计时未知的记录会从聚焦后的记录表中移除,清除选择则恢复完整分支。滚轮手势用于缩放时间域。右键单击会清除区间选择;右键拖动则只会平移已放大的 viewport,不会改变该选区。聚焦后,Overview 区域仍保留完整时间范围,以便在不失去方位的情况下调整或清除选择。 - 实时历史更新仅在用户已经跟随记录表末尾时保留底部位置。向上滚动会清除跟随状态,因此流式分块和新追加的记录不会打断对旧记录的检查。 +- Trajectory 通过 `data-conversation-composer-overlay` 启用由会话持有的 composer 浮层模式。`ConversationRoot` 负责定位 composer seat 并发布其实时高度;Trajectory 让记录表保持全高,并在记录表与检查器的纵向滚动容器内预留该高度加 16 px。这两个窗格会根据可用宽度自适应,而不会在浮层下方暴露横向滚动条。 - 此局部检查器与会话级 Chat 详情栏相互独立。在窄屏下,检查器会覆盖记录表,并且仍可通过键盘或指针关闭。 ## 曾考虑的替代方案 @@ -35,10 +36,12 @@ Status: implemented **复用全局 Chat 详情栏。** 不予采纳:这会让局部检查与会话导航耦合,还会使行点击意外改变另一个视图的状态。 +**由 Trajectory CSS 覆盖 composer seat。** 不予采纳:跨包(package)选择器会依赖生成类选择器的优先级和样式表顺序。显式视图标记让 seat 几何形状和活跃阶段优先级留在 `ConversationRoot` 中,而 Trajectory 只负责自身内部的避让空间。 + **将计时保留在独立的 waterfall 标签页中。** 不予采纳:占位实现汇总的是节点数而非记录计时,并迫使用户离开想要聚焦的记录。保留完整时间范围的 Overview 区域让计时和筛选后的记录处于同一视觉上下文中。 **修改全局主题 token 以匹配参考设计。** 不予采纳:现有主题已经提供配对的亮色与暗色语义层,局部重新设计不足以成为修改无关表面的理由。 ## 后果 -轨迹视图在保留轮次与请求定位的同时,每个视口可以显示更多有效记录。上下文 `rewrite` 与压缩保持在周边历史中的原始位置,`rewind` 则建立仅继承保留前缀的后继分支。主记录表省略 token 用量和耗时,让内容获得可用宽度;局部检查器展示这些数据以及完整载荷、来源、schema 和请求计时。Overview 区域使用记录的开始时间、耗时与 token 边界数据,而不虚构实时流逝时间,其包含边界的聚焦行为与用户熟悉的 Chrome DevTools Network 交互一致。针对性组件测试锁定末尾跟随、计时投影、延迟展示详情、折叠、记录与区间选择、实体特定标签页和运行/错误语义;组装后的 Web 快照则通过真实客户端组合锁定记录表、Overview 计时详情与检查器。 +轨迹视图在保留轮次与请求定位的同时,每个视口可以显示更多有效记录。上下文 `rewrite` 与压缩保持在周边历史中的原始位置,`rewind` 则建立仅继承保留前缀的后继分支。浮动 composer 让记录表一直显示到视口边缘,同时不会遮住最后几行,也不会隐藏横向控件。主记录表省略 token 用量和耗时,让内容获得可用宽度;局部检查器展示这些数据以及完整载荷、来源、schema 和请求计时。Overview 区域使用记录的开始时间、耗时与 token 边界数据,而不虚构实时流逝时间,其包含边界的聚焦行为与用户熟悉的 Chrome DevTools Network 交互一致。针对性组件测试锁定末尾跟随、计时投影、延迟展示详情、折叠、记录与区间选择、实体特定标签页和运行/错误语义;组装后的 Web 快照则通过真实客户端组合锁定记录表、Overview 计时详情、composer 浮层几何形状与检查器。 diff --git a/packages/client/ui-trajectory/README.i18n.yaml b/packages/client/ui-trajectory/README.i18n.yaml index a62816aad2..36e56c4569 100644 --- a/packages/client/ui-trajectory/README.i18n.yaml +++ b/packages/client/ui-trajectory/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-trajectory/README.md -README.md: 7136532bff6f6b3fb79eb29a5bbb667fe44a6b74 -README.zh.md: 1edc81b2f05acca9aaa21995e2e9db812cbf4f62 +README.md: 5d0ea3bbbbfca2b8c0ee02ed07ca956fbd377e11 +README.zh.md: 1bfff4c18ea2e834781e2c6cb76773595eeed5ad diff --git a/packages/client/ui-trajectory/README.md b/packages/client/ui-trajectory/README.md index 7136532bff..5d0ea3bbbb 100644 --- a/packages/client/ui-trajectory/README.md +++ b/packages/client/ui-trajectory/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. A fixed Overview above the ledger projects real record start/duration timing from left to right; Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full branch. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. Streaming updates keep the ledger pinned only when it was already at the bottom, so reading earlier records suspends tail following. The runtime's independent history source supplies raw context lineage and projects cancellation-frozen Assistant and Tool records, so Trajectory neither reads nor changes the Chat conversation snapshot. The package remains a pure-consumer plugin (registers one view tab into the conversation's `'conversation.view'` slot ring, provides no service, declares no Context merge). Contract: api-contracts v3 §8. +Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. A fixed Overview above the ledger projects real record start/duration timing from left to right; Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full branch. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. Streaming updates keep the ledger pinned only when it was already at the bottom, so reading earlier records suspends tail following. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. The runtime's independent history source supplies raw context lineage and projects cancellation-frozen Assistant and Tool records, so Trajectory neither reads nor changes the Chat conversation snapshot. The package remains a pure-consumer plugin (registers one view tab into the conversation's `'conversation.view'` slot ring, provides no service, declares no Context merge). Contract: api-contracts v3 §8. ## Model Experience diff --git a/packages/client/ui-trajectory/README.zh.md b/packages/client/ui-trajectory/README.zh.md index 1edc81b2f0..1bfff4c18e 100644 --- a/packages/client/ui-trajectory/README.zh.md +++ b/packages/client/ui-trajectory/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带数值所有者的压缩仍位于其所属轮次内。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整分支。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。仅当记录表在流式更新前已经位于底部时,更新才会保持贴底;向上阅读旧记录会暂停跟随。运行时的独立历史数据源提供原始上下文谱系,并投影因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包(package)保持为纯消费方插件(向会话的 `'conversation.view'` slot 环注册一个视图标签页,不提供服务,也不声明 Context 合并)。契约:api-contracts v3 §8。 +Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带数值所有者的压缩仍位于其所属轮次内。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整分支。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。仅当记录表在流式更新前已经位于底部时,更新才会保持贴底;向上阅读旧记录会暂停跟随。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。运行时的独立历史数据源提供原始上下文谱系,并投影因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包(package)保持为纯消费方插件(向会话的 `'conversation.view'` slot 环注册一个视图标签页,不提供服务,也不声明 Context 合并)。契约:api-contracts v3 §8。 ## 模型体验 From 0a4ad25ce7390a3bec55e09a3d32644e68769b8e Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Sun, 2 Aug 2026 23:06:00 -0700 Subject: [PATCH 104/129] fix(web): expand aborted Bash rows --- apps/web/tests/bash-abort-row.e2e.ts | 79 +++++++++++++++++++ .../snapshots/bash-abort-row/ui.expected.md | 32 ++++++++ apps/web/tsconfig.json | 3 +- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../client/toolviews/bash-sample.module.css | 57 +++++++++++++ .../src/client/toolviews/bash-sample.tsx | 54 ++++++++++--- .../tests/terminal-card.spec.tsx | 22 ++++++ tsconfig.host.json | 1 + 10 files changed, 240 insertions(+), 16 deletions(-) create mode 100644 apps/web/tests/bash-abort-row.e2e.ts create mode 100644 apps/web/tests/snapshots/bash-abort-row/ui.expected.md diff --git a/apps/web/tests/bash-abort-row.e2e.ts b/apps/web/tests/bash-abort-row.e2e.ts new file mode 100644 index 0000000000..657d910e6d --- /dev/null +++ b/apps/web/tests/bash-abort-row.e2e.ts @@ -0,0 +1,79 @@ +// Web e2e scenario: a cancelled Bash call can settle without terminal-card +// material. Borrow the real cancellation fixture and prove the keyed Bash row +// still exposes the recorded command and full error without any model call. +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, + launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +const FIXTURE = fileURLToPath(new URL('../../../examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl', import.meta.url)) +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/bash-abort-row', import.meta.url)) +const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md') +const MODE = webSnapshotMode() +const SEED_ID = 'bash-abort-row-web-e2e' +const PROMPT = 'Run two shell commands: wait for cancellation, then write skipped.txt.' + +describe.skipIf(MODE === 'record')('web e2e: cancelled Bash row disclosure', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + const fixture = await readFile(FIXTURE, 'utf8') + expect(fixtureUserPrompts(fixture)).toEqual([PROMPT]) + scaffold = await launchWebScaffold({}) + await seedSession(scaffold, fixture, SEED_ID) + 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 }) + + const groupRow = page.locator('[role="treeitem"]').first() + await groupRow.waitFor({ timeout: 15_000 }) + await groupRow.click() + const sessionRow = page.locator('[role="treeitem"]').nth(1) + await sessionRow.waitFor({ timeout: 10_000 }) + await sessionRow.click() + await page.locator('[data-sample="bash"]').nth(1).waitFor({ timeout: 15_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('expands the aborted row to its command and full error', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-bash-abort-row')) + const row = page.locator('[data-sample="bash"]').first() + const call = row.locator('xpath=..') + await expect.poll(() => row.getAttribute('aria-expanded')).toBe('false') + await expect.poll(() => call.getByText('Error: command aborted', { exact: true }).count()).toBe(1) + await row.click() + + await expect.poll(() => row.getAttribute('aria-expanded')).toBe('true') + await call.getByText('IN', { exact: true }).waitFor() + await call.getByText('OUT', { exact: true }).waitFor() + await call.getByText('Wait until cancellation', { exact: false }).waitFor() + await call.getByText('setInterval(() => {}, 1000)', { exact: false }).waitFor() + await expect.poll(() => call.getByText('Error: command aborted', { exact: true }).count()).toBe(2) + + const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) + .split(SEED_ID).join('{{seededId}}') + await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 60_000) + + it('keeps its snapshot inventory closed', async () => { + await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md']) + }) +}) diff --git a/apps/web/tests/snapshots/bash-abort-row/ui.expected.md b/apps/web/tests/snapshots/bash-abort-row/ui.expected.md new file mode 100644 index 0000000000..798111ee21 --- /dev/null +++ b/apps/web/tests/snapshots/bash-abort-row/ui.expected.md @@ -0,0 +1,32 @@ +- banner: + - navigation "Session hierarchy": + - 'button "Run two shell commands: wait" [disabled]' + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- text: "Run two shell commands: wait for cancellation, then write skipped.txt. 7/18 {{clock}}" +- button "Copy": + - img +- button "Branch into a new conversation": + - img +- button "Context injection": + - img + - img + - text: Context injection +- 'button "Failed Bash Error: command aborted" [expanded]': + - img + - text: "Failed Bash Error: command aborted" +- text: "IN { \"command\": \"node -e \\\"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\\\"\", \"description\": \"Wait until cancellation\" } OUT Error: command aborted" +- button "Inspect" +- 'button "Failed Bash Error: tool call aborted before dispatch"': + - img + - text: "Failed Bash Error: tool call aborted before dispatch" +- 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] +- text: 1 turns · 1 steps Tool call {{duration}} Cache hit 0% Input 10 tok · Output 10 tok diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 9693ed1885..5128d01da8 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -53,7 +53,8 @@ "tests/shipped-composition.e2e.ts", "tests/goal-bar.e2e.ts", "tests/startup-auto-selection.e2e.ts", - "tests/subagent-conversation.e2e.ts" + "tests/subagent-conversation.e2e.ts", + "tests/bash-abort-row.e2e.ts" ], "references": [ { diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index d131e31202..1dc3761c5f 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/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-conversation/README.md -README.md: e610b990dd89204fd7e22e8b86f807d10b8ba439 -README.zh.md: 268e05a806db1468ba689608c178646af132fe25 +README.md: 728d67aaccee609d5f28ee8744c1f11ca74b040a +README.zh.md: eec1d36b9758dddcf63eb75c3a74c51b4afb0a72 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index e610b990dd..728d67aacc 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -20,7 +20,7 @@ A Think row stays collapsed by default and exposes live reasoning throughput wit Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is a hover-underline link that opens the file with the host OS default application (`host.openPath`, relative paths resolve against the session cwd). Tool rows are not whole-row click targets and do not open the details panel. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged). Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering. -A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed per render intent — the terminal and web cards, each with its own bound; a generic tool's content remains panel-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)). +A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed per render intent — the terminal and web cards, each with its own bound. A Bash execution failure that settles on the generic path instead exposes its original arguments and full error through the same bounded IN/OUT disclosure, while successful generic results such as a background-start acknowledgement remain summary-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)). A tool call declaring the `web` render intent renders its web retrieval inline, at both conversation render sites, through ui-primitives' `WebBlock`. `contract/web-card-model.ts` is the single derivation from the snapshot's `resultView`, mirroring the terminal card, so the sites cannot disagree about what a web call shows; it yields null — the generic path — for a running call, a non-web result view, a generic result view, a `card` tag this client version does not know, or a web card whose `kind` this client version does not know (a newer host's value, which the wire cannot be trusted to be `search` or `fetch`). The keyed `WebRow` registers one component under both `web_search` and `web_fetch`, discriminating on the tool name only for its icon and title; it composes the shared `ToolRow`, feeding the card as ToolRow's `web` body, so the retrieval is the row's collapsed-by-default expanded card (the same unified expand every card row has). A web-declaring tool without a keyed row lands on the `GenericToolCard` fallback, which routes the card through ToolRow the same way, and the details panel renders it at the primitive's full source allowance and, below the card, the flattened model-visible result content — a fetch body is readable only there, since its card carries only the URL and status. Rows cap at `CHAT_WEB_MAX_SOURCES` (8) against the panel's 16, the same summary-versus-reading split the terminal card draws ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md)). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 268e05a806..eec1d36b97 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -18,7 +18,7 @@ Think 行默认保持折叠,并在不展开思维链的情况下暴露实时 通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和路径摘要;该路径是悬停下划线链接,点击后通过宿主操作系统的默认应用打开文件(`host.openPath`,相对路径相对会话 cwd 解析)。工具行不再是整行点击目标,也不会打开 details 面板。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行)。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect`、`Mount temporary Plugin` 和 `Unmount temporary Plugin`;mount 行保留 code 变体的可展开源码渲染。 -声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView`/`resultView` 对推导的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null,落回通用路径。因此两个渲染点也都显示卡片的运行状态点,它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片常驻在摘要行下方;由于工具行已不再是详情面板的点击目标,卡片的复制与展开控件就是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`(8),面板为 16,正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出按渲染意图开放——终端卡片与 web 卡片,各有自己的上限;通用工具的内容仍然只在面板中呈现([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。 +声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView`/`resultView` 对推导的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null,落回通用路径。因此两个渲染点也都显示卡片的运行状态点,它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片常驻在摘要行下方;由于工具行已不再是详情面板的点击目标,卡片的复制与展开控件就是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`(8),面板为 16,正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出按渲染意图开放——终端卡片与 web 卡片,各有自己的上限。若 Bash 执行失败时落在通用路径,则改用同样有界的 IN/OUT 展开区暴露原始参数和完整错误;后台启动确认等成功的通用结果仍只显示摘要([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。 声明 `web` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `WebBlock` 内联渲染其 web 检索。`contract/web-card-model.ts` 是从快照的 `resultView` 推导的唯一位置,镜像终端卡片,因此两个渲染点不可能对一次 web 调用的显示产生分歧;对运行中的调用、非 web 的 result view、generic result view、本客户端版本不认识的 `card` 标签,或本客户端版本不认识 `kind` 的 web 卡片(更新的 host 发来的值,wire 上不可信其为 `search` 或 `fetch`),它返回 null,落回通用路径。键控的 `WebRow` 把一个组件注册在 `web_search` 与 `web_fetch` 两个键下,仅根据工具名判别以选取图标与标题;它组合共享的 `ToolRow`,把卡片作为 ToolRow 的 `web` body 传入,因此检索成为该行默认折叠的展开卡片(与每个卡片行相同的统一展开交互)。没有自己键控行的 web 声明工具落到 `GenericToolCard` 兜底,它以同样方式经 ToolRow 渲染卡片,详情面板则以原语的完整 source 额度渲染它,并在卡片下方渲染摊平的模型可见结果内容——fetch 正文只在此处可读,因为其卡片只携带 URL 和状态。行的上限是 `CHAT_WEB_MAX_SOURCES`(8),面板为 16,与终端卡片所画的摘要面对阅读面的同一划分([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md))。 diff --git a/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css b/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css index ef9f246dd7..d4323309d4 100644 --- a/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css +++ b/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css @@ -20,6 +20,63 @@ border: 1px solid var(--dsw-alias-border-l1); } +/* A bash execution error can settle without terminal-card material (for + example, command cancellation). Preserve ToolRow's bounded IN/OUT fallback + so the original command and full error remain available from this keyed row. */ +.ioCard { + display: flex; + flex-direction: column; + margin: 4px 0 4px 4px; + border: 1px solid var(--dsw-alias-border-l1); + border-radius: 12px; + background: var(--dsw-alias-markdown-code-block); + font: var(--dsw-font-markdown-code-block-small); +} + +.ioSection { + display: grid; + grid-template-columns: max-content 1fr; + column-gap: 14px; + align-items: baseline; + padding: 12px 16px; + max-height: 150px; + overflow-y: auto; +} + +.ioSection::-webkit-scrollbar-thumb { + border: 2px solid transparent; + background-clip: padding-box; + border-radius: 6px; +} + +.ioSection::-webkit-scrollbar-track { + margin: 6px 0; +} + +.ioLabel { + position: sticky; + top: 0; + align-self: start; + color: var(--dsw-alias-label-caption); +} + +.ioDivider { + flex: none; + height: 1px; + background: var(--dsw-alias-border-l2); +} + +.ioText { + min-width: 0; + white-space: pre-wrap; + word-break: break-word; + color: var(--dsw-alias-label-secondary); +} + +.ioText[data-error] { + color: var(--dsw-alias-state-error-primary); +} + /* ToolRow's unified expand interaction, replicated per the registrant posture: pointer on the expandable row (the icon→chevron hover preview is the affordance, no row fill). */ diff --git a/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx b/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx index 5a3e3f40fe..fbe05e0a1b 100644 --- a/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx +++ b/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx @@ -2,8 +2,10 @@ // (ctx.slots.register + ToolRowProps only — never imports the chat domain). // Product chrome matches ToolRow / Think (figma: Bash · {description}). // -// A bash call declares the terminal render intent, so this row renders the -// command's own output through TerminalBlock — expand-gated exactly like +// A bash call normally declares the terminal render intent, so this row renders +// the command's own output through TerminalBlock. Execution failures that +// settle without terminal material use the bounded generic IN/OUT fallback — +// both are expand-gated exactly like // ToolRow's unified interaction: collapsed by default, the whole summary row // is the toggle (click / Enter / Space, icon→chevron hover preview; the // summary stays inline while open), @@ -48,7 +50,7 @@ function stateStatus(state: ToolRowState, t: BashRowProps['t']): string | null { /** * Bash row: icon + Bash · {description} in the shared ToolRow chrome, the - * whole row toggling the command's terminal card (ToolRow's unified + * whole row toggling the command's terminal or generic error card (ToolRow's unified * expand interaction, replicated locally per the registrant posture). */ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }: BashRowProps) { @@ -64,7 +66,13 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }: : model.state const status = stateStatus(state, t) const [expanded, setExpanded] = useState(false) - const expandable = terminal !== null + // Execution failures (for example cancellation before the process reports a + // terminal result) use the generic presenter. Keep their recorded args and + // full error reachable instead of collapsing the row to the first line. + const genericError = terminal === null + && model.state === 'error' + && (model.body !== null || model.output !== null) + const expandable = terminal !== null || genericError const open = expanded && expandable const failureLine = model.state === 'error' ? model.errorSummary : null const toggleExpand = () => { @@ -109,16 +117,40 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }: {failureLine ?? terminal?.description ?? model.summary}
- {terminal !== null && open && ( + {open && ( /* Same hover-Inspect posture as ToolRow's expanded body, replicated locally per the registrant posture. */
- + {terminal !== null + ? ( + + ) + : ( +
+ {model.body !== null && ( +
+ IN + {model.body} +
+ )} + {model.body !== null && model.output !== null && ( + + )} + {model.output !== null && ( +
+ OUT + + {model.output} + +
+ )} +
+ )} {inspect !== undefined && (
diff --git a/packages/client/ui-trajectory/tests/table.spec.tsx b/packages/client/ui-trajectory/tests/table.spec.tsx index aaea399a83..794fd8ca09 100644 --- a/packages/client/ui-trajectory/tests/table.spec.tsx +++ b/packages/client/ui-trajectory/tests/table.spec.tsx @@ -263,19 +263,22 @@ describe('TrajectoryTable', () => { expect(retry.style.getPropertyValue('--request-boundary-offset')).toBe('8px') }) - it('renders responsive role icons with a custom tooltip', () => { + it('shows the custom role tooltip only from the responsive icon', () => { const view = render() const toolTag = view.container.querySelector('[data-role-kind="tool"]') + const toolIcon = toolTag?.querySelector('[data-role-icon="wrench"]') expect(toolTag).not.toBeNull() expect(toolTag?.getAttribute('title')).toBeNull() - expect(toolTag?.querySelector('[data-role-icon="wrench"]')).toBeTruthy() + expect(toolIcon).toBeTruthy() fireEvent.mouseEnter(toolTag as HTMLElement) + expect(screen.queryByRole('tooltip')).toBeNull() + fireEvent.mouseEnter(toolIcon as HTMLElement) const tooltip = screen.getByRole('tooltip') expect(tooltip.textContent).toBe('TOOL') expect(tooltip.getAttribute('data-side')).toBe('right') - fireEvent.mouseLeave(toolTag as HTMLElement) + fireEvent.mouseLeave(toolIcon as HTMLElement) expect(screen.queryByRole('tooltip')).toBeNull() }) From 59d923395a76db74846be329bbeb4b2bf1112db1 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Mon, 3 Aug 2026 00:14:35 -0700 Subject: [PATCH 110/129] fix(web): document remote welcome constraints --- apps/web/tests/scaffold.ts | 6 +++++- packages/client/connection/README.i18n.yaml | 4 ++-- packages/client/connection/README.md | 2 +- packages/client/connection/README.zh.md | 2 +- packages/client/connection/src/loopback-hostname.ts | 9 +++++++++ .../ui-settings-general/src/client/welcome-store.ts | 4 +++- 6 files changed, 21 insertions(+), 6 deletions(-) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 62a1d31d0d..e1bb5b58c4 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -166,7 +166,11 @@ export interface LaunchOptions { } /** Leave the current welcome notice unacknowledged; ordinary scenarios publish it as complete before browser boot. */ welcomeNoticePending?: boolean - /** Browse through this trusted non-loopback hostname while the test server stays bound to loopback. */ + /** + * Browse through a trusted non-loopback hostname that the browser resolves + * to loopback (for example `*.localhost`). The test server stays bound to + * 127.0.0.1; a non-resolving authority fails before Host trust is exercised. + */ remoteAuthority?: string } diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index 974e3014d6..e159696db3 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/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/connection/README.md -README.md: c8b7c4787cbcbf6a202fb944459a589fcadd7c8d -README.zh.md: 693420183ffa4fb20e1fecbff523a12261a45d45 +README.md: 522ae6a14a3b4b07e7f2917133d16a5e83433f69 +README.zh.md: 4eaed862df678997b328d7a4ddcab1f5254c7c60 diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index c8b7c4787c..522ae6a14a 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`, reads included, since describing returns the exposed configuration and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3. +Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The dedicated `./loopback-hostname` source subpath exposes the zero-dependency predicate shared by the `/api` Host fence and browser welcome-persistence selection; client bundlers inline this source entry, while plain Node cannot load it directly, so it must remain browser-safe and dependency-free. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`, reads included, since describing returns the exposed configuration and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3. ## /api browser-trust fence diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index 693420183f..4eaed862df 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`,读取也在内,因为 describe 会返回已暴露的配置,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。 +协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。专用的 `./loopback-hostname` 源码子路径导出 `/api` Host fence 与浏览器欢迎页持久化选择共用的零依赖判定函数;客户端 bundler 会内联这一源码入口,而 plain Node 无法直接加载它,因此它必须保持浏览器安全且零依赖。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`,读取也在内,因为 describe 会返回已暴露的配置,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。 ## /api 浏览器信任栅栏 diff --git a/packages/client/connection/src/loopback-hostname.ts b/packages/client/connection/src/loopback-hostname.ts index 8fd30445bd..5666f0714d 100644 --- a/packages/client/connection/src/loopback-hostname.ts +++ b/packages/client/connection/src/loopback-hostname.ts @@ -1,3 +1,12 @@ +/** + * Browser-safe, zero-dependency loopback classification shared by the `/api` + * Host fence and browser welcome-persistence selection. The dedicated + * `./loopback-hostname` source subpath is inlined into client bundles instead + * of loaded by plain Node, so this module must not add Node-only or runtime + * dependencies. + * @module @deepseek-ai/dsh-client-connection/loopback-hostname + */ + /** * Whether a normalized URL hostname names the local loopback authority. * @param hostname - WHATWG URL hostname (IPv6 literals retain brackets). diff --git a/packages/client/ui-settings-general/src/client/welcome-store.ts b/packages/client/ui-settings-general/src/client/welcome-store.ts index fdff28f052..c95c9e46d8 100644 --- a/packages/client/ui-settings-general/src/client/welcome-store.ts +++ b/packages/client/ui-settings-general/src/client/welcome-store.ts @@ -117,7 +117,9 @@ export class WelcomeNoticeStore { } /** - * Refresh only after the welcome step has begun reading durable state. + * Refresh only after welcome state has left idle. A memory-mode load retains + * acknowledgement so reconnect and settings-change refreshes do not reopen a + * process-local notice. * @param controller - welcome state owner whose current status decides whether to load. */ export function refreshWelcomeIfLoaded(controller: WelcomeNoticeStore): void { From d5583210d4af98164e221900d70da0023116ed45 Mon Sep 17 00:00:00 2001 From: kingwl Date: Mon, 3 Aug 2026 15:19:53 +0800 Subject: [PATCH 111/129] tighten branchless subagent catalog layout --- .../subagent-conversation/branchless.expected.md | 2 ++ apps/web/tests/subagent-conversation.e2e.ts | 11 ++++++++++- packages/client/ui-subagent/README.i18n.yaml | 4 ++-- packages/client/ui-subagent/README.md | 2 +- packages/client/ui-subagent/README.zh.md | 2 +- .../src/client/SubagentCatalogAction.tsx | 7 +++++-- .../ui-subagent/tests/conversation-ui.spec.tsx | 14 ++++++++++++++ 7 files changed, 35 insertions(+), 7 deletions(-) create mode 100644 apps/web/tests/snapshots/subagent-conversation/branchless.expected.md diff --git a/apps/web/tests/snapshots/subagent-conversation/branchless.expected.md b/apps/web/tests/snapshots/subagent-conversation/branchless.expected.md new file mode 100644 index 0000000000..1f51342ed6 --- /dev/null +++ b/apps/web/tests/snapshots/subagent-conversation/branchless.expected.md @@ -0,0 +1,2 @@ +- tree "Subagent sessions": + - treeitem "example editor continuable · not running 0 tok · {{duration}}" [level=1]: example editor continuable · not running 0 tok {{duration}} diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index ac2e4fe1f2..6f339cf9a9 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -20,6 +20,7 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor const BASE_FIXTURE = fileURLToPath(new URL('./snapshots/live-interactions/session.jsonl', import.meta.url)) const AVAILABLE_CHILD_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/ui.expected.md', import.meta.url)) const TREE_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/tree.expected.md', import.meta.url)) +const BRANCHLESS_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/branchless.expected.md', import.meta.url)) const STALE_CATALOG_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/stale-catalog.expected.md', import.meta.url)) const SIDEBAR_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/sidebar.expected.md', import.meta.url)) const UNAVAILABLE_GRANDCHILD_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/nested.expected.md', import.meta.url)) @@ -396,7 +397,15 @@ describe('web e2e: persisted subagent conversation and human continuation', () = it('opens an unavailable persisted grandchild after recording the available child', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-grandchild')) await page.getByRole('button', { name: '1 subagent' }).click() - await page.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }).click() + const tree = page.getByRole('tree', { name: 'Subagent sessions' }) + const nestedRow = tree.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }) + expect(await nestedRow.locator(':scope > *').count()).toBe(1) + await compareOrRefreshGolden( + BRANCHLESS_EXPECTED, + await captureStableAria(page, '[role="tree"][aria-label="Subagent sessions"]', scaffold.workspaceCwd), + MODE, + ) + await nestedRow.click() await page.getByText('The parent session is offline; reopen it to continue sending messages.').waitFor() const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' }) const crumbs = await hierarchy.getByRole('button').allTextContents() diff --git a/packages/client/ui-subagent/README.i18n.yaml b/packages/client/ui-subagent/README.i18n.yaml index 536b11709f..a519393a5a 100644 --- a/packages/client/ui-subagent/README.i18n.yaml +++ b/packages/client/ui-subagent/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-subagent/README.md -README.md: 1ced0a82e5ec9340ee87620dfaaf7655fe4f6a3e -README.zh.md: 778d3354ac78658044c2a13b3af632510b547f04 +README.md: cb210b219a8c66985eb4e1370468372eed9614b4 +README.zh.md: 7b87fa1095c404eda96066189b1e4480cd6d4c3c diff --git a/packages/client/ui-subagent/README.md b/packages/client/ui-subagent/README.md index 1ced0a82e5..cb210b219a 100644 --- a/packages/client/ui-subagent/README.md +++ b/packages/client/ui-subagent/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Web subagent feature owner: contributes the lazily expandable catalog tree to `conversation.session.header.actions`, reason-specific read-only replacements to the conversation composer chain, and the existing `@` reference source to `ctx.slash`. -The header action reads `subagentsByParent` and session summaries through the standard `useSessions` hook. After a non-empty direct catalog arrives, its trigger counts the complete subagent-only descendant lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. The compact tree remains direct-catalog authoritative: continuable and one-shot rows display mode plus `running`/`inactive` activity and an optional log-backed title, while the trailing column stacks total durable provider usage above active-turn duration. Token totals sum the four disjoint `tokenUsage` buckets. Visual duration stays exact to the second below one day, then uses at most two adjacent units—days/hours, approximate months/days, or approximate years/months—while hover and the accessible name retain the exact day/hour/minute/second value. Duration sums completed `subagentTiming` turns, advances once per second only for an open turn on a running child, and freezes after the child becomes inactive; an interrupted open turn is bounded by its same-cut `active.through`, never by newer session metadata. An unlabeled one-shot row falls back to its session id, while corrupt, unsupported, or unavailable rows remain readable but disabled. Each healthy row's `hasChildren` hint determines disclosure before interaction, so known leaves never show an arrow; expanding a branch immediately reserves one disabled loading row per known direct descendant, then lazily replaces them with that child's authoritative catalog. Every visible branch is reported to the runtime so membership frames cause a debounced refresh only where the tree is being consumed. Selecting any depth calls `SessionsService.openSubagent()` with the row's exact `{parentSessionId, childSessionId, mode}` address. Component-local state owns tree visibility, expanded branches, keyboard focus, and the running-duration clock. ArrowRight/ArrowLeft expand and collapse branches; ArrowUp/ArrowDown, Home, End, and Escape navigate or close the tree; closing returns focus to the trigger. Styling uses tokens only. +The header action reads `subagentsByParent` and session summaries through the standard `useSessions` hook. After a non-empty direct catalog arrives, its trigger counts the complete subagent-only descendant lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. The compact tree remains direct-catalog authoritative: continuable and one-shot rows display mode plus `running`/`inactive` activity and an optional log-backed title, while the trailing column stacks total durable provider usage above active-turn duration. Token totals sum the four disjoint `tokenUsage` buckets. Visual duration stays exact to the second below one day, then uses at most two adjacent units—days/hours, approximate months/days, or approximate years/months—while hover and the accessible name retain the exact day/hour/minute/second value. Duration sums completed `subagentTiming` turns, advances once per second only for an open turn on a running child, and freezes after the child becomes inactive; an interrupted open turn is bounded by its same-cut `active.through`, never by newer session metadata. An unlabeled one-shot row falls back to its session id, while corrupt, unsupported, or unavailable rows remain readable but disabled. Each healthy row's `hasChildren` hint determines disclosure before interaction, so known leaves never show an arrow; a catalog level reserves the disclosure column only when at least one healthy row is a branch, allowing branchless levels to start at the leading status marker. Expanding a branch immediately reserves one disabled loading row per known direct descendant, then lazily replaces them with that child's authoritative catalog. Every visible branch is reported to the runtime so membership frames cause a debounced refresh only where the tree is being consumed. Selecting any depth calls `SessionsService.openSubagent()` with the row's exact `{parentSessionId, childSessionId, mode}` address. Component-local state owns tree visibility, expanded branches, keyboard focus, and the running-duration clock. ArrowRight/ArrowLeft expand and collapse branches; ArrowUp/ArrowDown, Home, End, and Escape navigate or close the tree; closing returns focus to the trigger. Styling uses tokens only. A one-shot child always elects a read-only composer that identifies the transcript as a completed execution record. A continuable child does so only when its exact parent is unavailable, with copy explaining the recovery path. A continuable child with a live parent keeps the ordinary input chrome, whose Session routes through `subagent.prompt`; running input remains Send because every follow-up joins the child's FIFO inbox, and addressed sessions never expose Stop. This package never receives host context or calls a model-facing tool. The catalog and composer behavior are specified by the [Web subagent conversations Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md). diff --git a/packages/client/ui-subagent/README.zh.md b/packages/client/ui-subagent/README.zh.md index 778d3354ac..7b87fa1095 100644 --- a/packages/client/ui-subagent/README.zh.md +++ b/packages/client/ui-subagent/README.zh.md @@ -4,7 +4,7 @@ Web subagent 功能 owner:向 `conversation.session.header.actions` 贡献可懒加载展开的目录树,向会话编辑器链贡献按原因区分的只读替代呈现,并保留注册到 `ctx.slash` 的既有 `@` 引用 source。 -页头操作通过标准 `useSessions` 钩子读取 `subagentsByParent` 与会话摘要。非空直接目录到达后,其触发器会统计仅含 subagent 的完整后代谱系,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。紧凑树仍以直接目录为权威依据:可继续和 one-shot 行会显示 mode、`running`/`inactive` 活动状态和由日志支撑的可选 title,尾随列则在上行显示提供方的持久化 token 用量总计,在下行显示活跃轮次耗时。token 用量总计为四个互不重叠的 `tokenUsage` 桶之和。视觉耗时在不足一天时精确到秒,达到一天后则最多使用两个相邻单位——天/小时、近似月份/天或近似年份/月份——而悬停信息与无障碍名称会保留精确的天/小时/分钟/秒数值。耗时会累加已完成的 `subagentTiming` 轮次,仅在运行中 child 存在未结束轮次时每秒递增一次,并在 child 变为 inactive 后冻结;被中断的未结束轮次以其同一切面的 `active.through` 为上界,绝不使用更新的会话元数据。没有 label 的 one-shot 行会回退到其会话 id,而损坏、不受支持或不可用的行仍保持可读但禁用。每个健康行的 `hasChildren` 提示会在交互前决定是否显示展开控件,因此已知叶子节点从不显示箭头;展开分支时,会立即为每个已知直接后代预留一行禁用的加载行,随后再用该 child 的权威目录懒加载结果替换这些占位行。每个可见分支都会上报给运行时,使成员帧只在树正被消费的位置触发去抖动刷新。选择任意深度的条目都会使用该行的确切地址 `{parentSessionId, childSessionId, mode}` 调用 `SessionsService.openSubagent()`。组件局部状态负责树的可见性、已展开分支、键盘焦点与运行中耗时时钟。ArrowRight/ArrowLeft 展开和折叠分支;ArrowUp/ArrowDown、Home、End 与 Escape 用于导航或关闭树;关闭后焦点返回触发器。样式只使用 token。 +页头操作通过标准 `useSessions` 钩子读取 `subagentsByParent` 与会话摘要。非空直接目录到达后,其触发器会统计仅含 subagent 的完整后代谱系,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。紧凑树仍以直接目录为权威依据:可继续和 one-shot 行会显示 mode、`running`/`inactive` 活动状态和由日志支撑的可选 title,尾随列则在上行显示提供方的持久化 token 用量总计,在下行显示活跃轮次耗时。token 用量总计为四个互不重叠的 `tokenUsage` 桶之和。视觉耗时在不足一天时精确到秒,达到一天后则最多使用两个相邻单位——天/小时、近似月份/天或近似年份/月份——而悬停信息与无障碍名称会保留精确的天/小时/分钟/秒数值。耗时会累加已完成的 `subagentTiming` 轮次,仅在运行中 child 存在未结束轮次时每秒递增一次,并在 child 变为 inactive 后冻结;被中断的未结束轮次以其同一切面的 `active.through` 为上界,绝不使用更新的会话元数据。没有 label 的 one-shot 行会回退到其会话 id,而损坏、不受支持或不可用的行仍保持可读但禁用。每个健康行的 `hasChildren` 提示会在交互前决定是否显示展开控件,因此已知叶子节点从不显示箭头;每层目录仅在其中至少一个健康行是分支时才预留展开列,使完全不含分支的层级能从最前面的状态标记开始。展开分支时,会立即为每个已知直接后代预留一行禁用的加载行,随后再用该 child 的权威目录懒加载结果替换这些占位行。每个可见分支都会上报给运行时,使成员帧只在树正被消费的位置触发去抖动刷新。选择任意深度的条目都会使用该行的确切地址 `{parentSessionId, childSessionId, mode}` 调用 `SessionsService.openSubagent()`。组件局部状态负责树的可见性、已展开分支、键盘焦点与运行中耗时时钟。ArrowRight/ArrowLeft 展开和折叠分支;ArrowUp/ArrowDown、Home、End 与 Escape 用于导航或关闭树;关闭后焦点返回触发器。样式只使用 token。 one-shot child 始终选用只读编辑器,并将 transcript(文本记录)说明为已完成的执行记录。可继续 child 仅在其确切 parent 不可用时选用只读编辑器,并以文案说明恢复路径。确切 parent 存活时,可继续 child 保留普通输入 chrome,其 Session 会通过 `subagent.prompt` 路由;child 运行期间,输入操作仍为 Send,因为每条后续消息都会进入 child 的 FIFO inbox,且已寻址会话绝不公开 Stop。本包绝不接收宿主 context,也不调用面向模型的工具。目录与编辑器行为由 [Web subagent 对话 Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md)规定。 diff --git a/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx b/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx index b7aefb7987..76a216ebd8 100644 --- a/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx +++ b/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx @@ -237,6 +237,9 @@ function CatalogRows({ openChild, refresh, toggleBranch, closeCatalog, t, }: CatalogRowsProps & { t: TranslateNS }) { const emptyLoading = catalog.state === 'loading' && catalog.entries.length === 0 + const reserveDisclosure = catalog.entries.some( + entry => entry.kind === 'child' && entry.hasChildren, + ) return ( <> {emptyLoading && ( @@ -273,7 +276,7 @@ function CatalogRows({ className={`${css.row} ${css.disabled}`} title={reason} > - + {reserveDisclosure && } {entry.id} @@ -352,7 +355,7 @@ function CatalogRows({ onKeyDown={handleKey} > {knownLeaf - ? + ? reserveDisclosure && : ( {thinkingExpanded && ( { render() fireEvent.click(screen.getByRole('row', { name: /ASSISTANT/ })) - const toggle = screen.getByRole('button', { name: 'Thinking ...' }) + const toggle = screen.getByRole('button', { name: 'Show thinking' }) + expect(toggle.getAttribute('aria-expanded')).toBe('false') expect(screen.queryByText(thinking)).toBeNull() fireEvent.click(toggle) + expect(screen.getByRole('button', { name: 'Hide thinking' })).toBe(toggle) + expect(toggle.getAttribute('aria-expanded')).toBe('true') expect(toggle.parentElement?.textContent?.length).toBeGreaterThan(thinking.length) }) From c5050f018e467a2f73a8e02f1bb9d976e8004402 Mon Sep 17 00:00:00 2001 From: kingwl Date: Mon, 3 Aug 2026 16:19:49 +0800 Subject: [PATCH 115/129] fix(web): keep ineligible message forks disabled --- ...ions-require-completed-turn-tail.i18n.yaml | 4 +-- ...ork-actions-require-completed-turn-tail.md | 6 ++-- ...-actions-require-completed-turn-tail.zh.md | 6 ++-- apps/web/tests/message-actions.e2e.ts | 19 +++++++---- .../snapshots/bash-abort-row/ui.expected.md | 3 +- .../snapshots/code-mode-round/ui.expected.md | 3 ++ .../cordis-tool-round/ui.expected.md | 3 ++ .../snapshots/fresh-round-trip/ui.expected.md | 3 ++ .../lifecycle-chrome/reloaded.expected.md | 3 ++ .../live-interactions/cancel.expected.md | 3 ++ .../live-interactions/error-auth.expected.md | 3 ++ .../live-interactions/loading.expected.md | 3 ++ .../live-interactions/retry.expected.md | 3 ++ .../snapshots/message-actions/ui.expected.md | 10 +++++- .../plan-review/approved.expected.md | 3 ++ .../question-composer/answered.expected.md | 3 ++ .../queue-actions/collapsed.expected.md | 3 ++ .../queue-actions/editing.expected.md | 3 ++ .../queue-actions/preserved.expected.md | 6 ++++ .../snapshots/queue-actions/ui.expected.md | 3 ++ .../seeded-history/command-row.expected.md | 3 ++ .../snapshots/seeded-history/ui.expected.md | 3 ++ .../snapshots/steering/mid-steer.expected.md | 3 ++ .../snapshots/steering/settled.expected.md | 6 ++++ .../subagent-conversation/ui.expected.md | 6 ++++ .../snapshots/web-search-round/ui.expected.md | 3 ++ packages/client/runtime/README.i18n.yaml | 4 +-- packages/client/runtime/README.md | 2 +- packages/client/runtime/README.zh.md | 2 +- .../client/ui-conversation/README.i18n.yaml | 4 +-- packages/client/ui-conversation/README.md | 6 ++-- packages/client/ui-conversation/README.zh.md | 6 ++-- .../src/client/chat/AssistantMarkdown.tsx | 9 ++++-- .../src/client/chat/ChatView.tsx | 6 ++-- .../client/chat/MessageIconActions.module.css | 20 ++++++++++++ .../src/client/chat/MessageIconActions.tsx | 23 ++++++++++--- .../src/client/chat/MessageItem.tsx | 7 ++-- .../src/client/chat/chat-flow.ts | 4 +-- .../ui-conversation/src/client/locales.ts | 2 ++ .../tests/chat-branch-tails.spec.tsx | 24 ++++++++++++++ .../ui-conversation/tests/chat-view.spec.tsx | 32 +++++++++++++------ 41 files changed, 220 insertions(+), 48 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.i18n.yaml index 052d983988..9ac8e49fe6 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.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 .agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md -2026-08-02-message-fork-actions-require-completed-turn-tail.md: 38a81d9e6966b459ed8d7dd19ba0cbdd790b85b6 -2026-08-02-message-fork-actions-require-completed-turn-tail.zh.md: 09d2ecc2b110e66348b7cd7c5eb253b4c8cdbb9a +2026-08-02-message-fork-actions-require-completed-turn-tail.md: f2e7fd67b65a6ce4a86ba3f4405f78842be8f234 +2026-08-02-message-fork-actions-require-completed-turn-tail.zh.md: 2c3feeaa3ef01dbde67faa73257520918996f9c8 diff --git a/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md b/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md index 38a81d9e69..f2e7fd67b6 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md +++ b/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md @@ -10,7 +10,7 @@ The Web conversation attached branch to the last assistant node with nonempty te ## Decision -`ConversationSnapshot.turnEnds` retains the completed turn boundaries present in the raw event window. The conversation view walks transcript nodes through each boundary and exposes branch only when the boundary's last node is a user message, a durable steering message, or a content-bearing assistant message. Open turns have no eligible message, and a later tool result, reasoning-only interruption, turn error, or other transcript node suppresses branch on earlier messages. Copy and clock remain available under their existing message chrome, and the Host's completed-turn fork semantics remain unchanged. +`ConversationSnapshot.turnEnds` retains the completed turn boundaries present in the raw event window. The conversation view walks transcript nodes through each boundary and enables branch only when the boundary's last node is a user message, a durable steering message, or a content-bearing assistant message. Open turns have no eligible message, and a later tool result, reasoning-only interruption, turn error, or other transcript node leaves branch unavailable on earlier messages. The unavailable control stays visible, focusable, and hoverable; `aria-disabled`, a tooltip, and `aria-describedby` explain the completed-tail requirement without sending a Host request. Copy and clock remain available under their existing message chrome, and the Host's completed-turn fork semantics remain unchanged. This narrows the message eligibility established by the earlier [Web session fork action decision](../feature/2026-07-27-web-session-fork-actions.md). Session-row forking still selects the latest completed turn, and eligible message actions still pass their event seq through the shared client runtime operation. @@ -22,6 +22,8 @@ This narrows the message eligibility established by the earlier [Web session for **Hide branch from every interrupted turn.** Rejected because an aborted turn is durably closed and its final interrupted text can be the true transcript tail. Eligibility depends on the completed boundary and node order, not the outcome kind. +**Hide ineligible message controls.** Rejected because a disappearing control does not explain the boundary requirement and shifts otherwise stable message chrome. A focusable unavailable control preserves the affordance while preventing the request. + ## Consequences -A branch icon now denotes the same completed-turn boundary that the Host will copy. In the reported response → tool → interrupted Think shape, the response keeps copy and clock but no longer advertises branch. This change deliberately does not provide same-turn transcript editing or a retry-before-turn operation; the Session-row action remains available when a reader wants to copy the latest completed turn in full. Runtime tests pin boundary projection and reference stability, while conversation tests cover assistant, user-only, and durable-steering tails plus suppression by later tool and interrupted reasoning rows. +An enabled branch icon denotes the same completed-turn boundary that the Host will copy. In the reported response → tool → interrupted Think shape, the response keeps copy, clock, and a disabled branch control that explains why it cannot act. This change deliberately does not provide same-turn transcript editing or a retry-before-turn operation; the Session-row action remains available when a reader wants to copy the latest completed turn in full. Runtime tests pin boundary projection and reference stability, while conversation tests cover assistant, user-only, and durable-steering tails plus unavailable controls caused by later tool and interrupted reasoning rows. diff --git a/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.zh.md b/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.zh.md index 09d2ecc2b1..2c3feeaa3e 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.zh.md @@ -10,7 +10,7 @@ Web 会话把分支操作挂到每个轮次中最后一个文本非空的 assist ## 决策 -`ConversationSnapshot.turnEnds` 保留原始事件窗口中的已完成轮次边界。会话视图按各边界遍历 transcript(文本记录)节点,仅当边界的最后一个节点是用户消息、持久 steering(中途引导)消息或含内容的 assistant 消息时才暴露分支操作。开放轮次没有符合条件的消息;如果后面还有工具结果、只有推理内容的中断、轮次错误或其他 transcript 节点,较早消息上的分支操作就会被抑制。复制和时钟仍可在既有消息 chrome 下使用,Host 按已完成轮次 fork 的语义保持不变。 +`ConversationSnapshot.turnEnds` 保留原始事件窗口中的已完成轮次边界。会话视图按各边界遍历 transcript(文本记录)节点,仅当边界的最后一个节点是用户消息、持久 steering(中途引导)消息或含内容的 assistant 消息时才启用分支操作。开放轮次没有符合条件的消息;如果后面还有工具结果、只有推理内容的中断、轮次错误或其他 transcript 节点,较早消息上的分支操作会保持不可用。不可用的控件仍然可见、可聚焦、可悬停;`aria-disabled`、tooltip 与 `aria-describedby` 会说明已完成尾部这一要求,且不会发送 Host 请求。复制和时钟仍可在既有消息 chrome 下使用,Host 按已完成轮次 fork 的语义保持不变。 本决策收紧了较早的 [Web 会话 fork 操作决策](../feature/2026-07-27-web-session-fork-actions.md)所定义的消息资格。Session 行 fork 仍选择最新的已完成轮次;符合条件的消息操作仍通过共享 client 运行时操作传递其事件 seq。 @@ -22,6 +22,8 @@ Web 会话把分支操作挂到每个轮次中最后一个文本非空的 assist **对每个被中断轮次隐藏分支。** 不予采纳:已中止的轮次会持久关闭,其最终的中断文本可能正是真正的 transcript 尾部。资格取决于已完成边界与节点顺序,而非结果类别。 +**隐藏不符合条件的消息控件。** 不予采纳:消失的控件无法说明边界要求,还会让本应稳定的消息 chrome 发生位移。保留可聚焦但不可用的控件,既能维持操作提示,也能阻止请求。 + ## 后果 -分支图标现在表示的已完成轮次边界与 Host 实际复制的边界一致。在所报告的「响应 → 工具 → 被中断的 Think」形态中,响应仍保留复制和时钟,但不再显示分支。本变更刻意不提供同轮次 transcript 编辑,也不提供轮次前重试操作;当读者希望完整复制最新的已完成轮次时,仍可使用 Session 行操作。运行时测试固定边界投影和引用稳定性,会话测试则覆盖 assistant 尾部、纯用户消息尾部、持久 steering 尾部,以及后续工具行和被中断推理行对分支操作的抑制。 +启用的分支图标现在表示的已完成轮次边界与 Host 实际复制的边界一致。在所报告的「响应 → 工具 → 被中断的 Think」形态中,响应仍保留复制、时钟,以及一个说明无法操作原因的禁用分支控件。本变更刻意不提供同轮次 transcript 编辑,也不提供轮次前重试操作;当读者希望完整复制最新的已完成轮次时,仍可使用 Session 行操作。运行时测试固定边界投影和引用稳定性,会话测试则覆盖 assistant 尾部、纯用户消息尾部、持久 steering 尾部,以及后续工具行和被中断推理行导致的不可用控件。 diff --git a/apps/web/tests/message-actions.e2e.ts b/apps/web/tests/message-actions.e2e.ts index 96dd27f393..aa64816420 100644 --- a/apps/web/tests/message-actions.e2e.ts +++ b/apps/web/tests/message-actions.e2e.ts @@ -95,7 +95,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => { await scaffold?.close() }) - it.skipIf(MODE === 'record')('shows branch only on the completed transcript tail', async () => { + it.skipIf(MODE === 'record')('enables branch only on the completed transcript tail', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-message-actions')) const groupRow = page.locator('[role="treeitem"]').first() await groupRow.waitFor({ timeout: 15_000 }) @@ -107,13 +107,20 @@ describe('web e2e: message IconActions and clocks on settled history', () => { await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBe(1) // Focus-reveal the footers (hover:hover keeps them opacity-hidden until - // hover/focus-within). All message rows keep copy, but only the final - // assistant at a completed transcript tail has branch. + // hover/focus-within). Every durable message footer keeps branch visible, + // but only the final assistant at a completed transcript tail enables it. const copyButtons = page.getByRole('button', { name: 'Copy' }) await expect.poll(() => copyButtons.count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(4) await copyButtons.first().focus() - await expect.poll(() => page.getByRole('button', { name: 'Branch into a new conversation' }).count(), { timeout: 5_000 }) - .toBe(1) + const branchButtons = page.getByRole('button', { name: 'Branch into a new conversation' }) + await expect.poll(() => branchButtons.count(), { timeout: 5_000 }).toBe(4) + await expect.poll( + () => branchButtons.evaluateAll(buttons => buttons.map(button => button.getAttribute('aria-disabled'))), + { timeout: 5_000 }, + ).toEqual(['true', 'true', 'true', null]) + await branchButtons.first().focus() + await expect.poll(() => page.getByRole('tooltip').textContent(), { timeout: 5_000 }) + .toBe('Available only on the last message of a completed turn') await expect.poll(() => page.getByRole('button', { name: 'Edit' }).count(), { timeout: 5_000 }).toBe(0) }, 60_000) @@ -132,7 +139,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => { it.skipIf(MODE === 'record')('forks through the settled-message and session-row actions', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-message-fork')) - // The sole message action belongs to the completed second-turn assistant. + // The last message action belongs to the completed second-turn assistant. await page.getByRole('button', { name: 'Branch into a new conversation' }).last().click() await expect.poll( () => scaffold.ctx.agents.list().find(agent => agent.session.header.parentSession === SessionId(SEED_ID)), diff --git a/apps/web/tests/snapshots/bash-abort-row/ui.expected.md b/apps/web/tests/snapshots/bash-abort-row/ui.expected.md index 8bb3c00b66..8f09d36efd 100644 --- a/apps/web/tests/snapshots/bash-abort-row/ui.expected.md +++ b/apps/web/tests/snapshots/bash-abort-row/ui.expected.md @@ -7,8 +7,9 @@ - text: "Run two shell commands: wait for cancellation, then write skipped.txt. {{date}} {{clock}}" - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/code-mode-round/ui.expected.md b/apps/web/tests/snapshots/code-mode-round/ui.expected.md index 239c4d4548..c6799a2247 100644 --- a/apps/web/tests/snapshots/code-mode-round/ui.expected.md +++ b/apps/web/tests/snapshots/code-mode-round/ui.expected.md @@ -7,6 +7,9 @@ - text: "Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt catching its error in the program. Return an object with both outcomes. Then reply DONE and stop. {{clock}}" - button "Copy": - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md index cc502f2b5f..fc6d312821 100644 --- a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md +++ b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md @@ -7,6 +7,9 @@ - text: "Use only Cordis tools. First call cordis_inspect with what \"temporary\". Then call cordis_mount with this exact code: \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\". Read its returned id and call cordis_unmount with that exact id. After all three calls succeed, reply exactly CORDIS_UI_DONE and stop. {{clock}}" - button "Copy": - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md index 73817d6c7e..17df480cd9 100644 --- a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md +++ b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md @@ -7,6 +7,9 @@ - text: "Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop. {{clock}}" - button "Copy": - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md index d961d20c4f..5e0b4f73bb 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md @@ -7,6 +7,9 @@ - text: Reply with the single word LIGHTHOUSE and stop. {{clock}} - button "Copy": - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/live-interactions/cancel.expected.md b/apps/web/tests/snapshots/live-interactions/cancel.expected.md index 57d2ea37dc..d18e061a1a 100644 --- a/apps/web/tests/snapshots/live-interactions/cancel.expected.md +++ b/apps/web/tests/snapshots/live-interactions/cancel.expected.md @@ -7,6 +7,9 @@ - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md index 9539eb180f..1a4aec678c 100644 --- a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md +++ b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md @@ -7,6 +7,9 @@ - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/live-interactions/loading.expected.md b/apps/web/tests/snapshots/live-interactions/loading.expected.md index caee0ad6aa..c50b440f86 100644 --- a/apps/web/tests/snapshots/live-interactions/loading.expected.md +++ b/apps/web/tests/snapshots/live-interactions/loading.expected.md @@ -7,6 +7,9 @@ - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/live-interactions/retry.expected.md b/apps/web/tests/snapshots/live-interactions/retry.expected.md index a46b0966a9..d5df08c05e 100644 --- a/apps/web/tests/snapshots/live-interactions/retry.expected.md +++ b/apps/web/tests/snapshots/live-interactions/retry.expected.md @@ -7,6 +7,9 @@ - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/message-actions/ui.expected.md b/apps/web/tests/snapshots/message-actions/ui.expected.md index 65152bb4de..3d085b0931 100644 --- a/apps/web/tests/snapshots/message-actions/ui.expected.md +++ b/apps/web/tests/snapshots/message-actions/ui.expected.md @@ -8,6 +8,9 @@ - button "Copy": - img - tooltip "Copy" +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn - button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel.": - img - img @@ -15,7 +18,9 @@ - paragraph: I will read both files before answering. - button "Copy": - img -- text: 7/25 {{clock}} +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn 7/25 {{clock}} - button "Read a.txt": - img - img @@ -33,6 +38,9 @@ - text: Stopped Now give the final answer. 7/25 {{clock}} - button "Copy": - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn - paragraph: DONE - button "Copy": - img diff --git a/apps/web/tests/snapshots/plan-review/approved.expected.md b/apps/web/tests/snapshots/plan-review/approved.expected.md index cd0d50c095..108ca47986 100644 --- a/apps/web/tests/snapshots/plan-review/approved.expected.md +++ b/apps/web/tests/snapshots/plan-review/approved.expected.md @@ -8,6 +8,9 @@ - text: "plan Plan mode on. Use /plan off to leave. Plan a small change: add a --greeting flag to a CLI. Do not read or write any files. Call exit_plan_mode with a short plan of at most five bullet points. Once the plan is approved, reply with the single word DONE and stop. {{clock}}" - button "Copy": - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/question-composer/answered.expected.md b/apps/web/tests/snapshots/question-composer/answered.expected.md index e02232a945..b5ed63a63e 100644 --- a/apps/web/tests/snapshots/question-composer/answered.expected.md +++ b/apps/web/tests/snapshots/question-composer/answered.expected.md @@ -7,6 +7,9 @@ - text: "Use the ask_user_question tool to ask me exactly one question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" After I answer, reply with the single word DONE and stop. {{clock}}" - button "Copy": - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md index f51e7b3a1b..bd44e33ad0 100644 --- a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md +++ b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md @@ -7,6 +7,9 @@ - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/queue-actions/editing.expected.md b/apps/web/tests/snapshots/queue-actions/editing.expected.md index 4d0e5af1f1..8df2ea2940 100644 --- a/apps/web/tests/snapshots/queue-actions/editing.expected.md +++ b/apps/web/tests/snapshots/queue-actions/editing.expected.md @@ -7,6 +7,9 @@ - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/queue-actions/preserved.expected.md b/apps/web/tests/snapshots/queue-actions/preserved.expected.md index a166375107..74de289ee9 100644 --- a/apps/web/tests/snapshots/queue-actions/preserved.expected.md +++ b/apps/web/tests/snapshots/queue-actions/preserved.expected.md @@ -7,6 +7,9 @@ - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img @@ -20,6 +23,9 @@ - text: {{clock}} Edited queue item {{clock}} - button "Copy": - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn - paragraph: partial - status: Deep diving... - list: diff --git a/apps/web/tests/snapshots/queue-actions/ui.expected.md b/apps/web/tests/snapshots/queue-actions/ui.expected.md index e0c648dd1f..7afdf3c8b4 100644 --- a/apps/web/tests/snapshots/queue-actions/ui.expected.md +++ b/apps/web/tests/snapshots/queue-actions/ui.expected.md @@ -7,6 +7,9 @@ - text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "Copy": - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/seeded-history/command-row.expected.md b/apps/web/tests/snapshots/seeded-history/command-row.expected.md index e3f8cdc925..b4eddfc24e 100644 --- a/apps/web/tests/snapshots/seeded-history/command-row.expected.md +++ b/apps/web/tests/snapshots/seeded-history/command-row.expected.md @@ -7,6 +7,9 @@ - text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. 7/25 {{clock}}" - button "Copy": - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn - button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel.": - img - img diff --git a/apps/web/tests/snapshots/seeded-history/ui.expected.md b/apps/web/tests/snapshots/seeded-history/ui.expected.md index fee3747f32..4643c60f3b 100644 --- a/apps/web/tests/snapshots/seeded-history/ui.expected.md +++ b/apps/web/tests/snapshots/seeded-history/ui.expected.md @@ -7,6 +7,9 @@ - text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. 7/25 {{clock}}" - button "Copy": - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn - button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel.": - img - img diff --git a/apps/web/tests/snapshots/steering/mid-steer.expected.md b/apps/web/tests/snapshots/steering/mid-steer.expected.md index d95a55f029..5b316f4efb 100644 --- a/apps/web/tests/snapshots/steering/mid-steer.expected.md +++ b/apps/web/tests/snapshots/steering/mid-steer.expected.md @@ -7,6 +7,9 @@ - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} - button "Copy": - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/apps/web/tests/snapshots/steering/settled.expected.md b/apps/web/tests/snapshots/steering/settled.expected.md index d4d1098ead..4d8d35a085 100644 --- a/apps/web/tests/snapshots/steering/settled.expected.md +++ b/apps/web/tests/snapshots/steering/settled.expected.md @@ -7,6 +7,9 @@ - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} - button "Copy": - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img @@ -22,6 +25,9 @@ - text: "Interjection: include the word BANANA in your final reply. {{clock}}" - button "Copy": - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn - button "Think The user selected \"Yes\" and wants me to include the word \"BANANA\" in my final reply. Let me acknowledge their answer.": - img - img diff --git a/apps/web/tests/snapshots/subagent-conversation/ui.expected.md b/apps/web/tests/snapshots/subagent-conversation/ui.expected.md index d2bafefdcb..9497cc36d3 100644 --- a/apps/web/tests/snapshots/subagent-conversation/ui.expected.md +++ b/apps/web/tests/snapshots/subagent-conversation/ui.expected.md @@ -12,6 +12,9 @@ - text: Explain event sourcing in one sentence. {{clock}} - button "Copy": - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img @@ -28,6 +31,9 @@ - text: {{clock}} Now give the same explanation to a human reader. {{clock}} - button "Copy": - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn - button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.": - img - img diff --git a/apps/web/tests/snapshots/web-search-round/ui.expected.md b/apps/web/tests/snapshots/web-search-round/ui.expected.md index c0e55a5051..97ca405141 100644 --- a/apps/web/tests/snapshots/web-search-round/ui.expected.md +++ b/apps/web/tests/snapshots/web-search-round/ui.expected.md @@ -7,6 +7,9 @@ - text: Use web_search to search exactly "DeepSeek Harness snapshot search". Then reply exactly SEARCH_DONE and stop. {{clock}} - button "Copy": - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn - button "Context injection": - img - img diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 280f1e8108..c3850804ff 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: be4463343a04f7287cfba8c48bdd71fa4d2cade4 -README.zh.md: cec93ef091a0dec0a10c1de1b04cf86966b972bd +README.md: 89e58f967f852bb0786a5b7d73fa8e924fa282e0 +README.zh.md: 960e2fceede1b500af9ee2063ec9283e2b7b271a diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index be4463343a..89e58f967f 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -26,7 +26,7 @@ SlotsService gives the renderer separate bare observables for `useSessions` and ## The human transcript -`ConversationSnapshot.nodes` is the human transcript, not the model surface. `TranscriptAdapter` projects the raw window in log order — every append-origin surface event (`isAppendSurfaceEvent`) at its own log position, plus one `CompactionSummaryNode` marker per landed compaction checkpoint — and never consults surface order. `ConversationSnapshot.turnEnds` maps each completed turn in that window to its `turn/end` seq, retaining turn completion independently from the transcript so presentation can require a real boundary before exposing an action. A landed compaction therefore keeps the conversation it shadowed on the model side: the marker reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies stay out: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary. A checkpoint is a `user/message` carrying the compaction seam's plugin source that **replaced** a surface range; an appending plugin-sourced `user/message` is injected context, not a compaction. The adapter's plugin literal is pinned to the seam's own declaration by a type-only import of the cordis-free [`dsh-compact/checkpoint`](../../compact/compact/README.md) leaf, so renaming it there fails `tsc` here; a **value** import of the package would fail the client purity gate, and the package **root** is unreachable even as a type (it reaches `dsh-session`'s root, whose `Context` merge collides the host `sessions` with this program's). `tests/compact-checkpoint-pin.spec.ts` covers the same drift behaviorally. +`ConversationSnapshot.nodes` is the human transcript, not the model surface. `TranscriptAdapter` projects the raw window in log order — every append-origin surface event (`isAppendSurfaceEvent`) at its own log position, plus one `CompactionSummaryNode` marker per landed compaction checkpoint — and never consults surface order. `ConversationSnapshot.turnEnds` maps each completed turn in that window to its `turn/end` seq, retaining turn completion independently from the transcript so presentation can require a real boundary before enabling an action. A landed compaction therefore keeps the conversation it shadowed on the model side: the marker reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies stay out: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary. A checkpoint is a `user/message` carrying the compaction seam's plugin source that **replaced** a surface range; an appending plugin-sourced `user/message` is injected context, not a compaction. The adapter's plugin literal is pinned to the seam's own declaration by a type-only import of the cordis-free [`dsh-compact/checkpoint`](../../compact/compact/README.md) leaf, so renaming it there fails `tsc` here; a **value** import of the package would fail the client purity gate, and the package **root** is unreachable even as a type (it reaches `dsh-session`'s root, whose `Context` merge collides the host `sessions` with this program's). `tests/compact-checkpoint-pin.spec.ts` covers the same drift behaviorally. Because the projection is log-ordered, the node array is seq-monotonic by construction: log-only `command/run` / `command/done` nodes splice in by seq, `Session` merges interrupted frozen nodes by their fractional seqs, and a window whose checkpoint cites a shadowed range outside it renders the marker with nothing logged. The marker's summary text comes from the checkpoint's `compact/summary` provenance; a window cut that left the provenance outside makes the row non-expandable rather than empty, and a later page that supplies it resolves the text. Performance contract: one append materializes at most one node and copies the projection only when it adds that node; an event that changes no node keeps the previous array reference (a chunk storm costs nothing), and unchanged nodes keep their object identity. diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index cec93ef091..960e2fceed 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -26,7 +26,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 ## 面向人的 transcript(文本记录) -`ConversationSnapshot.nodes` 是面向人的 transcript,不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口——每个 append 来源的 surface 事件(`isAppendSurfaceEvent`)落在它自己的日志位置上,外加每次落地的压缩(compaction)检查点贡献一个 `CompactionSummaryNode` 标记——且从不查询 surface 顺序。`ConversationSnapshot.turnEnds` 把该窗口中的每个已完成轮次映射到其 `turn/end` seq;它独立于 transcript 保留轮次完成状态,使呈现层能够在暴露操作前要求存在真实边界。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话:标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不标记任何边界。检查点是携带压缩 seam 插件来源、且**替换**了一段 surface 范围的 `user/message`;一条 append 的插件来源 `user/message` 是注入上下文,不是压缩。适配器的插件字面量通过对无 cordis 的 [`dsh-compact/checkpoint`](../../compact/compact/README.md) 叶子做仅类型导入,钉在压缩 seam 自己的声明上:在那里改名会让此处 `tsc` 失败;而对该包(package)做**值**导入会被客户端纯度门禁拒绝,包的**根**即便作为类型也无法到达(它会到达 `dsh-session` 的根,其 `Context` 合并会让 host 的 `sessions` 与本程序的冲突)。`tests/compact-checkpoint-pin.spec.ts` 从行为侧覆盖同一漂移。 +`ConversationSnapshot.nodes` 是面向人的 transcript,不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口——每个 append 来源的 surface 事件(`isAppendSurfaceEvent`)落在它自己的日志位置上,外加每次落地的压缩(compaction)检查点贡献一个 `CompactionSummaryNode` 标记——且从不查询 surface 顺序。`ConversationSnapshot.turnEnds` 把该窗口中的每个已完成轮次映射到其 `turn/end` seq;它独立于 transcript 保留轮次完成状态,使呈现层能够在启用操作前要求存在真实边界。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话:标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不标记任何边界。检查点是携带压缩 seam 插件来源、且**替换**了一段 surface 范围的 `user/message`;一条 append 的插件来源 `user/message` 是注入上下文,不是压缩。适配器的插件字面量通过对无 cordis 的 [`dsh-compact/checkpoint`](../../compact/compact/README.md) 叶子做仅类型导入,钉在压缩 seam 自己的声明上:在那里改名会让此处 `tsc` 失败;而对该包(package)做**值**导入会被客户端纯度门禁拒绝,包的**根**即便作为类型也无法到达(它会到达 `dsh-session` 的根,其 `Context` 合并会让 host 的 `sessions` 与本程序的冲突)。`tests/compact-checkpoint-pin.spec.ts` 从行为侧覆盖同一漂移。 由于投影按日志顺序,节点数组天然按 seq 单调:仅日志的 `command/run` / `command/done` 节点按 seq 插入,`Session` 按分数 seq 归并被打断的冻结节点,而检查点所引范围落在窗口之外的窗口会渲染出标记且不打印任何日志。标记的摘要文本来自检查点的 `compact/summary` 溯源;窗口切分把溯源留在窗口外时该行不可展开而非空白,后续补上溯源的分页会解析出文本。性能契约:一次追加最多物化一个节点,并且仅在加入该节点时复制投影;不改变任何节点的事件保持上一次的数组引用(分片风暴零成本),未变化的节点保持其对象标识。 diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 2fe596c31b..09e17a4371 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/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-conversation/README.md -README.md: 4f06b407a6c2288be2c58ad01655fd37e7e368ae -README.zh.md: db0f31cc506f0d3d600296d5d00ae70e12df4fc8 +README.md: e1dbe7d4d5992b6b5b029fddfc9d9857ccae7443 +README.zh.md: fd82ad65f8e903a6f7106e8b8ff8eccbf1435957 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 4f06b407a6..e1dbe7d4d5 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -38,7 +38,7 @@ The todo surfaces are two registrations over that shape, both plain registrant p `QueueDock` is the terminal input-dock entry at `order: 20`. It hides while empty, renders one pending row directly, and defaults two or more rows to a collapsed `" 条排队消息"` header whose button expands or collapses the complete list. The header exposes `aria-expanded` and `aria-controls`; the expanded list scrolls within a 180px height bound. An active edit or mutation keeps its rows visible, and emptying the queue restores the collapsed default for the next queue. Each visible ordinary-session row remains a single-line preview with its exact-occurrence edit, delete, and strict-steer actions; addressed subagents retain the rows as a read-only projection because their continuation transport does not expose queue mutation. If strict steer loses to a closed window, the original occurrence remains queued for normal delivery; if the driver already claimed it, normal delivery is already underway. Neither converged race displays a failure, while transport and unknown failures do. -The Host's placement-aware `session/queue` snapshot also carries pending steering. QueueDock filters it out, while ChatView projects it as a user-style bubble with Copy at the conversation tail; Fork stays absent because the message has not entered a durable turn. The Host delays steering retirement until the durable `steering/message` has entered the mux stream. On that accepted live event, the client runtime retires the first matching current steering occurrence before publishing the snapshot; historical events cannot hide later occurrences that reuse the same `MessageId`. The bubble therefore hands off without a gap or duplicate, immediately restores Copy from the durable node, exposes Fork only when that node is the completed turn's transcript tail, and survives reconnect from the same authority. +The Host's placement-aware `session/queue` snapshot also carries pending steering. QueueDock filters it out, while ChatView projects it as a user-style bubble with Copy at the conversation tail; Fork stays absent because the message has not entered a durable turn. The Host delays steering retirement until the durable `steering/message` has entered the mux stream. On that accepted live event, the client runtime retires the first matching current steering occurrence before publishing the snapshot; historical events cannot hide later occurrences that reuse the same `MessageId`. The bubble therefore hands off without a gap or duplicate, immediately restores Copy and the branch control from the durable node, enables branch only when that node is the completed turn's transcript tail, and survives reconnect from the same authority. Keyboard message submission resolves delivery from the addressed session's running state and steering capability. While idle, Enter and Cmd/Ctrl+Enter both perform an ordinary Queue send. While a primary session is running, the browser-persisted General Settings preference assigns plain Enter to `Queue` (the default) or `Steer`, and Cmd/Ctrl+Enter performs the other behavior; Shift+Enter remains a newline. Addressed subagents keep both gestures on their Queue-only continuation transport even while running. The preference affects only the steer-capable busy-state gesture pair, and the send button and non-keyboard submit actions remain Queue. Composer Steer uses the existing best-effort `session.prompt(mode: 'steer')` contract: if the current next-step window closes before acceptance, AgentLoop admits the message as the next waking Queue turn without surfacing a failure or losing the draft transaction. @@ -63,8 +63,8 @@ None; this package neither assembles nor sends a provider request. - **Compaction markers show no scale** — the row does not yet report how many messages or which range the checkpoint replaced. - **Stats-line durations cover the in-window flow only** — LLM and tool wall times fold the snapshot's assistant `timing` and tool call/result pairs, so nodes outside the loaded event window (older history) are not counted. - **Details panel is the minimal form and currently has no entry point** — selected call args/result raw display; the Input/Output/Metadata switch, Prev/Next stepping, and See-in-trajectory deep link are deferred. Tool rows stopped being details-panel click targets and nothing replaced that gesture, so `ChatViewInjected.openDetails` is implemented but uncalled and the panel (including its terminal card) is unreachable in the assembled application; its rendering stays covered by mounting it with a selection directly. -- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / clock, plus branch when eligible) ships under the last content-text assistant of each turn only; mid-turn narration and Think-only nodes stay chrome-free. Branch appears only when that message is also the last transcript node of a completed turn, then forks through that turn, increments the inherited title on the client, and opens the child; a fork or rename failure leaves the source selected ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md)). -- **Sent user messages cannot be edited** — user bubbles retain clock and copy, while branch appears only for a completed turn whose transcript ends at that user message. Editing returns with the capability behind it: a client mutation over a settled user message, plus the host behavior for the turn that already consumed it ([decision](../../../.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md)). +- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / clock / branch) ships under the last content-text assistant of each turn only; mid-turn narration and Think-only nodes stay chrome-free. Branch stays disabled unless that message is also the last transcript node of a completed turn; when enabled, it forks through that turn, increments the inherited title on the client, and opens the child. A fork or rename failure leaves the source selected ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md)). +- **Sent user messages cannot be edited** — user bubbles retain clock, copy, and branch; branch stays disabled unless a completed turn's transcript ends at that user message. Editing returns with the capability behind it: a client mutation over a settled user message, plus the host behavior for the turn that already consumed it ([decision](../../../.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md)). - **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export. - **The approval panel's "Always allow this type" is deferred** — durable grants need a grant-storage design; only allow-once/reject answer today. - **TodoPanel truncates long item text to one ellipsized line** — the figma strip has no wrap or expand affordance; full text is not readable inline. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index db0f31cc50..fd82ad65f8 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -38,7 +38,7 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插 `QueueDock` 是 `order: 20` 的末端 input-dock 条目。队列为空时隐藏;只有一个待处理项时直接渲染该行;存在两个或更多待处理项时,默认收起为 `" 条排队消息"` 表头,其按钮可展开或收起完整列表。表头暴露 `aria-expanded` 和 `aria-controls`;展开后的列表以 180px 为高度上限,并可滚动。存在进行中的编辑或变更时,列表行会保持可见;队列清空后,下一次出现队列时会恢复默认收起状态。普通会话中的每条可见行仍是单行预览,并提供针对精确单次入队项的编辑、删除和严格 steering(中途引导)操作;已寻址 subagent 则保留只读行,因为其继续执行传输不提供 Queue 变更。如果严格 steering 输给已关闭的窗口,原单次入队项会留在 Queue 中正常投递;如果驱动器已经认领该项,正常投递就已开始。这两种已收敛的竞态都不显示失败,传输和未知错误仍会显示。 -Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。QueueDock 会将其过滤掉,ChatView 则把它投影为会话流末尾带复制操作的用户样式气泡;消息尚未进入持久轮次,因此不显示 fork。Host 会等持久 `steering/message` 进入 mux 流之后再退役 steering。客户端运行时接纳该实时事件时,会在发布快照前退役第一个匹配的当前 steering 单次入队项;历史事件无法隐藏后来复用同一 `MessageId` 的单次入队项。气泡交接时因而不会产生空档或重复,会立即从持久节点恢复复制操作,仅当该节点是已完成轮次的 transcript 尾部时才显示 fork,并能在重连后从同一权威恢复。 +Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。QueueDock 会将其过滤掉,ChatView 则把它投影为会话流末尾带复制操作的用户样式气泡;消息尚未进入持久轮次,因此不显示 fork。Host 会等持久 `steering/message` 进入 mux 流之后再退役 steering。客户端运行时接纳该实时事件时,会在发布快照前退役第一个匹配的当前 steering 单次入队项;历史事件无法隐藏后来复用同一 `MessageId` 的单次入队项。气泡交接时因而不会产生空档或重复,会立即从持久节点恢复复制操作与分支控件,仅当该节点是已完成轮次的 transcript 尾部时才启用分支,并能在重连后从同一权威恢复。 键盘消息提交会根据所寻址会话的运行状态和 steering 能力解析投递方式。空闲时,Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。主会话运行期间,浏览器持久化的 General Settings 偏好会把普通 Enter 分配为 `Queue`(默认值)或 `Steer`,Cmd/Ctrl+Enter 则执行另一种行为;Shift+Enter 仍然换行。已寻址 subagent 即使正在运行,也会让这两个手势都使用其仅支持 Queue 的继续执行传输。该偏好只影响支持 steering 的繁忙态手势对,发送按钮与非键盘提交操作仍使用 Queue。Composer Steer 复用现有尽力而为的 `session.prompt(mode: 'steer')` 契约:如果当前 next-step 窗口在接纳前关闭,AgentLoop 会把消息接纳为下一条唤醒 Queue 轮次,不显示失败,也不会丢失草稿事务。 @@ -63,8 +63,8 @@ Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。Qu - **压缩标记不显示规模**:该行尚不报告检查点替换了多少条消息或哪段范围。 - **统计行的耗时只覆盖窗口内消息流**:LLM 与工具墙钟时间由快照的 assistant `timing` 与工具 call/result 配对折算,落在已加载事件窗口之外的节点(更早的历史)不计入。 - **详情面板是最小形态,且当前没有入口**:以原始形式显示已选择调用的参数/结果;Input/Output/Metadata 切换、Prev/Next 步进与 See-in-trajectory 深链接暂缓实现。工具行已不再是详情面板的点击目标,且没有任何手势接替它,因此 `ChatViewInjected.openDetails` 虽已实现却无人调用,该面板(含其终端卡片)在组装后的应用中不可达;其渲染仍由直接以选中态挂载它来覆盖。 -- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/时钟,符合条件时再显示分支)只挂在每个轮次中最后一条带 text 内容的 assistant 下;轮次中间的叙述与纯 Think 节点不带 chrome。只有当该消息同时也是已完成轮次的最后一个 transcript 节点时才显示分支;随后 fork 到该轮次末尾,在 client 端递增继承标题并打开子会话;fork 或改名失败时源会话保持选中([决策](../../../.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md))。 -- **已发送的 user 消息无法编辑**:user 气泡保留时钟和复制;仅当已完成轮次的 transcript 结束于该 user 消息时才显示分支。编辑功能要与其背后的能力一起回归:既需要针对已定稿 user 消息的 client 变更,也需要 host 侧对已经消费过它的轮次给出行为([决策](../../../.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md))。 +- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/时钟/分支)只挂在每个轮次中最后一条带 text 内容的 assistant 下;轮次中间的叙述与纯 Think 节点不带 chrome。除非该消息同时也是已完成轮次的最后一个 transcript 节点,否则分支保持禁用;启用后,它会 fork 到该轮次末尾,在 client 端递增继承标题并打开子会话。fork 或改名失败时源会话保持选中([决策](../../../.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md))。 +- **已发送的 user 消息无法编辑**:user 气泡保留时钟、复制和分支;除非已完成轮次的 transcript 结束于该 user 消息,否则分支保持禁用。编辑功能要与其背后的能力一起回归:既需要针对已定稿 user 消息的 client 变更,也需要 host 侧对已经消费过它的轮次给出行为([决策](../../../.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md))。 - **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。 - **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。 - **TodoPanel 将过长条目截成单行省略号**:figma 条没有换行或展开入口,完整文本无法在行内读完。 diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index 45104002f6..3f87e40e8b 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -5,7 +5,7 @@ // step-summary flow). Shared by finalized nodes and the streaming partial; // the turn-level loading dots live in the chat view's tail, not here. // Finalized content (text) nodes append IconActions once streaming ends -// (`time` is omitted for mid-turn narration); their branch action is present +// (`time` is omitted for mid-turn narration); their branch action is enabled // only when the node is also the completed turn's transcript tail. Think / // tool-head-only nodes stay chrome-free. @@ -29,8 +29,10 @@ export interface AssistantMarkdownProps { time?: number | undefined /** Event sequence used as the fork boundary; omitted while streaming. */ seq?: number | undefined - /** Fork the session through this finalized message's completed turn. */ + /** Fork the session through this finalized message's completed turn when eligible. */ onFork?: ((seq: number) => void) | undefined + /** The message is not the transcript tail of a completed turn. */ + forkUnavailable?: boolean | undefined /** The owning view's locale seat, passed down as a plain prop. */ t: ChatViewSlotProps['t'] } @@ -77,7 +79,7 @@ function ThinkRow({ text, running, t }: { text: string; running: boolean; t: Ass } export const AssistantMarkdown = memo(function AssistantMarkdown({ - blocks, streaming, interrupted, time, seq, onFork, t, + blocks, streaming, interrupted, time, seq, onFork, forkUnavailable, t, }: AssistantMarkdownProps) { // Stable per locale revision (t identity changes on switch): a fresh object // per render would rebuild MarkdownText's component table every chunk. @@ -121,6 +123,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ time={time} clock="end" onBranch={onFork === undefined || seq === undefined ? undefined : () => { onFork(seq) }} + branchUnavailable={forkUnavailable} className={css.actions} t={t} /> diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index 021d3f0e4a..076da6c6ab 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -414,7 +414,8 @@ export function ChatView({ interrupted={node.interrupted} time={actionSeqs.has(node.seq) ? node.time : undefined} seq={node.seq} - onFork={branchSeqs.has(node.seq) ? forkAt : undefined} + onFork={forkAt} + forkUnavailable={!branchSeqs.has(node.seq)} t={t} /> ) @@ -429,7 +430,8 @@ export function ChatView({ key={item.key} node={node} retryActive={node.kind === 'model-retry' && node.seq === activeRetry} - {...branchSeqs.has(node.seq) ? { onFork: forkAt } : {}} + onFork={forkAt} + forkUnavailable={!branchSeqs.has(node.seq)} t={t} /> ) diff --git a/packages/client/ui-conversation/src/client/chat/MessageIconActions.module.css b/packages/client/ui-conversation/src/client/chat/MessageIconActions.module.css index b247b7e2bf..8a43b42f83 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageIconActions.module.css +++ b/packages/client/ui-conversation/src/client/chat/MessageIconActions.module.css @@ -43,3 +43,23 @@ background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-secondary); } + +/* Unavailable stays focusable and hoverable so Tooltip can explain why. */ +.action[data-unavailable] { + cursor: default; + opacity: 0.4; +} + +.action[data-unavailable]:hover { + background: transparent; + color: var(--dsw-alias-label-tertiary); +} + +.visuallyHidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} diff --git a/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx b/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx index 6b862a9021..6d77d4db68 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx @@ -1,7 +1,7 @@ // Shared IconActions chrome for user, steering, and assistant messages: copy // live, optional branch wiring, and an optional date-aware clock. -import { useCallback } from 'react' +import { useCallback, useId } from 'react' import { IconBranchOutline16, IconCopyOutline16, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' @@ -19,6 +19,8 @@ export interface MessageIconActionsProps { clock: 'start' | 'end' /** Fork the session at this message; omission hides the branch action. */ onBranch?: (() => void) | undefined + /** The message is not a completed transcript tail, so branch stays visible but unavailable. */ + branchUnavailable?: boolean | undefined /** Additional branch visibility gate for transient message chrome; defaults to true. */ showBranch?: boolean | undefined /** Parent layout class composed onto the actions row. */ @@ -33,9 +35,10 @@ export interface MessageIconActionsProps { * @returns The actions row element. */ export function MessageIconActions({ - text, time, clock, onBranch, showBranch = true, className, t, + text, time, clock, onBranch, branchUnavailable = false, showBranch = true, className, t, }: MessageIconActionsProps) { const day = useCalendarDay() + const reasonId = useId() const onCopy = useCallback(() => { void writeClipboard(text) }, [text]) @@ -53,12 +56,24 @@ export function MessageIconActions({ {showBranch && onBranch !== undefined && ( - - )} + {showBranch && onBranch !== undefined && branchUnavailable && ( + {t('message.branchUnavailable')} + )} {clock === 'end' ? clockEl : null}
) diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index 7ea870e326..df04ac19c2 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -26,8 +26,10 @@ export interface MessageItemProps { | TurnErrorNode | UnknownSurfaceNode retryActive?: boolean - /** Fork through this message's completed turn when it is the transcript tail. */ + /** Fork through this message's completed turn when eligible. */ onFork?: (seq: number) => void + /** The message is not the transcript tail of a completed turn. */ + forkUnavailable?: boolean /** The owning view's locale seat, passed down as a plain prop. */ t: ChatViewSlotProps['t'] } @@ -220,7 +222,7 @@ export function PendingSteeringBubble({ content, t }: { } export const MessageItem = memo(function MessageItem({ - node, retryActive = false, onFork, t, + node, retryActive = false, onFork, forkUnavailable = false, t, }: MessageItemProps) { const truncated = (total: number): string => t('json.truncated', { total }) switch (node.kind) { @@ -236,6 +238,7 @@ export const MessageItem = memo(function MessageItem({ time={node.time} clock="start" onBranch={onFork === undefined ? undefined : () => { onFork(node.seq) }} + branchUnavailable={forkUnavailable} className={css.actions} t={t} /> diff --git a/packages/client/ui-conversation/src/client/chat/chat-flow.ts b/packages/client/ui-conversation/src/client/chat/chat-flow.ts index cabcba23dd..9de9876d89 100644 --- a/packages/client/ui-conversation/src/client/chat/chat-flow.ts +++ b/packages/client/ui-conversation/src/client/chat/chat-flow.ts @@ -50,8 +50,8 @@ export function assistantActionsSeqs(nodes: readonly ConversationNode[]): Readon /** * Seq set of message rows that may fork: the last transcript node of a * completed turn, when that node owns message chrome. A later tool, reasoning, - * error, or other transcript node suppresses the earlier message's branch - * action even though the Host would include the whole turn. + * error, or other transcript node leaves the earlier message's branch action + * unavailable because the Host would include the whole turn. * @param nodes - snapshot nodes in event order. * @param turnEnds - completed turn boundaries retained from the event window. * @returns Message seq values whose visible position matches the fork boundary. diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 87252fbc77..7bc01b4d95 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -59,6 +59,7 @@ export const zh = { 'message.unknownBlock': '未知内容块', 'message.stopped': '已停止', 'message.branch': '在新对话中分支', + 'message.branchUnavailable': '仅可从已完成轮次的最后一条消息分支', 'message.retry.active': '正在重试模型请求', 'message.retry.cancelled': '模型请求重试已取消', 'message.retry.started': '已重试模型请求', @@ -166,6 +167,7 @@ export const en = { 'message.unknownBlock': 'Unknown content block', 'message.stopped': 'Stopped', 'message.branch': 'Branch into a new conversation', + 'message.branchUnavailable': 'Available only on the last message of a completed turn', 'message.retry.active': 'Retrying model request', 'message.retry.cancelled': 'Model request retry cancelled', 'message.retry.started': 'Retried model request', diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index 92a804ebfd..89a09ca28d 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -78,6 +78,30 @@ describe('MessageItem arms', () => { expect(exec).toHaveBeenCalledWith('copy') }) + it('keeps an unavailable branch focusable and explains why without sending a fork', () => { + const onFork = vi.fn() + render( + , + ) + const branch = screen.getByRole('button', { name: '在新对话中分支' }) as HTMLButtonElement + expect(branch.disabled).toBe(false) + expect(branch.getAttribute('aria-disabled')).toBe('true') + const reasonId = branch.getAttribute('aria-describedby') + expect(reasonId).not.toBeNull() + expect(document.getElementById(reasonId!)?.textContent).toBe('仅可从已完成轮次的最后一条消息分支') + fireEvent.click(branch) + expect(onFork).not.toHaveBeenCalled() + fireEvent.focus(branch) + expect(screen.getByRole('tooltip').textContent).toBe('仅可从已完成轮次的最后一条消息分支') + }) + it('user copy stays quiet when execCommand throws or is absent', () => { Object.defineProperty(navigator, 'clipboard', { configurable: true, diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 5a06f588ee..a041aca214 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -325,14 +325,19 @@ describe('ChatView', () => { expect(view.getAllByText('interrupt now')).toHaveLength(1) expect(view.container.querySelector('[data-pending-steering]')).toBeNull() expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(2) - expect(view.queryByRole('button', { name: '在新对话中分支' })).toBeNull() + const durableBubble = view.getByText('interrupt now').closest('[class*="userRow"]') as HTMLElement + const unavailable = within(durableBubble).getByRole('button', { name: '在新对话中分支' }) + expect(unavailable.getAttribute('aria-disabled')).toBe('true') + fireEvent.click(unavailable) + expect(h.forkAt).not.toHaveBeenCalled() act(() => { h.set({ running: false, turnEnds: new Map([[1, 3]]) }) }) const branchButtons = view.getAllByRole('button', { name: '在新对话中分支' }) - expect(branchButtons).toHaveLength(1) - fireEvent.click(branchButtons[0]!) + expect(branchButtons).toHaveLength(2) + expect(branchButtons.map(button => button.getAttribute('aria-disabled'))).toEqual(['true', null]) + fireEvent.click(branchButtons[1]!) expect(h.forkAt).toHaveBeenCalledWith(2) }) @@ -435,24 +440,28 @@ describe('ChatView', () => { turnEnds: new Map([[1, 4], [2, 6]]), }) const view = render() - // User rows keep copy/clock, while only the two completed assistant tails may branch. + // Every message footer keeps branch visible; only completed assistant tails enable it. expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(4) - expect(view.getAllByRole('button', { name: '在新对话中分支' })).toHaveLength(2) + const branchButtons = view.getAllByRole('button', { name: '在新对话中分支' }) + expect(branchButtons).toHaveLength(4) + expect(branchButtons.map(button => button.getAttribute('aria-disabled'))).toEqual(['true', null, 'true', null]) }) - it('forks only from a finalized assistant at the completed transcript tail', () => { + it('enables fork only on the finalized assistant at the completed transcript tail', () => { const h = makeHarness({ nodes: [user(1, 'question'), assistant(2, 'answer')], turnEnds: new Map([[1, 3]]), }) const view = render() const buttons = view.getAllByRole('button', { name: '在新对话中分支' }) - expect(buttons).toHaveLength(1) + expect(buttons).toHaveLength(2) + expect(buttons.map(button => button.getAttribute('aria-disabled'))).toEqual(['true', null]) fireEvent.click(buttons[0]!) + fireEvent.click(buttons[1]!) expect(h.forkAt.mock.calls).toEqual([[2]]) }) - it('keeps copy chrome but hides branch when tool and interrupted Think follow the response', () => { + it('keeps branch visible but unavailable when tool and interrupted Think follow the response', () => { const interruptedThink: AssistantMessageNode = { kind: 'assistant', seq: 4.1, time: 4_100, turn: 1, step: 2, blocks: [{ kind: 'reasoning', text: 'bad path' }], interrupted: true, @@ -463,7 +472,12 @@ describe('ChatView', () => { }) const view = render() expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(2) - expect(view.queryByRole('button', { name: '在新对话中分支' })).toBeNull() + const buttons = view.getAllByRole('button', { name: '在新对话中分支' }) + expect(buttons).toHaveLength(2) + expect(buttons.every(button => button.getAttribute('aria-disabled') === 'true')).toBe(true) + fireEvent.click(buttons[0]!) + fireEvent.click(buttons[1]!) + expect(h.forkAt).not.toHaveBeenCalled() }) it('renders assistant Markdown across history, streaming, final, and interrupted states while user text stays literal', () => { From 886c39d30200ed36dc8196bd14a673292bec038d Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 3 Aug 2026 16:21:02 +0800 Subject: [PATCH 116/129] test(web): refresh Markdown image snapshot --- .../snapshots/markdown-images/ui.expected.md | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/apps/web/tests/snapshots/markdown-images/ui.expected.md b/apps/web/tests/snapshots/markdown-images/ui.expected.md index cd33a09eb9..76e01397c2 100644 --- a/apps/web/tests/snapshots/markdown-images/ui.expected.md +++ b/apps/web/tests/snapshots/markdown-images/ui.expected.md @@ -5,27 +5,24 @@ - tab "Chat" [selected] - tab "Trajectory" - text: Show the Markdown image policy. {{clock}} -- button "复制": +- button "Copy": - img -- button "在新对话中分支": - - img -- button "编辑": +- button "Branch into a new conversation": - img - heading "Markdown images" [level=2] - paragraph: - img "Remote test image" - paragraph: Local test image - paragraph: REMOTE_IMAGE_DONE -- button "复制": +- button "Copy": - img -- button "在新对话中分支": +- button "Branch into a new conversation": - img - text: {{clock}} - textbox "Message the agent" -- button "Add attachment": +- button "Commands": - img -- 'button "Access mode, current: Danger Full Access"': Danger Full Access -- button "Plan mode off, press to turn on": Plan off +- 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current deepseek-v4-flash": - text: deepseek-v4-flash - img From 9c261516ce3f8cddf8c4f66fdad8601c980f8cb8 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 3 Aug 2026 16:22:03 +0800 Subject: [PATCH 117/129] fix(ui-trajectory): preserve thinking label --- packages/client/ui-trajectory/src/client/TrajectoryTable.tsx | 2 +- packages/client/ui-trajectory/tests/table.spec.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index 491462d7b5..1296ea24d3 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -1232,7 +1232,7 @@ function MarkdownRecordContent({ aria-expanded={thinkingExpanded} onClick={() => { onThinkingExpandedChange(!thinkingExpanded) }} > - {thinkingExpanded ? 'Hide thinking' : 'Show thinking'} + Thinking {thinkingExpanded && ( diff --git a/packages/client/ui-trajectory/tests/table.spec.tsx b/packages/client/ui-trajectory/tests/table.spec.tsx index 1f1eb7d3ca..a80a256634 100644 --- a/packages/client/ui-trajectory/tests/table.spec.tsx +++ b/packages/client/ui-trajectory/tests/table.spec.tsx @@ -101,12 +101,12 @@ describe('TrajectoryTable', () => { render() fireEvent.click(screen.getByRole('row', { name: /ASSISTANT/ })) - const toggle = screen.getByRole('button', { name: 'Show thinking' }) + const toggle = screen.getByRole('button', { name: 'Thinking' }) expect(toggle.getAttribute('aria-expanded')).toBe('false') expect(screen.queryByText(thinking)).toBeNull() fireEvent.click(toggle) - expect(screen.getByRole('button', { name: 'Hide thinking' })).toBe(toggle) + expect(screen.getByRole('button', { name: 'Thinking' })).toBe(toggle) expect(toggle.getAttribute('aria-expanded')).toBe('true') expect(toggle.parentElement?.textContent?.length).toBeGreaterThan(thinking.length) }) From b5f9fcdea45855a0ad9b481f7867c852374053ca Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 3 Aug 2026 16:24:26 +0800 Subject: [PATCH 118/129] fix(ui-trajectory): clarify assistant request timing --- packages/client/ui-trajectory/src/client/TrajectoryTable.tsx | 2 +- packages/client/ui-trajectory/tests/table.spec.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index 1296ea24d3..6f37c852c5 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -2528,7 +2528,7 @@ export function TrajectoryTable({ )} {selectedAssistantRequestTarget !== undefined && ( { selectRequest(selectedAssistantRequestTarget, 'timing') }} diff --git a/packages/client/ui-trajectory/tests/table.spec.tsx b/packages/client/ui-trajectory/tests/table.spec.tsx index a80a256634..b9f5e5d7b7 100644 --- a/packages/client/ui-trajectory/tests/table.spec.tsx +++ b/packages/client/ui-trajectory/tests/table.spec.tsx @@ -64,7 +64,7 @@ describe('TrajectoryTable', () => { it('shows assistant timing facts after keyboard selection', () => { render() fireEvent.keyDown(screen.getByRole('row', { name: /ASSISTANT/ }), { key: 'Enter' }) - fireEvent.click(screen.getByRole('button', { name: 'Timing' })) + fireEvent.click(screen.getByRole('button', { name: 'Request Timing' })) expect(screen.getByText('500 ms')).toBeTruthy() expect(screen.getByText('1.00 s')).toBeTruthy() From f3fd8e4cd34c919d42057e77d95e2488ff799954 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:38:11 +0800 Subject: [PATCH 119/129] chore: add Issue management runtime --- .github/ISSUE_TEMPLATE/bug.md | 22 + .github/ISSUE_TEMPLATE/config.yml | 2 + .github/ISSUE_TEMPLATE/feature.md | 20 + .github/ISSUE_TEMPLATE/idea.md | 20 + .github/ISSUE_TEMPLATE/research.md | 21 + .github/ISSUE_TEMPLATE/task.md | 20 + .github/issue-management/config.json | 17 + .github/issue-management/policy.mjs | 545 +++++++++++++++++++++++ .github/issue-management/policy.test.mjs | 240 ++++++++++ .github/pull_request_template.md | 13 + 10 files changed, 920 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug.md create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature.md create mode 100644 .github/ISSUE_TEMPLATE/idea.md create mode 100644 .github/ISSUE_TEMPLATE/research.md create mode 100644 .github/ISSUE_TEMPLATE/task.md create mode 100644 .github/issue-management/config.json create mode 100644 .github/issue-management/policy.mjs create mode 100644 .github/issue-management/policy.test.mjs create mode 100644 .github/pull_request_template.md diff --git a/.github/ISSUE_TEMPLATE/bug.md b/.github/ISSUE_TEMPLATE/bug.md new file mode 100644 index 0000000000..9427dc1d52 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.md @@ -0,0 +1,22 @@ +--- +name: Bug +about: 记录现有预期行为的失效 +title: '' +labels: '' +assignees: '' +type: Bug +--- + + +一句话说明错误结果。 + +
+复现、预期与验收 + +- 复现步骤: +- 实际结果: +- 预期结果: +- 环境: +- 验收条件: + +
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000..8005e32267 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,2 @@ +blank_issues_enabled: false +contact_links: [] diff --git a/.github/ISSUE_TEMPLATE/feature.md b/.github/ISSUE_TEMPLATE/feature.md new file mode 100644 index 0000000000..2c65f5544d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.md @@ -0,0 +1,20 @@ +--- +name: Feature +about: 新增或有意改变可观察行为 +title: '' +labels: '' +assignees: '' +type: Feature +--- + + +一句话说明预期结果。 + +
+验收与细节 + +- 验收条件: +- 用户或模型可见变化: +- 测试证据: + +
diff --git a/.github/ISSUE_TEMPLATE/idea.md b/.github/ISSUE_TEMPLATE/idea.md new file mode 100644 index 0000000000..c8bf80402d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/idea.md @@ -0,0 +1,20 @@ +--- +name: Idea +about: 记录尚未承诺实施、但具有行动可能的想法 +title: '' +labels: '' +assignees: '' +type: Idea +--- + + +一句话说明价值假设。 + +
+价值与细节 + +- 价值假设: +- 需要验证: +- 可能的后续工作: + +
diff --git a/.github/ISSUE_TEMPLATE/research.md b/.github/ISSUE_TEMPLATE/research.md new file mode 100644 index 0000000000..8acfe2cc6e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/research.md @@ -0,0 +1,21 @@ +--- +name: Research +about: 形成结论、证据或决策 +title: '' +labels: '' +assignees: '' +type: Research +--- + + +一句话说明待回答的问题。 + +
+问题与证据标准 + +- 核心问题: +- 证据标准: +- 交付结论: +- 可能的后续工作: + +
diff --git a/.github/ISSUE_TEMPLATE/task.md b/.github/ISSUE_TEMPLATE/task.md new file mode 100644 index 0000000000..376855c27c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/task.md @@ -0,0 +1,20 @@ +--- +name: Task +about: 明确的非 Feature、非 Bug 工作 +title: '' +labels: '' +assignees: '' +type: Task +--- + + +一句话说明要完成的工作。 + +
+验收与细节 + +- 验收条件: +- 交付物: +- 测试证据: + +
diff --git a/.github/issue-management/config.json b/.github/issue-management/config.json new file mode 100644 index 0000000000..41019f0aa2 --- /dev/null +++ b/.github/issue-management/config.json @@ -0,0 +1,17 @@ +{ + "organization": "deepseek-harness", + "repository": "deepseek-harness", + "projectNumber": 1, + "projectTitle": "DSH Issue Management", + "priorityField": "Priority", + "allowUnassignedOwner": true, + "statuses": [ + "Inbox", + "Backlog", + "Ready", + "In progress", + "In review", + "Done", + "No action" + ] +} diff --git a/.github/issue-management/policy.mjs b/.github/issue-management/policy.mjs new file mode 100644 index 0000000000..bc8c881eda --- /dev/null +++ b/.github/issue-management/policy.mjs @@ -0,0 +1,545 @@ +#!/usr/bin/env node + +import fs from 'node:fs' +import process from 'node:process' +import { pathToFileURL } from 'node:url' + +import config from './config.json' with { type: 'json' } + +const API_VERSION = '2026-03-10' +const BODY_LIMIT = 50 +const AUDIT_MARKER = '' +const OWNER_LINE = /^Owner: @([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?)$/ +const TYPES = new Set(['Idea', 'Feature', 'Bug', 'Research', 'Task']) +const PRIORITIES = ['p0', 'p1', 'p2', 'p3'] + +/** + * Return Markdown outside balanced details elements. + * @param {string} body Markdown body. + * @returns {{text: string, balanced: boolean, detailsCount: number, allCollapsed: boolean}} Visible source and details shape. + */ +export function extractOutsideDetails(body) { + const source = body.replace(//g, '') + const tag = /<\/?details\b[^>]*>/gi + let depth = 0 + let cursor = 0 + let balanced = true + let text = '' + let detailsCount = 0 + let allCollapsed = true + + for (const match of source.matchAll(tag)) { + const index = match.index ?? 0 + if (depth === 0) text += source.slice(cursor, index) + if (/^<\//.test(match[0])) { + if (depth === 0) balanced = false + else depth -= 1 + } else { + depth += 1 + detailsCount += 1 + if (/\sopen(?:\s|=|>)/i.test(match[0])) allCollapsed = false + } + cursor = index + match[0].length + } + + if (depth === 0) text += source.slice(cursor) + if (depth !== 0) balanced = false + return { text, balanced, detailsCount, allCollapsed } +} + +/** + * Count Chinese characters and contiguous Latin, numeric, or code tokens. + * @param {string} body Markdown body. + * @returns {{units: number, balanced: boolean, detailsCount: number, allCollapsed: boolean}} Visible unit count and details shape. + */ +export function countVisibleUnits(body) { + const outside = extractOutsideDetails(body) + const visible = outside.text + .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1') + .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') + .replace(/\[([^\]]+)\]\[[^\]]*\]/g, '$1') + .replace(/<((?:https?:\/\/|mailto:)[^>]+)>/gi, '$1') + .replace(/<[^>]+>/g, ' ') + .replace(/&(?:[A-Za-z]+|#\d+|#x[0-9A-Fa-f]+);/g, ' ') + .replace(/[\u0060*~\[\]{}()<>#!|]/g, ' ') + const han = visible.match(/\p{Script=Han}/gu)?.length ?? 0 + const tokens = visible.match(/[\p{Script=Latin}\p{Number}_./:@+-]+/gu)?.length ?? 0 + return { + units: han + tokens, + balanced: outside.balanced, + detailsCount: outside.detailsCount, + allCollapsed: outside.allCollapsed, + } +} + +function firstNonblankLine(body) { + return body + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean) +} + +/** + * Validate body shape and Owner against assignees. + * @param {{body: string, assignees: string[], allowUnassignedOwner?: boolean}} input Body input. + * @returns {string[]} Validation errors. + */ +export function validateBody({ + body, + assignees, + allowUnassignedOwner = config.allowUnassignedOwner ?? false, +}) { + const errors = [] + const count = countVisibleUnits(body) + const owner = firstNonblankLine(body)?.match(OWNER_LINE)?.[1] ?? null + const normalized = [...new Set(assignees.map((login) => login.toLowerCase()))] + + if (!count.balanced) errors.push('details 标签必须成对闭合') + if (count.detailsCount === 0) errors.push('正文必须包含默认收起的
区域') + if (!count.allCollapsed) errors.push('details 必须默认收起,不得设置 open') + if (count.units > BODY_LIMIT) { + errors.push(`正文外露部分为 ${count.units} 单位,超过 50 单位`) + } + if (normalized.length >= 2 && !owner) { + errors.push('多个 Assignees 时首个非空行必须是 Owner: @login') + } else if (normalized.length >= 2 && !normalized.includes(owner.toLowerCase())) { + errors.push('Owner 必须属于 Assignees') + } else if ( + normalized.length < 2 && + owner && + !(normalized.length === 0 && allowUnassignedOwner) + ) { + errors.push('零或一个 Assignee 时不得写 Owner 行') + } + return errors +} + +/** + * Decide whether a PR has entered the human-review enforcement boundary. + * @param {{isDraft: boolean, authorType: string, reviewRequestCount: number, reviewCount: number}} input PR state. + * @returns {boolean} Whether the PR policy is mandatory. + */ +export function requiresPullRequestPolicy({ + isDraft, + authorType, + reviewRequestCount, + reviewCount, +}) { + const automated = authorType === 'Bot' || authorType === 'App' + return !isDraft && !automated && (reviewRequestCount > 0 || reviewCount > 0) +} + +function stripIgnoredMarkdown(body) { + const lines = body.replace(//g, '').split(/\r?\n/) + const kept = [] + let fence = null + for (const line of lines) { + const marker = line.match(/^\s*([\u0060~]{3,})/) + if (marker) { + if (fence === null) fence = marker[1][0] + else if (marker[1][0] === fence) fence = null + continue + } + if (fence === null) kept.push(line) + } + return kept.join('\n').replace(/\u0060[^\u0060]*\u0060/g, ' ') +} + +/** + * Parse same-repository resolving and informational references. + * @param {{body: string, repository: string}} input PR body and repository. + * @returns {{all: number[], resolving: number[], related: number[]}} References. + */ +export function parseReferences({ body, repository }) { + const source = stripIgnoredMarkdown(body) + const expected = repository.toLowerCase() + const all = new Set() + const resolving = new Set() + const reference = + /(?:([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)#|#)(\d+)|https:\/\/github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)\/issues\/(\d+)/gi + const closing = + /\b(?:close(?:s|d)?|fix(?:es|ed)?|resolve(?:s|d)?)\s*:?\s+(?:(?:([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)#|#)(\d+)|https:\/\/github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)\/issues\/(\d+))/gi + + for (const match of source.matchAll(reference)) { + const explicit = (match[1] ?? match[3] ?? '').toLowerCase() + const number = Number(match[2] ?? match[4]) + if (!explicit || explicit === expected) all.add(number) + } + for (const match of source.matchAll(closing)) { + const explicit = (match[1] ?? match[3] ?? '').toLowerCase() + const number = Number(match[2] ?? match[4]) + if (!explicit || explicit === expected) { + all.add(number) + resolving.add(number) + } + } + return { + all: [...all].sort((left, right) => left - right), + resolving: [...resolving].sort((left, right) => left - right), + related: [...all].filter((number) => !resolving.has(number)).sort((a, b) => a - b), + } +} + +/** + * Validate one Issue with its Project status. + * @param {{title: string, body: string, assignees: string[], labels: string[], type: string|null, priority: string|null, status: string|null, state: string, stateReason: string|null}} issue Issue snapshot. + * @returns {string[]} Validation errors. + */ +export function validateIssue(issue) { + const errors = validateBody(issue) + const status = issue.status + + if (!/\p{Script=Han}/u.test(issue.title)) errors.push('Issue 标题必须包含中文') + if ( + /^\s*(?:\[(?:Idea|Feature|Bug|Research|Task|P[0-3]|Inbox|Backlog|Ready|In progress|In review|Done|No action|Owner|area\/[^\]]+)[^\]]*\]|(?:Idea|Feature|Bug|Research|Task|P[0-3]|Inbox|Backlog|Ready|In progress|In review|Done|No action|Owner|area\/[^:: ]+)\s*[::-])/iu.test( + issue.title, + ) + ) { + errors.push('Issue 标题不得带 Type、Priority、Status、area 或 Owner 前缀') + } + if (!TYPES.has(issue.type ?? '')) errors.push('Type 必须是五种原生英文 Type 之一') + if (!status || !config.statuses.includes(status)) errors.push('Issue 必须在 Project 中且具有合法 Status') + if (issue.priority !== null && !PRIORITIES.includes(issue.priority.toLowerCase())) { + errors.push('Priority 必须为空或为 P0–P3') + } + if (status === 'Done' && (issue.state !== 'closed' || issue.stateReason !== 'completed')) { + errors.push('Done 必须对应 Completed 关闭原因') + } + if ( + status === 'No action' && + (issue.state !== 'closed' || issue.stateReason !== 'not_planned') + ) { + errors.push('No action 必须对应 Not planned 关闭原因') + } + if (!['Done', 'No action'].includes(status ?? '') && issue.state !== 'open') { + errors.push(`${status} 必须对应开放 Issue`) + } + return errors +} + +/** + * Validate PR metadata and its referenced Issues. + * @param {{authorType: string, labels: string[], references: ReturnType, issues: Map}} input PR snapshot. + * @returns {string[]} Validation errors. + */ +export function validatePullRequest(input) { + if (!requiresPullRequestPolicy(input)) return [] + const errors = [] + const kinds = input.labels.filter((label) => label.startsWith('kind/')) + const priorities = input.labels.filter((label) => PRIORITIES.includes(label)) + const areas = input.labels.filter((label) => label.startsWith('area/')) + + if (input.references.all.length === 0) errors.push('PR 正文必须引用至少一个同仓库 Issue') + if (kinds.length !== 1) errors.push(`PR 必须恰好有一个 kind/*,当前为 ${kinds.length}`) + if (priorities.length > 1) errors.push(`PR 最多有一个 p0–p3,当前为 ${priorities.length}`) + if (areas.length === 0) errors.push('PR 必须至少有一个 area/*') + for (const number of input.references.all) { + if (!input.issues.has(number)) errors.push(`#${number} 不是同仓库 Issue`) + } + + const resolving = input.references.resolving + .map((number) => [number, input.issues.get(number)]) + .filter((entry) => entry[1]) + if (resolving.length === 0) return errors + + const issuePriorities = resolving + .map(([, issue]) => issue.priority?.toLowerCase()) + .filter((priority) => PRIORITIES.includes(priority)) + if (priorities.length === 0 && issuePriorities.length > 0) { + const highest = issuePriorities.sort( + (left, right) => PRIORITIES.indexOf(left) - PRIORITIES.indexOf(right), + )[0] + errors.push(`PR Priority 应为 ${highest}`) + } else if (priorities.length === 1 && issuePriorities.length !== resolving.length) { + errors.push('有 Priority 的解决型 PR 要求每个被解决 Issue 都设置 Priority') + } else if (priorities.length === 1) { + const highest = issuePriorities.sort( + (left, right) => PRIORITIES.indexOf(left) - PRIORITIES.indexOf(right), + )[0] + if (priorities[0] !== highest) errors.push(`PR Priority 应为 ${highest}`) + } + return errors +} + +function token() { + const value = process.env.GH_TOKEN || process.env.GITHUB_TOKEN + if (!value) throw new Error('GH_TOKEN 或 GITHUB_TOKEN 未设置') + return value +} + +async function api(path, options = {}) { + const response = await fetch(`${process.env.GITHUB_API_URL ?? 'https://api.github.com'}${path}`, { + ...options, + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${token()}`, + 'X-GitHub-Api-Version': API_VERSION, + 'User-Agent': 'dsh-issue-policy', + ...options.headers, + }, + }) + if (options.allow404 && response.status === 404) return null + if (!response.ok) { + const body = await response.text() + throw new Error(`${options.method ?? 'GET'} ${path}: ${response.status} ${body}`) + } + if (response.status === 204) return null + return response.json() +} + +async function graphql(query, variables) { + const result = await api('/graphql', { + method: 'POST', + body: JSON.stringify({ query, variables }), + headers: { 'Content-Type': 'application/json' }, + }) + if (result.errors?.length) throw new Error(result.errors.map((error) => error.message).join('; ')) + return result.data +} + +async function issueSnapshot(number, status = undefined) { + const issue = await api(`/repos/${config.organization}/${config.repository}/issues/${number}`) + if (issue.pull_request) return null + const values = await api( + `/repos/${config.organization}/${config.repository}/issues/${number}/issue-field-values?per_page=100`, + ) + const field = (name) => values.find((value) => value.issue_field_name === name) + return { + number, + nodeId: issue.node_id, + title: issue.title, + body: issue.body ?? '', + assignees: issue.assignees.map((assignee) => assignee.login), + labels: issue.labels.map((label) => label.name), + type: issue.type?.name ?? null, + priority: field(config.priorityField)?.single_select_option?.name ?? null, + status: status === undefined ? await projectStatus(number) : status, + state: issue.state, + stateReason: issue.state_reason ?? null, + } +} + +async function projectContext(number) { + const data = await graphql( + `query($organization: String!, $repository: String!, $number: Int!, $project: Int!) { + organization(login: $organization) { + projectV2(number: $project) { + id + title + fields(first: 50) { + nodes { + ... on ProjectV2SingleSelectField { id name options { id name } } + } + } + } + } + repository(owner: $organization, name: $repository) { + issue(number: $number) { + id + projectItems(first: 20, includeArchived: true) { + nodes { + id + project { id } + fieldValueByName(name: "Status") { + ... on ProjectV2ItemFieldSingleSelectValue { name optionId } + } + } + } + } + } + }`, + { + organization: config.organization, + repository: config.repository, + number, + project: config.projectNumber, + }, + ) + const project = data.organization?.projectV2 + const issue = data.repository?.issue + if (!project || project.title !== config.projectTitle) throw new Error('目标 Project 不存在或标题不匹配') + if (!issue) throw new Error(`#${number} 不存在`) + const statusField = project.fields.nodes.find((field) => field?.name === 'Status') + if (!statusField) throw new Error('Project 缺少 Status 字段') + const item = issue.projectItems.nodes.find((candidate) => candidate.project.id === project.id) + return { project, issue, statusField, item } +} + +async function projectStatus(number) { + const context = await projectContext(number) + return context.item?.fieldValueByName?.name ?? null +} + +async function ensureProjectItem(number) { + const context = await projectContext(number) + if (context.item) return context + const data = await graphql( + `mutation($projectId: ID!, $contentId: ID!) { + addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) { + item { id } + } + }`, + { projectId: context.project.id, contentId: context.issue.id }, + ) + return { + ...context, + item: { id: data.addProjectV2ItemById.item.id, fieldValueByName: null }, + } +} + +async function setStatus(number, status) { + const context = await ensureProjectItem(number) + const option = context.statusField.options.find((candidate) => candidate.name === status) + if (!option) throw new Error(`Status 不存在:${status}`) + if (context.item.fieldValueByName?.name === status) return + await graphql( + `mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId, + itemId: $itemId, + fieldId: $fieldId, + value: {singleSelectOptionId: $optionId} + }) { projectV2Item { id } } + }`, + { + projectId: context.project.id, + itemId: context.item.id, + fieldId: context.statusField.id, + optionId: option.id, + }, + ) +} + +async function upsertAudit(number, errors) { + const comments = await api( + `/repos/${config.organization}/${config.repository}/issues/${number}/comments?per_page=100`, + ) + const existing = comments.find( + (comment) => comment.user?.type === 'Bot' && comment.body?.includes(AUDIT_MARKER), + ) + if (errors.length === 0) { + if (existing) { + await api(`/repos/${config.organization}/${config.repository}/issues/comments/${existing.id}`, { + method: 'DELETE', + }) + } + return + } + const body = `${AUDIT_MARKER}\n⚠️ Issue policy 未通过:\n\n${errors.map((error) => `- ${error}`).join('\n')}` + if (existing) { + if (existing.body === body) return + await api(`/repos/${config.organization}/${config.repository}/issues/comments/${existing.id}`, { + method: 'PATCH', + body: JSON.stringify({ body }), + headers: { 'Content-Type': 'application/json' }, + }) + } else { + await api(`/repos/${config.organization}/${config.repository}/issues/${number}/comments`, { + method: 'POST', + body: JSON.stringify({ body }), + headers: { 'Content-Type': 'application/json' }, + }) + } +} + +async function auditIssue(number, extraErrors = [], status = undefined) { + const issue = await issueSnapshot(number, status) + if (!issue) return [] + const errors = [...extraErrors, ...validateIssue(issue)] + await upsertAudit(number, errors) + return errors +} + +async function pullRequestSnapshot(number) { + const pull = await api(`/repos/${config.organization}/${config.repository}/pulls/${number}`) + const [reviewRequests, reviews] = await Promise.all([ + api(`/repos/${config.organization}/${config.repository}/pulls/${number}/requested_reviewers`), + api(`/repos/${config.organization}/${config.repository}/pulls/${number}/reviews?per_page=100`), + ]) + const references = parseReferences({ + body: pull.body ?? '', + repository: `${config.organization}/${config.repository}`, + }) + const issues = new Map() + for (const issueNumber of references.all) { + const issue = await issueSnapshot(issueNumber, null) + if (issue) issues.set(issueNumber, issue) + } + return { + number, + isDraft: pull.draft, + authorType: pull.user?.type ?? 'User', + reviewRequestCount: reviewRequests.users.length + reviewRequests.teams.length, + reviewCount: reviews.length, + labels: pull.labels.map((label) => label.name), + references, + issues, + } +} + +async function moveResolvingIssues(pull, from, to) { + for (const number of pull.references.resolving) { + const current = await issueSnapshot(number) + if (!current || current.status !== from) continue + await setStatus(number, to) + await auditIssue(number) + } +} + +async function runPullRequestCheck(event) { + const pull = await pullRequestSnapshot(event.pull_request.number) + const errors = validatePullRequest(pull) + if (errors.length > 0) { + for (const error of errors) process.stdout.write(`::error::${error}\n`) + throw new Error(`Issue policy 未通过,共 ${errors.length} 项`) + } + process.stdout.write( + requiresPullRequestPolicy(pull) ? 'Issue policy 通过。\n' : 'PR 尚未进入 Issue policy 强制范围。\n', + ) +} + +async function runLifecycle(eventName, event) { + if (eventName === 'issues') { + const number = event.issue.number + if (event.action === 'opened') await setStatus(number, 'Inbox') + if (event.action === 'closed') { + const target = event.issue.state_reason === 'not_planned' ? 'No action' : 'Done' + await setStatus(number, target) + } + if (event.action === 'reopened') { + await setStatus(number, 'Inbox') + } + await ensureProjectItem(number) + await auditIssue(number) + return + } + + if (eventName === 'pull_request' || eventName === 'pull_request_review') { + const pull = await pullRequestSnapshot(event.pull_request.number) + const errors = validatePullRequest(pull) + if (errors.length > 0) return + await moveResolvingIssues(pull, 'Ready', 'In progress') + if (pull.reviewRequestCount > 0 || pull.reviewCount > 0) { + await moveResolvingIssues(pull, 'In progress', 'In review') + } + } +} + +function readEvent() { + if (!process.env.GITHUB_EVENT_PATH) throw new Error('GITHUB_EVENT_PATH 未设置') + return JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH, 'utf8')) +} + +async function main(argv) { + const [command] = argv + if (command === 'pr') await runPullRequestCheck(readEvent()) + else if (command === 'lifecycle') await runLifecycle(process.env.GITHUB_EVENT_NAME, readEvent()) + else throw new Error('用法:policy.mjs pr|lifecycle') +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main(process.argv.slice(2)).catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`) + process.exitCode = 1 + }) +} diff --git a/.github/issue-management/policy.test.mjs b/.github/issue-management/policy.test.mjs new file mode 100644 index 0000000000..890247db29 --- /dev/null +++ b/.github/issue-management/policy.test.mjs @@ -0,0 +1,240 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + countVisibleUnits, + parseReferences, + requiresPullRequestPolicy, + validateBody, + validateIssue, + validatePullRequest, +} from './policy.mjs' + +const withDetails = (summary) => + `${summary}\n\n
验收与细节待补充。
` + +const legalIssue = { + title: '完成议题管理校验', + body: withDetails('完成议题管理校验。'), + assignees: [], + labels: [], + type: 'Idea', + priority: null, + status: 'In review', + state: 'open', + stateReason: null, +} + +test('counts only text outside details', () => { + assert.deepEqual(countVisibleUnits('支持 GitHub Project。
隐藏文字
'), { + units: 4, + balanced: true, + detailsCount: 1, + allCollapsed: true, + }) +}) + +test('requires a balanced default-collapsed details region', () => { + assert.deepEqual(validateBody({ body: '完成工作。', assignees: [] }), [ + '正文必须包含默认收起的
区域', + ]) + assert.deepEqual( + validateBody({ + body: '完成工作。\n\n
细节待补充。
', + assignees: [], + }), + ['details 必须默认收起,不得设置 open'], + ) + assert.deepEqual( + validateBody({ body: '完成工作。\n\n
细节', assignees: [] }), + ['details 标签必须成对闭合'], + ) +}) + +test('requires Owner for multiple assignees', () => { + assert.deepEqual( + validateBody({ + body: withDetails('完成工作。'), + assignees: ['tianyicui', 'tianyicui-bot'], + }), + ['多个 Assignees 时首个非空行必须是 Owner: @login'], + ) +}) + +test('accepts an intended Owner while assignment permission is pending', () => { + assert.deepEqual( + validateBody({ + body: withDetails('Owner: @octocat\n\n完成工作。'), + assignees: [], + }), + [], + ) + assert.deepEqual( + validateBody({ + body: withDetails('Owner: @octocat\n\n完成工作。'), + assignees: ['hubot'], + }), + ['零或一个 Assignee 时不得写 Owner 行'], + ) +}) + +test('allows optional metadata in every open Status', () => { + assert.deepEqual(validateIssue(legalIssue), []) + for (const status of ['Inbox', 'Backlog', 'Ready', 'In progress', 'In review']) { + assert.deepEqual(validateIssue({ ...legalIssue, status }), []) + } +}) + +test('rejects metadata prefixes in an Issue title', () => { + const errors = validateIssue({ ...legalIssue, title: '[Bug] 修复恢复错误' }) + assert.ok(errors.includes('Issue 标题不得带 Type、Priority、Status、area 或 Owner 前缀')) +}) + +test('keeps terminal Status aligned with the native close reason', () => { + assert.deepEqual( + validateIssue({ ...legalIssue, status: 'Done', state: 'closed', stateReason: 'completed' }), + [], + ) + assert.deepEqual( + validateIssue({ + ...legalIssue, + status: 'No action', + state: 'closed', + stateReason: 'not_planned', + }), + [], + ) + assert.ok(validateIssue({ ...legalIssue, status: 'Done' }).includes('Done 必须对应 Completed 关闭原因')) +}) + +test('separates resolving and informational references', () => { + assert.deepEqual( + parseReferences({ + body: 'Fixes #12\nRelated to #4\nRefs deepseekharness/dsh-test#7', + repository: 'deepseekharness/dsh-test', + }), + { all: [4, 7, 12], resolving: [12], related: [4, 7] }, + ) +}) + +test('allows informational references without cross-object constraints', () => { + const errors = validatePullRequest({ + isDraft: false, + authorType: 'User', + reviewRequestCount: 1, + reviewCount: 0, + labels: ['kind/cleanup', 'area/infra'], + references: { all: [4], resolving: [], related: [4] }, + issues: new Map([[4, { type: 'Bug', priority: 'P0', labels: ['area/web'] }]]), + }) + assert.deepEqual(errors, []) +}) + +test('enforces highest resolving Priority without Type or area synchronization', () => { + const pull = { + isDraft: false, + authorType: 'User', + reviewRequestCount: 0, + reviewCount: 1, + labels: ['kind/cleanup', 'p0', 'area/web'], + references: { all: [2, 3], resolving: [2, 3], related: [] }, + issues: new Map([ + [2, { type: 'Feature', priority: 'P2', labels: ['area/web'] }], + [3, { type: 'Bug', priority: 'P0', labels: ['area/session'] }], + ]), + } + assert.deepEqual(validatePullRequest(pull), []) + assert.ok( + validatePullRequest({ ...pull, labels: ['kind/cleanup', 'p2', 'area/web'] }).includes( + 'PR Priority 应为 p0', + ), + ) +}) + +test('requires policy only after a human PR enters review', () => { + assert.equal( + requiresPullRequestPolicy({ + isDraft: false, + authorType: 'User', + reviewRequestCount: 1, + reviewCount: 0, + }), + true, + ) + assert.equal( + requiresPullRequestPolicy({ + isDraft: false, + authorType: 'User', + reviewRequestCount: 0, + reviewCount: 0, + }), + false, + ) +}) + +test('exempts Draft, Bot, and App PRs', () => { + const invalid = { + isDraft: false, + labels: [], + references: { all: [], resolving: [], related: [] }, + issues: new Map(), + reviewRequestCount: 1, + reviewCount: 0, + } + assert.deepEqual(validatePullRequest({ ...invalid, authorType: 'Bot' }), []) + assert.deepEqual(validatePullRequest({ ...invalid, authorType: 'App' }), []) + assert.deepEqual(validatePullRequest({ ...invalid, authorType: 'User', isDraft: true }), []) + assert.ok(validatePullRequest({ ...invalid, authorType: 'User' }).length > 0) +}) + +test('requires repository PR labels in the enforcement scope', () => { + const errors = validatePullRequest({ + isDraft: false, + authorType: 'User', + reviewRequestCount: 1, + reviewCount: 0, + labels: [], + references: { all: [2], resolving: [], related: [2] }, + issues: new Map([[2, { priority: null }]]), + }) + assert.ok(errors.includes('PR 必须恰好有一个 kind/*,当前为 0')) + assert.ok(errors.includes('PR 必须至少有一个 area/*')) +}) + +test('accepts repository-extensible kind labels', () => { + assert.deepEqual( + validatePullRequest({ + isDraft: false, + authorType: 'User', + reviewRequestCount: 1, + reviewCount: 0, + labels: ['kind/dependency', 'area/infra'], + references: { all: [2], resolving: [], related: [2] }, + issues: new Map([[2, { priority: null }]]), + }), + [], + ) +}) + +test('allows missing Priority only when resolving Issues are also unprioritized', () => { + const pull = { + isDraft: false, + authorType: 'User', + reviewRequestCount: 1, + reviewCount: 0, + labels: ['kind/feature', 'area/web'], + references: { all: [2], resolving: [2], related: [] }, + issues: new Map([[2, { priority: null }]]), + } + assert.deepEqual(validatePullRequest(pull), []) + assert.ok( + validatePullRequest({ ...pull, issues: new Map([[2, { priority: 'P2' }]]) }).includes( + 'PR Priority 应为 p2', + ), + ) + assert.ok( + validatePullRequest({ ...pull, labels: [...pull.labels, 'p2'] }).includes( + '有 Priority 的解决型 PR 要求每个被解决 Issue 都设置 Priority', + ), + ) +}) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000000..e960016ae8 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,13 @@ + + + + +关联 Issue: + +
+变更与验证 + +- 变更: +- 验证: + +
From 4c8b47f3c6252b83f190218ef97b1dfd29d556c5 Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 3 Aug 2026 17:00:33 +0800 Subject: [PATCH 120/129] test(web): refresh markdown image golden --- apps/web/tests/snapshots/markdown-images/ui.expected.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/tests/snapshots/markdown-images/ui.expected.md b/apps/web/tests/snapshots/markdown-images/ui.expected.md index 76e01397c2..58b72e0d65 100644 --- a/apps/web/tests/snapshots/markdown-images/ui.expected.md +++ b/apps/web/tests/snapshots/markdown-images/ui.expected.md @@ -7,8 +7,9 @@ - text: Show the Markdown image policy. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - heading "Markdown images" [level=2] - paragraph: - img "Remote test image" From a3d897359f8f5b90a3695f34d7630534fb998d65 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:11:36 +0800 Subject: [PATCH 121/129] test(web): refresh Markdown image golden for the fork-eligibility gate --- apps/web/tests/snapshots/markdown-images/ui.expected.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/tests/snapshots/markdown-images/ui.expected.md b/apps/web/tests/snapshots/markdown-images/ui.expected.md index 76e01397c2..58b72e0d65 100644 --- a/apps/web/tests/snapshots/markdown-images/ui.expected.md +++ b/apps/web/tests/snapshots/markdown-images/ui.expected.md @@ -7,8 +7,9 @@ - text: Show the Markdown image policy. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - heading "Markdown images" [level=2] - paragraph: - img "Remote test image" From 553fb9a119b0518c144266a357aecadb79796b59 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:17:41 +0800 Subject: [PATCH 122/129] fix: test --- apps/web/tests/snapshots/markdown-images/ui.expected.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/tests/snapshots/markdown-images/ui.expected.md b/apps/web/tests/snapshots/markdown-images/ui.expected.md index 76e01397c2..58b72e0d65 100644 --- a/apps/web/tests/snapshots/markdown-images/ui.expected.md +++ b/apps/web/tests/snapshots/markdown-images/ui.expected.md @@ -7,8 +7,9 @@ - text: Show the Markdown image policy. {{clock}} - button "Copy": - img -- button "Branch into a new conversation": +- button "Branch into a new conversation" [disabled]: - img +- text: Available only on the last message of a completed turn - heading "Markdown images" [level=2] - paragraph: - img "Remote test image" From 3b58ed65b7110f073f6f00c97a830a6bd1c1bc7e Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 3 Aug 2026 17:15:04 +0800 Subject: [PATCH 123/129] feat(tui): wrap and page question dialogs --- ...24-tui-question-dialog-multiline.i18n.yaml | 6 + ...026-07-24-tui-question-dialog-multiline.md | 41 ++ ...-07-24-tui-question-dialog-multiline.zh.md | 41 ++ docs/cordis-catalog/services.md | 2 +- packages/ui/tui/README.i18n.yaml | 4 +- packages/ui/tui/README.md | 6 +- packages/ui/tui/README.zh.md | 6 +- packages/ui/tui/src/chat/questions.ts | 10 +- packages/ui/tui/src/components/dialogs.ts | 395 ++++++++++++-- .../ui/tui/src/extension/overlay-manager.ts | 24 +- packages/ui/tui/src/index.ts | 62 ++- .../question-dialog-detail-paged.expected.txt | 39 ++ .../question-dialog-paged.expected.txt | 39 ++ ...question-dialog-single-option.expected.txt | 30 +- .../question-dialog-validation.expected.txt | 72 +-- .../snapshots/question-dialog.expected.txt | 66 +-- .../snapshots/untrusted-controls.expected.txt | 39 +- packages/ui/tui/tests/tui.snapshot.ts | 16 +- packages/ui/tui/tests/tui.spec.ts | 511 ++++++++++++++++++ 19 files changed, 1231 insertions(+), 178 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.md create mode 100644 .agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.zh.md create mode 100644 packages/ui/tui/tests/snapshots/question-dialog-detail-paged.expected.txt create mode 100644 packages/ui/tui/tests/snapshots/question-dialog-paged.expected.txt diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.i18n.yaml new file mode 100644 index 0000000000..8cc4d64238 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.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-07-24-tui-question-dialog-multiline.md +2026-07-24-tui-question-dialog-multiline.md: fc6e9bceeee4abc46a69a23124d09fcd4f3c7224 +2026-07-24-tui-question-dialog-multiline.zh.md: a56821921bad1016009687bde63eae5f4d893cdf diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.md b/.agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.md new file mode 100644 index 0000000000..fc6e9bceee --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.md @@ -0,0 +1,41 @@ +# Agent Note: TUI QuestionDialog renders options across multiple lines + +Status: implemented + +English | [中文](2026-07-24-tui-question-dialog-multiline.zh.md) + +## Problem + +`ctx.userInteraction.ask()` must keep question text, supporting `detail`, option labels, descriptions, validation, and controls readable inside configured width and height bounds. The question panel also belongs directly above the editor: placing it at the terminal edge separates the pending decision from both the transcript that prompted it and the input that follows it. + +## Decision + +The TUI renders a pending question as an inline modal between the transcript/status area and the editor while retaining the shared FIFO with model and plugin overlays: + +- `InlineModalComponent` applies `questionDialogWidth` and `questionDialogMaxHeight` inside the normal component flow. The effective question height is additionally clamped to the current viewport after reserving the editor, so the editor remains below the question during resize. +- `renderOptionBlock` wraps each label beneath its cursor/number prefix and renders the muted description on separately wrapped, equally indented lines. The progress header, question, custom-answer hint, validation text, and final rows are width-bounded as well; the final ellipsis clamp is only a safety boundary for prefixes or other indivisible content. The explicit `↑ N lines hidden` fallback is reserved for a viewport below the configured minimum, where the whole semantic layout cannot fit. +- When question text or `detail` exceeds the header allocation, the header becomes a paged line viewport with its own `… lines A-B/N • PgUp/PgDn` status row. Page Up and Page Down traverse both line viewports: forward navigation exhausts the header/detail pages before entering oversized selected-option pages, and backward navigation reverses that order. This keeps plan-review detail reachable rather than leaving it behind the height clamp. +- The option-line budget subtracts padding, header, position, and footer rows before `windowBlocks` runs. The window obeys both `maxQuestionOptions` and the remaining row budget, keeps the selected option visible, and renders omitted options as `↑ N more` / `↓ N more` markers. If fixed chrome would leave fewer than four option rows, the compact header becomes the line pager so selected content, paging status, and both option markers still fit. +- When one selected block exceeds its allocation, it becomes a line viewport with a `lines A-B/N • PgUp/PgDn` status row. Page Up and Page Down expose every wrapped line without allowing the block to hide the option markers, validation, or controls. + +Package tests pin count and height bounds, header and selected-block paging order, narrow-width wrapping, selection behavior, and placement relative to retained editor input. Semantic TUI snapshots pin the assembled terminal layout, header/detail and selected-option page transitions, and validation state. + +## Alternatives considered + +**Ellipsis-only horizontal truncation.** Keeping one option per row would signal lost text without making the description readable and would not address vertical bounds. The implementation wraps readable content and retains an ellipsis only as a final safety boundary. + +**Wrap the combined label and description.** A composite row couples their widths, so either side can starve the other. Separate lines keep both widths predictable. + +**Keep the question as a bottom-edge overlay.** A terminal-edge anchor can place the panel after the editor or cover lower chrome, depending on transcript and viewport height. The inline modal preserves ordering while the modal manager retains focus and FIFO ownership. + +**Push the bounds into pi-tui.** Generic overlay slicing cannot identify option boundaries, selected content, controls, or the inline editor relationship. The owning dialog therefore applies semantic count, row, and paging rules. + +**Use only the option-count cap.** `maxQuestionOptions` remains a public count bound, but it cannot contain wrapped blocks by itself. The dialog enforces the count and row bounds together. + +## Consequences + +- Descriptions consume additional rows, so fewer options can be visible than `maxQuestionOptions`; markers state the omitted option counts. +- Long question text and plan-review detail remain reachable inside a height-bounded panel, at the cost of sharing Page Up and Page Down with selected-option paging. +- An oversized selected block reserves one status row and requires Page Up or Page Down to read beyond the current line page. +- The inline question can displace older transcript rows from a short viewport. Below the configured minimum height, the final fallback can collapse upper rows behind an explicit hidden-line marker so the input controls and editor remain available. +- The model-facing schema, selected labels, abort/cancel behavior, and ACP elicitation path are unchanged. diff --git a/.agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.zh.md b/.agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.zh.md new file mode 100644 index 0000000000..a56821921b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-tui-question-dialog-multiline.zh.md @@ -0,0 +1,41 @@ +# Agent Note: TUI QuestionDialog 以多行方式渲染选项 + +Status: implemented + +[English](2026-07-24-tui-question-dialog-multiline.md) | 中文 + +## 问题 + +`ctx.userInteraction.ask()` 必须确保问题正文、`detail` 补充内容、选项标签、描述、校验信息和控件在已配置的宽度与高度边界内均可读。问题面板也直接位于编辑器上方:若将其置于终端边缘,待处理决策就会同时脱离触发该决策的 transcript(文本记录)和后续输入。 + +## 决策 + +TUI 将待处理问题渲染为位于 transcript/状态区域与编辑器之间的内联模态框,同时仍与模型浮层和插件浮层共享 FIFO: + +- `InlineModalComponent` 在正常组件流内应用 `questionDialogWidth` 和 `questionDialogMaxHeight`。系统在为编辑器预留空间后,还会根据当前视口限制问题的实际高度,因此调整窗口大小时,编辑器仍位于问题下方。 +- `renderOptionBlock` 将每个标签换行到光标/编号前缀下方,并在另行换行且缩进相同的行上渲染弱化的描述。进度标题、问题、自定义答案提示、校验文本和末尾行也受宽度边界约束;最终的省略号截断仅作为前缀或其他不可拆分内容的安全边界。明确的 `↑ N lines hidden` 回退仅用于低于已配置最小值、无法容纳完整语义布局的视口。 +- 当问题正文或 `detail` 超出头部分配的空间时,头部会成为带有独立 `… lines A-B/N • PgUp/PgDn` 状态行的分页行视口。Page Up 和 Page Down 会遍历这两个行视口:向前导航先翻完问题正文/`detail` 页面,再进入超大选中选项页面;向后导航则采用相反顺序。这样可确保计划评审的 `detail` 内容始终可达,而不会被高度边界挡住。 +- 在 `windowBlocks` 运行前,选项行预算会扣除内边距、标题行、位置行和页脚行。窗口同时遵守 `maxQuestionOptions` 和剩余行预算,保持选中项可见,并将省略的选项渲染为 `↑ N more`/`↓ N more` 标记。若固定界面元素会使选项行少于四行,紧凑头部会转为行分页器,从而容纳选中内容、分页状态和上下两个选项标记。 +- 当一个选中块超出分配空间时,它会成为带有 `lines A-B/N • PgUp/PgDn` 状态行的行视口。Page Up 和 Page Down 可展示每一行已换行内容,同时防止该块遮住选项标记、校验信息或控件。 + +包(package)测试固定数量和高度边界、头部与选中块的分页顺序、窄宽度换行、选择行为,以及问题相对于保留的编辑器输入的位置。语义 TUI 快照固定组装后的终端布局、头部/详情与选中选项的分页转换,以及校验状态。 + +## 备选方案 + +**仅用省略号进行横向截断。** 保持每个选项占一行,只能提示文本有所丢失,无法使描述变得可读,也无法处理纵向边界。该实现会对可读内容换行,仅将省略号保留为最终安全边界。 + +**将标签与描述合并后换行。** 组合行会将两者的宽度耦合在一起,任一方都可能挤占另一方的空间。分行渲染可使二者的宽度保持可预测。 + +**将问题保留为终端底边浮层。** 根据 transcript 和视口高度,锚定在终端边缘的面板可能出现在编辑器之后,也可能遮盖下方界面元素。内联模态框可保留顺序,同时由模态管理器继续负责焦点和 FIFO 所有权。 + +**将边界处理下推至 pi-tui。** 通用浮层切片无法识别选项边界、选中内容、控件或内联编辑器关系。因此,负责该语义的对话框会应用数量、行数和分页规则。 + +**仅使用选项数量上限。** `maxQuestionOptions` 仍是公开的数量边界,但仅靠它无法容纳已换行的块。对话框会同时执行数量边界和行数边界。 + +## 后果 + +- 描述会占用额外行,因此可见选项数可能少于 `maxQuestionOptions`;标记会说明省略的选项数量。 +- 较长的问题正文和计划评审 `detail` 在受高度约束的面板内仍然可达,代价是 Page Up 和 Page Down 需要与选中选项分页共用。 +- 超出空间的选中块会预留一行状态信息;若要阅读当前页面之外的行,必须使用 Page Up 或 Page Down。 +- 在较矮的视口内,内联问题可能将较早的 transcript 行挤出可见区域。低于已配置最小高度时,最终回退可能将上部行折叠到明确的隐藏行标记之后,从而让输入控件和编辑器仍然可用。 +- 面向模型的 schema、选中的标签、中止/取消行为,以及 ACP(Agent Client Protocol)的 elicitation 路径均保持不变。 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 8e76c1c8de..b518f37876 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2451,7 +2451,7 @@ The concrete provider retains pi-tui, focus, and terminal lifecycle state. Plugi abstract openOverlay(request: TuiOverlayRequest): TuiOverlaySession ``` -Source: [`packages/ui/tui/src/index.ts:244`](../../packages/ui/tui/src/index.ts) +Source: [`packages/ui/tui/src/index.ts:245`](../../packages/ui/tui/src/index.ts) ## `ctx.typert` — `TypertRegistry` diff --git a/packages/ui/tui/README.i18n.yaml b/packages/ui/tui/README.i18n.yaml index 246e459e57..a2487c1b20 100644 --- a/packages/ui/tui/README.i18n.yaml +++ b/packages/ui/tui/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/ui/tui/README.md -README.md: 60e42a64305931a6bf93a470f851f662be33e2bb -README.zh.md: fc12b75a1e320103a1d4cc70cc00adfeb639a3d9 +README.md: e441cf949d15ea76f93efc89f970aa2096e83153 +README.zh.md: cc98ea4405ff57b56059e283ecf1b9c8d4d95b91 diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index 60e42a6430..e441cf949d 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -12,7 +12,7 @@ This package owns interactive terminal presentation and input only. It injects ` After terminal startup succeeds, the package provides the terminal-local `ctx.tui` extension service. A plugin that injects it can call `openOverlay()` with a component factory and constrained layout options; the host exposes the viewport, semantic theme (including terminal-safe DeepSeek `brand` treatment), display-text escaping, redraw, close, and a lifetime signal, but not the pi-tui tree, terminal, focus controller, or overlay handle. Plugin overlays, the model selector, and user questions share one FIFO modal queue. Each request is an effect of the calling plugin fiber, so unload removes queued work or closes visible work before cleanup settles; terminal shutdown unloads dependents before stopping pi-tui. Overlay state is not logged or replayed. Component code is trusted and may render ANSI styling, but must pass untrusted text through `host.display()`. The [interactive-extension Agent Note](../../../.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md) owns the boundary and rejected alternatives. -The TUI rebuilds resumed history from the append-origin session events, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the standing `todo/write` plan above the editor (cleared on the next `turn/start`), and presents `ctx.userInteraction` questions in a wide bottom-left keyboard panel with progress, numbered options, and aligned descriptions. The latest logged session title becomes the header subtitle, with `welcome` before a title exists, and the terminal window title becomes ``. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Its idle view compares token-meter pressure with `ctx.llm.resolveModelInfo()` context for the current route, displays `context unknown` when the adapter has no capacity metadata, and also shows tool-card mode plus the current model and any explicitly selected reasoning effort; while the agent runs, an elapsed working indicator and `esc interrupt` replace that summary. A surface replacement never rewrites the rendered transcript: the conversation it shadows stays readable, and a landed compaction checkpoint adds one dim `… earlier context was compacted …` marker at its log position, so the terminal reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies — a pruned tool result, a regenerated assistant message — render nothing. +The TUI rebuilds resumed history from the append-origin session events, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the standing `todo/write` plan above the editor (cleared on the next `turn/start`), and presents `ctx.userInteraction` questions inline between the transcript/status area and the editor. The question panel shows progress, numbered options, wrapped labels, and separately indented descriptions; it obeys both `maxQuestionOptions` and `questionDialogMaxHeight`, marks hidden options with `↑ N more` / `↓ N more`, and uses Page Up / Page Down to page long question/detail content before an individually oversized selected block while keeping the editor visible. The latest logged session title becomes the header subtitle, with `welcome` before a title exists, and the terminal window title becomes ``. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Its idle view compares token-meter pressure with `ctx.llm.resolveModelInfo()` context for the current route, displays `context unknown` when the adapter has no capacity metadata, and also shows tool-card mode plus the current model and any explicitly selected reasoning effort; while the agent runs, an elapsed working indicator and `esc interrupt` replace that summary. A surface replacement never rewrites the rendered transcript: the conversation it shadows stays readable, and a landed compaction checkpoint adds one dim `… earlier context was compacted …` marker at its log position, so the terminal reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies — a pruned tool result, a regenerated assistant message — render nothing. An embedding may provide `TuiRuntime.formatCwd` when its logical workspace label differs from the session's host directory. The override changes only the footer label; tools continue to use the session `cwd`. @@ -51,11 +51,11 @@ A launcher can seed a fresh session's first turn by providing `INITIAL_SKILL_KEY | `showReasoning` | `true` | Render reasoning blocks | | `maxToolOutputLines` | `6` | Output lines retained across a collapsed tool card's head/tail preview | | `maxDiffEditLength` | `1000` | Maximum added and removed lines explored for an exact diff before whole-side fallback | -| `maxQuestionOptions` | `8` | Visible options in a question panel | +| `maxQuestionOptions` | `8` | Maximum option blocks visible at once; the row bound may reduce this further | | `maxModelOptions` | `8` | Visible models in the model selector | | `maxResumeOptions` | `8` | Visible sessions in the resume selector | | `questionDialogWidth` | `200` | Question-panel width in columns, clamped to the terminal | -| `questionDialogMaxHeight` | `20` | Question-panel maximum rows | +| `questionDialogMaxHeight` | `20` | Maximum question-panel rows, further bounded to retain the editor | | `modelDialogWidth` | `76` | Model-selector width in columns | | `modelDialogMaxHeight` | `20` | Model-selector maximum rows | | `detailsDialogWidth` | `72` | Transcript-details selector width in columns | diff --git a/packages/ui/tui/README.zh.md b/packages/ui/tui/README.zh.md index fc12b75a1e..cc98ea4405 100644 --- a/packages/ui/tui/README.zh.md +++ b/packages/ui/tui/README.zh.md @@ -12,7 +12,7 @@ DeepSeek Harness agent(智能体)的交互式终端入口,基于 [`@earend 终端成功启动后,本包会提供终端本地的 `ctx.tui` 扩展服务。注入该服务的插件可以使用组件工厂和受限布局选项调用 `openOverlay()`;宿主会公开 viewport、语义化主题(包括终端安全的 DeepSeek `brand` 样式)、显示文本转义、重绘、关闭和生命周期信号,但不公开 pi-tui 树、终端、焦点控制器或 overlay 句柄。插件 overlay、模型选择器和用户问题共用一个 FIFO 模态队列。每个请求都是调用方插件 fiber 的 effect,因此卸载会移除排队工作,或在清理结算前关闭可见工作;终端关闭会先卸载依赖项,再停止 pi-tui。Overlay 状态不会记录或回放。组件代码受信任,可以渲染 ANSI 样式,但必须通过 `host.display()` 处理不受信任文本。[交互式扩展 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md)持有该边界和未采用的替代方案。 -TUI 从追加来源的会话事件重建已恢复历史,渲染 Markdown 响应与 reasoning,将每个工具的 `presentCall` / `presentResult` 意图应用到终端、diff 或通用卡片,把站立的 `todo/write` 计划保留在编辑器上方(下一个 `turn/start` 时清空),并在左下方宽键盘面板中展示 `ctx.userInteraction` 问题,包含进度、编号选项和对齐说明。最新记录的会话标题成为 header 副标题;标题不存在时使用 `welcome`,终端窗口标题则变为 ``。持久 `llm/retry` 事件会撤回失败步骤的实时 chunk,并在 transcript(文本记录)中渲染计划重试次数、延迟和失败;成功、耗尽与取消随后通过普通会话事件结算。Footer 会对每个已记录模型步骤的用量只计一次,包括失败尝试;对于没有用量 chunk 的日志,以已提交消息的用量回退。其空闲视图会将 token-meter 压力与 `ctx.llm.resolveModelInfo()` 为当前路由返回的上下文容量进行比较;适配器没有容量元数据时显示 `context unknown`,并显示工具卡片模式、当前模型,以及任何显式选择的推理强度。Agent 运行时,这些摘要会替换为已经过工作时间指示器和 `esc interrupt`。表层替换从不重写已渲染的 transcript:被它遮蔽的对话仍可阅读,而已落地的压缩(compaction)检查点会在其日志位置添加一行暗色 `… earlier context was compacted …` 标记,因此终端报告的是模型从何处起不再看到那段历史,而不是把它抹掉。仅供模型使用的替换副本——被裁剪的工具结果、重新生成的 assistant 消息——不渲染任何内容。 +TUI 从追加来源的会话事件重建已恢复历史,渲染 Markdown 响应与 reasoning,将每个工具的 `presentCall` / `presentResult` 意图应用到终端、diff 或通用卡片,把站立的 `todo/write` 计划保留在编辑器上方(下一个 `turn/start` 时清空),并在 transcript/状态区域与编辑器之间内联展示 `ctx.userInteraction` 问题。问题面板会显示进度、编号选项、换行标签和另行缩进的描述;它同时遵守 `maxQuestionOptions` 和 `questionDialogMaxHeight`,用 `↑ N more`/`↓ N more` 标记隐藏选项,并在保持编辑器可见的同时,通过 Page Up 和 Page Down 先分页浏览过长的问题/详情内容,再分页浏览单个超大的选中块。最新记录的会话标题成为 header 副标题;标题不存在时使用 `welcome`,终端窗口标题则变为 ``。持久 `llm/retry` 事件会撤回失败步骤的实时 chunk,并在 transcript(文本记录)中渲染计划重试次数、延迟和失败;成功、耗尽与取消随后通过普通会话事件结算。Footer 会对每个已记录模型步骤的用量只计一次,包括失败尝试;对于没有用量 chunk 的日志,以已提交消息的用量回退。其空闲视图会将 token-meter 压力与 `ctx.llm.resolveModelInfo()` 为当前路由返回的上下文容量进行比较;适配器没有容量元数据时显示 `context unknown`,并显示工具卡片模式、当前模型,以及任何显式选择的推理强度。Agent 运行时,这些摘要会替换为已经过工作时间指示器和 `esc interrupt`。表层替换从不重写已渲染的 transcript:被它遮蔽的对话仍可阅读,而已落地的压缩(compaction)检查点会在其日志位置添加一行暗色 `… earlier context was compacted …` 标记,因此终端报告的是模型从何处起不再看到那段历史,而不是把它抹掉。仅供模型使用的替换副本——被裁剪的工具结果、重新生成的 assistant 消息——不渲染任何内容。 如果逻辑工作区标签与会话宿主目录不同,嵌入方可以提供 `TuiRuntime.formatCwd`。该覆盖只改变 footer 标签;工具仍使用会话 `cwd`。 @@ -51,11 +51,11 @@ Footer 将会话报告的用量汇总为 `↑`;任 | `showReasoning` | `true` | 渲染 reasoning 块 | | `maxToolOutputLines` | `6` | 折叠工具卡片的头尾预览所保留的输出行数 | | `maxDiffEditLength` | `1000` | 回退到整侧展示前,精确 diff 最多探索的新增与删除行总数 | -| `maxQuestionOptions` | `8` | 问题面板中可见的选项数 | +| `maxQuestionOptions` | `8` | 一次最多可见的选项块数;行数边界可能进一步减少可见数量 | | `maxModelOptions` | `8` | 模型选择器中可见的模型数 | | `maxResumeOptions` | `8` | 恢复选择器中可见的会话数 | | `questionDialogWidth` | `200` | 问题面板宽度(列数),以终端宽度为上限 | -| `questionDialogMaxHeight` | `20` | 问题面板最大行数 | +| `questionDialogMaxHeight` | `20` | 问题面板最大行数,会进一步受限以保留编辑器 | | `modelDialogWidth` | `76` | 模型选择器宽度(列数) | | `modelDialogMaxHeight` | `20` | 模型选择器最大行数 | | `detailsDialogWidth` | `72` | transcript 细节选择器宽度(列数) | diff --git a/packages/ui/tui/src/chat/questions.ts b/packages/ui/tui/src/chat/questions.ts index e5f2c8b806..5d96282860 100644 --- a/packages/ui/tui/src/chat/questions.ts +++ b/packages/ui/tui/src/chat/questions.ts @@ -29,7 +29,10 @@ interface PendingQuestion { } /** Collaborators the question queue needs from the chat channel. */ -export type QuestionQueueDeps = ChatChannelDeps +export interface QuestionQueueDeps extends ChatChannelDeps { + /** Current row budget after reserving the editor. */ + questionMaxHeight(): number +} /** Ask-user-question controller for one chat channel. */ export interface QuestionQueue { @@ -85,6 +88,7 @@ export function createQuestionQueue(deps: QuestionQueueDeps): QuestionQueue { pending.request.questions.length, pending.request.questions.length - pending.answers.length, resolved.maxQuestionOptions, + () => deps.questionMaxHeight(), palette, (selection) => { pending.overlay = undefined @@ -102,10 +106,8 @@ export function createQuestionQueue(deps: QuestionQueueDeps): QuestionQueue { options: { width: resolved.questionDialogWidth, maxHeight: resolved.questionDialogMaxHeight, - anchor: 'bottom-left', - margin: { bottom: 1 }, }, - }) + }, 'inline') pending.overlay = session void session.closed.then((result) => { if (pending.overlay !== session) return diff --git a/packages/ui/tui/src/components/dialogs.ts b/packages/ui/tui/src/components/dialogs.ts index 946055f1d8..a92fdae87a 100644 --- a/packages/ui/tui/src/components/dialogs.ts +++ b/packages/ui/tui/src/components/dialogs.ts @@ -822,10 +822,18 @@ export class ResumePicker implements Component, Focusable { } } -/** Bottom-anchored dialog for one user question with option or custom-answer modes. */ +interface SelectedBlockPage { + offset: number + size: number + maxOffset: number +} + +/** Inline dialog for one user question with option or custom-answer modes. */ export class QuestionDialog implements Component, Focusable { private selectedIndex = 0 private selected = new Set() + private headerPage: SelectedBlockPage = { offset: 0, size: 1, maxOffset: 0 } + private selectedBlockPage: SelectedBlockPage = { offset: 0, size: 1, maxOffset: 0 } private mode: 'options' | 'custom' private error = '' private readonly input = new Input() @@ -838,6 +846,7 @@ export class QuestionDialog implements Component, Focusable { private readonly total: number, private readonly unanswered: number, private readonly maxVisible: number, + private readonly maxHeight: () => number, private readonly palette: Palette, private readonly done: (selection: QuestionSelection) => void, private readonly cancel: () => void, @@ -861,6 +870,14 @@ export class QuestionDialog implements Component, Focusable { handleInput(data: string): void { this.invalidate() + if (matchesKey(data, Key.pageUp)) { + this.pageBackward() + return + } + if (matchesKey(data, Key.pageDown)) { + this.pageForward() + return + } if (this.mode === 'custom') { this.input.focused = this.focused this.input.handleInput(data) @@ -868,8 +885,10 @@ export class QuestionDialog implements Component, Focusable { } const options = this.options if (matchesKey(data, Key.up)) { + this.selectedBlockPage = { offset: 0, size: 1, maxOffset: 0 } this.selectedIndex = this.selectedIndex === 0 ? options.length - 1 : this.selectedIndex - 1 } else if (matchesKey(data, Key.down)) { + this.selectedBlockPage = { offset: 0, size: 1, maxOffset: 0 } this.selectedIndex = this.selectedIndex === options.length - 1 ? 0 : this.selectedIndex + 1 } else if (matchesKey(data, Key.space) && this.question.multiSelect) { if (this.selected.has(this.selectedIndex)) this.selected.delete(this.selectedIndex) @@ -883,6 +902,7 @@ export class QuestionDialog implements Component, Focusable { this.done({ selected: indices.map(index => options[index]?.label).filter((label): label is string => label !== undefined) }) } else if (matchesKey(data, Key.tab) || data.toLowerCase() === 'c') { this.mode = 'custom' + this.selectedBlockPage = { offset: 0, size: 1, maxOffset: 0 } this.error = '' } else if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) { this.cancel() @@ -898,75 +918,346 @@ export class QuestionDialog implements Component, Focusable { this.done({ selected: [], custom }) } + /** Page backward through an oversized option, then through question detail. */ + private pageBackward(): void { + if (this.mode === 'options' && this.selectedBlockPage.offset > 0) { + this.selectedBlockPage = { + ...this.selectedBlockPage, + offset: Math.max(0, this.selectedBlockPage.offset - this.selectedBlockPage.size), + } + return + } + this.headerPage = { + ...this.headerPage, + offset: Math.max(0, this.headerPage.offset - this.headerPage.size), + } + } + + /** Page forward through question detail, then through an oversized option. */ + private pageForward(): void { + if (this.headerPage.offset < this.headerPage.maxOffset) { + this.headerPage = { + ...this.headerPage, + offset: Math.min( + this.headerPage.maxOffset, + this.headerPage.offset + this.headerPage.size, + ), + } + return + } + if (this.mode === 'custom') return + this.selectedBlockPage = { + ...this.selectedBlockPage, + offset: Math.min( + this.selectedBlockPage.maxOffset, + this.selectedBlockPage.offset + this.selectedBlockPage.size, + ), + } + } + render(width: number): string[] { this.input.focused = this.focused - const innerWidth = Math.max(1, width - 4) + const horizontalPadding = Math.min(2, Math.max(0, Math.floor((width - 1) / 2))) + const innerWidth = Math.max(1, width - horizontalPadding * 2) const header = `Question ${this.position}/${this.total} (${this.unanswered} unanswered)${this.question.header === undefined ? '' : ` · ${displayText(this.question.header)}`}` - const lines = [ - this.palette.dim(header), - ...wrapTextWithAnsi(this.palette.text(displayText(this.question.question)), innerWidth), + const questionLines = wrapTextWithAnsi( + this.palette.text(displayText(this.question.question)), + innerWidth, + ) + const contentLines = [...questionLines] + const headerLines: string[] = [ + ...wrapTextWithAnsi(this.palette.dim(header), innerWidth), + ...questionLines, ] - const push = (line: string): void => { lines.push(line) } // Supporting detail (e.g. the full plan under review) renders between the // question and the answer surface, kept out of option labels. if (this.question.detail !== undefined) { - push('') - for (const line of wrapTextWithAnsi(displayText(this.question.detail), innerWidth)) push(line) - } - push('') - if (this.mode === 'custom') { - for (const line of this.input.render(innerWidth)) push(line) - push(this.palette.dim(this.options.length > 0 ? 'Enter submit • Esc options' : 'Enter submit • Esc cancel')) - } else { - const options = this.options - const start = Math.max(0, Math.min( - this.selectedIndex - Math.floor(this.maxVisible / 2), - options.length - this.maxVisible, - )) - const end = Math.min(options.length, start + this.maxVisible) - const optionRows = options.slice(start, end).map((option, offset) => { - const index = start + offset - const mark = this.question.multiSelect - ? this.selected.has(index) ? '[x] ' : '[ ] ' - : '' - return `${index === this.selectedIndex ? '›' : ' '} ${index + 1}. ${mark}${displayText(option.label)}` - }) - const descriptionColumn = Math.min( - Math.max(...optionRows.map(row => visibleWidth(row))) + 2, - Math.max(1, Math.floor(innerWidth * 0.55)), - ) - for (let index = start; index < end; index += 1) { - // `index < end <= options.length`; the options array is borrowed immutably for this dialog. - const option = options[index] as NonNullable[number] - const mark = this.question.multiSelect - ? this.selected.has(index) ? '[x] ' : '[ ] ' - : '' - const left = `${index === this.selectedIndex ? '›' : ' '} ${index + 1}. ${mark}${displayText(option.label)}` - const leftStyled = index === this.selectedIndex - ? this.palette.bold(this.palette.accent(left)) - : left - const description = option.description === undefined - ? '' - : `${' '.repeat(Math.max(1, descriptionColumn - visibleWidth(left)))}${this.palette.dim(displayText(option.description))}` - push(`${leftStyled}${description}`) + headerLines.push('') + contentLines.push('') + for (const line of wrapTextWithAnsi(displayText(this.question.detail), innerWidth)) { + headerLines.push(line) + contentLines.push(line) } - if (options.length > this.maxVisible) push(this.palette.dim(`${this.selectedIndex + 1}/${options.length}`)) + } + headerLines.push('') + + const customHint = this.palette.dim(this.options.length > 0 + ? 'Enter submit • Esc options' + : 'Enter submit • Esc cancel') + const footerLines: string[] = [] + if (this.mode === 'custom') { + for (const line of this.input.render(innerWidth)) footerLines.push(line) + for (const line of wrapTextWithAnsi(customHint, innerWidth)) footerLines.push(line) + } else { const controls = [ 'Tab custom answer', - ...(options.length > 1 ? ['↑/↓ navigate'] : []), + ...(this.options.length > 1 ? ['↑/↓ navigate'] : []), ...(this.question.multiSelect ? ['Space toggle'] : []), 'Enter submit', 'Esc interrupt', ] const hint = this.palette.dim(controls.join(' • ')) - for (const line of wrapTextWithAnsi(hint, innerWidth)) push(line) + for (const line of wrapTextWithAnsi(hint, innerWidth)) footerLines.push(line) } if (this.error) { - for (const line of wrapTextWithAnsi(this.palette.error(this.error), innerWidth)) push(line) + for (const line of wrapTextWithAnsi(this.palette.error(this.error), innerWidth)) footerLines.push(line) } - return ['', ...lines, ''].map((line) => { - const clipped = truncateToWidth(line, innerWidth, '') - return ` ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} ` + const positionLines = this.mode === 'options' && this.options.length > this.maxVisible + ? [this.palette.dim(`${this.selectedIndex + 1}/${this.options.length}`)] + : [] + + // Options receive only the rows left after fixed chrome and outer padding. + // The final height window handles fixed chrome that cannot fit even alone. + const paddingRows = 2 + const maxHeight = this.maxHeight() + const availableForOptions = Math.max( + this.mode === 'options' ? 4 : 1, + maxHeight - paddingRows - headerLines.length - positionLines.length - footerLines.length, + ) + + const body: string[] = [...headerLines] + const optionLines: string[] = [] + if (this.mode === 'custom') { + for (const line of footerLines) body.push(line) + } else { + const optionBlocks = this.options.map((option, index) => this.renderOptionBlock(option, index, innerWidth)) + const { visibleBlocks, hiddenBefore, hiddenAfter } = this.windowBlocks(optionBlocks, availableForOptions, innerWidth) + if (hiddenBefore > 0) optionLines.push(this.palette.dim(`↑ ${hiddenBefore} more`)) + for (const block of visibleBlocks) { + for (const line of block) optionLines.push(line) + } + if (hiddenAfter > 0) optionLines.push(this.palette.dim(`↓ ${hiddenAfter} more`)) + for (const line of optionLines) body.push(line) + for (const line of positionLines) body.push(line) + for (const line of footerLines) body.push(line) + } + + const rows = ['', ...body, ''] + let visibleRows = rows + if (rows.length <= maxHeight) this.headerPage = { offset: 0, size: 1, maxOffset: 0 } + if (rows.length > maxHeight && this.mode === 'options' && maxHeight >= 6) { + const headerBudget = Math.max( + 0, + maxHeight - optionLines.length - (this.error === '' ? 1 : 2), + ) + const compactFooter = [ + ...this.error === '' + ? [] + : [truncateToWidth(this.palette.error(`Error: ${this.error}`), innerWidth, '…')], + this.compactOptionControls( + innerWidth, + headerBudget === 1 && contentLines.length > headerBudget, + ), + ] + const compactHeader = this.compactQuestionHeader(contentLines, headerBudget, innerWidth) + visibleRows = [...compactHeader, ...optionLines, ...compactFooter] + } else if (rows.length > maxHeight && this.mode === 'custom' && maxHeight >= 2) { + const compactFooterSource = [ + ...this.input.render(innerWidth), + this.compactCustomControls(innerWidth), + ...this.error === '' + ? [] + : [truncateToWidth(this.palette.error(this.error), innerWidth, '…')], + ] + const footerBudget = Math.max(1, maxHeight - 1) + const compactFooter = compactFooterSource.length <= footerBudget + ? compactFooterSource + : footerBudget === 1 + ? compactFooterSource.slice(0, 1) + : [ + ...compactFooterSource.slice(0, 1), + ...compactFooterSource.slice(-(footerBudget - 1)), + ] + const compactHeader = this.compactQuestionHeader( + contentLines, + Math.max(0, maxHeight - compactFooter.length), + innerWidth, + ) + visibleRows = [...compactHeader, ...compactFooter] + } + if (visibleRows.length > maxHeight) { + visibleRows = maxHeight === 1 + ? [this.palette.dim(`↑ ${visibleRows.length} lines hidden`)] + : [ + this.palette.dim(`↑ ${visibleRows.length - maxHeight + 1} lines hidden`), + ...visibleRows.slice(-(maxHeight - 1)), + ] + } + return visibleRows.map((line) => { + const bounded = truncateToWidth(line, innerWidth, '…') + const pad = ' '.repeat(Math.max(0, innerWidth - visibleWidth(bounded))) + const outerPad = ' '.repeat(horizontalPadding) + return `${outerPad}${bounded}${pad}${outerPad}` }) } + + /** Render one option as wrapped label and indented description lines. */ + private renderOptionBlock( + option: NonNullable[number], + index: number, + innerWidth: number, + ): string[] { + const cursor = index === this.selectedIndex ? '›' : ' ' + const number = `${index + 1}. ` + const mark = this.question.multiSelect + ? this.selected.has(index) ? '[x] ' : '[ ] ' + : '' + const labelPrefixPlain = ` ${cursor} ${number}${mark}` + const labelPrefixWidth = visibleWidth(labelPrefixPlain) + const labelBodyWidth = Math.max(1, innerWidth - labelPrefixWidth) + const labelLines = wrapTextWithAnsi(displayText(option.label), labelBodyWidth) + const continuation = ' '.repeat(labelPrefixWidth) + const lines: string[] = [] + for (const [lineIndex, labelLine] of labelLines.entries()) { + const prefix = lineIndex === 0 ? labelPrefixPlain : continuation + const composed = `${prefix}${labelLine}` + lines.push(index === this.selectedIndex ? this.palette.bold(this.palette.accent(composed)) : composed) + } + if (option.description !== undefined) { + const descIndent = ' '.repeat(labelPrefixWidth) + const descBodyWidth = Math.max(1, innerWidth - labelPrefixWidth) + const descLines = wrapTextWithAnsi(displayText(option.description), descBodyWidth) + for (const descLine of descLines) lines.push(`${descIndent}${this.palette.dim(descLine)}`) + } + return lines + } + + /** Keep the question visible when fixed chrome must be compacted. */ + private compactQuestionHeader( + contentLines: readonly string[], + budget: number, + innerWidth: number, + ): string[] { + if (budget <= 0) return [] + if (contentLines.length <= budget) { + this.headerPage = { offset: 0, size: 1, maxOffset: 0 } + return [...contentLines] + } + const pageSize = Math.max(1, budget - 1) + const maxOffset = Math.max(0, contentLines.length - pageSize) + const offset = Math.min(this.headerPage.offset, maxOffset) + this.headerPage = { offset, size: pageSize, maxOffset } + const keptLines = contentLines.slice(offset, offset + pageSize) + if (budget === 1) { + // A page is non-empty because pageSize is one and offset is clamped inside contentLines. + return [keptLines[0] as string] + } + return [ + ...keptLines, + this.pagerStatus(offset + 1, offset + keptLines.length, contentLines.length, innerWidth), + ] + } + + /** Keep Page Up / Page Down discoverable when a full pager status cannot fit. */ + private pagerStatus(first: number, last: number, total: number, innerWidth: number): string { + const full = `… lines ${first}-${last}/${total} • PgUp/PgDn` + const compact = `PgUp/PgDn ${first}/${total}` + return this.palette.dim(truncateToWidth( + visibleWidth(full) <= innerWidth ? full : compact, + innerWidth, + '…', + )) + } + + /** Render custom-mode controls on one row when the header must compact. */ + private compactCustomControls(innerWidth: number): string { + const controls = this.options.length > 0 + ? 'Enter submit • Esc options' + : 'Enter submit • Esc cancel' + const fallback = this.options.length > 0 ? '↵ Esc options' : 'Enter Esc cancel' + const line = visibleWidth(controls) <= innerWidth ? controls : fallback + return this.palette.dim(truncateToWidth(line, innerWidth, '…')) + } + + /** Render a one-row option footer that retains every mode-specific control. */ + private compactOptionControls(innerWidth: number, showPager = false): string { + const controls = [ + ...(this.options.length > 1 ? ['↑/↓'] : []), + 'Tab custom', + ...(this.question.multiSelect ? ['Space toggle'] : []), + 'Enter', + 'Esc interrupt', + ...(showPager ? ['PgUp/PgDn'] : []), + ].join(' • ') + const optionNavigation = this.options.length > 1 ? '↑↓ ' : '' + const fallback = showPager + ? `P↑↓ ${optionNavigation}Tab${this.question.multiSelect ? ' S' : ''}↵Esc` + : this.question.multiSelect ? `${optionNavigation}Tab Sp ↵Esc` : `${optionNavigation}Tab ↵ Esc` + const line = visibleWidth(controls) <= innerWidth ? controls : fallback + return this.palette.dim(truncateToWidth(line, innerWidth, '…')) + } + + /** + * Choose option blocks that fit while keeping the selected option visible. + * Omitted blocks are counted at each end for explicit overflow markers. + */ + private windowBlocks( + blocks: readonly string[][], + budget: number, + innerWidth: number, + ): { visibleBlocks: string[][]; hiddenBefore: number; hiddenAfter: number } { + const totalLines = blocks.reduce((sum, block) => sum + block.length, 0) + if (totalLines <= budget && blocks.length <= this.maxVisible) { + return { visibleBlocks: [...blocks], hiddenBefore: 0, hiddenAfter: 0 } + } + // `blocks` is dense and selectedIndex is derived from the same options. + let start = this.selectedIndex + let end = this.selectedIndex + 1 + /* v8 ignore next -- selectedIndex stays inside [0, options.length). */ + let used = blocks[this.selectedIndex]?.length ?? 0 + const markerLines = (before: number, after: number): number => + (before > 0 ? 1 : 0) + (after > 0 ? 1 : 0) + const fits = (nextStart: number, nextEnd: number, nextUsed: number): boolean => + nextEnd - nextStart <= this.maxVisible + && nextUsed + markerLines(nextStart, blocks.length - nextEnd) <= budget + const selectedMarkers = markerLines(start, blocks.length - end) + if (used + selectedMarkers > budget) { + /* v8 ignore next -- selectedIndex stays inside [0, options.length). */ + const selectedBlock = blocks[this.selectedIndex] ?? [] + const hiddenBefore = start + const hiddenAfter = blocks.length - end + const pageSize = budget - selectedMarkers - 1 + const maxOffset = Math.max(0, selectedBlock.length - pageSize) + const offset = Math.min(this.selectedBlockPage.offset, maxOffset) + this.selectedBlockPage = { offset, size: pageSize, maxOffset } + const keptLines = selectedBlock.slice(offset, offset + pageSize) + const first = offset + 1 + const last = offset + keptLines.length + const overflow = this.pagerStatus(first, last, selectedBlock.length, innerWidth) + return { + visibleBlocks: [[...keptLines, overflow]], + hiddenBefore, + hiddenAfter, + } + } + this.selectedBlockPage = { offset: 0, size: 1, maxOffset: 0 } + let expanded = true + while (expanded && (start > 0 || end < blocks.length)) { + expanded = false + if (end < blocks.length) { + /* v8 ignore next -- guarded by `end < blocks.length` above. */ + const next = blocks[end]?.length ?? 0 + if (fits(start, end + 1, used + next)) { + used += next + end += 1 + expanded = true + continue + } + } + if (start > 0) { + /* v8 ignore next -- guarded by `start > 0` above. */ + const previous = blocks[start - 1]?.length ?? 0 + if (fits(start - 1, end, used + previous)) { + used += previous + start -= 1 + expanded = true + } + } + } + return { + visibleBlocks: blocks.slice(start, end), + hiddenBefore: start, + hiddenAfter: blocks.length - end, + } + } } diff --git a/packages/ui/tui/src/extension/overlay-manager.ts b/packages/ui/tui/src/extension/overlay-manager.ts index 59d84ff3d5..ab80f49cab 100644 --- a/packages/ui/tui/src/extension/overlay-manager.ts +++ b/packages/ui/tui/src/extension/overlay-manager.ts @@ -12,7 +12,6 @@ import type { TuiExtensionService } from '../index.ts' import type { Component, Focusable, - OverlayHandle, } from '@earendil-works/pi-tui' import type { TuiComponent, @@ -36,14 +35,20 @@ export interface TuiOverlayDriver { theme(): TuiTheme /** Escape text at the terminal display boundary. */ display(value: string): string - /** Mount one guarded component and return its private pi-tui handle. */ - show(component: Component, options: TuiOverlayOptions | undefined): OverlayHandle + /** Mount one guarded modal and return its private focus/lifecycle handle. */ + show(component: Component, options: TuiOverlayOptions | undefined, placement: TuiOverlayPlacement): TuiModalHandle /** Invalidate the mounted UI and request a render. */ invalidate(): void /** Report a contained extension failure. */ reportError(error: unknown): void } +type TuiOverlayPlacement = 'overlay' | 'inline' + +interface TuiModalHandle { + hide(): void +} + interface OverlayEntry { readonly request: TuiOverlayRequest readonly controller: AbortController @@ -51,9 +56,10 @@ interface OverlayEntry { readonly closed: Promise readonly resolveClosed: (outcome: TuiOverlayOutcome) => void readonly session: TuiOverlaySession + readonly placement: TuiOverlayPlacement state: TuiOverlayState component?: GuardedOverlayComponent - handle?: OverlayHandle + handle?: TuiModalHandle removeRequestAbort?: () => void outcome?: TuiOverlayOutcome failing?: boolean @@ -165,11 +171,12 @@ export class TuiOverlayManager { } /** - * Queue one overlay without assigning Cordis ownership. + * Queue one modal without assigning Cordis ownership. * @param request - component factory, constraints, and request signal. + * @param placement - terminal overlay for extensions, or inline for the built-in question panel. * @returns an internal session that can close with an ownership reason. */ - open(request: TuiOverlayRequest): TuiOverlaySession & { + open(request: TuiOverlayRequest, placement: TuiOverlayPlacement = 'overlay'): TuiOverlaySession & { closeWith(reason: Exclude): Promise } { if (!this.accepting) throw new Error('TUI is shutting down') @@ -202,6 +209,7 @@ export class TuiOverlayManager { closed: deferred.promise, resolveClosed: deferred.resolve, session, + placement, state: 'queued', } if (requestSignal?.aborted === true) { @@ -251,7 +259,7 @@ export class TuiOverlayManager { }) entry.component = guarded try { - const handle = this.driver.show(guarded, entry.request.options) + const handle = this.driver.show(guarded, entry.request.options, entry.placement) if (this.active !== entry) { this.hide(handle) return @@ -306,7 +314,7 @@ export class TuiOverlayManager { } } - private hide(handle: OverlayHandle): void { + private hide(handle: TuiModalHandle): void { try { handle.hide() } catch (error) { diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index f50894b59d..0ddb73ed9c 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -15,6 +15,7 @@ import { ProcessTerminal, matchesKey, visibleWidth, + type Component, type EditorTheme, type SlashCommand, type TerminalColorScheme, @@ -289,6 +290,23 @@ interface FadingStatus { timer: ReturnType } +/** Width/height adapter for a modal component rendered inside the base TUI flow. */ +class InlineModalComponent extends Container { + constructor( + component: Component, + private readonly width: number, + private readonly maxHeight: number, + ) { + super() + this.addChild(component) + } + + override render(width: number): string[] { + const lines = super.render(Math.max(1, Math.min(width, this.width))) + return lines.slice(0, Math.max(1, this.maxHeight)) + } +} + /** Lifecycle handle for a mounted interactive terminal channel. */ export interface TuiController { /** Stop rendering, restore the terminal, and reject pending questions. */ @@ -316,6 +334,7 @@ export function createTuiChat( const ui = new TUI(runtime.terminal, resolved.showHardwareCursor) const chat = new Container() const todoContainer = new Container() + const questionContainer = new Container() const inputTemplate = parseTuiPromptTemplate(displayInlineText(resolved.theme.inputPrompt)) const renderInputPrompt = (): string => renderTuiPromptTemplate(inputTemplate, valueName => ctx.tuiPrompt.get(valueName)) const initialInputPrompt = renderInputPrompt() @@ -480,6 +499,7 @@ export function createTuiChat( ui.addChild(todoContainer) ui.addChild(compactionStatusLine) ui.addChild(promptContext) + ui.addChild(questionContainer) ui.addChild(editor) ui.setFocus(editor) const updateTerminalTitle = (): void => { @@ -529,14 +549,32 @@ export function createTuiChat( }), theme: () => extensionTheme, display: displayText, - show: (component, options) => ui.showOverlay(component, options === undefined - ? undefined - : { - ...options, - ...typeof options.margin === 'object' - ? { margin: { ...options.margin } } - : {}, - }), + show: (component, options, placement) => { + if (placement === 'overlay') { + return ui.showOverlay(component, options === undefined + ? undefined + : { + ...options, + ...typeof options.margin === 'object' + ? { margin: { ...options.margin } } + : {}, + }) + } + const modal = new InlineModalComponent( + component, + resolved.questionDialogWidth, + resolved.questionDialogMaxHeight, + ) + questionContainer.clear() + questionContainer.addChild(modal) + ui.setFocus(component) + return { + hide(): void { + questionContainer.clear() + ui.setFocus(editor) + }, + } + }, invalidate: requestRender, reportError: (error) => { const message = errorChain(error) @@ -956,6 +994,14 @@ export function createTuiChat( overlayManager, requestRender, isDisposed, + questionMaxHeight: () => { + const width = runtime.terminal.columns + const editorRows = editor.render(width).length + return Math.max(1, Math.min( + resolved.questionDialogMaxHeight, + runtime.terminal.rows - editorRows, + )) + }, }) const resume = createResumeController({ diff --git a/packages/ui/tui/tests/snapshots/question-dialog-detail-paged.expected.txt b/packages/ui/tui/tests/snapshots/question-dialog-detail-paged.expected.txt new file mode 100644 index 0000000000..415bedd976 --- /dev/null +++ b/packages/ui/tui/tests/snapshots/question-dialog-detail-paged.expected.txt @@ -0,0 +1,39 @@ +terminal 56x20 buffer=normal length=25 base=5 viewport=5 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=56 viewportRow=13 bufferRow=18 +viewport +5| "Model wait 0.0s " + style 0-14 dim +6| +7| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 " + style 0-17 fg=bright-magenta bold + style 18-31 dim + style 34-50 dim + style 53-55 dim +8| " Review the complete plan including every required " +9| " checkpoint including every required checkpoint " +10| " including every required checkpoint including every " +11| " required checkpoint including every required " +12| " checkpoint including every required checkpoint " +13| " including every required checkpoint including every " +14| " required checkpoint including every required " +15| " checkpoint including every required checkpoint " +16| " including every required checkpoint including every " +17| " required checkpoint visible plan tail " +18| " … lines 4-13/13 • PgUp/PgDn " + style 2-28 dim +19| " › 1. [ ] Code Mode " + style 2-20 fg=bright-magenta bold +20| " run_code programs and captured output with " + style 12-53 dim +21| " … lines 1-2/12 • PgUp/PgDn " + style 2-27 dim +22| " ↓ 3 more " + style 2-9 dim +23| " ↑↓ Tab Sp ↵Esc " + style 2-15 dim +24| " dsh > " + style 1-3 fg=bright-magenta bold + style 5-6 dim + style 7-7 inverse diff --git a/packages/ui/tui/tests/snapshots/question-dialog-paged.expected.txt b/packages/ui/tui/tests/snapshots/question-dialog-paged.expected.txt new file mode 100644 index 0000000000..2969918f17 --- /dev/null +++ b/packages/ui/tui/tests/snapshots/question-dialog-paged.expected.txt @@ -0,0 +1,39 @@ +terminal 56x20 buffer=normal length=25 base=5 viewport=5 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=56 viewportRow=16 bufferRow=21 +viewport +5| "Model wait 0.0s " + style 0-14 dim +6| +7| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 " + style 0-17 fg=bright-magenta bold + style 18-31 dim + style 34-50 dim + style 53-55 dim +8| " Review the complete plan including every required " +9| " checkpoint including every required checkpoint " +10| " including every required checkpoint including every " +11| " required checkpoint including every required " +12| " checkpoint including every required checkpoint " +13| " including every required checkpoint including every " +14| " required checkpoint including every required " +15| " checkpoint including every required checkpoint " +16| " including every required checkpoint including every " +17| " required checkpoint visible plan tail " +18| " … lines 4-13/13 • PgUp/PgDn " + style 2-28 dim +19| " detail with complete wrapped detail " + style 12-46 dim +20| " visible tail " + style 12-23 dim +21| " … lines 11-12/12 • PgUp/PgDn " + style 2-29 dim +22| " ↓ 3 more " + style 2-9 dim +23| " ↑↓ Tab Sp ↵Esc " + style 2-15 dim +24| " dsh > " + style 1-3 fg=bright-magenta bold + style 5-6 dim + style 7-7 inverse diff --git a/packages/ui/tui/tests/snapshots/question-dialog-single-option.expected.txt b/packages/ui/tui/tests/snapshots/question-dialog-single-option.expected.txt index a81556d42a..6cfef15ead 100644 --- a/packages/ui/tui/tests/snapshots/question-dialog-single-option.expected.txt +++ b/packages/ui/tui/tests/snapshots/question-dialog-single-option.expected.txt @@ -1,7 +1,7 @@ terminal 56x20 buffer=normal length=20 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=0 viewportRow=19 bufferRow=19 +cursor hidden column=56 viewportRow=16 bufferRow=16 viewport 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-magenta bold @@ -21,20 +21,20 @@ viewport style 18-31 dim style 34-50 dim style 53-55 dim -8| " dsh > " +8| " " +9| " Question 1/1 (1 unanswered) · Confirm " + style 2-38 dim +10| " Continue with this change? " +11| " " +12| " › 1. Proceed " + style 2-14 fg=bright-magenta bold +13| " Apply the proposed change " + style 8-32 dim +14| " Tab custom answer • Enter submit • Esc interrupt " + style 2-49 dim +15| " " +16| " dsh > " style 1-3 fg=bright-magenta bold style 5-6 dim style 7-7 inverse -9-11| -12| " " -13| " Question 1/1 (1 unanswered) · Confirm " - style 2-38 dim -14| " Continue with this change? " -15| " " -16| " › 1. Proceed Apply the proposed change " - style 2-13 fg=bright-magenta bold - style 16-40 dim -17| " Tab custom answer • Enter submit • Esc interrupt " - style 2-49 dim -18| " " -19| +17-19| diff --git a/packages/ui/tui/tests/snapshots/question-dialog-validation.expected.txt b/packages/ui/tui/tests/snapshots/question-dialog-validation.expected.txt index 14193efdda..ecd949cb8d 100644 --- a/packages/ui/tui/tests/snapshots/question-dialog-validation.expected.txt +++ b/packages/ui/tui/tests/snapshots/question-dialog-validation.expected.txt @@ -1,40 +1,40 @@ -terminal 56x20 buffer=normal length=20 base=0 viewport=0 +terminal 56x20 buffer=normal length=25 base=5 viewport=5 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=56 viewportRow=17 bufferRow=17 +cursor hidden column=56 viewportRow=17 bufferRow=22 viewport -0| " DEEPSEEK HARNESS" - style 1-8 fg=bright-magenta bold - style 10-16 bold -1| " Snapshot agent ready." - style 1-21 dim -2| " main-session" - style 1-12 dim -3| -4| "Assistant " - style 0-8 fg=bright-magenta bold underline -5| " " -6| " Question 1/3 (3 unanswered) · Coverage " - style 2-39 dim -7| " Which advanced TUI states belong in the required " -8| " matrix? " -9| " " -10| " › 1. [ ] Code Mode run_code programs and capture " - style 2-19 fg=bright-magenta bold - style 25-53 dim -11| " 2. [ ] Workflows phases and parallel agents " - style 25-50 dim -12| " 3. [ ] Cordis tools inspect, mount, and unmount " - style 25-51 dim -13| " 1/4 " - style 2-4 dim -14| " Tab custom answer • ↑/↓ navigate • Space toggle • " - style 2-55 dim -15| " Enter submit • Esc interrupt " +5| "Model wait 0.0s " + style 0-14 dim +6| +7| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 " + style 0-17 fg=bright-magenta bold + style 18-31 dim + style 34-50 dim + style 53-55 dim +8| " Review the complete plan including every required " +9| " checkpoint including every required checkpoint " +10| " including every required checkpoint including every " +11| " required checkpoint including every required " +12| " checkpoint including every required checkpoint " +13| " including every required checkpoint including every " +14| " required checkpoint including every required " +15| " checkpoint including every required checkpoint " +16| " including every required checkpoint including every " +17| " … lines 4-12/13 • PgUp/PgDn " + style 2-28 dim +18| " detail with complete wrapped detail " + style 12-46 dim +19| " visible tail " + style 12-23 dim +20| " … lines 11-12/12 • PgUp/PgDn " style 2-29 dim -16| " Select at least one option, or press Tab for a " - style 2-55 fg=red -17| " custom answer. " - style 2-15 fg=red -18| " " -19| +21| " ↓ 3 more " + style 2-9 dim +22| " Error: Select at least one option, or press Tab for… " + style 2-52 fg=red +23| " ↑↓ Tab Sp ↵Esc " + style 2-15 dim +24| " dsh > " + style 1-3 fg=bright-magenta bold + style 5-6 dim + style 7-7 inverse diff --git a/packages/ui/tui/tests/snapshots/question-dialog.expected.txt b/packages/ui/tui/tests/snapshots/question-dialog.expected.txt index 640bd6fed2..4b576099ae 100644 --- a/packages/ui/tui/tests/snapshots/question-dialog.expected.txt +++ b/packages/ui/tui/tests/snapshots/question-dialog.expected.txt @@ -1,39 +1,39 @@ -terminal 56x20 buffer=normal length=20 base=0 viewport=0 +terminal 56x20 buffer=normal length=25 base=5 viewport=5 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=0 viewportRow=19 bufferRow=19 +cursor hidden column=56 viewportRow=19 bufferRow=24 viewport -0| " DEEPSEEK HARNESS" - style 1-8 fg=bright-magenta bold - style 10-16 bold -1| " Snapshot agent ready." - style 1-21 dim -2| " main-session" - style 1-12 dim -3| -4| "Assistant " - style 0-8 fg=bright-magenta bold underline 5| "Model wait 0.0s " style 0-14 dim 6| -7| " " -8| " Question 1/3 (3 unanswered) · Coverage " - style 2-39 dim -9| " Which advanced TUI states belong in the required " -10| " matrix? " -11| " " -12| " › 1. [ ] Code Mode run_code programs and capture " - style 2-19 fg=bright-magenta bold - style 25-53 dim -13| " 2. [ ] Workflows phases and parallel agents " - style 25-50 dim -14| " 3. [ ] Cordis tools inspect, mount, and unmount " - style 25-51 dim -15| " 1/4 " - style 2-4 dim -16| " Tab custom answer • ↑/↓ navigate • Space toggle • " - style 2-55 dim -17| " Enter submit • Esc interrupt " - style 2-29 dim -18| " " -19| +7| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 " + style 0-17 fg=bright-magenta bold + style 18-31 dim + style 34-50 dim + style 53-55 dim +8| " Which advanced TUI states belong in the required " +9| " matrix? " +10| " " +11| " Review the complete plan including every required " +12| " checkpoint including every required checkpoint " +13| " including every required checkpoint including every " +14| " required checkpoint including every required " +15| " checkpoint including every required checkpoint " +16| " including every required checkpoint including every " +17| " required checkpoint including every required " +18| " … lines 1-10/13 • PgUp/PgDn " + style 2-28 dim +19| " › 1. [ ] Code Mode " + style 2-20 fg=bright-magenta bold +20| " run_code programs and captured output with " + style 12-53 dim +21| " … lines 1-2/12 • PgUp/PgDn " + style 2-27 dim +22| " ↓ 3 more " + style 2-9 dim +23| " ↑↓ Tab Sp ↵Esc " + style 2-15 dim +24| " dsh > " + style 1-3 fg=bright-magenta bold + style 5-6 dim + style 7-7 inverse diff --git a/packages/ui/tui/tests/snapshots/untrusted-controls.expected.txt b/packages/ui/tui/tests/snapshots/untrusted-controls.expected.txt index e5a625afc0..5cb2529355 100644 --- a/packages/ui/tui/tests/snapshots/untrusted-controls.expected.txt +++ b/packages/ui/tui/tests/snapshots/untrusted-controls.expected.txt @@ -1,7 +1,7 @@ -terminal 100x34 buffer=normal length=34 base=0 viewport=0 +terminal 100x34 buffer=normal length=40 base=6 viewport=6 lifecycle started=1 stopped=0 progress=inactive title "Unsafe terminal title \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m" -cursor hidden column=0 viewportRow=33 bufferRow=33 +cursor hidden column=100 viewportRow=33 bufferRow=39 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-magenta bold @@ -48,15 +48,30 @@ buffer 24| 25| "Unsafe turn error \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " style 0-62 fg=red -26| " " -27| " Question 1/1 (1 unanswered) · Unsafe header \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " +26-27| +28| "Plan" + style 0-3 fg=bright-magenta bold +29| " ● Unsafe todo \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m" + style 2-2 fg=yellow +30| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context" + style 0-17 fg=bright-magenta bold + style 18-31 dim + style 34-50 dim + style 53-57 dim + style 60-69 dim +31| " " +32| " Question 1/1 (1 unanswered) · Unsafe header \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " style 2-90 dim -28| " Unsafe question \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " -29| " " -30| " › 1. Unsafe option \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m Unsafe detail \\x1b]2;snapshot-c " - style 2-65 fg=bright-magenta bold - style 67-97 dim -31| " Tab custom answer • Enter submit • Esc interrupt " +33| " Unsafe question \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " +34| " " +35| " › 1. Unsafe option \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " + style 2-66 fg=bright-magenta bold +36| " Unsafe detail \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " + style 8-66 dim +37| " Tab custom answer • Enter submit • Esc interrupt " style 2-49 dim -32| " " -33| +38| " " +39| " dsh > " + style 1-3 fg=bright-magenta bold + style 5-6 dim + style 7-7 inverse diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts index 72718cfae1..21696153a2 100644 --- a/packages/ui/tui/tests/tui.snapshot.ts +++ b/packages/ui/tui/tests/tui.snapshot.ts @@ -49,6 +49,8 @@ const CHECKPOINTS = [ 'details-selector', 'untrusted-controls', 'question-dialog', + 'question-dialog-detail-paged', + 'question-dialog-paged', 'question-dialog-single-option', 'question-dialog-validation', 'surface-before-compaction', @@ -786,9 +788,13 @@ describe('TUI terminal-state snapshots', () => { id: 'coverage', header: 'Coverage', question: 'Which advanced TUI states belong in the required matrix?', + detail: `Review the complete plan ${'including every required checkpoint '.repeat(12)}visible plan tail`, multiSelect: true, options: [ - { label: 'Code Mode', description: 'run_code programs and captured output' }, + { + label: 'Code Mode', + description: `run_code programs and captured output ${'with complete wrapped detail '.repeat(12)}visible tail`, + }, { label: 'Workflows', description: 'phases and parallel agents' }, { label: 'Cordis tools', description: 'inspect, mount, and unmount' }, { label: 'Compaction', description: 'surface replacement and reflow' }, @@ -803,6 +809,14 @@ describe('TUI terminal-state snapshots', () => { await harness.terminal.waitForFrame(beforeQuestion) await checkpoint('question-dialog', harness.terminal) + await renderAfter(harness, () => { harness.terminal.send('\x1b[6~') }) + await checkpoint('question-dialog-detail-paged', harness.terminal) + + await renderAfter(harness, () => { + for (let page = 0; page < 30; page += 1) harness.terminal.send('\x1b[6~') + }) + await checkpoint('question-dialog-paged', harness.terminal) + await renderAfter(harness, () => { harness.terminal.send('\r') }) await checkpoint('question-dialog-validation', harness.terminal) controller.abort() diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index b442df0710..c5c12ffc5d 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -50,6 +50,7 @@ import { disposeTuiTestHarness, type TuiHarnessOptions, } from './harness.ts' +import { HeadlessTerminal } from './headless-terminal.ts' import { TestSessionQueryService } from './session-query.ts' const UNUSED_TOOL_OUTPUT: ToolDefinition['output'] = { @@ -5510,6 +5511,54 @@ describe('tool cards and surface replay', () => { }) describe('TUI user-interaction dialogs', () => { + it('limits the visible option window to maxQuestionOptions', async () => { + const result = await setup({ + config: { maxQuestionOptions: 1, questionDialogWidth: 60, questionDialogMaxHeight: 20 }, + }) + const answer = result.ctx.userInteraction.ask({ + questions: [{ + id: 'cap', + question: 'Pick one', + options: [{ label: 'Visible first' }, { label: 'Hidden second' }], + }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + expect(result.terminal.output).toContain('Visible first') + expect(result.terminal.output).not.toContain('Hidden second') + expect(result.terminal.output).toContain('↓ 1 more') + result.terminal.send('\x03') + await rejected + + await dispose(result) + }) + + it('renders a pending question between the transcript and editor', async () => { + const result = await setup({ + config: { questionDialogWidth: 40, questionDialogMaxHeight: 10 }, + }) + result.terminal.send('draft input') + const answer = result.ctx.userInteraction.ask({ + questions: [{ + id: 'placement', + question: 'Pick one', + options: [{ label: 'First' }, { label: 'Second' }], + }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + result.terminal.resize(60, 20) + await tick() + const render = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J')) + const questionIndex = render.indexOf('Pick one') + const editorIndex = render.indexOf('draft input') + expect(questionIndex).toBeGreaterThanOrEqual(0) + expect(editorIndex).toBeGreaterThan(questionIndex) + result.terminal.send('\x03') + await rejected + await dispose(result) + }) + it('answers single-select, multi-select, custom, and optionless questions', async () => { const result = await setup({ config: { maxQuestionOptions: 1 } }) @@ -5603,6 +5652,468 @@ describe('TUI user-interaction dialogs', () => { await dispose(result) }) + it('scrolls tall option lists with ↑/↓ overflow markers when the dialog height is capped', async () => { + const result = await setup({ + config: { + questionDialogWidth: 60, + questionDialogMaxHeight: 12, + maxQuestionOptions: 8, + }, + }) + const answer = result.ctx.userInteraction.ask({ + questions: [{ + id: 'scroll', + question: 'Pick one', + options: [ + { label: 'Alpha', description: 'first choice with a description that will wrap to multiple lines when the dialog is narrow' }, + { label: 'Bravo', description: 'second choice' }, + { label: 'Charlie', description: 'third choice' }, + { label: 'Delta', description: 'fourth choice' }, + { label: 'Echo', description: 'fifth choice' }, + { label: 'Foxtrot', description: 'sixth choice' }, + ], + }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + expect(result.terminal.output).toContain('↓') + expect(result.terminal.output).toContain('more') + for (let step = 0; step < 5; step += 1) result.terminal.send('\x1b[B') + await tick() + expect(result.terminal.output).toContain('↑') + result.terminal.send('\x03') + await rejected + await dispose(result) + }) + + it('keeps controls visible when the selected option block exceeds the row budget', async () => { + const result = await setup({ + config: { questionDialogWidth: 40, questionDialogMaxHeight: 10 }, + }) + const answer = result.ctx.userInteraction.ask({ + questions: [{ + id: 'oversize', + question: 'Pick one', + options: [ + { label: 'Huge', description: `start ${'middle '.repeat(40)}visible tail` }, + { label: 'Other' }, + ], + }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + expect(result.terminal.output).toContain('Huge') + expect(result.terminal.output).toContain('PgUp/PgDn') + expect(result.terminal.output).toContain('↑↓ Tab ↵ Esc') + expect(result.terminal.output).not.toContain('visible tail') + for (let page = 0; page < 30; page += 1) result.terminal.send('\x1b[6~') + await tick() + expect(result.terminal.output).toContain('visible tail') + for (let page = 0; page < 30; page += 1) result.terminal.send('\x1b[5~') + result.terminal.send('\x1b[6~') + await tick() + expect(result.terminal.output).toContain('start middle') + result.terminal.send('\x03') + await rejected + await dispose(result) + }) + + it('pages long question detail so every plan-review line remains reachable', async () => { + const result = await setup({ + config: { questionDialogWidth: 20, questionDialogMaxHeight: 10 }, + }) + const answer = result.ctx.userInteraction.ask({ + questions: [{ + id: 'long-detail', + question: 'Approve this plan?', + detail: `visible start ${'review step '.repeat(60)}visible tail`, + options: [{ label: 'Approve' }, { label: 'Reject' }], + }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + result.terminal.resize(60, 20) + await tick() + const initialRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J')) + expect(initialRender).toContain('plan?') + expect(initialRender).toContain('visible start') + expect(initialRender).not.toContain('visible tail') + expect(initialRender).toMatch(/PgUp\/PgDn \d+\/\d+/u) + for (let page = 0; page < 30; page += 1) result.terminal.send('\x1b[6~') + await tick() + const finalRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J')) + expect(finalRender).toContain('visible tail') + expect(finalRender).toContain('Approve') + result.terminal.send('\x1b[B') + await tick() + const movedRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J')) + expect(movedRender).toContain('visible tail') + expect(movedRender).toContain('Reject') + result.terminal.send('\x1b[A') + result.terminal.send('\t') + await tick() + result.terminal.send('\x1b[6~') + await tick() + result.terminal.send('\x1b[5~') + result.terminal.resize(61, 20) + await tick() + const customPagedRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J')) + expect(customPagedRender).not.toContain('visible tail') + expect(customPagedRender).toContain('Esc options') + result.terminal.send('\x1b') + for (let page = 0; page < 30; page += 1) result.terminal.send('\x1b[5~') + await tick() + const restoredRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J')) + expect(restoredRender).toContain('visible start') + result.terminal.send('\x03') + await rejected + await dispose(result) + }) + + it('reclaims enough rows to keep selected content, paging, and option markers visible', async () => { + const result = await setup({ + config: { questionDialogWidth: 60, questionDialogMaxHeight: 8 }, + }) + const answer = result.ctx.userInteraction.ask({ + questions: [{ + id: 'one-row', + question: 'Pick one', + options: [ + { label: 'Selected first', description: `start ${'middle '.repeat(30)}visible tail` }, + { label: 'Hidden second' }, + ], + }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + expect(result.terminal.output).toContain('Selected first') + expect(result.terminal.output).not.toContain('Hidden second') + expect(result.terminal.output).toContain('↓ 1 more') + expect(result.terminal.output).toContain('PgUp/PgDn') + expect(result.terminal.output).toContain('Esc interrupt') + for (let page = 0; page < 30; page += 1) result.terminal.send('\x1b[6~') + await tick() + expect(result.terminal.output).toContain('visible tail') + result.terminal.send('\x03') + await rejected + await dispose(result) + }) + + it('preserves both option markers and controls at the minimum configured height', async () => { + const result = await setup({ + config: { questionDialogWidth: 60, questionDialogMaxHeight: 6 }, + }) + const answer = result.ctx.userInteraction.ask({ + questions: [{ + id: 'minimum-options', + question: 'Pick one', + multiSelect: true, + options: ['One', 'Two', 'Three', 'Four', 'Five'].map(label => ({ + label, + description: `${label} ${'wrapped detail '.repeat(20)}`, + })), + }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + result.terminal.send('\x1b[B') + result.terminal.send('\x1b[B') + await tick() + expect(result.terminal.output).toContain('↑ 2 more') + expect(result.terminal.output).toContain('Three') + expect(result.terminal.output).toContain('PgUp/PgDn') + expect(result.terminal.output).toContain('↓ 2 more') + expect(result.terminal.output).toContain('Tab custom') + expect(result.terminal.output).toContain('Space toggle') + expect(result.terminal.output).toContain('Esc interrupt') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('Error: Select at least one') + result.terminal.resize(61) + await tick() + const validationRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J')) + expect(validationRender).toContain('Tab custom') + expect(validationRender).toContain('Space toggle') + result.terminal.send('\x03') + await rejected + await dispose(result) + }) + + it('preserves detail text and every action when one compact header row remains', async () => { + const result = await setup({ + config: { questionDialogWidth: 20, questionDialogMaxHeight: 6 }, + }) + const answer = result.ctx.userInteraction.ask({ + questions: [{ + id: 'one-header-row', + question: 'Plan?', + detail: 'abcdvisible tail', + multiSelect: true, + options: [ + { label: 'Yes', description: 'accept' }, + { label: 'No', description: 'reject' }, + ], + }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + result.terminal.resize(60, 20) + await tick() + const initialRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J')) + expect(initialRender).toContain('P↑↓ ↑↓ Tab S↵Esc') + result.terminal.send('\x1b[6~') + result.terminal.send('\x1b[6~') + await tick() + const detailRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J')) + expect(detailRender).toContain('visible tail') + result.terminal.send('\x03') + await rejected + + const single = result.ctx.userInteraction.ask({ + questions: [{ + id: 'one-header-row-single', + question: 'Plan?', + detail: 'abcdvisible tail', + options: [ + { label: 'Yes', description: 'accept' }, + { label: 'No', description: 'reject' }, + ], + }], + }) + const singleRejected = expect(single).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + result.terminal.resize(61, 20) + await tick() + const singleRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J')) + expect(singleRender).toContain('P↑↓ ↑↓ Tab↵Esc') + result.terminal.send('\x03') + await singleRejected + + const compact = result.ctx.userInteraction.ask({ + questions: [{ + id: 'one-header-row-compact', + question: 'Pick?', + multiSelect: true, + options: [ + { label: 'Yes', description: 'accept' }, + { label: 'No', description: 'reject' }, + ], + }], + }) + const compactRejected = expect(compact).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + result.terminal.resize(60, 20) + await tick() + const compactRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J')) + expect(compactRender).toContain('↑↓ Tab Sp ↵Esc') + result.terminal.send('\x03') + await compactRejected + + const oneOption = result.ctx.userInteraction.ask({ + questions: [{ + id: 'one-header-row-one-option', + question: 'Pick?', + detail: 'Review every line.', + options: [{ label: 'Yes', description: 'wrapped detail '.repeat(8) }], + }], + }) + const oneOptionRejected = expect(oneOption).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + result.terminal.resize(61, 20) + await tick() + const oneOptionRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J')) + expect(oneOptionRender).toContain('P↑↓ Tab↵Esc') + expect(oneOptionRender).not.toContain('P↑↓ ↑↓') + result.terminal.send('\x03') + await oneOptionRejected + await dispose(result) + }) + + it('expands the visible option window forward and backward around the selection', async () => { + const result = await setup({ + config: { + questionDialogWidth: 60, + questionDialogMaxHeight: 14, + maxQuestionOptions: 8, + }, + }) + const answer = result.ctx.userInteraction.ask({ + questions: [{ + id: 'middle-scroll', + question: 'Pick one', + options: [ + { label: 'One', description: 'a' }, + { label: 'Two', description: 'b' }, + { label: 'Three', description: 'c' }, + { label: 'Four', description: 'd' }, + { label: 'Five', description: 'e' }, + { label: 'Six', description: 'f' }, + { label: 'Seven', description: 'g' }, + { label: 'Eight', description: 'h' }, + { label: 'Nine', description: 'i' }, + { label: 'Ten', description: 'j' }, + ], + }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + for (let step = 0; step < 4; step += 1) result.terminal.send('\x1b[B') + await tick() + expect(result.terminal.output).toContain('↑') + expect(result.terminal.output).toContain('↓') + expect(result.terminal.output).toContain('Five') + result.terminal.send('\x03') + await rejected + await dispose(result) + }) + + it('wraps a long option label across multiple lines instead of truncating it', async () => { + const result = await setup({ config: { questionDialogWidth: 40 } }) + const longLabel = 'this is a very long option label that will not fit on one line in a narrow dialog' + const answer = result.ctx.userInteraction.ask({ + questions: [{ + id: 'long-label', + question: 'Pick one', + options: [{ label: longLabel }, { label: 'Short' }], + }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + expect(result.terminal.output).toContain('narrow dialog') + result.terminal.send('\x03') + await rejected + await dispose(result) + }) + + it('wraps fixed question chrome within the minimum dialog width', async () => { + const result = await setup({ config: { questionDialogWidth: 20 } }) + const answer = result.ctx.userInteraction.ask({ + questions: [{ id: 'narrow', question: 'Answer?' }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + expect(result.terminal.output).not.toContain('Question 1/1 (1 unanswered)') + expect(result.terminal.output).toContain('unanswered)') + expect(result.terminal.output).not.toContain('Enter submit • Esc cancel') + expect(result.terminal.output).toContain('Esc cancel') + result.terminal.send('\x03') + await rejected + await dispose(result) + }) + + it('keeps custom controls visible at the minimum dialog height', async () => { + const result = await setup({ + config: { questionDialogWidth: 20, questionDialogMaxHeight: 6 }, + }) + const answer = result.ctx.userInteraction.ask({ + questions: [{ id: 'short-viewport', question: 'Answer this deliberately long question?' }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + expect(result.terminal.output).toContain('long question?') + expect(result.terminal.output).toContain('Esc cancel') + result.terminal.resize(60, 4) + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('Enter an answer') + expect(result.terminal.output).toContain('long question?') + result.terminal.send('\x03') + await rejected + await dispose(result) + }) + + it('compacts custom controls for a question that also has options', async () => { + const result = await setup({ + config: { questionDialogWidth: 20, questionDialogMaxHeight: 6 }, + }) + const answer = result.ctx.userInteraction.ask({ + questions: [{ + id: 'compact-custom-options', + question: 'Choose or type a deliberately long answer', + options: [{ label: 'Default' }], + }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + result.terminal.send('\t') + await tick() + expect(result.terminal.output).toContain('Esc options') + result.terminal.send('\x1b') + await tick() + result.terminal.send('\x03') + await rejected + await dispose(result) + }) + + it('reports hidden question rows when the viewport leaves one row', async () => { + const result = await setup({ + config: { questionDialogWidth: 60, questionDialogMaxHeight: 6 }, + }) + result.terminal.resize(60, 2) + const answer = result.ctx.userInteraction.ask({ + questions: [{ id: 'one-row-dialog', question: 'Answer this deliberately long question?' }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + expect(result.terminal.output).toContain('lines hidden') + result.terminal.send('\x03') + await rejected + await dispose(result) + }) + + it('keeps question text when the viewport leaves two question rows', async () => { + const result = await setup({ + config: { questionDialogWidth: 60, questionDialogMaxHeight: 6 }, + }) + result.terminal.resize(60, 3) + const answer = result.ctx.userInteraction.ask({ + questions: [{ id: 'two-row-dialog', question: 'Answer this deliberately long question?' }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + expect(result.terminal.output).toContain('long question?') + result.terminal.send('\x03') + await rejected + await dispose(result) + }) + + it('bounds option mode when the viewport leaves three question rows', async () => { + const result = await setup({ + config: { questionDialogWidth: 60, questionDialogMaxHeight: 6 }, + }) + result.terminal.resize(60, 4) + const answer = result.ctx.userInteraction.ask({ + questions: [{ + id: 'three-row-options', + question: 'Pick one', + options: [{ label: 'First' }, { label: 'Second' }], + }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + expect(result.terminal.output).toContain('lines hidden') + result.terminal.send('\x03') + await rejected + await dispose(result) + }) + + it('keeps question rows within a sub-five-column viewport', async () => { + const terminal = new HeadlessTerminal(4, 12) + const result = await createTuiTestHarness(terminal, vi.fn(), { + config: { questionDialogWidth: 20 }, + }) + const beforeQuestion = terminal.frames + const answer = result.ctx.userInteraction.ask({ + questions: [{ id: 'narrow-viewport', question: 'Pick?', options: [{ label: 'Yes' }] }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await terminal.waitForFrame(beforeQuestion) + await expect(terminal.snapshot()).resolves.toContain('terminal 4x12') + terminal.send('\x03') + await rejected + await disposeTuiTestHarness(result) + }) + it('asks batches in order and rejects cancelled or aborted work', async () => { const result = await setup() const preAborted = new AbortController() From 5baa86b97f7d0c207bc0dfab1ffabf5c3bba407b Mon Sep 17 00:00:00 2001 From: kingwl Date: Mon, 3 Aug 2026 17:22:51 +0800 Subject: [PATCH 124/129] fix(web): align subagent catalog spacing --- .../branchless-layout.expected.md | 5 ++ apps/web/tests/subagent-conversation.e2e.ts | 75 +++++++++++++++++++ .../client/SubagentCatalogAction.module.css | 70 +++++++++++------ 3 files changed, 127 insertions(+), 23 deletions(-) create mode 100644 apps/web/tests/snapshots/subagent-conversation/branchless-layout.expected.md diff --git a/apps/web/tests/snapshots/subagent-conversation/branchless-layout.expected.md b/apps/web/tests/snapshots/subagent-conversation/branchless-layout.expected.md new file mode 100644 index 0000000000..962e68b808 --- /dev/null +++ b/apps/web/tests/snapshots/subagent-conversation/branchless-layout.expected.md @@ -0,0 +1,5 @@ +menu: 360px; padding 4px; gap 4px; radius 12px +row: 352×54px; padding 7px 8px 7px 4px; radius 8px +content: 340×40px; left inset 8px; right inset 12px +label: 14px/20px/400 +summary: 12px/18px/400 diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index 6f339cf9a9..94b67a29c1 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -21,6 +21,7 @@ const BASE_FIXTURE = fileURLToPath(new URL('./snapshots/live-interactions/sessio const AVAILABLE_CHILD_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/ui.expected.md', import.meta.url)) const TREE_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/tree.expected.md', import.meta.url)) const BRANCHLESS_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/branchless.expected.md', import.meta.url)) +const BRANCHLESS_LAYOUT_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/branchless-layout.expected.md', import.meta.url)) const STALE_CATALOG_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/stale-catalog.expected.md', import.meta.url)) const SIDEBAR_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/sidebar.expected.md', import.meta.url)) const UNAVAILABLE_GRANDCHILD_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/nested.expected.md', import.meta.url)) @@ -400,6 +401,80 @@ describe('web e2e: persisted subagent conversation and human continuation', () = const tree = page.getByRole('tree', { name: 'Subagent sessions' }) const nestedRow = tree.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }) expect(await nestedRow.locator(':scope > *').count()).toBe(1) + const clickArea = nestedRow.locator(':scope > *') + const label = nestedRow.getByText(NESTED_LABEL, { exact: true }) + const summary = nestedRow.getByText('continuable · not running', { exact: true }) + const [treeBox, rowBox, clickAreaBox, treeStyle, rowStyle, labelStyle, summaryStyle] = await Promise.all([ + tree.boundingBox(), + nestedRow.boundingBox(), + clickArea.boundingBox(), + tree.evaluate((element) => { + const style = getComputedStyle(element) + return { gap: style.gap, padding: style.padding, radius: style.borderRadius } + }), + nestedRow.evaluate((element) => { + const style = getComputedStyle(element) + return { padding: style.padding, radius: style.borderRadius } + }), + label.evaluate((element) => { + const style = getComputedStyle(element) + return { size: style.fontSize, lineHeight: style.lineHeight, weight: style.fontWeight } + }), + summary.evaluate((element) => { + const style = getComputedStyle(element) + return { size: style.fontSize, lineHeight: style.lineHeight, weight: style.fontWeight } + }), + ]) + expect(treeBox).not.toBeNull() + expect(rowBox).not.toBeNull() + expect(clickAreaBox).not.toBeNull() + const leftInset = Math.round(clickAreaBox!.x - treeBox!.x) + const rightInset = Math.round( + treeBox!.x + treeBox!.width - clickAreaBox!.x - clickAreaBox!.width, + ) + const layout = { + menuWidth: Math.round(treeBox!.width), + menuPadding: treeStyle.padding, + menuGap: treeStyle.gap, + menuRadius: treeStyle.radius, + rowWidth: Math.round(rowBox!.width), + rowHeight: Math.round(rowBox!.height), + rowPadding: rowStyle.padding, + rowRadius: rowStyle.radius, + contentWidth: Math.round(clickAreaBox!.width), + contentHeight: Math.round(clickAreaBox!.height), + leftInset, + rightInset, + label: labelStyle, + summary: summaryStyle, + } + expect(layout).toEqual({ + menuWidth: 360, + menuPadding: '4px', + menuGap: '4px', + menuRadius: '12px', + rowWidth: 352, + rowHeight: 54, + rowPadding: '7px 8px 7px 4px', + rowRadius: '8px', + contentWidth: 340, + contentHeight: 40, + leftInset: 8, + rightInset: 12, + label: { size: '14px', lineHeight: '20px', weight: '400' }, + summary: { size: '12px', lineHeight: '18px', weight: '400' }, + }) + await compareOrRefreshGolden( + BRANCHLESS_LAYOUT_EXPECTED, + [ + `menu: ${layout.menuWidth}px; padding ${layout.menuPadding}; gap ${layout.menuGap}; radius ${layout.menuRadius}`, + `row: ${layout.rowWidth}×${layout.rowHeight}px; padding ${layout.rowPadding}; radius ${layout.rowRadius}`, + `content: ${layout.contentWidth}×${layout.contentHeight}px; left inset ${layout.leftInset}px; right inset ${layout.rightInset}px`, + `label: ${layout.label.size}/${layout.label.lineHeight}/${layout.label.weight}`, + `summary: ${layout.summary.size}/${layout.summary.lineHeight}/${layout.summary.weight}`, + ].join('\n'), + MODE, + ) await compareOrRefreshGolden( BRANCHLESS_EXPECTED, await captureStableAria(page, '[role="tree"][aria-label="Subagent sessions"]', scaffold.workspaceCwd), diff --git a/packages/client/ui-subagent/src/client/SubagentCatalogAction.module.css b/packages/client/ui-subagent/src/client/SubagentCatalogAction.module.css index 239081c59c..88e75de7ce 100644 --- a/packages/client/ui-subagent/src/client/SubagentCatalogAction.module.css +++ b/packages/client/ui-subagent/src/client/SubagentCatalogAction.module.css @@ -49,42 +49,43 @@ box-sizing: border-box; display: flex; flex-direction: column; - width: 336px; + gap: 4px; + width: 360px; max-width: min(400px, calc(100vw - 32px)); - max-height: min(560px, calc(100vh - 140px)); + max-height: calc(100vh - 140px); padding: 4px; overflow: auto; - border: 1px solid var(--dsw-alias-border-l2); + border: 0; border-radius: 12px; background: var(--dsw-specific-menu); --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); - box-shadow: var(--dsw-shadow-lv3); + box-shadow: inset 0 0 0 1px var(--dsw-alias-border-inverted), var(--dsw-shadow-lv3); } .node { position: relative; + display: flex; + flex-direction: column; + gap: 4px; min-width: 0; } -.menu > .node { - margin-left: -8px; -} .row { position: relative; display: flex; align-items: flex-start; - gap: 8px; + gap: 4px; box-sizing: border-box; width: 100%; - min-height: 50px; - padding: 7px 8px 7px 11px; + min-height: 54px; + padding: 7px 8px 7px 4px; border: 0; border-radius: 8px; background: transparent; color: var(--dsw-alias-label-primary); - font-size: 13px; - line-height: 18px; + font-size: 14px; + line-height: 20px; text-align: left; cursor: pointer; outline: none; @@ -101,16 +102,26 @@ flex: 1; align-self: stretch; align-items: flex-start; - gap: 8px; + gap: 6px; min-width: 0; - margin: -7px -8px -7px; - padding: 7px 8px; - border-radius: 8px; + border-radius: 12px; } -.row > :global([data-state]), .clickarea > :global([data-state]) { - margin-top: 4px; + margin: 5px 3px 0; +} + +.row:has(> .disclosure) > .clickarea, +.row:has(> .disclosureSpace) > .clickarea { + padding-left: 18px; +} + +.row:has(> .disclosureSpace):not(:has(> .clickarea)) { + padding-left: 22px; +} + +.row > :global([data-state]) { + margin: 5px 3px 0; } .disabled { @@ -128,9 +139,16 @@ .disclosure, .disclosureSpace { + position: absolute; + top: 7px; + left: 4px; flex: none; width: 14px; - height: 18px; + height: 20px; +} + +.disclosureSpace { + display: none; } .disclosure { @@ -157,6 +175,7 @@ display: flex; flex: 1; flex-direction: column; + gap: 2px; min-width: 0; } @@ -169,19 +188,21 @@ .label { color: inherit; + font-size: 14px; font-weight: 400; + line-height: 20px; } .summary, .metrics { color: var(--dsw-alias-label-tertiary); - font-size: 11px; - line-height: 16px; + font-size: 12px; + line-height: 18px; } .metrics { display: grid; - grid-template-rows: 18px 16px; + grid-template-rows: 20px 18px; flex: none; font-variant-numeric: tabular-nums; text-align: right; @@ -190,7 +211,7 @@ .metricToken { grid-row: 1; - line-height: 18px; + line-height: 20px; } .metricDuration { @@ -199,6 +220,9 @@ .children { position: relative; + display: flex; + flex-direction: column; + gap: 4px; margin-left: 18px; padding-left: 4px; } From 5884a44e6ba2b53ef2081e3554b14dabdaaf54f6 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Mon, 3 Aug 2026 02:43:27 -0700 Subject: [PATCH 125/129] docs(ui): clarify approval wait ownership --- packages/client/ui-conversation/README.i18n.yaml | 4 ++-- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- packages/client/ui-workspace/README.i18n.yaml | 4 ++-- packages/client/ui-workspace/README.md | 2 +- packages/client/ui-workspace/README.zh.md | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 09e17a4371..da6ea1a597 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/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-conversation/README.md -README.md: e1dbe7d4d5992b6b5b029fddfc9d9857ccae7443 -README.zh.md: fd82ad65f8e903a6f7106e8b8ff8eccbf1435957 +README.md: 78572ba0ab3ce9475dba31dee8844017564e2a18 +README.zh.md: 7708e980e24f4ea4365fbbacd641a5be6c61b138 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index e1dbe7d4d5..78572ba0ab 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -10,7 +10,7 @@ The resident conversation shell survives no-session and session transitions. Wit The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: `), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves. -Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The sidebar mirrors the blocked state through the manager-tracked `waitingApproval` list bit (lit for uninstantiated sessions too), which outranks the running ring until the question resolves. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels. Safe preset picks submit `/permission ` immediately through the bar's injected `command` callback, while `danger-full-access` is presented as `Full access` and first opens an in-page Modal risk confirmation. The enabling action stays disabled until the user checks the acknowledgement; cancel, Escape, close, and mask click submit nothing. +Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The runtime manager tracks this approval wait through the `waitingApproval` list bit even for uninstantiated sessions; `ui-workspace` owns its sidebar presentation. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels. Safe preset picks submit `/permission ` immediately through the bar's injected `command` callback, while `danger-full-access` is presented as `Full access` and first opens an in-page Modal risk confirmation. The enabling action stays disabled until the user checks the acknowledgement; cancel, Escape, close, and mask click submit nothing. The session header declares and renders the session-scoped `'conversation.session.header.actions'` list beside the title, allowing feature plugins to contribute controls without entering the skeleton. The composer chain currency includes the current conversation `session`; ui-subagent selects one-shot or parent-unavailable addressed sessions for reason-specific read-only copy, while the ordinary InputBar keeps every addressed child Send-only because the continuation service exposes no public per-Activation cancellation operation and `session.cancel` would bypass its ownership. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index fd82ad65f8..7708e980e2 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -32,7 +32,7 @@ Think 行默认保持折叠,并在不展开思维链的情况下暴露实时 工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openFile`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);bash 示例是第三方姿态的范例。Trajectory/waterfall(瀑布式事件)工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。 -审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。侧边栏通过 manager 跟踪的 `waitingApproval` 列表位(未实例化会话同样点亮)镜像该阻塞状态,其优先级高于运行中圆环,直至问题解决。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,其中 kebab-case 预设名渲染为 Title Case 标签;普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission `,而 `danger-full-access` 在界面中显示为 `Full access`,选择后先打开页面内的 Modal 风险确认。用户勾选确认项前启用按钮始终不可用;取消、Escape、关闭按钮与点击遮罩都不会提交命令。 +审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。运行时 manager 通过 `waitingApproval` 列表位跟踪这种审批等待,未实例化的 Session 也不例外;`ui-workspace` 负责其侧边栏呈现。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,其中 kebab-case 预设名渲染为 Title Case 标签;普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission `,而 `danger-full-access` 在界面中显示为 `Full access`,选择后先打开页面内的 Modal 风险确认。用户勾选确认项前启用按钮始终不可用;取消、Escape、关闭按钮与点击遮罩都不会提交命令。 todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: 0` 占用 `'conversation.input.dock'` 列表 slot(位于 Goal 与 Queue 之前),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。 diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 597b036340..374efd0f58 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/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-workspace/README.md -README.md: 09370a56d891044d14212992b63323448f0d4420 -README.zh.md: 7c6e9fee286fee172fe8476ad6ab294d45a8ece1 +README.md: 17105f9d70ab5fa0c0472c4b3fb39b759107f469 +README.zh.md: b40b9469271e539501a8f6fc0b70a84f8961f7ab diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index 09370a56d8..17105f9d70 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -12,7 +12,7 @@ Workspace and Session hover cards copy the value their row clips: activating a W The Session row's Fork action forks at the source's last completed turn, increments the inherited persisted title on the client, and then opens the child; a trailing ASCII or fullwidth parenthesized number is incremented in the same style, while an unnumbered title gets ` (1)` appended. The source and child always appear as peer rows within a workspace group, with lineage retained only as session data. A fork or rename failure leaves the current selection unchanged; after a rename failure, the created child remains in the list. -Session rows distinguish the runtime's live `waitingApproval` approval-request fact from an otherwise blue in-flight Session: an amber warning dot takes precedence over the running indicator, and the hover card reports **Waiting for approval** until the request is resolved. Every lit state carries a visually hidden label (`Waiting for approval` or `Running`) for assistive technology; an idle row leaves the reserved status slot empty. Question waits are tracked separately and do not set `waitingApproval`. +Session rows distinguish the runtime's live `waitingApproval` approval-request fact from an otherwise blue in-flight Session: an amber warning dot takes precedence over the running indicator, and the hover card reports **Waiting for approval** until the request is resolved. Every lit state carries a visually hidden label (`Waiting for approval` or `Running`) for assistive technology; an idle row leaves the reserved status slot empty. Question waits do not set a list-level status bit such as `waitingApproval`. Both target slots are declared by other plugins, so `apply` registers through declaration-aware deferral and re-registers after a declaring slot is restored. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index 7c6e9fee28..b40b946927 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -12,7 +12,7 @@ Workspace 和 Session 悬浮卡片会复制对应行被截断的值:激活 Wor Session 行内的 Fork 操作在源会话最后一个已完成轮次处 fork,在 client 端递增继承的持久化标题后再打开子会话;尾部半角或全角括号编号会原样式递增,无编号标题追加 ` (1)`。源会话与子会话在 workspace 组内始终作为同级行展示,谱系只保留为 session 数据。Fork 或改名失败都不会改变当前选中项,改名失败时已创建的子会话仍会留在列表中。 -Session 行会把 runtime 的实时 `waitingApproval` 审批请求状态与原本显示为蓝色的进行中 Session 区分开:琥珀色警告点优先于运行指示器,hover 卡片则在请求解决前显示**等待审批**。每种点亮状态都带有面向辅助技术的视觉隐藏标签(等待审批或进行中,随词典本地化);空闲行会保留空的状态槽位。问题等待由另一套状态跟踪,不会设置 `waitingApproval`。 +Session 行会把 runtime 的实时 `waitingApproval` 审批请求状态与原本显示为蓝色的进行中 Session 区分开:琥珀色警告点优先于运行指示器,hover 卡片则在请求解决前显示**等待审批**。每种点亮状态都带有面向辅助技术的视觉隐藏标签(等待审批或进行中,随词典本地化);空闲行会保留空的状态槽位。问题等待不会设置如 `waitingApproval` 这样的列表级状态位。 两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。 From 6585847144db642bd89e8de8d0300172cc175817 Mon Sep 17 00:00:00 2001 From: kingwl Date: Mon, 3 Aug 2026 18:24:25 +0800 Subject: [PATCH 126/129] fix(web): preserve subagent catalog styling --- .../branchless-layout.expected.md | 5 -- apps/web/tests/subagent-conversation.e2e.ts | 74 ++----------------- .../client/SubagentCatalogAction.module.css | 70 ++++++------------ 3 files changed, 28 insertions(+), 121 deletions(-) delete mode 100644 apps/web/tests/snapshots/subagent-conversation/branchless-layout.expected.md diff --git a/apps/web/tests/snapshots/subagent-conversation/branchless-layout.expected.md b/apps/web/tests/snapshots/subagent-conversation/branchless-layout.expected.md deleted file mode 100644 index 962e68b808..0000000000 --- a/apps/web/tests/snapshots/subagent-conversation/branchless-layout.expected.md +++ /dev/null @@ -1,5 +0,0 @@ -menu: 360px; padding 4px; gap 4px; radius 12px -row: 352×54px; padding 7px 8px 7px 4px; radius 8px -content: 340×40px; left inset 8px; right inset 12px -label: 14px/20px/400 -summary: 12px/18px/400 diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index 94b67a29c1..0049cb2791 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -21,7 +21,6 @@ const BASE_FIXTURE = fileURLToPath(new URL('./snapshots/live-interactions/sessio const AVAILABLE_CHILD_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/ui.expected.md', import.meta.url)) const TREE_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/tree.expected.md', import.meta.url)) const BRANCHLESS_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/branchless.expected.md', import.meta.url)) -const BRANCHLESS_LAYOUT_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/branchless-layout.expected.md', import.meta.url)) const STALE_CATALOG_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/stale-catalog.expected.md', import.meta.url)) const SIDEBAR_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/sidebar.expected.md', import.meta.url)) const UNAVAILABLE_GRANDCHILD_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/nested.expected.md', import.meta.url)) @@ -402,79 +401,16 @@ describe('web e2e: persisted subagent conversation and human continuation', () = const nestedRow = tree.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }) expect(await nestedRow.locator(':scope > *').count()).toBe(1) const clickArea = nestedRow.locator(':scope > *') - const label = nestedRow.getByText(NESTED_LABEL, { exact: true }) - const summary = nestedRow.getByText('continuable · not running', { exact: true }) - const [treeBox, rowBox, clickAreaBox, treeStyle, rowStyle, labelStyle, summaryStyle] = await Promise.all([ + const [treeBox, clickAreaBox] = await Promise.all([ tree.boundingBox(), - nestedRow.boundingBox(), clickArea.boundingBox(), - tree.evaluate((element) => { - const style = getComputedStyle(element) - return { gap: style.gap, padding: style.padding, radius: style.borderRadius } - }), - nestedRow.evaluate((element) => { - const style = getComputedStyle(element) - return { padding: style.padding, radius: style.borderRadius } - }), - label.evaluate((element) => { - const style = getComputedStyle(element) - return { size: style.fontSize, lineHeight: style.lineHeight, weight: style.fontWeight } - }), - summary.evaluate((element) => { - const style = getComputedStyle(element) - return { size: style.fontSize, lineHeight: style.lineHeight, weight: style.fontWeight } - }), ]) expect(treeBox).not.toBeNull() - expect(rowBox).not.toBeNull() expect(clickAreaBox).not.toBeNull() - const leftInset = Math.round(clickAreaBox!.x - treeBox!.x) - const rightInset = Math.round( - treeBox!.x + treeBox!.width - clickAreaBox!.x - clickAreaBox!.width, - ) - const layout = { - menuWidth: Math.round(treeBox!.width), - menuPadding: treeStyle.padding, - menuGap: treeStyle.gap, - menuRadius: treeStyle.radius, - rowWidth: Math.round(rowBox!.width), - rowHeight: Math.round(rowBox!.height), - rowPadding: rowStyle.padding, - rowRadius: rowStyle.radius, - contentWidth: Math.round(clickAreaBox!.width), - contentHeight: Math.round(clickAreaBox!.height), - leftInset, - rightInset, - label: labelStyle, - summary: summaryStyle, - } - expect(layout).toEqual({ - menuWidth: 360, - menuPadding: '4px', - menuGap: '4px', - menuRadius: '12px', - rowWidth: 352, - rowHeight: 54, - rowPadding: '7px 8px 7px 4px', - rowRadius: '8px', - contentWidth: 340, - contentHeight: 40, - leftInset: 8, - rightInset: 12, - label: { size: '14px', lineHeight: '20px', weight: '400' }, - summary: { size: '12px', lineHeight: '18px', weight: '400' }, - }) - await compareOrRefreshGolden( - BRANCHLESS_LAYOUT_EXPECTED, - [ - `menu: ${layout.menuWidth}px; padding ${layout.menuPadding}; gap ${layout.menuGap}; radius ${layout.menuRadius}`, - `row: ${layout.rowWidth}×${layout.rowHeight}px; padding ${layout.rowPadding}; radius ${layout.rowRadius}`, - `content: ${layout.contentWidth}×${layout.contentHeight}px; left inset ${layout.leftInset}px; right inset ${layout.rightInset}px`, - `label: ${layout.label.size}/${layout.label.lineHeight}/${layout.label.weight}`, - `summary: ${layout.summary.size}/${layout.summary.lineHeight}/${layout.summary.weight}`, - ].join('\n'), - MODE, - ) + expect([ + Math.round(clickAreaBox!.x - treeBox!.x), + Math.round(treeBox!.x + treeBox!.width - clickAreaBox!.x - clickAreaBox!.width), + ]).toEqual([5, 5]) await compareOrRefreshGolden( BRANCHLESS_EXPECTED, await captureStableAria(page, '[role="tree"][aria-label="Subagent sessions"]', scaffold.workspaceCwd), diff --git a/packages/client/ui-subagent/src/client/SubagentCatalogAction.module.css b/packages/client/ui-subagent/src/client/SubagentCatalogAction.module.css index 88e75de7ce..fc3ddfea46 100644 --- a/packages/client/ui-subagent/src/client/SubagentCatalogAction.module.css +++ b/packages/client/ui-subagent/src/client/SubagentCatalogAction.module.css @@ -49,43 +49,42 @@ box-sizing: border-box; display: flex; flex-direction: column; - gap: 4px; - width: 360px; + width: 336px; max-width: min(400px, calc(100vw - 32px)); - max-height: calc(100vh - 140px); + max-height: min(560px, calc(100vh - 140px)); padding: 4px; overflow: auto; - border: 0; + border: 1px solid var(--dsw-alias-border-l2); border-radius: 12px; background: var(--dsw-specific-menu); --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); - box-shadow: inset 0 0 0 1px var(--dsw-alias-border-inverted), var(--dsw-shadow-lv3); + box-shadow: var(--dsw-shadow-lv3); } .node { position: relative; - display: flex; - flex-direction: column; - gap: 4px; min-width: 0; } +.menu > .node { + margin-left: -3px; +} .row { position: relative; display: flex; align-items: flex-start; - gap: 4px; + gap: 8px; box-sizing: border-box; width: 100%; - min-height: 54px; - padding: 7px 8px 7px 4px; + min-height: 50px; + padding: 7px 8px 7px 11px; border: 0; border-radius: 8px; background: transparent; color: var(--dsw-alias-label-primary); - font-size: 14px; - line-height: 20px; + font-size: 13px; + line-height: 18px; text-align: left; cursor: pointer; outline: none; @@ -102,26 +101,16 @@ flex: 1; align-self: stretch; align-items: flex-start; - gap: 6px; + gap: 8px; min-width: 0; - border-radius: 12px; + margin: -7px -8px -7px; + padding: 7px 8px; + border-radius: 8px; } +.row > :global([data-state]), .clickarea > :global([data-state]) { - margin: 5px 3px 0; -} - -.row:has(> .disclosure) > .clickarea, -.row:has(> .disclosureSpace) > .clickarea { - padding-left: 18px; -} - -.row:has(> .disclosureSpace):not(:has(> .clickarea)) { - padding-left: 22px; -} - -.row > :global([data-state]) { - margin: 5px 3px 0; + margin-top: 4px; } .disabled { @@ -139,16 +128,9 @@ .disclosure, .disclosureSpace { - position: absolute; - top: 7px; - left: 4px; flex: none; width: 14px; - height: 20px; -} - -.disclosureSpace { - display: none; + height: 18px; } .disclosure { @@ -175,7 +157,6 @@ display: flex; flex: 1; flex-direction: column; - gap: 2px; min-width: 0; } @@ -188,21 +169,19 @@ .label { color: inherit; - font-size: 14px; font-weight: 400; - line-height: 20px; } .summary, .metrics { color: var(--dsw-alias-label-tertiary); - font-size: 12px; - line-height: 18px; + font-size: 11px; + line-height: 16px; } .metrics { display: grid; - grid-template-rows: 20px 18px; + grid-template-rows: 18px 16px; flex: none; font-variant-numeric: tabular-nums; text-align: right; @@ -211,7 +190,7 @@ .metricToken { grid-row: 1; - line-height: 20px; + line-height: 18px; } .metricDuration { @@ -220,9 +199,6 @@ .children { position: relative; - display: flex; - flex-direction: column; - gap: 4px; margin-left: 18px; padding-left: 4px; } From fc5ee9f786fb15b612474202f97ec83ec2c6fdfd Mon Sep 17 00:00:00 2001 From: imccyu Date: Mon, 3 Aug 2026 18:34:00 +0800 Subject: [PATCH 127/129] refactor(client): expose loopback state through connection --- ...versioned-gui-welcome-onboarding.i18n.yaml | 4 ++-- ...-07-30-versioned-gui-welcome-onboarding.md | 2 +- ...-30-versioned-gui-welcome-onboarding.zh.md | 2 +- packages/client/connection/README.i18n.yaml | 4 ++-- packages/client/connection/README.md | 2 +- packages/client/connection/README.zh.md | 2 +- packages/client/connection/package.json | 1 - .../client/connection/src/client/index.ts | 7 ++++++- .../connection/src/loopback-hostname.ts | 7 ++----- .../connection/tests/client-apply.spec.ts | 20 +++++++++++++------ .../client/runtime/tests/client-apply.spec.ts | 1 + .../client/runtime/tests/wire-events.spec.ts | 1 + packages/client/tsdown.client.ts | 2 +- .../ui-settings-general/src/client/index.ts | 7 +------ .../ui-settings-general/tests/apply.spec.ts | 11 ++++------ scripts/client-bundle-purity.spec.ts | 1 - 16 files changed, 38 insertions(+), 36 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.i18n.yaml index 6a109260c1..10de05ea42 100644 --- a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.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 .agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md -2026-07-30-versioned-gui-welcome-onboarding.md: 1199cec532dd23930c70b236e6fcac80832204ff -2026-07-30-versioned-gui-welcome-onboarding.zh.md: d59e8971689fad1836127fea79cd392d3d5787c3 +2026-07-30-versioned-gui-welcome-onboarding.md: 4707769d4fa9fbf184e09a2e73087dfd326070be +2026-07-30-versioned-gui-welcome-onboarding.zh.md: c9d2e6274c476c59abc077aa3255ffe57a8a48bc diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md index 1199cec532..4707769d4f 100644 --- a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md +++ b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md @@ -14,7 +14,7 @@ The GUI's credential onboarding begins with a DeepSeek-specific readiness check, **Ownerless product onboarding belongs to `ui-settings-general`.** `src/onboarding-copy.ts` is the single editable source for the complete notice, the Continue label, and `WELCOME_NOTICE_VERSION`; both supported GUI locales intentionally render the same Chinese owner copy. Runtime locale dictionaries derive their welcome values from that file, and tests import the same owner instead of repeating paragraph text. The notice is browser UI only: it creates no Session event and contributes no model-visible content. The notice identifies `DSH_TELEMETRY_DISABLED=1` as the telemetry opt-out. -**Loopback acknowledgement is durable per Harness profile.** The Host half registers a `ui-onboarding` section in the user-settings seam, stored under the active `$DSH_HOME/settings.yaml`. A loopback browser shows the notice unless `welcomeNoticeVersion` equals the owner constant exactly. Continue applies one path mutation with the current version and calls `complete()` only after the Host commits it; a failed write leaves the notice open, and closing the page or process writes nothing. Bumping the constant intentionally makes every profile acknowledge the revised copy once. A non-loopback browser must not call the loopback-only settings API. It presents the same notice, but explicit Continue completes the step only in the current browser process; reload or a new process presents it again. +**Loopback acknowledgement is durable per Harness profile.** The Host half registers a `ui-onboarding` section in the user-settings seam, stored under the active `$DSH_HOME/settings.yaml`. The connection plugin publishes whether the current page uses a loopback authority as `ctx.connection.isLoopback`; hostname classification remains internal to the connection package, and other client plugins consume the service state instead of importing its implementation. A loopback browser shows the notice unless `welcomeNoticeVersion` equals the owner constant exactly. Continue applies one path mutation with the current version and calls `complete()` only after the Host commits it; a failed write leaves the notice open, and closing the page or process writes nothing. Bumping the constant intentionally makes every profile acknowledge the revised copy once. A non-loopback browser must not call the loopback-only settings API. It presents the same notice, but explicit Continue completes the step only in the current browser process; reload or a new process presents it again. **Concurrent loopback views converge without stale replacement.** The acknowledgement write omits `expectedRevision` deliberately: every loopback tab writes the same version to one path, so the operation is idempotent and preserves sibling fields instead of rebuilding the section. `settings/document-updated` becomes `host/settings-changed`; an already mounted loopback tab refetches and advances when another tab or an external editor commits the current version. The API proxy exposes this one product namespace through a closed allowlist beside configurable-provider namespaces, without treating its changes as model-catalog invalidations. diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.zh.md b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.zh.md index d59e897168..c9d2e6274c 100644 --- a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.zh.md @@ -14,7 +14,7 @@ GUI 的凭据引导从 DeepSeek 专用的就绪状态检查开始,但内部测 **不属于单一功能的产品引导由 `ui-settings-general` 持有。** `src/onboarding-copy.ts` 是完整通知、「继续」按钮文案和 `WELCOME_NOTICE_VERSION` 的唯一可编辑来源;GUI 支持的两种 locale 都有意渲染同一份中文所有者文案。运行时 locale 字典从该文件派生欢迎文案,测试也导入同一个所有者,而不重复段落文本。该通知只存在于浏览器 UI:它不会创建会话事件,也不会贡献任何模型可见内容。通知明确以 `DSH_TELEMETRY_DISABLED=1` 作为遥测关闭方式。 -**loopback 确认状态按 Harness profile 持久化。** 宿主端在 user-settings seam 中注册 `ui-onboarding` 分节,并存入当前 `$DSH_HOME/settings.yaml`。除非 `welcomeNoticeVersion` 与文案所有者文件中的常量精确相等,否则 loopback 浏览器会显示通知。「继续」会以当前版本执行一次路径变更,并且仅在宿主端提交成功后调用 `complete()`;写入失败时通知保持打开,关闭页面或进程则不会写入任何内容。提升该常量会有意要求每个 profile 对修订后的文案重新确认一次。非 loopback 浏览器不能调用仅限 loopback 的 settings API;它仍显示同一通知,但显式点击「继续」只会在当前浏览器进程中完成该步骤,重新加载或新进程会再次显示通知。 +**loopback 确认状态按 Harness profile 持久化。** 宿主端在 user-settings seam 中注册 `ui-onboarding` 分节,并存入当前 `$DSH_HOME/settings.yaml`。connection 插件通过 `ctx.connection.isLoopback` 统一发布当前页面是否使用 loopback authority;hostname 判定函数留在 connection 包内,其他客户端插件只消费服务状态,不跨插件导入实现函数。除非 `welcomeNoticeVersion` 与文案所有者文件中的常量精确相等,否则 loopback 浏览器会显示通知。「继续」会以当前版本执行一次路径变更,并且仅在宿主端提交成功后调用 `complete()`;写入失败时通知保持打开,关闭页面或进程则不会写入任何内容。提升该常量会有意要求每个 profile 对修订后的文案重新确认一次。非 loopback 浏览器不能调用仅限 loopback 的 settings API;它仍显示同一通知,但显式点击「继续」只会在当前浏览器进程中完成该步骤,重新加载或新进程会再次显示通知。 **并发 loopback 视图无需陈旧的整体替换即可收敛。** 确认写入有意省略 `expectedRevision`:每个 loopback 标签页都向同一路径写入相同版本,因此该操作是幂等的,并会保留同级字段,而不是重建整个分节。`settings/document-updated` 会转为 `host/settings-changed`;另一个标签页或外部编辑器提交当前版本后,已挂载的 loopback 标签页会重新拉取状态并推进。API 网关在可配置提供方 namespace 之外,通过封闭的允许列表暴露这一个产品 namespace,同时不会把它的变更视为模型目录失效事件。 diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index e159696db3..a636d8bc49 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/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/connection/README.md -README.md: 522ae6a14a3b4b07e7f2917133d16a5e83433f69 -README.zh.md: 4eaed862df678997b328d7a4ddcab1f5254c7c60 +README.md: f537fee3273e3b5d2411197cf1a1a6e0d34af5f9 +README.zh.md: a29d2c00e7df3f6290a03ffdad59b70b43702aca diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index 522ae6a14a..f537fee327 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The dedicated `./loopback-hostname` source subpath exposes the zero-dependency predicate shared by the `/api` Host fence and browser welcome-persistence selection; client bundlers inline this source entry, while plain Node cannot load it directly, so it must remain browser-safe and dependency-free. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`, reads included, since describing returns the exposed configuration and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3. +Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. Loopback hostname classification stays package-internal: the `/api` Host fence uses it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`, reads included, since describing returns the exposed configuration and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3. ## /api browser-trust fence diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index 4eaed862df..a29d2c00e7 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。专用的 `./loopback-hostname` 源码子路径导出 `/api` Host fence 与浏览器欢迎页持久化选择共用的零依赖判定函数;客户端 bundler 会内联这一源码入口,而 plain Node 无法直接加载它,因此它必须保持浏览器安全且零依赖。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`,读取也在内,因为 describe 会返回已暴露的配置,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。 +协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`,读取也在内,因为 describe 会返回已暴露的配置,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。 ## /api 浏览器信任栅栏 diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index f2842ec061..d86b2bdf2a 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -19,7 +19,6 @@ "types": "./lib/types/client/index.d.ts", "default": "./lib/client.js" }, - "./loopback-hostname": "./src/loopback-hostname.ts", "./src/*": "./src/*", "./package.json": "./package.json" }, diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index a7ebfbbd86..9e33e63ce2 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -8,6 +8,7 @@ import type { IApiClient } from './api.ts' import { ConnectionController, type ConnectionConfig, type ConnectionSinks, type ConnectionState } from './connection.ts' import { FixtureApiClient } from './fixture.ts' import { WebApiClient } from './web-api-client.ts' +import { isLoopbackHostname } from '../loopback-hostname.ts' // ---- Contract re-exports (browser-safe apiproxy channels + core types) ---- export type { @@ -48,6 +49,8 @@ export const inject: string[] = [] export interface ConnectionHandle { /** Shared api client (fixture or real, decided at boot from the page URL). */ readonly api: IApiClient + /** Whether the current page authority is loopback; non-browser contexts default to true. */ + readonly isLoopback: boolean /** * Start the connect/pump/reconnect loop with the consumer's frame sinks. * One consumer owns the streams (the runtime object layer); a second call @@ -64,11 +67,13 @@ export interface ConnectionHandle { * @param ctx - client cordis context. */ export function apply(ctx: Context): void { - const fixture = typeof location !== 'undefined' && new URLSearchParams(location.search).has('fixture') + const pageLocation = typeof location === 'undefined' ? undefined : location + const fixture = pageLocation !== undefined && new URLSearchParams(pageLocation.search).has('fixture') const api: IApiClient = fixture ? new FixtureApiClient() : new WebApiClient() let started = false const handle: ConnectionHandle = { api, + isLoopback: pageLocation === undefined || isLoopbackHostname(pageLocation.hostname), start(sinks, config) { if (started) throw new Error('connection: the stream loop is already owned by another consumer') started = true diff --git a/packages/client/connection/src/loopback-hostname.ts b/packages/client/connection/src/loopback-hostname.ts index 5666f0714d..fe2fe93fc9 100644 --- a/packages/client/connection/src/loopback-hostname.ts +++ b/packages/client/connection/src/loopback-hostname.ts @@ -1,10 +1,7 @@ /** * Browser-safe, zero-dependency loopback classification shared by the `/api` - * Host fence and browser welcome-persistence selection. The dedicated - * `./loopback-hostname` source subpath is inlined into client bundles instead - * of loaded by plain Node, so this module must not add Node-only or runtime - * dependencies. - * @module @deepseek-ai/dsh-client-connection/loopback-hostname + * Host fence and the package's `ctx.connection` state. The predicate stays + * package-internal; client plugins consume the derived state through Cordis. */ /** diff --git a/packages/client/connection/tests/client-apply.spec.ts b/packages/client/connection/tests/client-apply.spec.ts index 6892dc7721..43c71dffb7 100644 --- a/packages/client/connection/tests/client-apply.spec.ts +++ b/packages/client/connection/tests/client-apply.spec.ts @@ -8,7 +8,7 @@ import { apply, type ConnectionHandle } from '../src/client/index.ts' import { FixtureApiClient } from '../src/client/fixture.ts' import { WebApiClient } from '../src/client/web-api-client.ts' -type Win = { location?: { search: string } } +type Win = { location?: { hostname: string; search: string } } afterEach(() => { delete (globalThis as Win).location @@ -24,20 +24,28 @@ async function mount(): Promise { describe('connection client apply', () => { it('mounts ctx.connection with the real client when no ?fixture switch is present', async () => { - ;(globalThis as Win).location = { search: '' } + ;(globalThis as Win).location = { hostname: 'localhost', search: '' } const handle = await mount() expect(handle.api).toBeInstanceOf(WebApiClient) + expect(handle.isLoopback).toBe(true) }) it('selects the fixture client under ?fixture (and with no location at all stays real)', async () => { - ;(globalThis as Win).location = { search: '?fixture' } + ;(globalThis as Win).location = { hostname: '127.0.0.1', search: '?fixture' } expect((await mount()).api).toBeInstanceOf(FixtureApiClient) delete (globalThis as Win).location - expect((await mount()).api).toBeInstanceOf(WebApiClient) + const handle = await mount() + expect(handle.api).toBeInstanceOf(WebApiClient) + expect(handle.isLoopback).toBe(true) + }) + + it('reports non-loopback page authority through the connection handle', async () => { + ;(globalThis as Win).location = { hostname: '192.0.2.20', search: '' } + expect((await mount()).isLoopback).toBe(false) }) it('start() hands out one loop, rejects a second consumer, and stop() aborts the streams', async () => { - ;(globalThis as Win).location = { search: '?fixture' } + ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } const handle = await mount() // config omitted: the `config ?? {}` default arm is part of the surface. const loop = handle.start({}) @@ -46,7 +54,7 @@ describe('connection client apply', () => { }) it('WebApiClient carries requests over globalThis.fetch', async () => { - ;(globalThis as Win).location = { search: '' } + ;(globalThis as Win).location = { hostname: 'localhost', search: '' } const handle = await mount() const original = globalThis.fetch const seen: string[] = [] diff --git a/packages/client/runtime/tests/client-apply.spec.ts b/packages/client/runtime/tests/client-apply.spec.ts index d389efe319..e5691a0619 100644 --- a/packages/client/runtime/tests/client-apply.spec.ts +++ b/packages/client/runtime/tests/client-apply.spec.ts @@ -26,6 +26,7 @@ async function mount(): Promise { const bench: Bench = { ctx, api, sinks: undefined, stopped: 0 } const handle: ConnectionHandle = { api, + isLoopback: true, start: (sinks) => { bench.sinks = sinks return { stop: () => { bench.stopped += 1 } } diff --git a/packages/client/runtime/tests/wire-events.spec.ts b/packages/client/runtime/tests/wire-events.spec.ts index fd7858d60c..21e7f1fc06 100644 --- a/packages/client/runtime/tests/wire-events.spec.ts +++ b/packages/client/runtime/tests/wire-events.spec.ts @@ -20,6 +20,7 @@ async function mount(): Promise { const bench: Bench = { ctx, sinks: undefined } const handle: ConnectionHandle = { api, + isLoopback: true, start: (sinks) => { bench.sinks = sinks return { stop: () => {} } diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index fad00fe4f0..2ff1856b3d 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -28,7 +28,7 @@ const CSS_VIRTUAL_SUFFIX = '.mjs' * Everything else under @deepseek-ai/* is either a module-table entry * (external) or a leak the purity gate rejects. */ -export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:host-apiproxy|session|llm|tools|brand)(?:\/|$)|@deepseek-ai\/dsh-client-connection\/loopback-hostname$)/ +export const INLINE_SAFE = /^@deepseek-ai\/dsh-(host-apiproxy|session|llm|tools|brand)(\/|$)/ /** * Documented TEMPORARY exemption, not a platform module (hence not in diff --git a/packages/client/ui-settings-general/src/client/index.ts b/packages/client/ui-settings-general/src/client/index.ts index 6a7510d4ac..87b05e86cb 100644 --- a/packages/client/ui-settings-general/src/client/index.ts +++ b/packages/client/ui-settings-general/src/client/index.ts @@ -8,7 +8,6 @@ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots' import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' -import { isLoopbackHostname } from '@deepseek-ai/dsh-client-connection/loopback-hostname' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' // Type-only: pulls the shell's SlotMap merges (trigger/header/section/item). import type {} from '@deepseek-ai/dsh-client-ui-settings/client' @@ -42,10 +41,6 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Dictionary namespace owned by this plugin (shell chrome + General copy). */ const NS = 'settings' -function welcomePersistence(): 'host' | 'memory' { - return typeof location === 'undefined' || isLoopbackHostname(location.hostname) ? 'host' : 'memory' -} - /** * Required services (cordis fiber inject). The target slots are declared by * ui-settings' apply, whose activation order relative to this one is NOT @@ -66,7 +61,7 @@ export function apply(ctx: ClientContext): void { // locale/change re-registration wiring. const t = ctx.locale.bind(NS) const connection = ctx.get('connection') as ConnectionHandle - const welcomeController = new WelcomeNoticeStore(connection.api, welcomePersistence()) + const welcomeController = new WelcomeNoticeStore(connection.api, connection.isLoopback ? 'host' : 'memory') const useWelcomeSnapshot = bindSnapshotSelector(welcomeController.store) const welcomeInjected = (): WelcomeNoticeInjected => ({ controller: welcomeController, diff --git a/packages/client/ui-settings-general/tests/apply.spec.ts b/packages/client/ui-settings-general/tests/apply.spec.ts index 17e4ced2b2..73f6d8207e 100644 --- a/packages/client/ui-settings-general/tests/apply.spec.ts +++ b/packages/client/ui-settings-general/tests/apply.spec.ts @@ -1,6 +1,6 @@ /** Ownerless-copy registrations: the four seats, the dictionaries, thunked labels, and HMR recovery. */ import { Context } from 'cordis' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' @@ -16,8 +16,6 @@ import { WELCOME_NOTICE_SETTINGS_NAMESPACE } from '../src/onboarding-copy.ts' // the shipped Chinese copy, so they state the browser they assume. usePinnedBrowserLanguages('zh-CN') -afterEach(() => { vi.unstubAllGlobals() }) - /** The five seats this plugin fills (slot name → expected component). */ const SEATS = [ ['settings.trigger', TriggerContent], @@ -27,7 +25,7 @@ const SEATS = [ ['settings.onboarding', WelcomeNotice], ] as const -async function bench() { +async function bench(isLoopback = true) { const ctx = new Context() await ctx.plugin(SlotsService).await() const locale = new LocaleService(ctx) @@ -49,7 +47,7 @@ async function bench() { }, }, })) - ctx.provide('connection', { api: { settings: { describe: settingsDescribe } } } as never) + ctx.provide('connection', { api: { settings: { describe: settingsDescribe } }, isLoopback } as never) return { ctx, slots: ctx.get('slots') as SlotsService, locale, settingsDescribe } } @@ -162,8 +160,7 @@ describe('ui-settings-general apply', () => { }) it('keeps remote welcome acknowledgement process-local', async () => { - vi.stubGlobal('location', { hostname: '192.0.2.20' }) - const b = await bench() + const b = await bench(false) declare(b.slots) await b.ctx.plugin({ inject: [...inject], apply }).await() const entry = b.slots.entries('settings.onboarding')[0]! diff --git a/scripts/client-bundle-purity.spec.ts b/scripts/client-bundle-purity.spec.ts index 2eef1ba5ef..d70964bdba 100644 --- a/scripts/client-bundle-purity.spec.ts +++ b/scripts/client-bundle-purity.spec.ts @@ -53,7 +53,6 @@ describe('client bundle purity gate', () => { expect(resolveId('@deepseek-ai/dsh-host-apiproxy/api')).toBeNull() expect(resolveId('@deepseek-ai/dsh-session/surface')).toBeNull() expect(resolveId('@deepseek-ai/dsh-brand')).toBeNull() - expect(resolveId('@deepseek-ai/dsh-client-connection/loopback-hostname')).toBeNull() }) it('throws on any other @deepseek-ai leak', () => { From 3770d947cf0c6b16ecd640d3e44e4e6bf683fcab Mon Sep 17 00:00:00 2001 From: imccyu Date: Mon, 3 Aug 2026 18:43:18 +0800 Subject: [PATCH 128/129] docs(web): correct welcome acknowledgement contract --- .../client/ui-settings-general/src/client/WelcomeNotice.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/client/ui-settings-general/src/client/WelcomeNotice.tsx b/packages/client/ui-settings-general/src/client/WelcomeNotice.tsx index 0c381e02c9..c25b1bf3aa 100644 --- a/packages/client/ui-settings-general/src/client/WelcomeNotice.tsx +++ b/packages/client/ui-settings-general/src/client/WelcomeNotice.tsx @@ -31,7 +31,7 @@ export interface WelcomeNoticeInjected { export type WelcomeNoticeProps = PropsRuntime<'settings.onboarding'> & PropsLocale<'settings'> & WelcomeNoticeInjected -/** Render the mandatory notice until its current version commits durably. */ +/** Render the mandatory notice until its current version is acknowledged. */ export function WelcomeNotice(props: WelcomeNoticeProps): ReactNode { const { complete, controller, useSnapshot, t } = props const state = useSnapshot(snapshot => snapshot) From aec9e3145e078ecb3078b19a0c839378584f8adc Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:53:25 +0800 Subject: [PATCH 129/129] chore: enable Issue management automation --- .github/issue-management/policy.mjs | 16 ++++++- .github/issue-management/policy.test.mjs | 19 ++++++++ .github/workflows/issue-lifecycle.yml | 58 ++++++++++++++++++++++++ .github/workflows/issue-policy.yml | 27 +++++++++++ 4 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/issue-lifecycle.yml create mode 100644 .github/workflows/issue-policy.yml diff --git a/.github/issue-management/policy.mjs b/.github/issue-management/policy.mjs index bc8c881eda..4c9242bab5 100644 --- a/.github/issue-management/policy.mjs +++ b/.github/issue-management/policy.mjs @@ -180,6 +180,20 @@ export function parseReferences({ body, repository }) { } } +/** + * Retain only references that resolve to Issues rather than pull requests. + * @param {{all: number[], resolving: number[], related: number[]}} references Parsed references. + * @param {Map} issues Resolved same-repository Issues. + * @returns {{all: number[], resolving: number[], related: number[]}} Issue-only references. + */ +export function retainIssueReferences(references, issues) { + return { + all: references.all.filter((number) => issues.has(number)), + resolving: references.resolving.filter((number) => issues.has(number)), + related: references.related.filter((number) => issues.has(number)), + } +} + /** * Validate one Issue with its Project status. * @param {{title: string, body: string, assignees: string[], labels: string[], type: string|null, priority: string|null, status: string|null, state: string, stateReason: string|null}} issue Issue snapshot. @@ -472,7 +486,7 @@ async function pullRequestSnapshot(number) { reviewRequestCount: reviewRequests.users.length + reviewRequests.teams.length, reviewCount: reviews.length, labels: pull.labels.map((label) => label.name), - references, + references: retainIssueReferences(references, issues), issues, } } diff --git a/.github/issue-management/policy.test.mjs b/.github/issue-management/policy.test.mjs index 890247db29..8e0c253796 100644 --- a/.github/issue-management/policy.test.mjs +++ b/.github/issue-management/policy.test.mjs @@ -4,6 +4,7 @@ import test from 'node:test' import { countVisibleUnits, parseReferences, + retainIssueReferences, requiresPullRequestPolicy, validateBody, validateIssue, @@ -117,6 +118,24 @@ test('separates resolving and informational references', () => { ) }) +test('does not treat pull request references as Issue associations', () => { + const references = { + all: [123, 1180, 1181], + resolving: [123, 1180], + related: [1181], + } + const issues = new Map([ + [1180, {}], + [1181, {}], + ]) + + assert.deepEqual(retainIssueReferences(references, issues), { + all: [1180, 1181], + resolving: [1180], + related: [1181], + }) +}) + test('allows informational references without cross-object constraints', () => { const errors = validatePullRequest({ isDraft: false, diff --git a/.github/workflows/issue-lifecycle.yml b/.github/workflows/issue-lifecycle.yml new file mode 100644 index 0000000000..4dc6869e27 --- /dev/null +++ b/.github/workflows/issue-lifecycle.yml @@ -0,0 +1,58 @@ +name: Issue lifecycle + +on: + issues: + types: + - opened + - edited + - assigned + - unassigned + - labeled + - unlabeled + - closed + - reopened + - field_added + - field_removed + pull_request: + types: + - opened + - edited + - synchronize + - reopened + - labeled + - unlabeled + - ready_for_review + - review_requested + pull_request_review: + types: + - submitted + +permissions: + contents: read + +concurrency: + group: issue-lifecycle-${{ github.event.issue.number || github.event.pull_request.number || github.run_id }} + cancel-in-progress: false + +jobs: + lifecycle: + name: Issue lifecycle + runs-on: ubuntu-latest + steps: + - name: Check out trusted policy + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + - name: Create project token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 + with: + client-id: ${{ vars.DSH_ISSUE_APP_CLIENT_ID }} + private-key: ${{ secrets.DSH_ISSUE_APP_PRIVATE_KEY }} + owner: deepseek-harness + repositories: deepseek-harness + - name: Handle repository event + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: node .github/issue-management/policy.mjs lifecycle diff --git a/.github/workflows/issue-policy.yml b/.github/workflows/issue-policy.yml new file mode 100644 index 0000000000..dde9462c33 --- /dev/null +++ b/.github/workflows/issue-policy.yml @@ -0,0 +1,27 @@ +name: Issue policy + +on: + pull_request: + types: [opened, edited, synchronize, reopened, labeled, unlabeled, ready_for_review, review_requested] + pull_request_review: + types: [submitted] + +permissions: + contents: read + issues: read + pull-requests: read + +jobs: + policy: + name: Issue policy + runs-on: ubuntu-latest + steps: + - name: Check out trusted policy + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + - name: Validate pull request + env: + GITHUB_TOKEN: ${{ github.token }} + run: node .github/issue-management/policy.mjs pr