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

This commit is contained in:
Yichen Jiang
2026-08-27 13:21:22 +08:00
parent 94db8e881b
commit 49753b33fa
9 changed files with 92 additions and 56 deletions
@@ -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
@@ -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`.
@@ -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
View File
@@ -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 +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>
)
}
@@ -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
@@ -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
}
@@ -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}
/>