fix(web-search-card): follow base rename kind->shape and view-drops-content
The base (feat/search-presenter) renamed the search result view's discriminant from `kind` to `shape` and removed the view's `content` field (a UI without a card now falls back to the raw tool/result content). Adapt the web consumer: - searchCardModel switches on `result.shape`; SearchBlock's own `kind` prop is mapped from it. - The truncation recovery footer reads the block's raw `content` (where the `Full … stored at …` locator now lives) instead of the removed view content. - Fixture grep/glob views use `shape` and drop `content`; the recovery footer rides the raw tool/result text. - Tests and the bilingual Agent Note follow the rename and the recovery source.
This commit is contained in:
@@ -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-30-web-search-card.md
|
||||
2026-07-30-web-search-card.md: a3e3d7c3da1f686b4147e629fb4724d7750f8b6c
|
||||
2026-07-30-web-search-card.zh.md: c333ebf434f2f6798c2e1758e4a534dc35ea2ef9
|
||||
2026-07-30-web-search-card.md: 4c7ae6c8c658f4f10f0667b12853cb2e70df15b1
|
||||
2026-07-30-web-search-card.zh.md: 714a2979730dc2c83f6cfc1cf6d21978755a2d95
|
||||
|
||||
@@ -6,13 +6,13 @@ English | [中文](2026-07-30-web-search-card.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The `grep` and `glob` tools declare a result-time `card: 'search'` render intent ([search render card](2026-07-30-search-render-card.md)): a `SearchMatchesResultView` (`kind: 'matches'`) carrying grep's matches grouped by file, or a `SearchPathsResultView` (`kind: 'paths'`) carrying glob's flat path list, both with a `truncated`/`total` capping signal. That view already reaches the browser — host, connection, and runtime deliver it onto `ConversationSnapshot` as `resultView` — but the Web client ignored it: every non-terminal, non-diff tool result fell through to the generic card, which renders the model-facing text. A web frontend that wants an expandable per-file group of matches, or a scannable path list, had only the pre-formatted text.
|
||||
The `grep` and `glob` tools declare a result-time `card: 'search'` render intent ([search render card](2026-07-30-search-render-card.md)): a `SearchMatchesResultView` (`shape: 'matches'`) carrying grep's matches grouped by file, or a `SearchPathsResultView` (`shape: 'paths'`) carrying glob's flat path list, both with a `truncated`/`total` capping signal. That view already reaches the browser — host, connection, and runtime deliver it onto `ConversationSnapshot` as `resultView` — but the Web client ignored it: every non-terminal, non-diff tool result fell through to the generic card, which renders the model-facing text. A web frontend that wants an expandable per-file group of matches, or a scannable path list, had only the pre-formatted text.
|
||||
|
||||
This is the follow-up the search render card note names: that PR was the backend contract and its two producers; this PR is the web consumer.
|
||||
|
||||
## Decision
|
||||
|
||||
`SearchBlock` is a `ui-primitives` component that renders a completed search as either shape, and the Web render sites for a `grep`/`glob` call consume the search render intent through it. `ui-conversation/src/client/contract/search-card-model.ts` is the single place that turns the snapshot's `resultView` into the component's props, so no render site re-derives the shape. It returns null — the generic path — whenever the result view is not a search card, including a still-running call (a search card is result-time only, so there is nothing before `execute`), a generic result a `grep`/`glob` failure or a nested `run_code` dispatch produces, a terminal result view, a `card` value this client version does not know, a `card: 'search'` view whose `kind` this version does not compile, and — because `kind` and the grouped/flat shape ride the same untrusted wire frame the host schema only string-checks — a known `kind` whose `files`/`paths` is missing or malformed (which would otherwise crash `SearchBlock` at `.reduce`/`.map`).
|
||||
`SearchBlock` is a `ui-primitives` component that renders a completed search as either shape, and the Web render sites for a `grep`/`glob` call consume the search render intent through it. `ui-conversation/src/client/contract/search-card-model.ts` is the single place that turns the snapshot's `resultView` into the component's props, so no render site re-derives the shape. It returns null — the generic path — whenever the result view is not a search card, including a still-running call (a search card is result-time only, so there is nothing before `execute`), a generic result a `grep`/`glob` failure or a nested `run_code` dispatch produces, a terminal result view, a `card` value this client version does not know, a `card: 'search'` view whose `shape` this version does not compile, and — because `shape` and the grouped/flat contents ride the same untrusted wire frame the host schema only string-checks — a known `shape` whose `files`/`paths` is missing or malformed (which would otherwise crash `SearchBlock` at `.reduce`/`.map`). The result-view discriminant is `shape` (not `kind`, which the backend reserves for the call view's icon-picking tag); `SearchBlock`'s own prop stays `kind`, mapped from `shape` in this derivation.
|
||||
|
||||
The asymmetry with the terminal card is deliberate and inherited from the backend contract: `terminalCardModel` reads both `callView` and `resultView` because a command, cwd, and description exist at call time; `searchCardModel` reads only `resultView` because a search's matches or paths exist only after execution. A running search row therefore shows its summary alone, with no card.
|
||||
|
||||
@@ -23,7 +23,7 @@ The component's contract:
|
||||
- **Grouped matches, collapsible per file.** Each file is a header row (a bold path plus its match count, the whole row the collapse control) followed by its `lineNumber: line` rows. Collapsing a group drops its match rows from the flattened list and from the height cap's arithmetic, but never from the copy text.
|
||||
- **Flat path list.** The paths shape renders one path per row, no headers.
|
||||
- **A capped indicator.** When `truncated`, the banner summary folds the pre-cap total in — `显示 X / 共 N 处匹配 · K 个文件` for grep, `显示 X / 共 N 个路径` for glob — so the card never presents a capped page as the complete result. When not `truncated` the summary is a plain structural count (`{n} 处匹配 · {m} 个文件`, or `{n} 个路径`).
|
||||
- **A recovery footer for a capped result.** The card holds only the retained page, but the locator to the rest — grep/glob's `Full … stored at: <locator>` footer — lives only in the result view's `content` text, not in the structured matches/paths. Because every render site replaces the raw result with the card, `searchCardModel` surfaces that flattened `content` as `SearchCardModel.recovery` when (and only when) the result was capped, and each render site draws it below the card. Without this the one path to the dropped rows would vanish from the UI; an uncapped result carries every row, so its `content` adds nothing and is dropped.
|
||||
- **A recovery footer for a capped result.** The card holds only the retained page, but the locator to the rest — grep/glob's `Full … stored at: <locator>` footer — lives only in the raw `tool/result` content (the search view carries no result text; a UI without a card falls back to that raw content), not in the structured matches/paths. Because every render site replaces the raw result with the card, `searchCardModel` surfaces the block's own flattened result text as `SearchCardModel.recovery` when (and only when) the result was capped, and each render site draws it below the card. Without this the one path to the dropped rows would vanish from the UI; an uncapped result carries every row, so its raw text adds nothing and is dropped.
|
||||
- **No soft wrapping.** Result rows are `white-space: pre` inside a horizontally scrolling box, so a long match line or a deep path scrolls sideways rather than folding.
|
||||
- **Height cap with an expand control.** More than `DEFAULT_SEARCH_MAX_LINES` (16) rows shows a head/tail slice with a button reporting the hidden count, the same shape and arithmetic as `TerminalBlock`.
|
||||
- **Copy.** The copy control writes the whole structured result — every file and match, or every path — regardless of the height cap or which groups are collapsed, so the clipboard carries the result rather than what the card happens to be showing.
|
||||
|
||||
@@ -6,13 +6,13 @@ Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
`grep` 与 `glob` 工具声明了一个仅在结果阶段存在的 `card: 'search'` render intent([search render card](2026-07-30-search-render-card.md)):`SearchMatchesResultView`(`kind: 'matches'`)携带 grep 按文件分组的匹配,或 `SearchPathsResultView`(`kind: 'paths'`)携带 glob 的扁平路径列表,两者都带 `truncated`/`total` 截断信号。该视图已经到达浏览器 —— host、connection、runtime 把它作为 `resultView` 投递到 `ConversationSnapshot` 上 —— 但 Web 客户端忽略了它:每个非终端、非 diff 的工具结果都落到 generic 卡片,渲染面向模型的文本。想把搜索结果渲染成可展开的按文件匹配分组、或可扫读的路径列表的 web 前端,只有那段预格式化文本。
|
||||
`grep` 与 `glob` 工具声明了一个仅在结果阶段存在的 `card: 'search'` render intent([search render card](2026-07-30-search-render-card.md)):`SearchMatchesResultView`(`shape: 'matches'`)携带 grep 按文件分组的匹配,或 `SearchPathsResultView`(`shape: 'paths'`)携带 glob 的扁平路径列表,两者都带 `truncated`/`total` 截断信号。该视图已经到达浏览器 —— host、connection、runtime 把它作为 `resultView` 投递到 `ConversationSnapshot` 上 —— 但 Web 客户端忽略了它:每个非终端、非 diff 的工具结果都落到 generic 卡片,渲染面向模型的文本。想把搜索结果渲染成可展开的按文件匹配分组、或可扫读的路径列表的 web 前端,只有那段预格式化文本。
|
||||
|
||||
这正是 search render card note 指名的后续:那个 PR 是后端契约和它的两个生产者,本 PR 是 web 消费者。
|
||||
|
||||
## Decision
|
||||
|
||||
`SearchBlock` 是一个 `ui-primitives` 组件,把一次已完成的搜索渲染成两种形态之一,`grep`/`glob` 调用的 Web 渲染点都通过它消费搜索 render intent。`ui-conversation/src/client/contract/search-card-model.ts` 是把 snapshot 的 `resultView` 转成组件 props 的唯一位置,因此没有渲染点重新推导形态。当结果视图不是搜索卡片时它返回 null(走 generic 路径),包括仍在运行的调用(搜索卡片仅在结果阶段存在,`execute` 前无内容)、`grep`/`glob` 失败或嵌套 `run_code` dispatch 产生的 generic 结果、terminal 结果视图、本客户端版本不认识的 `card` 值、`kind` 是本版本无法编译的 `card: 'search'` 视图,以及 —— 因为 `kind` 和分组/扁平形态与 host schema 只做字符串校验的那同一个不可信 wire 帧同行 —— 一个 `kind` 已知但 `files`/`paths` 缺失或格式错误的视图(否则会让 `SearchBlock` 在 `.reduce`/`.map` 处崩溃)。
|
||||
`SearchBlock` 是一个 `ui-primitives` 组件,把一次已完成的搜索渲染成两种形态之一,`grep`/`glob` 调用的 Web 渲染点都通过它消费搜索 render intent。`ui-conversation/src/client/contract/search-card-model.ts` 是把 snapshot 的 `resultView` 转成组件 props 的唯一位置,因此没有渲染点重新推导形态。当结果视图不是搜索卡片时它返回 null(走 generic 路径),包括仍在运行的调用(搜索卡片仅在结果阶段存在,`execute` 前无内容)、`grep`/`glob` 失败或嵌套 `run_code` dispatch 产生的 generic 结果、terminal 结果视图、本客户端版本不认识的 `card` 值、`shape` 是本版本无法编译的 `card: 'search'` 视图,以及 —— 因为 `shape` 和分组/扁平内容与 host schema 只做字符串校验的那同一个不可信 wire 帧同行 —— 一个 `shape` 已知但 `files`/`paths` 缺失或格式错误的视图(否则会让 `SearchBlock` 在 `.reduce`/`.map` 处崩溃)。结果视图的判别键是 `shape`(不是 `kind` —— 后端把 `kind` 留给 call view 的选图标签);`SearchBlock` 自身的 prop 仍是 `kind`,由本推导从 `shape` 映射得到。
|
||||
|
||||
与终端卡片的不对称是刻意的,继承自后端契约:`terminalCardModel` 同时读 `callView` 和 `resultView`,因为命令、cwd、description 在调用时就存在;`searchCardModel` 只读 `resultView`,因为搜索的匹配或路径只在执行后存在。因此运行中的搜索行只显示摘要,没有卡片。
|
||||
|
||||
@@ -23,7 +23,7 @@ Status: implemented
|
||||
- **按文件分组的匹配,逐文件可折叠。** 每个文件是一个头行(加粗路径加它的匹配计数,整行即折叠控件),后面跟它的 `lineNumber: line` 行。折叠一个组会把它的匹配行从压平列表和高度上限的算术里去掉,但绝不从复制文本里去掉。
|
||||
- **扁平路径列表。** paths 形态每行一个路径,无头行。
|
||||
- **截断指示。** `truncated` 时,横幅摘要把截断前总数折入 —— grep 为 `显示 X / 共 N 处匹配 · K 个文件`,glob 为 `显示 X / 共 N 个路径` —— 因此卡片绝不把一个被截断的页面呈现为完整结果。未 `truncated` 时摘要是一个朴素的结构计数(`{n} 处匹配 · {m} 个文件`,或 `{n} 个路径`)。
|
||||
- **被截断结果的恢复脚注。** 卡片只持有保留的那一页,但通往其余部分的定位符 —— grep/glob 的 `Full … stored at: <locator>` 脚注 —— 只存在于结果视图的 `content` 文本里,而非结构化的 matches/paths 中。由于每个渲染点都用卡片替换了原始结果,`searchCardModel` 在(且仅在)结果被截断时把压平后的 `content` 作为 `SearchCardModel.recovery` 暴露出来,每个渲染点把它画在卡片下方。没有它,通往被丢弃行的唯一路径就会从 UI 里消失;未截断的结果携带了每一行,其 `content` 不增加任何信息,因此被丢弃。
|
||||
- **被截断结果的恢复脚注。** 卡片只持有保留的那一页,但通往其余部分的定位符 —— grep/glob 的 `Full … stored at: <locator>` 脚注 —— 只存在于原始 `tool/result` 内容里(搜索视图不携带结果文本;没有卡片的 UI 回退到那段原始内容),而非结构化的 matches/paths 中。由于每个渲染点都用卡片替换了原始结果,`searchCardModel` 在(且仅在)结果被截断时把 block 自身压平后的结果文本作为 `SearchCardModel.recovery` 暴露出来,每个渲染点把它画在卡片下方。没有它,通往被丢弃行的唯一路径就会从 UI 里消失;未截断的结果携带了每一行,其原始文本不增加任何信息,因此被丢弃。
|
||||
- **不软换行。** 结果行在一个横向滚动的盒子里 `white-space: pre`,因此一条长匹配行或一个深路径横向滚动而不折叠。
|
||||
- **带展开控件的高度上限。** 超过 `DEFAULT_SEARCH_MAX_LINES`(16)行时显示一个头/尾切片,中间一个按钮报告被隐藏的行数,形状和算术与 `TerminalBlock` 相同。
|
||||
- **复制。** 复制控件写入整个结构化结果 —— 每个文件与匹配,或每个路径 —— 无关高度上限或哪些组被折叠,因此剪贴板携带的是结果本身,而不是卡片此刻恰好显示的内容。
|
||||
|
||||
Reference in New Issue
Block a user