`; they ride ToolRow's `output` (Output section) and `errorSummary` (collapsed summary first line), which already flatten the result text with the `error.name: error.code` fallback.
+- `bash-sample` keeps its own local expand chrome deliberately (the third-party-posture exemplar that never imports the chat domain); it was already collapsed, so its behavior is unchanged.
+
+## Alternatives considered
+
+- **Keep the rows resident, only unify chrome.** Rejected: the user's requirement is default-collapsed, and resident cards are what made the flow unscannable.
+- **A shared `CardRow` wrapper between the rows and ToolRow.** Rejected: ToolRow already is that wrapper once it takes every card kind; a second layer would be the premature extraction the package rules warn against.
diff --git a/.agents/notes/implemented/feature/2026-07-31-web-cards-toolrow.zh.md b/.agents/notes/implemented/feature/2026-07-31-web-cards-toolrow.zh.md
new file mode 100644
index 0000000000..4e9c429497
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-31-web-cards-toolrow.zh.md
@@ -0,0 +1,32 @@
+# Agent Note:卡片工具行通过同一个 ToolRow 折叠
+
+Status: implemented
+
+[English](2026-07-31-web-cards-toolrow.md) | 中文
+
+## 问题
+
+Web 客户端在连续几个 PR 里长出五种卡片渲染意图——terminal、diff、read、search、web,每一种都作为一个键控 toolview 注册项落在 `packages/client/ui-conversation/src/client/toolviews/` 下。它们在两处出现分歧,之前每个 PR 都承认却推迟处理:
+
+- **Chrome 重复。** `read-row`、`search-row`、`web-row`、`file-mutation-row` 各自把摘要行(行首状态槽、视觉隐藏状态、标题、分隔点、路径链接/摘要)手绘成自己的 `
`,配一份私有 `.module.css`,而不是组合共享的 `ToolRow`。`read-row` 带着一个 `jscpd:ignore` 标记,点名这处重复并指向"一处针对所有行一次性处理的独立改动"——就是本次改动。
+- **常驻 vs 折叠。** 那四个行把卡片(`ReadBlock`/`SearchBlock`/`WebBlock`/`DiffBlock`)常驻在摘要下方——始终展开——而终端卡片(经 `GenericToolCard`/`BashRow`)与每个文本行都从折叠状态起步,藏在 ToolRow 的整行展开之后。一个有多个 read/search/web/edit 调用的对话就成了一堵始终打开的卡片墙,违背了消息流作为摘要面的目的。
+
+## 决策
+
+`ToolRow` 拥有每一种卡片,而每个键控卡片行都组合它。ToolRow 原本就接收 `terminal` 与 `diff` 卡片材料;现在还接收 `read`、`search`、`web`,在其默认折叠的展开 body 里用对应原语渲染当前存在的那一种(按 chat 的 `CHAT_*` 上限截断)。一次调用最多携带一种卡片,因此这些 prop 互斥,body 取第一个存在的。
+
+四个键控行——`ReadRow`、`SearchRow`、`WebRow`、`FileMutationRow`——丢掉手绘 chrome 与私有 CSS,成为薄薄的 `ToolRow` 组合,与 `AskQuestionRow` 完全一样:推导卡片模型,作为对应的 ToolRow prop 传入,为文件工具转发 `filePath`/`onOpenFile`,为无卡片的失败路径转发 `output`/`errorSummary`。每个行现在是 `ToolRowProps & PropsLocale<'conversation'>` 并以 `locale: NS` 注册,因为 ToolRow 需要对话的 `t` 来渲染其终端/代码 body 文案。`GenericToolCard`(渲染点兜底)对 read/search/web 做同样的事,所以一个没有自己键控行的卡片声明工具也以同样方式折叠。
+
+`DetailsPanel` 的 Output 区不变:面板是单次调用的阅读面,因此它以原语的完整高度常驻渲染每张卡片,被截断的搜索也把恢复脚注留在那里。
+
+## 后果
+
+- 所有工具行共享一套展开交互:折叠时是单行摘要,整行切换卡片。卡片在展开前不在 DOM 里(`DisclosureRow` 只在打开时渲染 `children`),因此测试围绕一次 `[data-expandable]` 点击断言"先无后有"。
+- 已删除:`read-row.module.css`、`search-row.module.css`、`web-row.module.css`、`file-mutation-row.module.css`、`GenericToolCard.module.css`。这些行不再带自己的 CSS;ToolRow 的 module 拥有 chrome 与卡片 body 的缩进。
+- 无卡片的失败路径(出错的改动,出错/嵌套/旧日志的搜索)不再画自己的 `.failure`/恢复 `
`;它们改走 ToolRow 的 `output`(Output 区)与 `errorSummary`(折叠摘要首行),后者已经用 `error.name: error.code` 兜底压平结果文本。
+- `bash-sample` 有意保留自己本地的展开 chrome(第三方姿态的范例,从不引入 chat 域);它本来就是折叠的,因此行为不变。
+
+## 考虑过的替代方案
+
+- **保持行常驻,只统一 chrome。** 否决:用户的要求是默认折叠,而常驻卡片正是让流不可扫读的原因。
+- **在行与 ToolRow 之间加一层共享的 `CardRow` 包装。** 否决:ToolRow 一旦接收每一种卡片,它本身就是那层包装;再加一层就是 package 规则警告的过早抽取。
diff --git a/apps/web/tests/built-boot.snapshot.ts b/apps/web/tests/built-boot.snapshot.ts
index 6fa5aec1ab..d2f6d913dd 100644
--- a/apps/web/tests/built-boot.snapshot.ts
+++ b/apps/web/tests/built-boot.snapshot.ts
@@ -112,11 +112,19 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
}, { timeout: 10_000 })
// The write/edit turns render a real diff card through the assembled graph
- // (the keyed FileMutationRow + DiffBlock), not just the fixture's raw text.
- // The write turn's `hello fixture\n` proves the terminator rule end to end: a
- // trailing newline terminates its line, so the footer reads `+1` (not a
- // phantom `+2`) and one distinct file. The `+ ` prefix is a CSS ::before, so
- // it is absent from textContent — assert on the line body and the footer.
+ // (the keyed FileMutationRow composing ToolRow + DiffBlock), not just the
+ // fixture's raw text. The card is collapsed by default, so expand each edit/
+ // write row first. The write turn's `hello fixture\n` proves the terminator
+ // rule end to end: a trailing newline terminates its line, so the footer reads
+ // `+1` (not a phantom `+2`) and one distinct file. The `+ ` prefix is a CSS
+ // ::before, so it is absent from textContent — assert on the line body and the
+ // footer.
+ const mutationRows = [...document.querySelectorAll('[data-variant="write"],[data-variant="edit"]')]
+ expect(mutationRows.length).toBeGreaterThan(0)
+ for (const row of mutationRows) {
+ const toggle = row.querySelector('[data-expandable]')
+ if (toggle !== null) act(() => { fireEvent.click(toggle) })
+ }
const diffCards = [...document.querySelectorAll('[data-diff]')]
expect(diffCards.length).toBeGreaterThan(0)
const footers = diffCards.map(card => card.textContent ?? '')
@@ -125,13 +133,20 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
// The web render intent reaches the assembled boot graph: the fixture's
// web_search / web_fetch turns render their keyed WebRow cards, proving the
// registration, wire projection, and card rendering survive the real bundle
- // path (not just the per-package src benches). The selector pins the KEYED
- // WebRow (its own `data-variant="web"` wrapper), not the `[data-web]` attribute
- // WebBlock draws — the generic fallback renders the same WebBlock, so a silent
- // keyed-registration failure would still satisfy a bare `[data-web]` check.
+ // path (not just the per-package src benches). WebRow composes ToolRow, so the
+ // card is collapsed behind the row; the keyed row is pinned by its `data-tool`
+ // (ToolRow sets it from the wire tool name).
+ const webSearchRow = await waitFor(() => {
+ const row = document.querySelector('[data-tool="web_search"]')
+ expect(row).not.toBeNull()
+ expect(document.querySelector('[data-tool="web_fetch"]')).not.toBeNull()
+ return row!
+ }, { timeout: 10_000 })
+ // Expand the web_search row to prove its WebBlock card renders end to end.
+ const webToggle = webSearchRow.querySelector('[data-expandable]')
+ if (webToggle !== null) act(() => { fireEvent.click(webToggle) })
await waitFor(() => {
- expect(document.querySelector('[data-variant="web"][data-tool="web_search"]')).not.toBeNull()
- expect(document.querySelector('[data-variant="web"][data-tool="web_fetch"]')).not.toBeNull()
+ expect(webSearchRow.querySelector('[data-web]')).not.toBeNull()
}, { timeout: 10_000 })
// Every bundle injected its plugin-owned style tag (the loader's CSS path).
diff --git a/apps/web/tests/search-card.snapshot.ts b/apps/web/tests/search-card.snapshot.ts
index ddb2f607b5..2bfa4f9244 100644
--- a/apps/web/tests/search-card.snapshot.ts
+++ b/apps/web/tests/search-card.snapshot.ts
@@ -145,15 +145,21 @@ describe('assembled search card', () => {
await waitFor(() => {
expect(document.querySelector('[data-sample="bash-global"]')).not.toBeNull()
}, { timeout: 10_000 })
- // The grep turn's keyed SearchRow renders the card resident: wait for it.
+ // The grep turn's keyed SearchRow composes ToolRow: the card is collapsed
+ // by default, so wait for the summary row, then expand it to reach the card.
await waitFor(() => {
const tools = [...document.querySelectorAll('[data-tool]')].map(el => el.getAttribute('data-tool'))
expect(tools, `tools present: ${tools.join(', ')}`).toContain('grep')
}, { timeout: 10_000 })
- // `data-tool` sits on the summary row; the card and recovery footer are its
- // siblings inside the SearchRow wrapper, so shape the wrapper (its parent).
- const grepRow = document.querySelector('[data-tool="grep"]')!.parentElement!
+ // `data-tool` sits on the ToolRow root; the collapsed row is the expand
+ // toggle. Click it so the card and its recovery footer mount, then shape the
+ // whole row (the card lives inside ToolRow's body wrapper).
+ const grepRow = document.querySelector('[data-tool="grep"]')!
+ act(() => { fireEvent.click(grepRow.querySelector('[data-expandable]') ?? grepRow) })
+ await waitFor(() => {
+ expect(grepRow.querySelector('[data-search]')).not.toBeNull()
+ }, { timeout: 10_000 })
const shape = cardShape(grepRow)
if (refreshing) {
mkdirSync(dirname(EXPECTED), { recursive: true })
diff --git a/apps/web/tests/snapshots/code-mode-round/ui.expected.md b/apps/web/tests/snapshots/code-mode-round/ui.expected.md
index 8b170486a5..5d1979eadf 100644
--- a/apps/web/tests/snapshots/code-mode-round/ui.expected.md
+++ b/apps/web/tests/snapshots/code-mode-round/ui.expected.md
@@ -20,8 +20,10 @@
- img
- text: Code Run bash echo and catch missing file read
- img
-- text: Bash Echo CODE_ROUND_OK 失败 Read
-- button "missing.txt"
+- text: Bash Echo CODE_ROUND_OK
+- 'button "Read Error: cannot read \"{{cwd}}/workspace/missing.txt\": not found"':
+ - img
+ - text: "Read Error: cannot read \"{{cwd}}/workspace/missing.txt\": not found"
- button "Think The program ran successfully. Let me now reply DONE as instructed.":
- img
- img
diff --git a/apps/web/tests/snapshots/search-card/grep-card.expected.txt b/apps/web/tests/snapshots/search-card/grep-card.expected.txt
index 3d0efb3ecd..7fde9b0307 100644
--- a/apps/web/tests/snapshots/search-card/grep-card.expected.txt
+++ b/apps/web/tests/snapshots/search-card/grep-card.expected.txt
@@ -8,4 +8,20 @@ line=141: const [collapsed, setCollapsed] = useState>(() =
line=73: const search = searchCardModel(block)
line=90:
line=113: ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep' }, SearchRow)
-expand=… 其余 4 行
\ No newline at end of file
+expand=… 其余 4 行
+recovery=Found 9 of 42 matches
+
+packages/client/ui-primitives/src/SearchBlock.tsx
+Line 16: export const DEFAULT_SEARCH_MAX_LINES = 16
+Line 138: export function SearchBlock(props: SearchBlockProps) {
+Line 141: const [collapsed, setCollapsed] = useState>(() => new Set())
+packages/client/ui-conversation/src/client/contract/search-card-model.ts
+Line 24: export const CHAT_SEARCH_MAX_LINES = 8
+Line 60: export function searchCardModel(block: ToolCallBlock): SearchCardModel | null {
+packages/client/ui-conversation/src/client/toolviews/search-row.tsx
+Line 71: export function SearchRow({ toolName, block }: ToolRowProps) {
+Line 73: const search = searchCardModel(block)
+Line 90:
+Line 113: ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep' }, SearchRow)
+
+(Full grep result stored at: fixture://spill/grep-68. Read it to see every match.)
\ No newline at end of file
diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts
index 576df9d1df..6f508009db 100644
--- a/packages/client/connection/src/client/fixture.ts
+++ b/packages/client/connection/src/client/fixture.ts
@@ -165,7 +165,7 @@ const READ_SAMPLE_TOTAL = 180
const READ_SAMPLE_TEXT = READ_SAMPLE_SOURCE.map((text, index) => `${READ_SAMPLE_FIRST_LINE + index}: ${text}`).join('\n')
/**
- * Structured grep result for the search sample (turn 67): matches grouped by
+ * Structured grep result for the search sample (turn 68): matches grouped by
* file, authored inline because the client-side fixture cannot import the tool
* that produces the canonical value. `truncated` with a larger `total` than the
* retained match count exercises the search card's capped indicator; the file
@@ -211,11 +211,11 @@ const SEARCH_MATCHES_TEXT = [
...SEARCH_MATCHES_FIXTURE.map(file =>
[file.path, ...file.matches.map(m => `Line ${m.lineNumber}: ${m.line}`)].join('\n')),
'',
- '(Full grep result stored at: fixture://spill/grep-67. Read it to see every match.)',
+ '(Full grep result stored at: fixture://spill/grep-68. Read it to see every match.)',
].join('\n')
/**
- * Structured glob result for the search sample (turn 68): a flat path list,
+ * Structured glob result for the search sample (turn 69): a flat path list,
* truncated with a larger `total` so the path card shows its capped indicator.
*/
const SEARCH_PATHS_FIXTURE = [
@@ -234,11 +234,11 @@ const SEARCH_PATHS_FIXTURE = [
const SEARCH_PATHS_TEXT = [
...SEARCH_PATHS_FIXTURE,
'',
- '(Showing 5 of 23 paths. Full sorted result stored at: fixture://spill/glob-68. Read it to see every path.)',
+ '(Showing 5 of 23 paths. Full sorted result stored at: fixture://spill/glob-69. Read it to see every path.)',
].join('\n')
/**
- * The structured `web_search` result view for fixture turn 69, authored inline
+ * The structured `web_search` result view for fixture turn 70, authored inline
* because this client-side fixture cannot import the web tool that projects it.
* The sources exercise the citation list's features: a titled source with a
* snippet and a date, a source with no title (its hostname labels the link) and
@@ -268,7 +268,7 @@ const WEB_SEARCH_RESULT: Omit, 'card' | 'kind'> = {
url: 'https://www.deepseek.com/blog/harness-architecture',
statusCode: 200,
@@ -484,17 +484,17 @@ function buildAlphaLog(): SessionEvent[] {
// structured window.
toolTurn(66, 'read', `{"file_path":${JSON.stringify(READ_SAMPLE_PATH)},"offset":${READ_SAMPLE_FIRST_LINE}}`, READ_SAMPLE_TEXT)
- // Turns 67-68: the search card's two shapes. `grep` emits a `card: 'search'`
+ // Turns 68-69: the search card's two shapes. `grep` emits a `card: 'search'`
// `shape: 'matches'` result view (grouped-by-file matches, truncated with a
// larger `total`), `glob` emits `shape: 'paths'` (a flat path list, likewise
// truncated). Both ride the keyed SearchRow registration under their own
// names; the render-site fallback row is covered by the model derivation
// tests, since every fixture search tool has a keyed row. Ordered before the
// todo turn for the same standing-plan reason the bash turn is.
- toolTurn(67, 'grep', '{"pattern":"SEARCH_MAX_LINES","path":"packages/client"}', SEARCH_MATCHES_TEXT)
- toolTurn(68, 'glob', '{"pattern":"**/SearchBlock*","path":"packages/client"}', SEARCH_PATHS_TEXT)
+ toolTurn(68, 'grep', '{"pattern":"SEARCH_MAX_LINES","path":"packages/client"}', SEARCH_MATCHES_TEXT)
+ toolTurn(69, 'glob', '{"pattern":"**/SearchBlock*","path":"packages/client"}', SEARCH_PATHS_TEXT)
- // Turns 69-70: the web render intent — a web_search whose result view carries
+ // Turns 70-71: the web render intent — a web_search whose result view carries
// structured sources plus an answer (the citation list, one source lacking a
// title so its hostname labels the link, the capped indicator on), and a
// web_fetch whose result view carries the fetched URL and its HTTP status.
@@ -503,11 +503,11 @@ function buildAlphaLog(): SessionEvent[] {
// the real tools so they hit the keyed WebRow registration. Ordered BEFORE
// the todo turn for the same reason turn 65 is: the standing plan retires at
// the next turn/start, so a turn after it would empty the dock's plan strip.
- toolTurn(69, 'web_search', '{"query":"deepseek harness architecture"}', 'Search results for deepseek harness architecture.')
- toolTurn(70, 'web_fetch', '{"url":"https://www.deepseek.com/blog/harness-architecture"}', '# Harness architecture\n\nEverything is a plugin.')
+ toolTurn(70, 'web_search', '{"query":"deepseek harness architecture"}', 'Search results for deepseek harness architecture.')
+ toolTurn(71, 'web_fetch', '{"url":"https://www.deepseek.com/blog/harness-architecture"}', '# Harness architecture\n\nEverything is a plugin.')
const todoArgs = JSON.stringify({ todos: fixtureTodos })
- toolTurn(71, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 1 in progress, 1 completed.')
+ toolTurn(72, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 1 in progress, 1 completed.')
// The real tool appends the snapshot mid-execution — between tool/call and
// tool/result — so the fixture reproduces that exact ordering (the last
// toolTurn events run ... tool/call, tool/result, step/end, turn/end).
diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml
index cffb1cc453..1d9807ae38 100644
--- a/packages/client/ui-conversation/README.i18n.yaml
+++ b/packages/client/ui-conversation/README.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 packages/client/ui-conversation/README.md
-README.md: b183aab56a10c3a22eeab31f8b1b0e6b5219beba
-README.zh.md: af6427774a048de324d57bc9856f77547e9a03cc
+README.md: 8003e8901d580b4aae64b5920427e789e2233acc
+README.zh.md: ff734b719ccb569f303aada43a3f83fde70f5d84
diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md
index dce5fe10c7..8003e8901d 100644
--- a/packages/client/ui-conversation/README.md
+++ b/packages/client/ui-conversation/README.md
@@ -16,13 +16,13 @@ Generic tool rows classify the built-in bash, read, search, write, edit, and run
A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed per render intent — the terminal and web cards, each with its own bound; a generic tool's content remains panel-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)).
-A tool call declaring the `web` render intent renders its web retrieval inline, at both conversation render sites, through ui-primitives' `WebBlock`. `contract/web-card-model.ts` is the single derivation from the snapshot's `resultView`, mirroring the terminal card, so the sites cannot disagree about what a web call shows; it yields null — the generic path — for a running call, a non-web result view, a generic result view, a `card` tag this client version does not know, or a web card whose `kind` this client version does not know (a newer host's value, which the wire cannot be trusted to be `search` or `fetch`). The keyed `WebRow` registers one component under both `web_search` and `web_fetch`, discriminating on the tool name only for its icon and title; a web-declaring tool without a keyed row lands on the `GenericToolCard` fallback, which grows the same resident card, and the details panel renders it at the primitive's full source allowance and, below the card, the flattened model-visible result content — a fetch body is readable only there, since its card carries only the URL and status. Rows cap at `CHAT_WEB_MAX_SOURCES` (8) against the panel's 16, the same summary-versus-reading split the terminal card draws ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md)).
+A tool call declaring the `web` render intent renders its web retrieval inline, at both conversation render sites, through ui-primitives' `WebBlock`. `contract/web-card-model.ts` is the single derivation from the snapshot's `resultView`, mirroring the terminal card, so the sites cannot disagree about what a web call shows; it yields null — the generic path — for a running call, a non-web result view, a generic result view, a `card` tag this client version does not know, or a web card whose `kind` this client version does not know (a newer host's value, which the wire cannot be trusted to be `search` or `fetch`). The keyed `WebRow` registers one component under both `web_search` and `web_fetch`, discriminating on the tool name only for its icon and title; it composes the shared `ToolRow`, feeding the card as ToolRow's `web` body, so the retrieval is the row's collapsed-by-default expanded card (the same unified expand every card row has). A web-declaring tool without a keyed row lands on the `GenericToolCard` fallback, which routes the card through ToolRow the same way, and the details panel renders it at the primitive's full source allowance and, below the card, the flattened model-visible result content — a fetch body is readable only there, since its card carries only the URL and status. Rows cap at `CHAT_WEB_MAX_SOURCES` (8) against the panel's 16, the same summary-versus-reading split the terminal card draws ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md)).
-A tool call declaring the `diff` render intent (the `write`/`edit` tools) renders its applied change inline through ui-primitives' `DiffBlock`, the same four-layer shape. `contract/diff-card-model.ts` is the single derivation from the `callView`/`resultView` pair; the settled result's hunks replace the call-time diff, and it yields null — the generic path — for any other card tag or a generic result view (write/edit's execution errors). The keyed `FileMutationRow` (registered under both `write` and `edit`) carries the card resident below its summary, whose path link still opens the file through the host; the render-site fallback and the details panel are diff-aware too. Rows cap at `CHAT_DIFF_MAX_LINES` (8) against the panel's 16 ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md)).
+A tool call declaring the `diff` render intent (the `write`/`edit` tools) renders its applied change inline through ui-primitives' `DiffBlock`, the same four-layer shape. `contract/diff-card-model.ts` is the single derivation from the `callView`/`resultView` pair; the settled result's hunks replace the call-time diff, and it yields null — the generic path — for any other card tag or a generic result view (write/edit's execution errors). The keyed `FileMutationRow` (registered under both `write` and `edit`) composes the shared `ToolRow`, feeding the diff as ToolRow's `diff` body, so it is the row's collapsed-by-default expanded card; the summary path link still opens the file through the host, and an errored mutation (no diff card) surfaces its error text through ToolRow's Output section with the first line in the collapsed summary. The render-site fallback and the details panel are diff-aware too. Rows cap at `CHAT_DIFF_MAX_LINES` (8) against the panel's 16 ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md)).
The chat flow projects consecutive model-retry nodes across retry turns into one stable, muted status row updated to the latest attempt; every retry event remains in the runtime snapshot and session log. Its frontend countdown anchors the scheduled delay to client receipt, avoiding host/browser clock skew, rounds remaining time up to seconds, and has a one-second floor. The latest unresolved retry uses a left-to-right text shimmer. Subsequent turn facts distinguish an attempt that started from one cancelled during backoff, while the Host running bit only controls the live animation; the row then shows a static completed or cancelled label. Normal policy rows show the finite retry maximum; always policy rows show `∞`. Activating the row reveals the latest exact retry delay and failure message. The client runtime removes each failed step's streaming tail before its retry node arrives, while the status remains visible after a later attempt succeeds.
-A `grep`/`glob` call declaring the `search` render intent renders its result inline, at the same render sites, through ui-primitives' `SearchBlock` — grep's matches grouped by file (each a collapsible header of `lineNumber: line` rows), glob's flat path list. `contract/search-card-model.ts` is the single derivation from the snapshot's `resultView`; unlike the terminal card it reads no `callView`, since a search has no matches or paths before `execute`, so a running search shows its summary alone. It yields null — the generic path — for any non-search result view, a `card` or `kind` this client version does not compile, and (because those ride the untrusted wire frame) a known kind whose `files`/`paths` is malformed. The keyed `SearchRow`, registered under both `grep` and `glob` since the derived `kind` decides the shape, carries the card resident below its summary; the render-site fallback keeps it behind the expand control. Both cap at `CHAT_SEARCH_MAX_LINES` (8) against the panel's 16. A capped search drops rows from the card, but the locator to the rest — grep/glob's `Full … stored at …` footer — lives only in the result text, so the derivation surfaces that as a recovery footer below the card when (and only when) the result was truncated; a settled call with no card at all (an errored search, a nested `run_code` sub-dispatch, a legacy generic result) falls back to its flattened result text so nothing is lost behind a bare summary ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md)).
+A `grep`/`glob` call declaring the `search` render intent renders its result inline, at the same render sites, through ui-primitives' `SearchBlock` — grep's matches grouped by file (each a collapsible header of `lineNumber: line` rows), glob's flat path list. `contract/search-card-model.ts` is the single derivation from the snapshot's `resultView`; unlike the terminal card it reads no `callView`, since a search has no matches or paths before `execute`, so a running search shows its summary alone. It yields null — the generic path — for any non-search result view, a `card` or `kind` this client version does not compile, and (because those ride the untrusted wire frame) a known kind whose `files`/`paths` is malformed. The keyed `SearchRow`, registered under both `grep` and `glob` since the derived `kind` decides the shape, composes the shared `ToolRow`, feeding the card as ToolRow's `search` body, so it is the row's collapsed-by-default expanded card; the render-site fallback routes it the same way. Both cap at `CHAT_SEARCH_MAX_LINES` (8) against the panel's 16. A capped search drops rows from the card, but the locator to the rest — grep/glob's `Full … stored at …` footer — lives only in the result text, so the derivation surfaces that as a recovery footer below the card when (and only when) the result was truncated; a settled call with no card at all (an errored search, a nested `run_code` sub-dispatch, a legacy generic result) surfaces its flattened result text through ToolRow's Output section so nothing is lost behind a bare summary ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md)).
Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openFile`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders).
diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md
index 2c5581194d..ff734b719c 100644
--- a/packages/client/ui-conversation/README.zh.md
+++ b/packages/client/ui-conversation/README.zh.md
@@ -14,13 +14,13 @@
声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView`/`resultView` 对推导的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null,落回通用路径。因此两个渲染点也都显示卡片的运行状态点,它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片常驻在摘要行下方;由于工具行已不再是详情面板的点击目标,卡片的复制与展开控件就是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`(8),面板为 16,正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出按渲染意图开放——终端卡片与 web 卡片,各有自己的上限;通用工具的内容仍然只在面板中呈现([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。
-声明 `web` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `WebBlock` 内联渲染其 web 检索。`contract/web-card-model.ts` 是从快照的 `resultView` 推导的唯一位置,镜像终端卡片,因此两个渲染点不可能对一次 web 调用的显示产生分歧;对运行中的调用、非 web 的 result view、generic result view、本客户端版本不认识的 `card` 标签,或本客户端版本不认识 `kind` 的 web 卡片(更新的 host 发来的值,wire 上不可信其为 `search` 或 `fetch`),它返回 null,落回通用路径。键控的 `WebRow` 把一个组件注册在 `web_search` 与 `web_fetch` 两个键下,仅根据工具名判别以选取图标与标题;没有自己键控行的 web 声明工具落到 `GenericToolCard` 兜底,它长出同一张常驻卡片,详情面板则以原语的完整 source 额度渲染它,并在卡片下方渲染摊平的模型可见结果内容——fetch 正文只在此处可读,因为其卡片只携带 URL 和状态。行的上限是 `CHAT_WEB_MAX_SOURCES`(8),面板为 16,与终端卡片所画的摘要面对阅读面的同一划分([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md))。
+声明 `web` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `WebBlock` 内联渲染其 web 检索。`contract/web-card-model.ts` 是从快照的 `resultView` 推导的唯一位置,镜像终端卡片,因此两个渲染点不可能对一次 web 调用的显示产生分歧;对运行中的调用、非 web 的 result view、generic result view、本客户端版本不认识的 `card` 标签,或本客户端版本不认识 `kind` 的 web 卡片(更新的 host 发来的值,wire 上不可信其为 `search` 或 `fetch`),它返回 null,落回通用路径。键控的 `WebRow` 把一个组件注册在 `web_search` 与 `web_fetch` 两个键下,仅根据工具名判别以选取图标与标题;它组合共享的 `ToolRow`,把卡片作为 ToolRow 的 `web` body 传入,因此检索成为该行默认折叠的展开卡片(与每个卡片行相同的统一展开交互)。没有自己键控行的 web 声明工具落到 `GenericToolCard` 兜底,它以同样方式经 ToolRow 渲染卡片,详情面板则以原语的完整 source 额度渲染它,并在卡片下方渲染摊平的模型可见结果内容——fetch 正文只在此处可读,因为其卡片只携带 URL 和状态。行的上限是 `CHAT_WEB_MAX_SOURCES`(8),面板为 16,与终端卡片所画的摘要面对阅读面的同一划分([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md))。
-声明 `diff` 渲染意图的工具调用(`write`/`edit` 工具),通过 ui-primitives 的 `DiffBlock` 内联渲染其已应用的改动,采用同一套四层结构。`contract/diff-card-model.ts` 是从 `callView`/`resultView` 对推导的唯一位置;已结算 result 的 hunk 替换 call 时 diff,对任何其他 card 标签或 generic result view(write/edit 的执行错误)它返回 null,落回通用路径。键控的 `FileMutationRow`(在 `write` 与 `edit` 下都注册)把卡片常驻在摘要之下,其路径链接仍经 host 打开文件;渲染点兜底行与详情面板同样感知 diff。行的上限是 `CHAT_DIFF_MAX_LINES`(8),面板为 16([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md))。
+声明 `diff` 渲染意图的工具调用(`write`/`edit` 工具),通过 ui-primitives 的 `DiffBlock` 内联渲染其已应用的改动,采用同一套四层结构。`contract/diff-card-model.ts` 是从 `callView`/`resultView` 对推导的唯一位置;已结算 result 的 hunk 替换 call 时 diff,对任何其他 card 标签或 generic result view(write/edit 的执行错误)它返回 null,落回通用路径。键控的 `FileMutationRow`(在 `write` 与 `edit` 下都注册)组合共享的 `ToolRow`,把 diff 作为 ToolRow 的 `diff` body 传入,因此它是该行默认折叠的展开卡片;摘要路径链接仍经 host 打开文件,而出错的改动(没有 diff 卡片)经 ToolRow 的 Output 区呈现其错误文本,首行进入折叠摘要。渲染点兜底行与详情面板同样感知 diff。行的上限是 `CHAT_DIFF_MAX_LINES`(8),面板为 16([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md))。
聊天流会将跨重试轮次连续出现的模型重试节点投影为一个稳定的弱化状态行,并用最新一次尝试更新该行;每个重试事件仍保留在运行时快照与会话日志中。前端倒计时以客户端收到事件的时刻为计划延迟的起点,避免 Host 与浏览器的时钟偏差;剩余时间向上取整到秒,且下限为 1 秒。最近一次尚未完成的重试会显示从左到右的文字渐变动画。后续轮次事实用于区分已开始的尝试与在退避期间取消的尝试,Host 的 running 位只控制实时动画;随后该行会显示静态的已完成或已取消标签。normal 策略行显示有限重试上限;always 策略行显示 `∞`。激活该行会显示最近一次重试的精确延迟和失败消息。客户端运行时会在相应重试节点到达前移除每个失败步骤的流式输出尾部;后续某次尝试成功后,该状态仍保持可见。
-声明 `search` 渲染意图的 `grep`/`glob` 调用,会在同样的渲染点上通过 ui-primitives 的 `SearchBlock` 内联渲染其结果——grep 的匹配按文件分组(每个是一个可折叠的头,下辖 `lineNumber: line` 行),glob 是扁平路径列表。`contract/search-card-model.ts` 是从快照的 `resultView` 推导的唯一位置;与终端卡片不同,它不读 `callView`,因为搜索在 `execute` 前没有匹配或路径,所以运行中的搜索只显示摘要。对任何非搜索的结果视图、当前客户端版本无法编译的 `card` 或 `kind`、以及(因为这些都与不可信的 wire 帧同行)一个 `files`/`paths` 格式错误的已知 kind,它都返回 null,落回通用路径。键控的 `SearchRow` 因推导出的 `kind` 决定形态而同时注册在 `grep` 与 `glob` 下,把卡片常驻在摘要行下方;渲染点兜底行则把它保持在展开控件之后。两者上限都是 `CHAT_SEARCH_MAX_LINES`(8),面板为 16。被截断的搜索会从卡片里丢掉一些行,但通往其余部分的定位符——grep/glob 的 `Full … stored at …` 脚注——只存在于结果文本里,因此推导在(且仅在)结果被截断时把它作为恢复脚注画在卡片下方;一个完全没有卡片的已结算调用(出错的搜索、嵌套 `run_code` 子派发、旧日志的 generic 结果)则回退到其压平后的结果文本,从而不让任何内容丢失在一个光秃秃的摘要之后([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md))。
+声明 `search` 渲染意图的 `grep`/`glob` 调用,会在同样的渲染点上通过 ui-primitives 的 `SearchBlock` 内联渲染其结果——grep 的匹配按文件分组(每个是一个可折叠的头,下辖 `lineNumber: line` 行),glob 是扁平路径列表。`contract/search-card-model.ts` 是从快照的 `resultView` 推导的唯一位置;与终端卡片不同,它不读 `callView`,因为搜索在 `execute` 前没有匹配或路径,所以运行中的搜索只显示摘要。对任何非搜索的结果视图、当前客户端版本无法编译的 `card` 或 `kind`、以及(因为这些都与不可信的 wire 帧同行)一个 `files`/`paths` 格式错误的已知 kind,它都返回 null,落回通用路径。键控的 `SearchRow` 因推导出的 `kind` 决定形态而同时注册在 `grep` 与 `glob` 下,组合共享的 `ToolRow`,把卡片作为 ToolRow 的 `search` body 传入,因此它是该行默认折叠的展开卡片;渲染点兜底行以同样方式渲染它。两者上限都是 `CHAT_SEARCH_MAX_LINES`(8),面板为 16。被截断的搜索会从卡片里丢掉一些行,但通往其余部分的定位符——grep/glob 的 `Full … stored at …` 脚注——只存在于结果文本里,因此推导在(且仅在)结果被截断时把它作为恢复脚注画在卡片下方;一个完全没有卡片的已结算调用(出错的搜索、嵌套 `run_code` 子派发、旧日志的 generic 结果)则经 ToolRow 的 Output 区呈现其压平后的结果文本,从而不让任何内容丢失在一个光秃秃的摘要之后([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md))。
工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openFile`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);Session 区分在组件内部完成(`useSessions` 读取 `parentId`,bash 示例是第三方姿态的范例)。Trajectory/waterfall 工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。
diff --git a/packages/client/ui-conversation/tests/diff-card.spec.tsx b/packages/client/ui-conversation/tests/diff-card.spec.tsx
index e25045c9f0..40f637b3d4 100644
--- a/packages/client/ui-conversation/tests/diff-card.spec.tsx
+++ b/packages/client/ui-conversation/tests/diff-card.spec.tsx
@@ -12,7 +12,7 @@ import type {
ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
-import type { SelectionTarget, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
+import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { CHAT_DIFF_MAX_LINES, diffCardModel } from '../src/client/contract/diff-card-model.ts'
@@ -24,6 +24,9 @@ import { zh } from '../src/client/locales.ts'
afterEach(cleanup)
+/** FileMutationRow's full prop shape (ToolRow runtime share + conversation locale seat). */
+type FileMutationRowProps = Parameters[0]
+
const SID = 's1' as SessionId
const t = makeTranslate(zh, commonZh)
@@ -155,14 +158,23 @@ describe('FileMutationRow diff card', () => {
phase: 'ready',
})
- const rowProps = (block: RunningToolCall | ToolResultNode, toolName = 'edit'): ToolRowProps => ({
+ const rowProps = (block: RunningToolCall | ToolResultNode, toolName = 'edit'): FileMutationRowProps => ({
callId: 'c1', toolName, block, openFile: vi.fn(), cwd: '/w/app',
sessionId: SID, useSessions: bindSnapshotSelector(list()),
- } as unknown as ToolRowProps)
+ t,
+ } as unknown as FileMutationRowProps)
- it('renders the applied diff under the summary row, without an expand gesture', () => {
+ /** The whole summary row is the expand toggle (ToolRow's unified interaction). */
+ const toggleRow = (view: { container: HTMLElement }) => {
+ fireEvent.click(view.container.querySelector('[data-expandable]')!)
+ }
+
+ it('collapses to the summary row; expanding reveals the applied diff card', () => {
const view = render()
- // The diff card is resident (no expand toggle needed).
+ // The diff card is collapsed by default — not in the DOM until expanded.
+ expect(view.container.querySelector('[data-diff]')).toBeNull()
+ expect(view.queryByText('hello fixture')).toBeNull()
+ toggleRow(view)
expect(view.container.querySelector('[data-diff]')).not.toBeNull()
expect(view.getByText('hello fixture')).toBeTruthy()
expect(view.getByText('复制')).toBeTruthy()
@@ -171,6 +183,7 @@ describe('FileMutationRow diff card', () => {
it('the summary is a path link that opens the tool path through the host', () => {
const openFile = vi.fn()
const view = render()
+ // The path link rides the collapsed summary, so it opens without expanding.
fireEvent.click(view.getByRole('button', { name: 'notes/demo.txt' }))
// The row passes the tool's own path; the injected openFile resolves it
// against the session cwd (apply.ts), so the row must not resolve twice.
@@ -184,6 +197,8 @@ describe('FileMutationRow diff card', () => {
callView: { card: 'diff', title: 'Write notes/new.txt', diffs: [{ path: 'notes/new.txt', oldText: null, newText: 'hello fixture' }] },
resultView: { card: 'diff', title: 'Write notes/new.txt', diffs: [{ path: 'notes/new.txt', oldText: null, newText: 'hello fixture' }] },
}), 'write')} />)
+ // The footer counts live inside the collapsed diff card.
+ toggleRow(view)
expect(view.getByText('└ +1 -0 · 1 file')).toBeTruthy()
})
@@ -197,13 +212,16 @@ describe('FileMutationRow diff card', () => {
it('a mutation call with no diff view renders the summary row alone', () => {
const view = render()
+ // No diff material: expanding shows the args-JSON body, never a diff card.
+ expect(view.container.querySelector('[data-diff]')).toBeNull()
+ toggleRow(view)
expect(view.container.querySelector('[data-diff]')).toBeNull()
})
it('surfaces the result text when an errored mutation has no diff card', () => {
// write/edit return undefined from presentResult on isError, so the failure
- // has no diff — the row shows the model-facing error text instead of a bare
- // red dot.
+ // has no diff — ToolRow shows the model-facing error text as the collapsed
+ // summary's first line (errorSummary) instead of a bare red dot.
const view = render( {
expect(view.getByText('ToolError: sandbox_denied')).toBeTruthy()
})
- it('shows no failure text for a successful diff or a running call', () => {
+ it('shows no error summary for a successful diff or a running call', () => {
+ // ToolRow's error-color summary line is set only on the error state.
const ok = render()
- expect(ok.container.querySelector('[class*="_failure_"]')).toBeNull()
+ expect(ok.container.querySelector('[class*="_errorSummary_"]')).toBeNull()
cleanup()
const run = render()
- expect(run.container.querySelector('[class*="_failure_"]')).toBeNull()
+ expect(run.container.querySelector('[class*="_errorSummary_"]')).toBeNull()
})
it('shows the stopped state when the call was interrupted', () => {
@@ -234,8 +253,6 @@ describe('FileMutationRow diff card', () => {
error: { name: 'ToolError', code: 'interrupted' },
}))} />)
expect(view.container.querySelector('[data-state="stopped"]')).not.toBeNull()
- // The visually-hidden status label carries the stopped semantic for AT.
- expect(view.getByText('已停止')).toBeTruthy()
})
it('renders a plain summary span when the call carries no file path', () => {
diff --git a/packages/client/ui-conversation/tests/read-card.spec.tsx b/packages/client/ui-conversation/tests/read-card.spec.tsx
index ae458303e8..1c621ad7f4 100644
--- a/packages/client/ui-conversation/tests/read-card.spec.tsx
+++ b/packages/client/ui-conversation/tests/read-card.spec.tsx
@@ -2,8 +2,9 @@
// The read render intent on the web side: the pure readCardModel derivation
// over the settled result view, and both conversation render sites that consume
// it — the chat tool row (the keyed ReadRow and the GenericToolCard fallback,
-// each with the read card resident under the summary) and the details panel's
-// Output section. Also pins the keyed 'read' toolview registration.
+// each composing ToolRow with the read card as its collapsed-by-default expanded
+// body) and the details panel's Output section (resident, full height). Also
+// pins the keyed 'read' toolview registration.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render } from '@testing-library/react'
@@ -16,7 +17,7 @@ import type {
ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
-import type { SelectionTarget, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
+import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { CHAT_READ_MAX_LINES, readCardModel } from '../src/client/contract/read-card-model.ts'
import { createChatStore } from '../src/client/stores.ts'
import { GenericToolCard, type GenericToolCardProps } from '../src/client/chat/GenericToolCard.tsx'
@@ -128,11 +129,19 @@ describe('GenericToolCard read body', () => {
callId: 'c1', toolName: 'web_fetch', block, openFile: vi.fn(), t,
})
- it('renders the read card resident under the summary, capped tighter than the panel', () => {
+ /** The whole summary row is the expand toggle (ToolRow's unified interaction). */
+ const toggleRow = (view: { container: HTMLElement }) => {
+ fireEvent.click(view.container.querySelector('[data-expandable]')!)
+ }
+
+ it('expands to the read card, capped tighter than the panel', () => {
expect(CHAT_READ_MAX_LINES).toBeLessThan(16)
// web_fetch lands on the read variant without its own keyed row, so the
- // fallback card owns the resident read block.
+ // fallback card owns the read block once expanded.
const view = render()
+ // Collapsed: no read card in the DOM yet.
+ expect(view.container.querySelector('[data-read]')).toBeNull()
+ toggleRow(view)
expect(view.container.querySelector('[data-read]')).not.toBeNull()
expect(contentTexts(view.container)).toContain('export const a = 1')
// The gutter keeps the file's own line numbers.
@@ -145,6 +154,7 @@ describe('GenericToolCard read body', () => {
call: { name: 'echo', argsRaw: '{"text":"x"}' }, callView: null, resultView: null,
}), openFile: vi.fn(), t,
})} />)
+ toggleRow(view)
expect(view.container.querySelector('[data-read]')).toBeNull()
})
@@ -162,19 +172,34 @@ describe('ReadRow keyed toolview', () => {
phase: 'ready',
})
- const rowProps = (block: RunningToolCall | ToolResultNode): ToolRowProps => ({
+ const rowProps = (block: RunningToolCall | ToolResultNode): Parameters[0] => ({
callId: 'c1', toolName: 'read', block, openFile: vi.fn(),
sessionId: SID, useSessions: bindSnapshotSelector(list()),
- } as unknown as ToolRowProps)
+ t,
+ } as unknown as Parameters[0])
- it('renders the file path summary and the resident read card', () => {
+ /** The whole summary row is the expand toggle (ToolRow's unified interaction). */
+ const toggleRow = (view: { container: HTMLElement }) => {
+ fireEvent.click(view.container.querySelector('[data-expandable]')!)
+ }
+
+ it('collapses to the path summary; the whole row toggles the read card', () => {
const view = render()
expect(view.getByText('Read')).toBeTruthy()
- // The path appears twice: the row summary link and the card's banner label.
+ // Collapsed: the path is the summary link alone, and the card is absent.
+ expect(view.getAllByText('src/a.ts').length).toBe(1)
+ expect(view.container.querySelector('[data-read]')).toBeNull()
+ toggleRow(view)
+ // Expanded: the summary link stays inline and the card's banner label adds a
+ // second occurrence of the path.
expect(view.getAllByText('src/a.ts').length).toBe(2)
expect(view.container.querySelector('[data-read]')).not.toBeNull()
expect(contentTexts(view.container)).toContain('export const a = 1')
expect(view.getByText('显示 3 / 180 行')).toBeTruthy()
+ // Collapse back in place: the card unmounts, the summary link returns.
+ toggleRow(view)
+ expect(view.container.querySelector('[data-read]')).toBeNull()
+ expect(view.getAllByText('src/a.ts').length).toBe(1)
})
it('the path summary opens the file through the host', () => {
@@ -212,7 +237,8 @@ describe('ReadRow keyed toolview', () => {
const registered: { name: unknown; key?: unknown }[] = []
const ctx = { slots: { register: (options: { name: unknown; key?: unknown }) => { registered.push(options) } } } as unknown as Context
readToolview.apply(ctx)
- expect(registered).toEqual([{ name: 'conversation.chat.toolview', key: 'read' }])
+ // The row composes ToolRow, so it declares its locale namespace at the seat.
+ expect(registered).toEqual([{ name: 'conversation.chat.toolview', key: 'read', locale: 'conversation' }])
expect(readToolview.inject).toContain('conversation')
})
})
diff --git a/packages/client/ui-conversation/tests/search-card.spec.tsx b/packages/client/ui-conversation/tests/search-card.spec.tsx
index 9cea2c1661..1f3f0d7976 100644
--- a/packages/client/ui-conversation/tests/search-card.spec.tsx
+++ b/packages/client/ui-conversation/tests/search-card.spec.tsx
@@ -1,9 +1,10 @@
// @vitest-environment jsdom
// The search render intent on the web side: the pure searchCardModel derivation
// over resultView, and the conversation render sites that consume it — the chat
-// tool row (GenericToolCard's expand-gated body and SearchRow's resident card)
-// and the details panel's Output section. The keyed registration under both grep
-// and glob is pinned here too.
+// tool row (GenericToolCard's fallback body and SearchRow, both composing the
+// shared ToolRow with the search card collapsed by default) and the details
+// panel's Output section (resident, full height). The keyed registration under
+// both grep and glob is pinned here too.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render } from '@testing-library/react'
@@ -13,7 +14,7 @@ import type {
ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
-import type { SelectionTarget, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
+import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { CHAT_SEARCH_MAX_LINES, searchCardModel } from '../src/client/contract/search-card-model.ts'
@@ -23,6 +24,9 @@ import { GenericToolCard, type GenericToolCardProps } from '../src/client/chat/G
import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
import { SearchRow, searchToolview } from '../src/client/toolviews/search-row.tsx'
+/** SearchRow now composes ToolRow, so its props include the locale `t` seat. */
+type SearchRowProps = Parameters[0]
+
afterEach(cleanup)
/** Conversation-locale translate stub for the render sites' `t` seat. */
@@ -228,21 +232,32 @@ describe('chat row search body (GenericToolCard fallback)', () => {
})
describe('SearchRow keyed card', () => {
- const rowProps = (block: RunningToolCall | ToolResultNode, toolName: string): ToolRowProps => ({
- callId: 'c1', toolName, block, openFile: vi.fn(), sessionId: SID,
- } as unknown as ToolRowProps)
+ const rowProps = (block: RunningToolCall | ToolResultNode, toolName: string): SearchRowProps => ({
+ callId: 'c1', toolName, block, openFile: vi.fn(), sessionId: SID, t,
+ } as unknown as SearchRowProps)
- it('renders the grep card resident under the summary row, without an expand gesture', () => {
+ /** The whole summary row is the expand toggle (ToolRow's unified interaction). */
+ const toggleRow = (view: { container: HTMLElement }) => {
+ fireEvent.click(view.container.querySelector('[data-expandable]')!)
+ }
+
+ it('collapses to the summary row; expanding reveals the grep card', () => {
const view = render()
expect(view.getByText('Search')).toBeTruthy()
+ // Collapsed: the card is not in the DOM until the row is expanded.
+ expect(searchKindOf(view.container)).toBeNull()
+ expect(view.queryByText(/const foo = 1/)).toBeNull()
+ toggleRow(view)
expect(searchRows(view.container)).toContain('12: const foo = 1')
expect(searchKindOf(view.container)).toBe('matches')
- // The card's controls are the row's only interactions.
+ // The card's copy control lives inside the expanded body.
expect(view.getByText('复制')).toBeTruthy()
})
- it('renders the glob path card resident', () => {
+ it('expands to the glob path card', () => {
const view = render()
+ expect(searchKindOf(view.container)).toBeNull()
+ toggleRow(view)
expect(view.getByText('src/a.ts')).toBeTruthy()
expect(searchKindOf(view.container)).toBe('paths')
})
@@ -250,7 +265,7 @@ describe('SearchRow keyed card', () => {
it('agrees with the summary row about the run state', () => {
const runningView = render()
expect(runningView.container.querySelector('[data-variant="search"]')?.getAttribute('data-state')).toBe('running')
- // No result view yet, so no resident card.
+ // No result view yet, so no card even once material could expand.
expect(searchKindOf(runningView.container)).toBeNull()
cleanup()
const errorView = render( {
expect(errorView.container.querySelector('[data-variant="search"]')?.getAttribute('data-state')).toBe('error')
})
- it('surfaces the result text when an errored search has no card', () => {
+ it('surfaces the result text through the Output section when an errored search has no card', () => {
// grep/glob return no presentResult on error → no card; the row shows the
- // model-facing error text instead of a bare red dot.
+ // first error line as the collapsed summary and the full text once expanded.
const view = render()
expect(searchKindOf(view.container)).toBeNull()
+ // Error state: the first line is the collapsed summary.
expect(view.getByText('grep: invalid regular expression')).toBeTruthy()
+ toggleRow(view)
+ // Now in ToolRow's Output section too (the kept summary makes it appear twice).
+ expect(view.container.querySelector('[data-error]')?.textContent).toBe('grep: invalid regular expression')
})
- it('surfaces the result text for a settled non-error call with no card', () => {
+ it('surfaces the result text for a settled non-error call with no card once expanded', () => {
// A successful nested run_code sub-dispatch (backend computes no
// presentationMeta, so resultView is null) or a legacy generic result settles
// with search === null and state ok. The keyed SearchRow owns the slot, so
- // without the widened arm the content would be lost behind a bare summary.
+ // ToolRow's Output section carries the text; it is only visible expanded.
const view = render()
expect(view.container.querySelector('[data-variant="search"]')?.getAttribute('data-state')).toBe('ok')
expect(searchKindOf(view.container)).toBeNull()
+ // Collapsed: the ok row shows its args summary, not the output text.
+ expect(view.queryByText('nested run_code output line')).toBeNull()
+ toggleRow(view)
expect(view.getByText('nested run_code output line')).toBeTruthy()
})
@@ -290,12 +312,15 @@ describe('SearchRow keyed card', () => {
content: [{ type: 'text', text: recovery }],
resultView: resultMatches({ truncated: true, total: 42 }),
}), 'grep')} />)
+ toggleRow(view)
expect(searchKindOf(view.container)).toBe('matches')
expect(view.getByText(/Full grep result stored at: spill:\/\/grep-1/)).toBeTruthy()
})
it('shows no recovery footer for an uncapped search', () => {
const view = render()
+ toggleRow(view)
+ expect(searchKindOf(view.container)).toBe('matches')
expect(view.container.textContent).not.toMatch(/stored at/)
})
@@ -304,6 +329,7 @@ describe('SearchRow keyed card', () => {
isError: true, resultView: null, content: [],
error: { name: 'ToolError', code: 'timeout' },
}), 'grep')} />)
+ // Error state: the derived name/code line is the collapsed summary.
expect(view.getByText('ToolError: timeout')).toBeTruthy()
})
@@ -344,7 +370,7 @@ describe('DetailsPanel Output section (search)', () => {
if (selection !== null) chat.actions.select(selection)
const sessions = createSnapshotStore({ ids: [], byId: {}, current: undefined, phase: 'ready' })
const workspaces = createSnapshotStore({
- items: [], state: 'idle', phase: 'ready', error: null,
+ items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
})
return render(
diff --git a/packages/client/ui-conversation/tests/web-card.spec.tsx b/packages/client/ui-conversation/tests/web-card.spec.tsx
index fc4a27c78b..153e9962ee 100644
--- a/packages/client/ui-conversation/tests/web-card.spec.tsx
+++ b/packages/client/ui-conversation/tests/web-card.spec.tsx
@@ -4,17 +4,19 @@
// WebRow (registered under both web_search and web_fetch), the GenericToolCard
// render-site fallback, and the details panel's Output section. Mirrors
// terminal-card.spec.tsx: model derivation + null arms, both kinds, the chat
-// row's resident card, the panel arm, and the keyed registration.
+// row's collapsed-by-default ToolRow card, the panel arm, and the keyed
+// registration. WebRow now composes the shared ToolRow, so its web card is
+// collapsed by default and appears only once the whole row is expanded.
import { afterEach, describe, expect, it, vi } from 'vitest'
-import { cleanup, render } from '@testing-library/react'
+import { cleanup, fireEvent, render } from '@testing-library/react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type {
ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
-import type { SelectionTarget, ToolRowOwnerProps, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
+import type { SelectionTarget, ToolRowOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { CHAT_WEB_MAX_SOURCES, webCardModel } from '../src/client/contract/web-card-model.ts'
import { createChatStore } from '../src/client/stores.ts'
import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx'
@@ -122,36 +124,49 @@ describe('chat row web body', () => {
const ownerProps = (block: RunningToolCall | ToolResultNode, toolName: string): ToolRowOwnerProps => ({
callId: block.callId, toolName, block, openFile: vi.fn(),
})
- // WebRow reads only toolName/block off the full runtime share; the standard
- // kit is unused, so the cast supplies the owner slice alone (as BashRow's
- // tests do for the terminal card).
- const rowProps = (block: RunningToolCall | ToolResultNode, toolName: string): ToolRowProps =>
- ownerProps(block, toolName) as unknown as ToolRowProps
+ // WebRow reads only toolName/block off the full runtime share plus the locale
+ // seat; the standard kit is unused, so the cast supplies the owner slice and
+ // `t` alone (as BashRow's tests do for the terminal card).
+ const rowProps = (block: RunningToolCall | ToolResultNode, toolName: string): Parameters[0] =>
+ ({ ...ownerProps(block, toolName), t } as unknown as Parameters[0])
- it('the WebRow renders the search card resident under the summary, capped tighter than the panel', () => {
+ /** The whole summary row is the expand toggle (ToolRow's unified interaction). */
+ const toggleRow = (view: { container: HTMLElement }) => {
+ fireEvent.click(view.container.querySelector('[data-expandable]')!)
+ }
+
+ it('the WebRow collapses to the summary row, expanding to the search card capped tighter than the panel', () => {
expect(CHAT_WEB_MAX_SOURCES).toBeLessThan(16)
const view = render()
- // The summary row plus the resident card, without any expand gesture on the row itself.
+ // Collapsed: the summary row alone, no card in the DOM.
expect(view.getByText('Search')).toBeTruthy()
+ expect(view.queryByText('Titled')).toBeNull()
+ expect(view.container.querySelector('[data-web]')).toBeNull()
+ toggleRow(view)
+ // Expanded: the resident search card with every source field.
expect(view.getByText('Titled')).toBeTruthy()
expect(view.getByText('excerpt')).toBeTruthy()
// hostname fallback for the source with no title
expect(view.getByText('plain.example.org')).toBeTruthy()
})
- it('the WebRow renders the fetch card resident, titled Fetch', () => {
+ it('the WebRow expands to the fetch card, titled Fetch', () => {
const view = render()
expect(view.getByText('Fetch')).toBeTruthy()
- // The url shows in the summary row and as the card's link; scope to the card.
+ expect(view.container.querySelector('[data-web]')).toBeNull()
+ toggleRow(view)
+ // The url shows as the card's link; scope to the card.
const card = view.container.querySelector('[data-web="fetch"]')
expect(card?.querySelector('a')?.getAttribute('href')).toBe('https://example.com/page')
expect(view.getByText('HTTP 200')).toBeTruthy()
})
- it('a running web call is the summary row alone (no card until it settles)', () => {
+ it('a running web call is the summary row alone, with nothing to expand', () => {
const view = render()
expect(view.getByText('Search')).toBeTruthy()
expect(view.queryByText('Titled')).toBeNull()
+ // No card material and no expandable body: clicking the row reveals nothing.
+ expect(view.container.querySelector('[data-expandable]')).toBeNull()
expect(view.container.querySelector('[data-web]')).toBeNull()
})
@@ -165,12 +180,14 @@ describe('chat row web body', () => {
expect(view.container.querySelector('[data-state="error"]')).not.toBeNull()
})
- it('the GenericToolCard fallback also renders a resident web card for a web-declaring tool', () => {
+ it('the GenericToolCard fallback also expands to a web card for a web-declaring tool', () => {
// A web-declaring tool without its own keyed row lands on the fallback; its
- // card is resident there too.
+ // card routes through the same collapsed-by-default ToolRow.
const view = render()
+ expect(view.container.querySelector('[data-web]')).toBeNull()
+ toggleRow(view)
expect(view.getByText('Titled')).toBeTruthy()
expect(view.container.querySelector('[data-web="search"]')).not.toBeNull()
})
diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml
index d55fe45007..1e0cd70ea2 100644
--- a/packages/client/ui-primitives/README.i18n.yaml
+++ b/packages/client/ui-primitives/README.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 packages/client/ui-primitives/README.md
-README.md: 58be01d56a85c66a144df3f8054840961e987403
-README.zh.md: 2efbec77e64d664553e93b5a8f8dcd2ec7fce49e
+README.md: 43266c05baca8d78406f35106db82ffa8e48f744
+README.zh.md: ff3bc69492e5124b00528ae52280898d18f3bd65