فهرست منبع

fix(web): keep question card props data-only

Yichen Jiang 1 ماه پیش
والد
کامیت
49753b33fa

+ 2 - 2
.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

+ 1 - 1
.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`.
 

+ 1 - 1
.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`。
 

+ 7 - 1
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))
   }

+ 0 - 0
packages/client/ui-tool/src/client/tool/toolviews/ask-question-row.module.css → packages/client/ui-tool/src/client/tool/components/AskQuestionCard.module.css


+ 40 - 0
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 (
+      <div className={css.card}>
+        <p className={css.verdict}>{card.verdict}</p>
+        <ul className={css.questionList}>
+          {card.questions.map(question => (
+            <li className={css.unansweredQuestion} key={question.id}>{question.question}</li>
+          ))}
+        </ul>
+      </div>
+    )
+  }
+  return (
+    <dl className={css.card}>
+      {card.questions.map(question => (
+        <div className={css.item} key={question.id}>
+          <dt className={css.question}>{question.question}</dt>
+          <dd className={css.answer}>
+            {question.answers.length === 0
+              ? <span className={css.skipped}>{card.skippedLabel}</span>
+              : question.answers.map((answer, index) => (
+                <span className={css.answerLine} key={`${question.id}-${String(index)}`}>{answer}</span>
+              ))}
+          </dd>
+        </div>
+      ))}
+    </dl>
+  )
+}

+ 9 - 7
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({
         )}
       >
         <div className={css.bodyWrap}>
-          {ownedBody !== null
-            ? ownedBody
+          {askQuestionBody !== null
+            ? <AskQuestionCard card={askQuestionBody} />
             : terminalBody !== null
               ? (
                 <TerminalBlock

+ 25 - 0
packages/client/ui-tool/src/client/tool/models/ask-question-card-model.ts

@@ -0,0 +1,25 @@
+/** Pure ask-user transcript card data shared by its presenter and renderer. @module */
+
+interface AnsweredQuestionCardItem {
+  id: string
+  question: string
+  answers: readonly string[]
+}
+
+interface UnansweredQuestionCardItem {
+  id: string
+  question: string
+}
+
+/** Validated, localized data rendered by the ask-user transcript card. */
+export type AskQuestionCardModel =
+  | {
+    kind: 'answered'
+    questions: readonly AnsweredQuestionCardItem[]
+    skippedLabel: string
+  }
+  | {
+    kind: 'unanswered'
+    questions: readonly UnansweredQuestionCardItem[]
+    verdict: string
+  }

+ 7 - 44
packages/client/ui-tool/src/client/tool/toolviews/ask-question-row.tsx

@@ -2,10 +2,10 @@ import { IconQuestionOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { Context } from '@deepseek-ai/cordis'
 import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
 import type { ToolCallViewProps } from '../../contract/slots.ts'
+import type { AskQuestionCardModel } from '../models/ask-question-card-model.ts'
 import { toolRowModel } from '../models/tool-call-model.ts'
 import { ToolRow } from '../components/ToolRow.tsx'
 import { CONVERSATION_NS as NS } from '../../locale.ts'
-import css from './ask-question-row.module.css'
 
 /** One result entry after validating the fields used by the transcript card. */
 interface AnswerEntry {
@@ -32,10 +32,6 @@ interface AnswerPresentation {
   questions: AnsweredQuestion[] | null
 }
 
-type QuestionTranscript =
-  | { kind: 'answered'; questions: AnsweredQuestion[] }
-  | { kind: 'unanswered'; questions: QuestionEntry[]; verdict: string }
-
 function isRecord(value: unknown): value is Record<string, unknown> {
   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 (
-      <div className={css.card}>
-        <p className={css.verdict}>{transcript.verdict}</p>
-        <ul className={css.questionList}>
-          {transcript.questions.map(question => (
-            <li className={css.unansweredQuestion} key={question.id}>{question.question}</li>
-          ))}
-        </ul>
-      </div>
-    )
-  }
-  return (
-    <dl className={css.card}>
-      {transcript.questions.map(question => (
-        <div className={css.item} key={question.id}>
-          <dt className={css.question}>{question.question}</dt>
-          <dd className={css.answer}>
-            {question.answers.length === 0
-              ? <span className={css.skipped}>{t('ask.skipped')}</span>
-              : question.answers.map((answer, index) => (
-                <span className={css.answerLine} key={`${question.id}-${String(index)}`}>{answer}</span>
-              ))}
-          </dd>
-        </div>
-      ))}
-    </dl>
-  )
-}
-
-/** 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 : <QuestionTranscriptCard transcript={transcript} t={t} />}
+      askQuestion={transcript}
       state={state}
       inspect={inspect}
     />