mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
fix(web): keep question card props data-only
This commit is contained in:
+2
-2
@@ -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`。
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user