From 49753b33fa39051e65e12aafe56939d66ebcedaf Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 27 Aug 2026 13:21:22 +0800 Subject: [PATCH] fix(web): keep question card props data-only --- ...29-ask-question-web-presentation.i18n.yaml | 4 +- ...026-07-29-ask-question-web-presentation.md | 2 +- ...-07-29-ask-question-web-presentation.zh.md | 2 +- apps/web/tests/question-composer.e2e.ts | 8 ++- .../AskQuestionCard.module.css} | 0 .../tool/components/AskQuestionCard.tsx | 40 +++++++++++++++ .../src/client/tool/components/ToolRow.tsx | 16 +++--- .../tool/models/ask-question-card-model.ts | 25 +++++++++ .../tool/toolviews/ask-question-row.tsx | 51 +++---------------- 9 files changed, 92 insertions(+), 56 deletions(-) rename packages/client/ui-tool/src/client/tool/{toolviews/ask-question-row.module.css => components/AskQuestionCard.module.css} (100%) create mode 100644 packages/client/ui-tool/src/client/tool/components/AskQuestionCard.tsx create mode 100644 packages/client/ui-tool/src/client/tool/models/ask-question-card-model.ts 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 5d1e095629..b8b98a6ecb 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: c51c6458fc01d4d99ec9784566439cfca31c43ad -2026-07-29-ask-question-web-presentation.zh.md: ab027b33ba2874547b986251665d611f5b984762 +2026-07-29-ask-question-web-presentation.md: 280671570ca015457ab3c17241cea3e973b88fd2 +2026-07-29-ask-question-web-presentation.zh.md: 9ad687d655de19ea913ddc5255fb6426ef0fbb77 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 c51c6458fc..280671570c 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 @@ -42,7 +42,7 @@ 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 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. +The expanded transcript adds a typed, plain-data question-card model 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`. 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 ab027b33ba..9ad687d655 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 @@ -42,7 +42,7 @@ Web GUI 已经可以通过 `QuestionComposer` 的输入区接管收集回答, `ask_user_question` 与 `todo_write` 现在共同示范预期的 toolview 模式:复用 `ToolRow`、从调用参数或结果 JSON 做带形状校验回退的摘要、通过带 key 的 slot 注册。专用的 `todo-row.module.css` 已删除。 -展开问答记录为共享 `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` 已不复存在。 +展开问答记录为共享 `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`。 diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index 36aadf2f20..d0956666b0 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -75,6 +75,8 @@ function cancelledFixture(fixture: string): string { const event: unknown = JSON.parse(line) if (!isRecord(event)) throw new Error('question fixture event is invalid') if (event.type === 'session') { + // Keep the derived session's relative-time header stable as the source + // fixture ages. event.createdAt = Date.now() lines.push(JSON.stringify(event)) continue @@ -100,7 +102,11 @@ function cancelledFixture(fixture: string): string { text: 'Error: the user cancelled ask_user_question', }] message.content[0].isError = true - data.error = { name: 'UserQuestionError', code: 'ASK_CANCELLED' } + data.error = { + name: 'UserQuestionError', + message: 'the user cancelled ask_user_question', + code: 'ASK_CANCELLED', + } replaced = true lines.push(JSON.stringify(event)) } diff --git a/packages/client/ui-tool/src/client/tool/toolviews/ask-question-row.module.css b/packages/client/ui-tool/src/client/tool/components/AskQuestionCard.module.css similarity index 100% rename from packages/client/ui-tool/src/client/tool/toolviews/ask-question-row.module.css rename to packages/client/ui-tool/src/client/tool/components/AskQuestionCard.module.css diff --git a/packages/client/ui-tool/src/client/tool/components/AskQuestionCard.tsx b/packages/client/ui-tool/src/client/tool/components/AskQuestionCard.tsx new file mode 100644 index 0000000000..d704aea501 --- /dev/null +++ b/packages/client/ui-tool/src/client/tool/components/AskQuestionCard.tsx @@ -0,0 +1,40 @@ +/** Ask-user transcript rendering from validated plain card data. @module */ + +import type { AskQuestionCardModel } from '../models/ask-question-card-model.ts' +import css from './AskQuestionCard.module.css' + +/** + * Render a validated ask-user transcript from plain card data. + * @param props - Localized transcript card data. + * @returns the readable answered or unanswered question list. + */ +export function AskQuestionCard({ card }: { card: AskQuestionCardModel }) { + if (card.kind === 'unanswered') { + return ( +
+

{card.verdict}

+ +
+ ) + } + return ( +
+ {card.questions.map(question => ( +
+
{question.question}
+
+ {question.answers.length === 0 + ? {card.skippedLabel} + : question.answers.map((answer, index) => ( + {answer} + ))} +
+
+ ))} +
+ ) +} 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 d38bc55c9c..ccf211e468 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx +++ b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx @@ -13,8 +13,10 @@ import { import { diffBlockLabels, readBlockLabels, searchBlockLabels, webBlockLabels, } from '../models/primitive-labels.ts' +import type { AskQuestionCardModel } from '../models/ask-question-card-model.ts' import type { ToolRowState, ToolRowVariant } from '../models/tool-call-model.ts' import type { WebCardModelProps } from '../models/web-card-model.ts' +import { AskQuestionCard } from './AskQuestionCard.tsx' import css from './ToolRow.module.css' export interface ToolRowProps { @@ -37,8 +39,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 + /** Ask-user transcript card; card fields are mutually exclusive and replace text sections. */ + askQuestion?: AskQuestionCardModel | 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. */ @@ -93,7 +95,7 @@ export function ToolRow({ summarySuffix, body, output, - structuredBody, + askQuestion, errorSummary, terminal, diff, @@ -118,9 +120,9 @@ export function ToolRow({ const readBody = read ?? null const searchBody = search ?? null const webBody = web ?? null - const ownedBody = structuredBody ?? null + const askQuestionBody = askQuestion ?? null const outputText = output ?? null - const card = ownedBody ?? terminalBody ?? diffBody ?? readBody ?? searchBody ?? webBody + const card = askQuestionBody ?? terminalBody ?? diffBody ?? readBody ?? searchBody ?? webBody const expandable = body !== null || outputText !== null || card !== null const open = expanded && expandable const status = stateStatus(state, t) @@ -187,8 +189,8 @@ export function ToolRow({ )} >
- {ownedBody !== null - ? ownedBody + {askQuestionBody !== null + ? : terminalBody !== null ? ( { return typeof value === 'object' && value !== null && !Array.isArray(value) } @@ -125,42 +121,7 @@ function answeredPresentation( } } -function QuestionTranscriptCard({ transcript, t }: { - transcript: QuestionTranscript - t: AskQuestionRowProps['t'] -}) { - if (transcript.kind === 'unanswered') { - return ( -
-

{transcript.verdict}

-
    - {transcript.questions.map(question => ( -
  • {question.question}
  • - ))} -
-
- ) - } - return ( -
- {transcript.questions.map(question => ( -
-
{question.question}
-
- {question.answers.length === 0 - ? {t('ask.skipped')} - : question.answers.map((answer, index) => ( - {answer} - ))} -
-
- ))} -
- ) -} - -/** Answered-count summary from the result JSON (a skipped question has - * empty `selected` and no `custom`); null when answer fields are invalid. */ +/** Best-effort answered-count summary when strict transcript pairing fails. */ function answeredSummary(text: string, t: AskQuestionRowProps['t']): string | null { const parsed = parseJson(text) if (!isRecord(parsed)) return null @@ -187,7 +148,7 @@ export function AskQuestionRow({ toolName, block, inspect, t }: AskQuestionRowPr const argsRaw = ('kind' in block ? block.call?.argsRaw : block.argsRaw) ?? '' let summary = model.summary let state = model.state - let transcript: QuestionTranscript | null = null + let transcript: AskQuestionCardModel | null = null if (code === 'ASK_CANCELLED') { summary = t('ask.cancelled') state = 'ok' @@ -207,9 +168,11 @@ export function AskQuestionRow({ toolName, block, inspect, t }: AskQuestionRowPr } else if ('kind' in block && model.state === 'ok') { const text = block.content.filter(b => b.type === 'text').map(b => b.text).join('') const presentation = answeredPresentation(argsRaw, text, t) + // Full transcripts require stable ids and valid visible fields; retain the + // legacy best-effort count when only strict pairing is unsafe. summary = presentation?.summary ?? answeredSummary(text, t) ?? model.summary if (presentation?.questions !== null && presentation?.questions !== undefined) { - transcript = { kind: 'answered', questions: presentation.questions } + transcript = { kind: 'answered', questions: presentation.questions, skippedLabel: t('ask.skipped') } } } return ( @@ -222,7 +185,7 @@ export function AskQuestionRow({ toolName, block, inspect, t }: AskQuestionRowPr summary={summary} body={transcript === null ? model.body : null} output={transcript === null ? model.output : null} - structuredBody={transcript === null ? null : } + askQuestion={transcript} state={state} inspect={inspect} />