From 94db8e881bf7d68baf97acebb69e82b5668bd3de Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 27 Aug 2026 11:28:59 +0800 Subject: [PATCH] fix(web): render readable ask-user transcripts --- ...29-ask-question-web-presentation.i18n.yaml | 4 +- ...026-07-29-ask-question-web-presentation.md | 14 +- ...-07-29-ask-question-web-presentation.zh.md | 14 +- apps/web/tests/question-composer.e2e.ts | 118 ++++++++++- .../ui-conversation/src/client/locales.ts | 6 + packages/client/ui-tool/README.i18n.yaml | 4 +- packages/client/ui-tool/README.md | 4 +- packages/client/ui-tool/README.zh.md | 4 +- .../src/client/tool/components/ToolRow.tsx | 124 ++++++------ .../toolviews/ask-question-row.module.css | 67 +++++++ .../tool/toolviews/ask-question-row.tsx | 189 ++++++++++++++++-- .../tests/ask-question-row.client.spec.tsx | 113 +++++++++-- .../question-composer/answered.expected.md | 6 +- .../question-composer/cancelled.expected.md | 41 ++++ 14 files changed, 600 insertions(+), 108 deletions(-) create mode 100644 packages/client/ui-tool/src/client/tool/toolviews/ask-question-row.module.css create mode 100644 snapshots/web/question-composer/cancelled.expected.md diff --git a/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.i18n.yaml index 8cf0c30e2b..5d1e095629 100644 --- a/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-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/feature/2026-07-29-ask-question-web-presentation.md -2026-07-29-ask-question-web-presentation.md: fd6c326ccadc83cb9d2edc0151dd94984f2bca8b -2026-07-29-ask-question-web-presentation.zh.md: c26fe91c91280c3f5596a30a3e3483ed1fa784b4 +2026-07-29-ask-question-web-presentation.md: c51c6458fc01d4d99ec9784566439cfca31c43ad +2026-07-29-ask-question-web-presentation.zh.md: ab027b33ba2874547b986251665d611f5b984762 diff --git a/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md index fd6c326cca..c51c6458fc 100644 --- a/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md +++ b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md @@ -14,6 +14,10 @@ Separately, the composer visuals had drifted from the current design: an expand- A pending question owns exactly two surfaces: the composer takeover collects the answers, and a dedicated `ask_user_question` toolview row in the transcript names the interaction outcome. The row registers into the keyed `tool.call.toolview` hole exactly like `todo_write` and composes the shared `ToolRow` (chrome, running sweep, leading expansion). Its summary is the interaction verdict rather than args: `waiting` while running, `N/M answered` from the result JSON once settled (a skipped answer — empty `selected`, no `custom` — stays out of the count), `cancelled` for `ASK_CANCELLED`, and `interrupted` with the shared amber stopped semantics for `ASK_ABORTED`. Malformed or truncated results fall back to the generic summary. `PendingCard` narrowed to `PendingWait<'approval'>` and `ChatView` filtered the pending list to approval waits, leaving the placeholder card to approvals alone; the approval composer takeover ([web permission and approval](2026-07-23-web-permission-and-approval.md)) has since removed it entirely. +A successful row keeps the collapsed transcript to its one-line verdict and replaces generic JSON in the expanded body with a read-only question transcript. The presenter validates questions from call args and answers from result content, pairs them by the echoed stable `id`, preserves call order, and renders each model-authored question in a muted label followed by its selected and custom answer lines in primary text. A skipped question shows the localized `Not answered` verdict. The card caps its height and scrolls internally. Invalid JSON, duplicate ids, missing ids, count mismatches, unknown answer ids, and invalid visible fields all retain the generic input/output card instead of presenting a partial or incorrectly paired transcript. + +A cancelled or interrupted row has no answer payload to pair. Its expanded card shows a localized set-level verdict that no answers were submitted followed by the original model-authored questions; it does not label cancellation as a per-question skip or fabricate answer records. Invalid call args retain the generic diagnostic card. Cancellation uses the neutral settled state because the user chose it deliberately; interruption keeps the amber stopped state. + The composer redesign moves paging into the footer next to the actions, renders multi-select options with explicit checkboxes, keeps single-select numbered rows, and replaces the expand-to-open custom entry with an always-visible custom input row (textarea for optionless questions). The `parseQuestionTitle` multi-select suffix convention is deleted; `multi_select` is already structured metadata, so the title renders verbatim. Composer chrome copy becomes bilingual: the plugin registers zh/en dictionaries under the `question` namespace of `dsh-client-locale` and hands the entry a namespace-bound translator plus the locale snapshot as a hooks-compartment source through the slot inject face, so a locale flip re-renders a mounted composer. Validation feedback is stored as a dictionary key and re-translated on flip; carrier failure messages and all model-authored question/option text render verbatim. @@ -24,11 +28,13 @@ Two adjacent fixes ride along. All generic toolview leading icons (and the hover **Keep rendering questions through `PendingCard`.** Rejected: the card was a read-only placeholder from before the takeover existed, so a pending question showed the same content twice with one copy not answerable. The toolview row plus takeover covers both the transcript record and the collection surface. -**Show the questions or answers inline in the transcript row.** Rejected: the composer takeover owns question rendering and answer collection, and the row convention (`todo_write`) is one line with details in the panel. The row therefore reports only the outcome, mirroring how the todo row reports counts while the panel owns the list. +**Show the questions or answers in the collapsed transcript row.** Rejected: the composer takeover owns answer collection, and the row convention (`todo_write`) keeps the collapsed line scannable. The row therefore reports only the outcome until expanded; its expanded body owns the read-only question transcript. + +**Keep raw input and output JSON in the expanded body.** Rejected: the payload preserves all information but makes the user's own answers or the cancelled questions difficult to scan. The structured view presents the same authored text while retaining raw JSON as the fail-closed fallback when question parsing or answer pairing is not trustworthy. **Render `ASK_CANCELLED`/`ASK_ABORTED` through the generic error shape.** Rejected: dismissal is the user's own deliberate action and an interrupt is the shared stop gesture; both are expected outcomes, not tool failures. Naming the verdict (and keeping amber stopped semantics for the abort) matches how interrupted tool calls read elsewhere. -**Translate the row verdicts now.** Deferred by explicit product decision: the row's `waiting`/`answered`/`cancelled`/`interrupted` strings stay English for this change; the composer chrome i18n landed because its Chinese-only copy was already wrong for the en locale. +**Keep the row verdicts in English.** Initially deferred, then superseded when Client UI copy became locale-owned: the current conversation dictionaries localize the row verdicts and the expanded card's skipped-answer label, while model-authored questions and answers remain verbatim. **Keep the title-suffix multi-select convention.** Rejected: `multi_select` is structured request metadata and the checkbox affordance now carries the signal, so parsing `(可多选)` out of model text was a fragile duplicate channel. @@ -36,10 +42,10 @@ Two adjacent fixes ride along. All generic toolview leading icons (and the hover `ask_user_question` and `todo_write` now demonstrate the intended toolview pattern: compose `ToolRow`, summarize from call args or result JSON with shape-checked fallbacks, and register through the keyed slot. The bespoke `todo-row.module.css` is gone. -The row verdict strings are the one remaining hardcoded-English surface of the question flow; localizing them is deferred follow-up. The approval composer takeover shipped ([web permission and approval](2026-07-23-web-permission-and-approval.md), height-capped per the [approval-panel note](../bug-fix/2026-07-30-approval-panel-command-cap.md)), and `PendingCard` no longer exists. +The expanded transcript adds a structured-body path to the shared `ToolRow`; other tool views retain their existing generic or specialized cards. The question row reads only persisted call and result fields and does not add a Host presentation field. The approval composer takeover shipped ([web permission and approval](2026-07-23-web-permission-and-approval.md), height-capped per the [approval-panel note](../bug-fix/2026-07-30-approval-panel-command-cap.md)), and `PendingCard` no longer exists. `ui-user-questions` gains a `dsh-client-locale` dependency and an inject face where it previously had none; its contract (`QuestionComposerInjected`) lives with the consumer in `contract/slots.ts`. ## Verification -`ui-conversation` tests pin the row's waiting/answered/skipped/cancelled/interrupted/fallback matrix, the approval-only pending filter, and the slot registration; `ui-user-questions` tests pin the redesigned composer (checkbox multi-select, always-visible custom row, footer pager, dictionary-key feedback re-translation, IME-safe Enter) and the plugin's dictionary registration plus inject face; `ui-primitives` tests pin the icon set. The assembled Web GUI was exercised against a live session covering answer, cancel, and turn-interrupt paths. +`ui-tool` tests pin the row's waiting/answered/skipped/cancelled/interrupted matrix, readable id-based pairing, selected and custom answer lines, no-answer verdicts, and fail-closed fallback. Keyless assembled-Web snapshots expand successful and cancelled question rows and record their readable transcripts. `ui-user-questions` tests pin the redesigned composer (checkbox multi-select, always-visible custom row, footer pager, dictionary-key feedback re-translation, IME-safe Enter) and the plugin's dictionary registration plus inject face; `ui-primitives` tests pin the icon set. The assembled Web GUI was exercised against a live session covering answer, cancel, and turn-interrupt paths. diff --git a/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.zh.md b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.zh.md index c26fe91c91..ab027b33ba 100644 --- a/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.zh.md +++ b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.zh.md @@ -14,6 +14,10 @@ Web GUI 已经可以通过 `QuestionComposer` 的输入区接管收集回答, 一个待回答的问题恰好拥有两个界面:输入区接管收集回答,会话记录中一个专门的 `ask_user_question` toolview 行陈述交互结果。该行与 `todo_write` 完全一样注册进带 key 的 `tool.call.toolview` 槽位,并复用共享的 `ToolRow`(外观、运行扫光、前导展开)。其摘要是交互裁决而非参数:运行中显示 `waiting`,结算后从结果 JSON 得出 `N/M answered`(被跳过的回答 —— `selected` 为空且无 `custom` —— 不计入),`ASK_CANCELLED` 显示 `cancelled`,`ASK_ABORTED` 显示 `interrupted` 并沿用共享的琥珀色 stopped 语义。畸形或截断的结果回退到通用摘要。`PendingCard` 曾收窄为 `PendingWait<'approval'>`,`ChatView` 曾将待处理列表过滤为仅审批等待,使占位卡片只服务于审批;其后审批输入区接管([Web 权限与审批](2026-07-23-web-permission-and-approval.zh.md))已将它彻底移除。 +成功的问题行仍让折叠后的会话记录只显示单行裁决,并在展开内容中用只读问答记录取代通用 JSON。presenter 校验调用参数中的问题与结果内容中的回答,按回显的稳定 `id` 配对、保持调用顺序,并将每段模型撰写的问题显示为弱化标签,下方用主要文字显示已选项和自定义回答。被跳过的问题显示本地化的 `未回答` 裁决。卡片限制最大高度并在内部滚动。无效 JSON、重复 id、缺少 id、数量不符、未知回答 id 与无效可见字段都保留通用输入/输出卡片,不呈现不完整或错误配对的记录。 + +已取消或已中断的问题行没有可供配对的回答载荷。其展开卡片先显示本地化的整组裁决,说明未提交回答,再列出模型撰写的原始问题;它不会把取消标成逐题跳过,也不会虚构回答记录。无效调用参数保留通用诊断卡片。取消是用户主动选择,使用中性 settled 状态;中断保留琥珀色 stopped 状态。 + 输入区重设计将分页移到底部操作区旁,多选选项渲染显式复选框,单选保留编号行,并用始终可见的自定义输入行取代展开式自定义入口(无选项问题用多行文本框)。删除 `parseQuestionTitle` 的多选后缀约定;`multi_select` 已是结构化元数据,标题原样渲染。 输入区界面文案实现双语:插件在 `dsh-client-locale` 的 `question` 命名空间下注册中英词典,并通过 slot inject face 向条目提供绑定命名空间的翻译器和作为 hooks compartment 来源的 locale 快照,语言切换时已挂载的输入区会重新渲染。校验反馈以词典 key 存储、切换时重新翻译;载体失败消息与所有模型撰写的问题/选项文本原样渲染。 @@ -24,11 +28,13 @@ Web GUI 已经可以通过 `QuestionComposer` 的输入区接管收集回答, **继续通过 `PendingCard` 渲染问题。** 否决:该卡片是接管存在之前的只读占位,导致同一内容显示两份且其中一份不可作答。toolview 行加接管同时覆盖了记录与收集两个面。 -**在会话记录行内联显示问题或回答。** 否决:输入区接管拥有问题渲染与回答收集,而行的约定(`todo_write`)是单行、详情在面板。因此行只报告结果,正如 todo 行报告计数而面板拥有列表。 +**在折叠的会话记录行显示问题或回答。** 否决:输入区接管拥有回答收集,而行的约定(`todo_write`)让折叠单行保持易扫描。因此该行在展开前只报告结果,展开内容拥有只读问答记录。 + +**在展开内容中保留原始输入与输出 JSON。** 否决:载荷保留了全部信息,却让用户自己的回答或已取消的问题难以浏览。结构化视图展示相同的作者文本;问题解析或回答配对不可信时仍以原始 JSON 作为 fail-closed 回退。 **用通用错误形态渲染 `ASK_CANCELLED`/`ASK_ABORTED`。** 否决:放弃是用户自己的主动操作,打断是共享的停止手势;两者都是预期结果而非工具失败。命名裁决(且中止保持琥珀色 stopped 语义)与其他被打断的工具调用的呈现一致。 -**现在就翻译行内裁决文案。** 依明确的产品决定推迟:本次改动中行的 `waiting`/`answered`/`cancelled`/`interrupted` 字符串保持英文;输入区界面文案的国际化落地是因为其仅中文的文案在 en 语言下本就是错的。 +**让行内裁决保持英文。** 最初延期,随后在 Client UI 文案改由 locale 管理时被取代:当前 conversation 词典本地化行内裁决与展开卡片的未回答标签,而模型撰写的问题和回答保持原样。 **保留标题后缀的多选约定。** 否决:`multi_select` 是结构化请求元数据且复选框标识已承载该信号,从模型文本解析 `(可多选)` 是脆弱的重复通道。 @@ -36,10 +42,10 @@ Web GUI 已经可以通过 `QuestionComposer` 的输入区接管收集回答, `ask_user_question` 与 `todo_write` 现在共同示范预期的 toolview 模式:复用 `ToolRow`、从调用参数或结果 JSON 做带形状校验回退的摘要、通过带 key 的 slot 注册。专用的 `todo-row.module.css` 已删除。 -行内裁决字符串是问题流程仅剩的硬编码英文面;将其本地化是推迟的后续工作。审批输入区接管已交付([Web 权限与审批](2026-07-23-web-permission-and-approval.zh.md),并按[审批面板 Agent Note](../bug-fix/2026-07-30-approval-panel-command-cap.zh.md)施加高度上限),`PendingCard` 已不复存在。 +展开问答记录为共享 `ToolRow` 增加一条结构化内容路径;其他工具视图保留原有的通用或专用卡片。问题行只读取已持久化的调用与结果字段,不增加 Host 呈现字段。审批输入区接管已交付([Web 权限与审批](2026-07-23-web-permission-and-approval.zh.md),并按[审批面板 Agent Note](../bug-fix/2026-07-30-approval-panel-command-cap.zh.md)施加高度上限),`PendingCard` 已不复存在。 `ui-user-questions` 新增 `dsh-client-locale` 依赖和此前没有的 inject face;其约定(`QuestionComposerInjected`)与消费方一起放在 `contract/slots.ts`。 ## 验证 -`ui-conversation` 测试钉住行的 waiting/answered/skipped/cancelled/interrupted/回退矩阵、仅审批的待处理过滤和 slot 注册;`ui-user-questions` 测试钉住重设计的输入区(复选框多选、始终可见的自定义行、底部分页、词典 key 反馈重翻译、IME 安全的 Enter)以及插件的词典注册与 inject face;`ui-primitives` 测试钉住图标集。组装后的 Web GUI 在真实会话中演练了回答、取消与轮次打断路径。 +`ui-tool` 测试钉住行的 waiting/answered/skipped/cancelled/interrupted 矩阵、可读的 id 配对、已选项与自定义回答行、无回答裁决和 fail-closed 回退。无密钥的组装 Web 快照分别展开成功与取消的问题行并记录其可读内容。`ui-user-questions` 测试钉住重设计的输入区(复选框多选、始终可见的自定义行、底部分页、词典 key 反馈重翻译、IME 安全的 Enter)以及插件的词典注册与 inject face;`ui-primitives` 测试钉住图标集。组装后的 Web GUI 在真实会话中演练了回答、取消与轮次打断路径。 diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index 4dff77b911..36aadf2f20 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -17,7 +17,7 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, - launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, + launchWebScaffold, recordFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' @@ -29,7 +29,9 @@ 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 CANCELLED_EXPECTED = join(SNAPSHOT_DIR, 'cancelled.expected.md') const MODE = webSnapshotMode() +const CANCELLED_SEED_ID = 'ask-question-cancelled-row-web-e2e' // The composer's own growth cap, in text lines (QuestionComposer.module.css // .fieldMirror). Asserted as TEXT lines, not as a box height: the two variants @@ -61,6 +63,51 @@ async function capMetrics(field: Locator): Promise<{ textLines: number; scrolls: // collapsed row painting its copy outside its own box. 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.' +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** Replace the successful Tool settlement and omit the answer-dependent model step. */ +function cancelledFixture(fixture: string): string { + let replaced = false + const lines: string[] = [] + for (const line of fixture.trimEnd().split('\n')) { + const event: unknown = JSON.parse(line) + if (!isRecord(event)) throw new Error('question fixture event is invalid') + if (event.type === 'session') { + event.createdAt = Date.now() + lines.push(JSON.stringify(event)) + continue + } + if (replaced) { + const data = event.data + if ((event.type === 'step/end' && isRecord(data) && data.step === 1) + || event.type === 'turn/end') lines.push(line) + continue + } + if (event.type !== 'tool/result') { + lines.push(line) + continue + } + const data = event.data + if (!isRecord(data)) throw new Error('question fixture tool/result data is invalid') + const message = data.message + if (!isRecord(message) || !Array.isArray(message.content) || !isRecord(message.content[0])) { + throw new Error('question fixture tool/result message is invalid') + } + message.content[0].content = [{ + type: 'text', + text: 'Error: the user cancelled ask_user_question', + }] + message.content[0].isError = true + data.error = { name: 'UserQuestionError', code: 'ASK_CANCELLED' } + replaced = true + lines.push(JSON.stringify(event)) + } + if (!replaced) throw new Error('question fixture has no tool/result event') + return `${lines.join('\n')}\n` +} + describe('web e2e: resident question composer round trip', () => { let scaffold: WebScaffold let browser: Browser @@ -237,7 +284,13 @@ describe('web e2e: resident question composer round trip', () => { expect(await selectedRow.locator('[data-state="warning"]').count()).toBe(0) await expect.poll(() => page.locator('[data-composer-input]').first().isEnabled(), { timeout: 10_000 }).toBe(true) // Golden of the answered transcript: the ask_user_question round trip - // rendered as history (question tool row + DONE), composer takeover gone. + // rendered as history (expanded readable answers + DONE), composer takeover gone. + const answeredRow = page.getByRole('button', { name: 'Ask question 1/1 answered', exact: true }) + await answeredRow.click() + await page.getByText('Which color do you prefer?', { exact: true }).waitFor({ timeout: 10_000 }) + expect(await page.getByText('Blue', { exact: true }).count()).toBeGreaterThanOrEqual(1) + expect(await page.getByText('Include accessibility notes', { exact: true }).count()).toBe(1) + expect(await page.getByText(/"answers"/).count()).toBe(0) const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(ANSWERED_EXPECTED, snapshot, MODE) expect(tripwire.pageErrors).toEqual([]) @@ -285,13 +338,72 @@ describe('web e2e: resident question composer round trip', () => { await expect.poll(() => page.locator('[data-question-key]').count(), { timeout: 10_000 }).toBe(0) }, 60_000) - it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { +}) + +describe.skipIf(MODE === 'record')('web e2e: cancelled question transcript', () => { + let cancelledScaffold: WebScaffold + let cancelledBrowser: Browser + let cancelledPage: Page + let cancelledTripwire: ReturnType + + beforeAll(async () => { + cancelledScaffold = await launchWebScaffold({}) + await seedSession( + cancelledScaffold, + cancelledFixture(await readFile(FIXTURE, 'utf8')), + CANCELLED_SEED_ID, + ) + cancelledBrowser = await chromium.launch() + cancelledPage = await newEnglishPage(cancelledBrowser) + cancelledTripwire = watchConsole(cancelledPage) + await cancelledPage.goto(cancelledScaffold.authenticatedUrl, { waitUntil: 'load' }) + await cancelledPage.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + + const groupRow = cancelledPage.locator('[role="treeitem"]').first() + await groupRow.waitFor({ timeout: 15_000 }) + await groupRow.click() + const sessionRow = cancelledPage.locator('[role="treeitem"]').nth(1) + await sessionRow.waitFor({ timeout: 10_000 }) + await sessionRow.click() + }, 120_000) + + afterAll(async () => { + await cancelledBrowser?.close() + await cancelledScaffold?.close() + }) + + it('expands to the cancellation verdict and original questions', async () => { + onTestFailed(() => saveFailureShot(cancelledPage, 'web-e2e-question-cancelled-row')) + const row = cancelledPage.getByRole('button', { name: 'Ask question cancelled', exact: true }) + await row.waitFor({ timeout: 15_000 }) + await row.click() + + await cancelledPage + .getByText('This question set was cancelled before answers were submitted.', { exact: true }) + .waitFor({ timeout: 10_000 }) + await cancelledPage.getByText('Which color do you prefer?', { exact: true }).waitFor({ timeout: 10_000 }) + expect(await cancelledPage.getByText(/"questions"/).count()).toBe(0) + expect(await cancelledPage + .getByText('Error: the user cancelled ask_user_question', { exact: true }).count()).toBe(0) + + const snapshot = (await captureStableAria( + cancelledPage, + '[class*="centerCol"]', + cancelledScaffold.workspaceCwd, + )).split(CANCELLED_SEED_ID).join('{{seededId}}') + await compareOrRefreshGolden(CANCELLED_EXPECTED, snapshot, MODE) + expect(cancelledTripwire.pageErrors).toEqual([]) + expect(cancelledTripwire.warnings).toEqual([]) + }, 60_000) + + it('keeps the fixture inventory closed', async () => { await assertFixtureInventory(SNAPSHOT_DIR, [ 'session.jsonl', 'ui.expected.md', 'sidebar.expected.md', 'composed.expected.md', 'answered.expected.md', + 'cancelled.expected.md', ]) }) }) diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 480f73764b..853a1ace99 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -76,8 +76,11 @@ export const zh = { 'ask.rowTitle': '提问', 'ask.waiting': '等待回答', 'ask.cancelled': '已取消', + 'ask.cancelledDetail': '本轮已取消,未提交回答', 'ask.interrupted': '已中断', + 'ask.interruptedDetail': '本轮已中断,未提交回答', 'ask.answered': '{answered}/{total} 已回答', + 'ask.skipped': '未回答', 'bash.running': '运行中', 'bash.failed': '失败', 'bash.stopped': '已停止', @@ -221,8 +224,11 @@ export const en = { 'ask.rowTitle': 'Ask question', 'ask.waiting': 'waiting', 'ask.cancelled': 'cancelled', + 'ask.cancelledDetail': 'This question set was cancelled before answers were submitted.', 'ask.interrupted': 'interrupted', + 'ask.interruptedDetail': 'This question set was interrupted before answers were submitted.', 'ask.answered': '{answered}/{total} answered', + 'ask.skipped': 'Not answered', 'bash.running': 'Running', 'bash.failed': 'Failed', 'bash.stopped': 'Stopped', diff --git a/packages/client/ui-tool/README.i18n.yaml b/packages/client/ui-tool/README.i18n.yaml index ede6905289..11f131475f 100644 --- a/packages/client/ui-tool/README.i18n.yaml +++ b/packages/client/ui-tool/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-tool/README.md -README.md: 4a5c240cfcb6bad1b4b1c8917dcdbc54fd0ce7b9 -README.zh.md: b243b18cb969cdc1c505884ba8bbfbbd7bc3be41 +README.md: 792a70b7b3cd2d7a48187e872104bffac1d54202 +README.zh.md: 0feeba622cd4bcee1d5affff252e7596b7190338 diff --git a/packages/client/ui-tool/README.md b/packages/client/ui-tool/README.md index 4a5c240cfc..792a70b7b3 100644 --- a/packages/client/ui-tool/README.md +++ b/packages/client/ui-tool/README.md @@ -43,7 +43,7 @@ The owner payload is `ToolCallOwnerProps`: `callId`, `toolName`, the frozen `blo ### Built-in views -This package owns the generic fallback and the built-in shell/pwsh, read, write/edit, running `str_replace_editor` `create`/`str_replace`, grep/glob, web, todo, question, and Code Dispatch presentations. Structured cards derive directly from first-party raw event fields; Host `presentCall` and `presentResult` values never enter the Client. Foreground one-shot shell results use terminal cards. Settled persistent-shell results use the expandable generic input/output card because reset and partial-output diagnostics do not always describe one process exit status; background acknowledgements remain collapsed. Unsupported or malformed inputs fall back to flattened Tool result text. `ui-skill` demonstrates a business-owned registration for `skill`. +This package owns the generic fallback and the built-in shell/pwsh, read, write/edit, running `str_replace_editor` `create`/`str_replace`, grep/glob, web, todo, question, and Code Dispatch presentations. Structured cards derive directly from first-party raw event fields; Host `presentCall` and `presentResult` values never enter the Client. Foreground one-shot shell results use terminal cards. Settled persistent-shell results use the expandable generic input/output card because reset and partial-output diagnostics do not always describe one process exit status; background acknowledgements remain collapsed. A successful question row pairs call questions with result answers by their stable ids and shows readable question/answer lines when expanded. A cancelled or interrupted row shows its verdict and original questions without inventing answers. Unsupported, malformed, or ambiguous inputs fall back to flattened Tool input/result text. `ui-skill` demonstrates a business-owned registration for `skill`. ----- @@ -61,7 +61,7 @@ The package realizes one dispatch rule: atomic Tool views are keyed by wire Tool ### Details and cards -The package fills `conversation.details.tool` with `ToolDetails`. Row and Details renderers share one pure card model for each terminal, read, diff, search, and web card. These models validate raw call arguments, result content, failure state, persisted metadata, Code Dispatch `parentCallId`, and Session path facts. Unsupported or malformed inputs use flattened Tool result text. Card-specific limits and fallback rules remain in the owning [terminal](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md), [diff](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md), [read](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md), [search](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md), and [web](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md) notes. +The package fills `conversation.details.tool` with `ToolDetails`. Row and Details renderers share one pure card model for each terminal, read, diff, search, and web card. These models validate raw call arguments, result content, failure state, persisted metadata, Code Dispatch `parentCallId`, and Session path facts. Unsupported or malformed inputs use flattened Tool result text. Card-specific limits and fallback rules remain in the owning [terminal](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md), [diff](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md), [read](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md), [search](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md), [web](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md), and [question](../../../.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md) notes. diff --git a/packages/client/ui-tool/README.zh.md b/packages/client/ui-tool/README.zh.md index b243b18cb9..0feeba622c 100644 --- a/packages/client/ui-tool/README.zh.md +++ b/packages/client/ui-tool/README.zh.md @@ -43,7 +43,7 @@ owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block` ### 内置视图 -本包拥有 generic fallback,以及 shell/pwsh、read、write/edit、running `str_replace_editor` `create`/`str_replace`、grep/glob、web、todo、question 与 Code Dispatch 的内置展示。结构化卡片直接从第一方原始 event 字段派生;Host `presentCall` 与 `presentResult` 值不会进入 Client。前台一次性 shell 结果使用 terminal 卡片。已完成的持久 shell 结果使用可展开的 generic 输入/输出卡片,因为 reset 与部分输出诊断不一定描述单个进程的退出状态;后台启动回执保持折叠。不受支持或格式错误的输入回退为压平的工具结果文本。`ui-skill` 展示了业务包自行拥有的 `skill` 注册项。 +本包拥有 generic fallback,以及 shell/pwsh、read、write/edit、running `str_replace_editor` `create`/`str_replace`、grep/glob、web、todo、question 与 Code Dispatch 的内置展示。结构化卡片直接从第一方原始 event 字段派生;Host `presentCall` 与 `presentResult` 值不会进入 Client。前台一次性 shell 结果使用 terminal 卡片。已完成的持久 shell 结果使用可展开的 generic 输入/输出卡片,因为 reset 与部分输出诊断不一定描述单个进程的退出状态;后台启动回执保持折叠。成功的问题行按稳定 id 配对调用中的问题与结果中的回答,展开后显示可读的问答行。已取消或已中断的问题行显示其裁决与原始问题,不虚构回答。不受支持、格式错误或含糊的输入回退为压平的工具输入/结果文本。`ui-skill` 展示了业务包自行拥有的 `skill` 注册项。 ----- @@ -61,7 +61,7 @@ owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block` ### 详情与卡片 -本包通过 `ToolDetails` 填充 `conversation.details.tool`。行 renderer 与 Details renderer 分别为 terminal、read、diff、search 和 web 卡片复用同一个纯 card model。这些 model 校验原始调用参数、结果内容、失败状态、持久 metadata、Code Dispatch `parentCallId` 与 Session 路径事实。不受支持或格式错误的输入使用压平的工具结果文本。各类卡片的上限与 fallback 规则仍由对应的 [terminal](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md)、[diff](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md)、[read](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.zh.md)、[search](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md) 与 [web](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md) 笔记负责。 +本包通过 `ToolDetails` 填充 `conversation.details.tool`。行 renderer 与 Details renderer 分别为 terminal、read、diff、search 和 web 卡片复用同一个纯 card model。这些 model 校验原始调用参数、结果内容、失败状态、持久 metadata、Code Dispatch `parentCallId` 与 Session 路径事实。不受支持或格式错误的输入使用压平的工具结果文本。各类卡片的上限与 fallback 规则仍由对应的 [terminal](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md)、[diff](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md)、[read](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.zh.md)、[search](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md)、[web](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md) 与 [question](../../../.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.zh.md) 笔记负责。 diff --git a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx index 8102965a6d..d38bc55c9c 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx +++ b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx @@ -37,6 +37,8 @@ export interface ToolRowProps { body: string | null /** Flattened result text for the expanded Output section; null/absent = no output section. */ output?: string | null | undefined + /** Tool-owned structured body that replaces the generic input/output sections. */ + structuredBody?: ReactNode | null | undefined /** Error first line shown as the collapsed summary on an error row; null/absent = keep `summary`. */ errorSummary?: string | null | undefined /** Terminal card; card fields are mutually exclusive and replace text sections. */ @@ -91,6 +93,7 @@ export function ToolRow({ summarySuffix, body, output, + structuredBody, errorSummary, terminal, diff, @@ -115,8 +118,9 @@ export function ToolRow({ const readBody = read ?? null const searchBody = search ?? null const webBody = web ?? null + const ownedBody = structuredBody ?? null const outputText = output ?? null - const card = terminalBody ?? diffBody ?? readBody ?? searchBody ?? webBody + const card = ownedBody ?? terminalBody ?? diffBody ?? readBody ?? searchBody ?? webBody const expandable = body !== null || outputText !== null || card !== null const open = expanded && expandable const status = stateStatus(state, t) @@ -183,67 +187,69 @@ export function ToolRow({ )} >
- {terminalBody !== null - ? ( - - ) - : diffBody !== null - ? - : readBody !== null - ? - : searchBody !== null - ? ( - <> - - {/* A capped search's recovery locator lives only in the result - text; show it below the card so the dropped rows survive. */} - {searchBody.recovery !== undefined && ( -
{searchBody.recovery}
- )} - - ) - : webBody !== null - ? - : ( + {ownedBody !== null + ? ownedBody + : terminalBody !== null + ? ( + + ) + : diffBody !== null + ? + : readBody !== null + ? + : searchBody !== null + ? ( <> - {variant === 'code' && body !== null && ( -
- -
- )} - {(cardBody !== null || outputText !== null) && ( -
- {cardBody !== null && ( -
- {t('row.input')} - {cardBody} -
- )} - {cardBody !== null && outputText !== null && ( - - )} - {outputText !== null && ( -
- {t('row.output')} - - {outputText} - -
- )} -
+ + {/* A capped search's recovery locator lives only in the result + text; show it below the card so the dropped rows survive. */} + {searchBody.recovery !== undefined && ( +
{searchBody.recovery}
)} - )} + ) + : webBody !== null + ? + : ( + <> + {variant === 'code' && body !== null && ( +
+ +
+ )} + {(cardBody !== null || outputText !== null) && ( +
+ {cardBody !== null && ( +
+ {t('row.input')} + {cardBody} +
+ )} + {cardBody !== null && outputText !== null && ( + + )} + {outputText !== null && ( +
+ {t('row.output')} + + {outputText} + +
+ )} +
+ )} + + )} {inspect !== undefined && (