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
new file mode 100644
index 0000000000..6954c289bd
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# 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: 90eeb3cdcc1a851b7d5e184c0f31cbccd82cbf55
+2026-07-29-ask-question-web-presentation.zh.md: 5bb19d3a68dc0510ea766d7a22abdc1cff9c326a
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
new file mode 100644
index 0000000000..90eeb3cdcc
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md
@@ -0,0 +1,45 @@
+# Agent Note: Ask-question Web presentation
+
+Status: implemented
+
+English | [中文](2026-07-29-ask-question-web-presentation.zh.md)
+
+## Problem
+
+The Web GUI could already collect answers through the `QuestionComposer` composer takeover, but the transcript around it was wrong on three counts. A pending question rendered twice: once as the composer takeover and once as the read-only `PendingCard` placeholder that predates the takeover. A settled `ask_user_question` call rendered as the generic "Tool call" row dumping raw args JSON, so the two composer verdicts — the user dismissing the whole set (`ASK_CANCELLED`) and a turn interrupt landing while the question was pending (`ASK_ABORTED`) — both read as anonymous red-dot failures. And the composer's own chrome copy (pager, buttons, placeholders, validation feedback) was hardcoded Chinese while the surrounding client is bilingual through `dsh-client-locale`.
+
+Separately, the composer visuals had drifted from the current design: an expand-to-open custom answer entry, no multi-select affordance beyond a trailing check, header-mounted paging, and a `(可多选)` title-suffix convention parsed out of model text.
+
+## Decision
+
+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 `conversation.chat.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` narrows to `PendingWait<'approval'>` and `ChatView` filters the pending list to approval waits, so the placeholder card now exists only for the approval takeover still on the roadmap.
+
+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.
+
+Two adjacent fixes ride along. All generic toolview leading icons (and the hover chevron) now inherit the single tertiary label color — the others-variant secondary override and the separate chevron color rule are deleted, leaving only the intentional cordis business-primary accent. And the client dev-watch bundler registers each CSS module with `addWatchFile`, because the virtual-module indirection previously hid css-only edits from the watcher.
+
+## Alternatives considered
+
+**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.
+
+**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 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.
+
+## Consequences
+
+`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. `PendingCard` remains a visible-but-not-answerable approval placeholder until the approval composer takeover ships.
+
+`ui-question` 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-question` 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
new file mode 100644
index 0000000000..5bb19d3a68
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.zh.md
@@ -0,0 +1,45 @@
+# Agent Note:Ask-question Web 呈现
+
+Status: implemented
+
+[English](2026-07-29-ask-question-web-presentation.md) | 中文
+
+## 问题
+
+Web GUI 已经可以通过 `QuestionComposer` 的输入区接管收集回答,但其周边的会话记录呈现在三个方面是错的。待回答的问题会渲染两次:一次是输入区接管,一次是早于接管存在的只读 `PendingCard` 占位卡片。已结算的 `ask_user_question` 调用渲染为通用 "Tool call" 行并直接倾倒原始 args JSON,因此两种输入区裁决 —— 用户放弃整组问题(`ASK_CANCELLED`)与问题待回答期间轮次被打断(`ASK_ABORTED`)—— 都显示为无名的红点失败。而且输入区自身的界面文案(分页、按钮、占位符、校验反馈)是硬编码中文,而周边客户端已通过 `dsh-client-locale` 实现双语。
+
+另外,输入区视觉也偏离了当前设计:自定义回答需展开才能输入、多选除尾部对勾外没有可见标识、分页挂在头部、还有从模型文本里解析 `(可多选)` 标题后缀的约定。
+
+## 决定
+
+一个待回答的问题恰好拥有两个界面:输入区接管收集回答,会话记录中一个专门的 `ask_user_question` toolview 行陈述交互结果。该行与 `todo_write` 完全一样注册进带 key 的 `conversation.chat.toolview` 槽位,并复用共享的 `ToolRow`(外观、运行扫光、前导展开)。其摘要是交互裁决而非参数:运行中显示 `waiting`,结算后从结果 JSON 得出 `N/M answered`(被跳过的回答 —— `selected` 为空且无 `custom` —— 不计入),`ASK_CANCELLED` 显示 `cancelled`,`ASK_ABORTED` 显示 `interrupted` 并沿用共享的琥珀色 stopped 语义。畸形或截断的结果回退到通用摘要。`PendingCard` 收窄为 `PendingWait<'approval'>`,`ChatView` 将待处理列表过滤为仅审批等待,占位卡片从此只服务于仍在路线图上的审批接管。
+
+输入区重设计将分页移到底部操作区旁,多选选项渲染显式复选框,单选保留编号行,并用始终可见的自定义输入行取代展开式自定义入口(无选项问题用多行文本框)。删除 `parseQuestionTitle` 的多选后缀约定;`multi_select` 已是结构化元数据,标题原样渲染。
+
+输入区界面文案实现双语:插件在 `dsh-client-locale` 的 `question` 命名空间下注册中英词典,并通过槽位 inject face 向条目提供绑定命名空间的翻译器和作为 hooks 舱源的 locale 快照,语言切换时已挂载的输入区会重新渲染。校验反馈以词典 key 存储、切换时重新翻译;载体失败消息与所有模型撰写的问题/选项文本原样渲染。
+
+两个相邻修复随行。所有通用 toolview 前导图标(含悬停箭头)现在统一继承三级标签色 —— 删除了 others 变体的二级色覆盖和独立的箭头颜色规则,只保留有意为之的 cordis 业务主色强调。客户端 dev-watch 打包器用 `addWatchFile` 注册每个 CSS 模块,因为虚拟模块间接层此前使仅改 CSS 的编辑对 watcher 不可见。
+
+## 曾考虑的替代方案
+
+**继续通过 `PendingCard` 渲染问题。** 否决:该卡片是接管存在之前的只读占位,导致同一内容显示两份且其中一份不可作答。toolview 行加接管同时覆盖了记录与收集两个面。
+
+**在会话记录行内联显示问题或回答。** 否决:输入区接管拥有问题渲染与回答收集,而行的约定(`todo_write`)是单行、详情在面板。因此行只报告结果,正如 todo 行报告计数而面板拥有列表。
+
+**用通用错误形态渲染 `ASK_CANCELLED`/`ASK_ABORTED`。** 否决:放弃是用户自己的主动操作,打断是共享的停止手势;两者都是预期结果而非工具失败。命名裁决(且中止保持琥珀色 stopped 语义)与其他被打断的工具调用的呈现一致。
+
+**现在就翻译行内裁决文案。** 依明确的产品决定推迟:本次改动中行的 `waiting`/`answered`/`cancelled`/`interrupted` 字符串保持英文;输入区界面文案的国际化落地是因为其仅中文的文案在 en 语言下本就是错的。
+
+**保留标题后缀的多选约定。** 否决:`multi_select` 是结构化请求元数据且复选框标识已承载该信号,从模型文本解析 `(可多选)` 是脆弱的重复通道。
+
+## 后果
+
+`ask_user_question` 与 `todo_write` 现在共同示范预期的 toolview 模式:复用 `ToolRow`、从调用参数或结果 JSON 做带形状校验回退的摘要、通过带 key 的槽位注册。专用的 `todo-row.module.css` 已删除。
+
+行内裁决字符串是问题流程仅剩的硬编码英文面;将其本地化是推迟的后续工作。在审批输入区接管交付之前,`PendingCard` 仍是可见但不可操作的审批占位。
+
+`ui-question` 新增 `dsh-client-locale` 依赖和此前没有的 inject face;其契约(`QuestionComposerInjected`)与消费者一起放在 `contract/slots.ts`。
+
+## 验证
+
+`ui-conversation` 测试钉住行的 waiting/answered/skipped/cancelled/interrupted/回退矩阵、仅审批的待处理过滤和槽位注册;`ui-question` 测试钉住重设计的输入区(复选框多选、始终可见的自定义行、底部分页、词典 key 反馈重翻译、IME 安全的 Enter)以及插件的词典注册与 inject face;`ui-primitives` 测试钉住图标集。组装后的 Web GUI 在真实会话中演练了回答、取消与轮次打断路径。
diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md
index e9e5bb6e6a..2ab220305c 100644
--- a/docs/event-producer-consumer.md
+++ b/docs/event-producer-consumer.md
@@ -68,7 +68,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `internal/dispatch` | - | [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) |
| `internal/plugin` | - | `hmr`, `modules`, `webserver` |
| `internal/status` | - | [`agent`](../packages/core/agent) |
-| `locale/change` | `locale` (`emit`) | `locale`, `ui-models`, `ui-settings-general` |
+| `locale/change` | `locale` (`emit`) | `locale`, `ui-models`, `ui-question`, `ui-settings-general` |
| `slots/changed` | `runtime` (`emit`) | - |
| `theme/change` | `ui-theme` (`emit`) | `ui-layout`, `ui-theme` |
diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts
index 9b93feae8b..6b004c80b4 100644
--- a/packages/client/tsdown.client.ts
+++ b/packages/client/tsdown.client.ts
@@ -124,9 +124,12 @@ export function clientBundle(id: string, libEntry: readonly string[]): UserConfi
const abs = importer !== undefined ? resolvePath(dirname(importer), source) : source
return CSS_VIRTUAL_PREFIX + abs + CSS_VIRTUAL_SUFFIX
},
- async load(virtualId: string) {
+ async load(this: { addWatchFile?: (id: string) => void }, virtualId: string) {
if (!virtualId.startsWith(CSS_VIRTUAL_PREFIX)) return null
const fileId = virtualId.slice(CSS_VIRTUAL_PREFIX.length, -CSS_VIRTUAL_SUFFIX.length)
+ // Virtual modules hide the real file from the watcher; register it so
+ // dev-web rebuilds on a css-only edit.
+ this.addWatchFile?.(fileId)
const source = await readFile(fileId)
const { code, exports: cssExports } = transform({
filename: fileId,
diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts
index 62801ca0b8..48dee62787 100644
--- a/packages/client/ui-conversation/src/client/apply.ts
+++ b/packages/client/ui-conversation/src/client/apply.ts
@@ -14,6 +14,7 @@ import { InputBar } from './skeleton/InputBar.tsx'
import { ChatView } from './chat/ChatView.tsx'
import { bashToolviewSample } from './toolviews/bash-sample.tsx'
import { todoToolview } from './toolviews/todo-row.tsx'
+import { askQuestionToolview } from './toolviews/ask-question-row.tsx'
import { todoDockEntry } from './skeleton/TodoPanel.tsx'
import { queueDockEntry } from './queue/QueueDock.tsx'
import { ConversationRoot } from './skeleton/ConversationRoot.tsx'
@@ -187,6 +188,9 @@ export function apply(ctx: Context): void {
// The todo_write row rides the same seam (a product registration, not a sample).
ctx.plugin(todoToolview)
+ // The ask_user_question row: waiting/answered/cancelled interaction outcome.
+ ctx.plugin(askQuestionToolview)
+
// The plan strip rides the input dock above the queue rows (same posture).
ctx.plugin(todoDockEntry)
diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx
index deb7f09f6c..e5d80d52c6 100644
--- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx
+++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx
@@ -361,7 +361,10 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
))}
)}
- {pending.map(item => )}
+ {/* Approval waits only: a pending question already shows as the
+ ask_user_question row (waiting state) plus the composer takeover. */}
+ {pending.filter(item => item.kind === 'approval')
+ .map(item => )}
{/* Turn-level loading signal: rides the whole running turn (first-token
wait, tool execution, streaming) so it never flickers per step. */}
{running && }
diff --git a/packages/client/ui-conversation/src/client/chat/PendingCard.tsx b/packages/client/ui-conversation/src/client/chat/PendingCard.tsx
index b6825aed9a..5a2076fe85 100644
--- a/packages/client/ui-conversation/src/client/chat/PendingCard.tsx
+++ b/packages/client/ui-conversation/src/client/chat/PendingCard.tsx
@@ -1,31 +1,22 @@
-// PendingCard: approval/question placeholder card (visible, not answerable —
-// the composer-takeover approval panel is a P-II item; wire pending semantics
-// already exist so the flow must show them).
+// PendingCard: approval placeholder card (visible, not answerable — the
+// composer-takeover approval panel is a P-II item; wire pending semantics
+// already exist so the flow must show them). Question waits render through
+// the ask_user_question toolview row + the composer takeover instead.
import { memo } from 'react'
-import type { PendingInteraction } from '@deepseek-ai/dsh-client-runtime/client'
-import { JsonBlock } from '@deepseek-ai/dsh-client-ui-primitives'
+import type { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
import css from './PendingCard.module.css'
export interface PendingCardProps {
- item: PendingInteraction
+ item: PendingWait<'approval'>
}
export const PendingCard = memo(function PendingCard({ item }: PendingCardProps) {
return (
- {item.kind === 'approval' ? (
- <>
-
等待审批:{item.payload.toolName}
- {item.payload.reason !== undefined &&
{item.payload.reason}
}
- >
- ) : (
- <>
-
等待回答({item.payload.questions.length} 题)
-
- >
- )}
-
请在原客户端处理(web 端作答后续里程碑提供)
+
等待审批:{item.payload.toolName}
+ {item.payload.reason !== undefined &&
{item.payload.reason}
}
+
请在原客户端处理(web 端审批后续里程碑提供)
)
})
diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css
index 018529961f..c18bbefb01 100644
--- a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css
+++ b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css
@@ -62,12 +62,6 @@
color: var(--dsw-alias-label-tertiary);
}
-/* The others-variant sparkle glyph is one gray step darker than the icon
- family in the source design. */
-.root[data-variant='others'] .leading {
- color: var(--dsw-alias-label-secondary);
-}
-
/* Cordis lifecycle tools retain their generic row mechanics while carrying a
shared product accent and tool-owned action title. */
.root[data-tool^='cordis_'] .leading,
@@ -87,10 +81,6 @@ button.leading {
cursor: pointer;
}
-.chevron {
- color: var(--dsw-alias-label-secondary);
-}
-
/* Hover preview on expandable rows: the idle tool icon crossfades (100ms)
into a down chevron before the row is opened. The chevron overlays the
icon cell absolutely so both can stay mounted for the opacity transition. */
diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx
index 5c5d059292..6abca0d739 100644
--- a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx
+++ b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx
@@ -7,7 +7,6 @@
// expandable content, retiring the details-panel handoff where feasible.
import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
-import clsx from 'clsx'
import { CodeBlock, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ToolRowState, ToolRowVariant } from '../contract/tool-call-model.ts'
@@ -74,12 +73,12 @@ export function ToolRow({
? (
<>
{icon}
-
+
>
)
: icon
const leading = open
- ?
+ ?
: leadingFor(state, collapsedIcon)
return (
diff --git a/packages/client/ui-conversation/src/client/toolviews/ask-question-row.tsx b/packages/client/ui-conversation/src/client/toolviews/ask-question-row.tsx
new file mode 100644
index 0000000000..3ba94cc438
--- /dev/null
+++ b/packages/client/ui-conversation/src/client/toolviews/ask-question-row.tsx
@@ -0,0 +1,94 @@
+// ask_user_question toolview: question-flavored summary row replacing the
+// generic "Tool call" card, registered into the keyed
+// 'conversation.chat.toolview' hole like todo-row. The row composes ToolRow
+// (chrome, running sweep, leading expansion) and swaps in the interaction
+// outcome — `waiting` while pending, answered-count once settled, `cancelled`
+// when the user dismissed the whole set — because the questions themselves
+// render in the composer takeover.
+
+import { IconQuestionOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
+import type { Context } from 'cordis'
+import type { ToolRowProps } from '../contract/slots.ts'
+import { toolRowModel } from '../contract/tool-call-model.ts'
+import { ToolRow } from '../chat/ToolRow.tsx'
+
+/** One parsed answer entry, shape-checked (result JSON crosses the wire). */
+interface AnswerEntry { selected?: unknown; custom?: unknown }
+
+function isAnswer(value: unknown): value is AnswerEntry {
+ return typeof value === 'object' && value !== null
+}
+
+/** `${answered}/${total} answered` off the result JSON (a skipped question has
+ * empty `selected` and no `custom`); null on unexpected shape (generic fallback). */
+function answeredSummary(text: string): string | null {
+ let parsed: unknown
+ try {
+ parsed = JSON.parse(text)
+ } catch {
+ return null
+ }
+ if (typeof parsed !== 'object' || parsed === null) return null
+ const answers = (parsed as { answers?: unknown }).answers
+ if (!Array.isArray(answers) || !answers.every(isAnswer)) return null
+ const answered = answers.filter(a =>
+ (Array.isArray(a.selected) && a.selected.length > 0)
+ || (typeof a.custom === 'string' && a.custom !== '')).length
+ return `${answered}/${answers.length} answered`
+}
+
+/** One-line question-interaction row (row click opens details; leading toggle
+ * expands the raw args). */
+export function AskQuestionRow({ toolName, block, openDetails }: ToolRowProps) {
+ const model = toolRowModel(toolName, block)
+ // Composer verdicts settle the call as specific UserInteractionErrors
+ // (apiproxy ask_user_question handler): 'ASK_CANCELLED' is the user's own
+ // dismissal of the set, 'ASK_ABORTED' is a turn interrupt landing while the
+ // question was pending. Both name their verdict instead of the generic
+ // failed shape, and the abort keeps the shared stopped (amber) semantics of
+ // any other interrupted tool call.
+ const code = 'kind' in block ? block.error?.code : undefined
+ let summary = model.summary
+ let state = model.state
+ if (code === 'ASK_CANCELLED') {
+ summary = 'cancelled'
+ } else if (code === 'ASK_ABORTED') {
+ summary = 'interrupted'
+ state = 'stopped'
+ } else if (model.state === 'running') {
+ summary = 'waiting'
+ } else if ('kind' in block && model.state === 'ok') {
+ const text = block.content.filter(b => b.type === 'text').map(b => b.text).join('')
+ summary = answeredSummary(text) ?? model.summary
+ }
+ return (
+ }
+ title="Ask question"
+ summary={summary}
+ body={model.body}
+ state={state}
+ onOpenDetails={openDetails}
+ />
+ )
+}
+
+/**
+ * The ask-question row as a plain registrant plugin, riding the same
+ * load-order seam as todo-toolview: `inject: ['conversation']` guarantees the
+ * chat entry (and with it the 'conversation.chat.toolview' declaration) is on
+ * the ledger.
+ */
+export const askQuestionToolview = {
+ name: 'ask-question-toolview',
+ inject: ['slots', 'conversation'],
+ /**
+ * Register the ask-question row into the chat view's keyed toolview hole.
+ * @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
+ */
+ apply(ctx: Context): void {
+ ctx.slots.register({ name: 'conversation.chat.toolview', key: 'ask_user_question' }, AskQuestionRow)
+ },
+}
diff --git a/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css b/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css
deleted file mode 100644
index 1a1b142b3a..0000000000
--- a/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css
+++ /dev/null
@@ -1,58 +0,0 @@
-/* todo_write plan-update row: ToolRow chrome (figma 780:53675) —
- [16 checklist] gap6 [title 14/24] gap8 [2x2 dot] gap8 [summary FILL truncate]. */
-
-.row {
- display: flex;
- align-items: center;
- height: 24px;
- min-width: 0;
- cursor: pointer;
- border-radius: 6px;
-}
-
-.leading {
- flex: none;
- width: 16px;
- height: 16px;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- margin-right: 6px;
- color: var(--dsw-alias-label-tertiary);
-}
-
-.title {
- flex: none;
- font-size: 14px;
- line-height: 24px;
- font-weight: 500; /* figma wt510, rendered 500 */
- color: var(--dsw-alias-label-primary-dimmed);
-}
-
-.sep {
- flex: none;
- width: 2px;
- height: 2px;
- border-radius: 1px;
- margin: 0 8px;
- background: var(--dsw-alias-label-caption);
-}
-
-.summary {
- flex: 1 1 auto;
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- font-size: 14px;
- line-height: 24px;
- color: var(--dsw-alias-label-tertiary);
-}
-
-.err {
- flex: none;
- margin-left: 8px;
- color: var(--dsw-alias-state-error-primary);
- font-size: 11px;
- line-height: 16px;
-}
diff --git a/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx b/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx
index a47322b614..2d72cfc700 100644
--- a/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx
+++ b/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx
@@ -1,16 +1,16 @@
// todo_write toolview: plan-flavored summary row replacing the generic
// "Tool call" card, registered into the keyed 'conversation.chat.toolview'
// hole like the bash sample (a product registration, not a sample). The row
-// summarizes the written list (counts + active item) from the call args; the
+// composes ToolRow (chrome, running sweep, leading expansion) and swaps in a
+// summary of the written list (counts + active item) from the call args; the
// durable list itself renders in the TodoPanel above the composer, so the
-// row stays one line. Chrome matches ToolRow (figma 780:53675).
+// row stays one line.
-import type { KeyboardEvent } from 'react'
+import { IconChecklistOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context } from 'cordis'
-import { IconChecklistOutline16, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ToolRowProps } from '../contract/slots.ts'
-import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts'
-import css from './todo-row.module.css'
+import { toolRowModel } from '../contract/tool-call-model.ts'
+import { ToolRow } from '../chat/ToolRow.tsx'
/** One parsed args item, shape-checked (model JSON: any field may be missing or mistyped). */
interface TodoWriteItem { content?: unknown; status?: unknown }
@@ -40,48 +40,25 @@ function summarize(argsRaw: string): string | null {
: head
}
-/** Leading-slot state substitution matches ToolRow / bash: icon yields to the
- * state semantic while running or failed; ok keeps the checklist glyph. */
-function leadingFor(state: ToolRowState) {
- switch (state) {
- case 'running': return
- case 'error': return
- case 'stopped': return
- default: return
- }
-}
-
-/** One-line plan update row (click opens the raw args in details). Non-ok
- * execution states keep the generic row's dot semantics — a cancelled call
- * wrote no todo/write, so it must not read as a completed update. */
+/** One-line plan update row (row click opens details; leading toggle expands
+ * the raw args). Non-ok execution states keep the shared row's dot semantics
+ * — a cancelled call wrote no todo/write, so it must not read as a completed
+ * update. */
export function TodoRow({ toolName, block, openDetails }: ToolRowProps) {
const model = toolRowModel(toolName, block)
const argsRaw = ('kind' in block ? block.call?.argsRaw : block.argsRaw) ?? ''
const summary = summarize(argsRaw) ?? model.summary
- // Button semantics, not a