fix(web-search-card): surface truncation recovery, widen cardless fallback, validate wire shape, fix tail-cap
Address the ds-review-bot findings on the search card: - searchCardModel dropped the result view's `content`, so a capped search's `Full … stored at: <locator>` recovery footer vanished from the UI (the card replaces the raw text). Thread it through as `SearchCardModel.recovery` and render it below the card at all three sites, only when truncated. - SearchRow's fallback body was gated on `state === 'error'`, so a settled non-error call with no card (a successful nested run_code sub-dispatch, a legacy generic result) showed only its summary with content lost. Widen it to any settled call with `search === null`. - searchCardModel trusted the `files`/`paths` shape the host wire schema only string-checks; a malformed known-kind frame would crash SearchBlock. Validate the full shape and fall to the generic path on mismatch. - SearchBlock's restored tail file header added a row without consuming a tail slot, exceeding maxLines by one and overstating the hidden count. Make it consume a slot so the visible count holds at maxLines and `hidden` stays exact. Correct the fixture JSDoc (now genuinely exceeds the row cap) and the Agent Note recovery-text claim, sync the ui-conversation bilingual README with the search row, and add an assembled keyless snapshot (apps/web/tests/search-card.snapshot.ts) that pins the grep card's shape from the built bundles.
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: 1dff5ae5a4d789b1e57fcaef349959764583fbdf
|
||||
2026-07-30-web-search-card.zh.md: 09d38066bf16923655b27a30c717d97ccbe434bb
|
||||
2026-07-30-web-search-card.md: a3e3d7c3da1f686b4147e629fb4724d7750f8b6c
|
||||
2026-07-30-web-search-card.zh.md: c333ebf434f2f6798c2e1758e4a534dc35ea2ef9
|
||||
|
||||
@@ -12,7 +12,7 @@ This is the follow-up the search render card note names: that PR was the backend
|
||||
|
||||
## 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, and a `card` value this client version does not know.
|
||||
`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`).
|
||||
|
||||
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.
|
||||
|
||||
@@ -22,7 +22,8 @@ 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; a reader who wants the rest follows the spill locator in the model-facing text, exactly as the model does. When not `truncated` the summary is a plain structural count (`{n} 处匹配 · {m} 个文件`, or `{n} 个路径`).
|
||||
- **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.
|
||||
- **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.
|
||||
@@ -33,9 +34,9 @@ Geometry, radius, and fonts mirror `CodeBlock` and `TerminalBlock`, so a search
|
||||
|
||||
Three sites consume the derivation, mirroring the terminal card's placement exactly:
|
||||
|
||||
- **The keyed `SearchRow`** (`toolviews/search-row.tsx`) registers ONE component under both `grep` and `glob` in the `conversation.chat.toolview` keyed hole, and renders the card RESIDENT under the summary row, capped at `CHAT_SEARCH_MAX_LINES` (8) — the same posture `BashRow` takes for its terminal card. Both tool names get the same row because the derived `kind` decides the shape, so a second component would duplicate it. (This resident posture matches the current terminal/diff cards; a separate later PR unifies the whole-row collapse/expand interaction and flips all resident cards at once — out of scope here.)
|
||||
- **The generic fallback** (`chat/GenericToolCard` → `chat/ToolRow`) threads the derived model as an expand-gated body, the same arm `terminal` uses: a `grep`/`glob` result with no keyed row (none in the shipped app, since both are registered) still renders its card behind the row's expand toggle.
|
||||
- **The details panel** (`skeleton/DetailsPanel`) renders the card at the primitive's own full height in the Output section, keeping the JSON Input section.
|
||||
- **The keyed `SearchRow`** (`toolviews/search-row.tsx`) registers ONE component under both `grep` and `glob` in the `conversation.chat.toolview` keyed hole, and renders the card RESIDENT under the summary row, capped at `CHAT_SEARCH_MAX_LINES` (8) — the same posture `BashRow` takes for its terminal card. Both tool names get the same row because the derived `kind` decides the shape, so a second component would duplicate it. A capped result's recovery footer sits below the card. Because the keyed row owns this render slot, a settled call with no search card — an errored search (grep/glob emit no result view on error), a successful nested `run_code` sub-dispatch (the backend computes no `presentationMeta`, so `resultView` is null), or a legacy generic result — would otherwise show only its summary with its content lost; the row surfaces that model-facing text as a fallback body, keyed on `search === null && settled` rather than on the error state alone. (This resident posture matches the current terminal/diff cards; a separate later PR unifies the whole-row collapse/expand interaction and flips all resident cards at once — out of scope here.)
|
||||
- **The generic fallback** (`chat/GenericToolCard` → `chat/ToolRow`) threads the derived model as an expand-gated body, the same arm `terminal` uses: a `grep`/`glob` result with no keyed row (none in the shipped app, since both are registered) still renders its card, with the recovery footer, behind the row's expand toggle.
|
||||
- **The details panel** (`skeleton/DetailsPanel`) renders the card at the primitive's own full height in the Output section, with the recovery footer below it, keeping the JSON Input section.
|
||||
|
||||
`CHAT_SEARCH_MAX_LINES` (8) is the row cap, half the primitive's default the panel keeps, for the same reason as `CHAT_TERMINAL_MAX_LINES`: the chat flow is a summary surface read across many calls, the panel is the single-call reading surface.
|
||||
|
||||
@@ -55,7 +56,7 @@ Three sites consume the derivation, mirroring the terminal card's placement exac
|
||||
|
||||
`packages/client/ui-primitives/tests/search-block.spec.tsx` pins the component at per-file 100%: both kinds, the folded pre-cap total in the summary, the empty arm, per-file collapse/re-expand without touching neighbours, a file header counting as one capped row alongside its matches, the tail slice restoring its owning file header when the cut falls mid-file, the head/tail cap and its expand control across both shapes and the no-tail and default-cap edges, and the copy control writing the whole structured result on the accepted and refused clipboard paths.
|
||||
|
||||
`packages/client/ui-conversation/tests/search-card.spec.tsx` pins the wiring at every render site: `searchCardModel`'s derivation for both kinds, the truncation signal, the replacement title, and each null arm (running, no views, generic, terminal, unknown card); the chat row's expand-gated matches and paths bodies through `GenericToolCard` against the non-search args-JSON body; `SearchRow`'s resident card for both kinds, its agreement with the summary row's run state, the replacement-title precedence, and the keyed registration under both `grep` and `glob` with one component; and the details panel's Output section for both kinds against the non-search flattened form. `packages/client/ui-conversation/src/*` sits on the coverage exclude list, so this file is written against no gate pressure. `packages/client/connection/src/client/fixture.ts` gains a `grep` turn emitting `kind: 'matches'` and a `glob` turn emitting `kind: 'paths'` as `resultView`, both truncated, driving the built-boot snapshot and the live `?fixture` server.
|
||||
`packages/client/ui-conversation/tests/search-card.spec.tsx` pins the wiring at every render site: `searchCardModel`'s derivation for both kinds, the truncation signal, the replacement title, the recovery text surfaced only when capped, each null arm (running, no views, generic, terminal, unknown card, an uncompiled `kind`, and a known kind with a missing/malformed shape); the chat row's expand-gated matches and paths bodies through `GenericToolCard` (with the recovery footer) against the non-search args-JSON body; `SearchRow`'s resident card for both kinds, its recovery footer, its fallback body for both an errored search and a settled cardless result, its agreement with the summary row's run state, the replacement-title precedence, and the keyed registration under both `grep` and `glob` with one component; and the details panel's Output section for both kinds (with the recovery footer) against the non-search flattened form. `packages/client/ui-conversation/src/*` sits on the coverage exclude list, so this file is written against no gate pressure. `packages/client/connection/src/client/fixture.ts` gains a `grep` turn emitting `kind: 'matches'` (three files, twelve rows over the row cap, `truncated` with a spill-recovery footer, so it exercises the head/tail cap and the recovery footer in the assembled snapshot) and a `glob` turn emitting `kind: 'paths'`, both driving the built-boot snapshot and the live `?fixture` server. `apps/web/tests/search-card.snapshot.ts` is the assembled-output check the repo contract asks for: it boots the real built `client.js` bundles through the keyless fixture transport, opens the fixture session, and pins the grep card's assembled shape — kind, truncation summary, the head/tail slice, and its expand control — under `apps/web/tests/snapshots/search-card/`, so a broken SearchRow registration or a dropped card fails a golden the built-boot smoke (boot-only by contract) cannot.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ Status: implemented
|
||||
|
||||
## 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` 值。
|
||||
`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` 处崩溃)。
|
||||
|
||||
与终端卡片的不对称是刻意的,继承自后端契约:`terminalCardModel` 同时读 `callView` 和 `resultView`,因为命令、cwd、description 在调用时就存在;`searchCardModel` 只读 `resultView`,因为搜索的匹配或路径只在执行后存在。因此运行中的搜索行只显示摘要,没有卡片。
|
||||
|
||||
@@ -22,7 +22,8 @@ Status: implemented
|
||||
|
||||
- **按文件分组的匹配,逐文件可折叠。** 每个文件是一个头行(加粗路径加它的匹配计数,整行即折叠控件),后面跟它的 `lineNumber: line` 行。折叠一个组会把它的匹配行从压平列表和高度上限的算术里去掉,但绝不从复制文本里去掉。
|
||||
- **扁平路径列表。** paths 形态每行一个路径,无头行。
|
||||
- **截断指示。** `truncated` 时,横幅摘要把截断前总数折入 —— grep 为 `显示 X / 共 N 处匹配 · K 个文件`,glob 为 `显示 X / 共 N 个路径` —— 因此卡片绝不把一个被截断的页面呈现为完整结果;想要其余部分的读者跟随面向模型文本里的溢出定位符,与模型的做法完全一致。未 `truncated` 时摘要是一个朴素的结构计数(`{n} 处匹配 · {m} 个文件`,或 `{n} 个路径`)。
|
||||
- **截断指示。** `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` 不增加任何信息,因此被丢弃。
|
||||
- **不软换行。** 结果行在一个横向滚动的盒子里 `white-space: pre`,因此一条长匹配行或一个深路径横向滚动而不折叠。
|
||||
- **带展开控件的高度上限。** 超过 `DEFAULT_SEARCH_MAX_LINES`(16)行时显示一个头/尾切片,中间一个按钮报告被隐藏的行数,形状和算术与 `TerminalBlock` 相同。
|
||||
- **复制。** 复制控件写入整个结构化结果 —— 每个文件与匹配,或每个路径 —— 无关高度上限或哪些组被折叠,因此剪贴板携带的是结果本身,而不是卡片此刻恰好显示的内容。
|
||||
@@ -33,9 +34,9 @@ Status: implemented
|
||||
|
||||
三个渲染点消费该推导,与终端卡片的落位完全一致:
|
||||
|
||||
- **keyed `SearchRow`**(`toolviews/search-row.tsx`)把一个组件同时注册到 `conversation.chat.toolview` keyed hole 的 `grep` 与 `glob` 键下,并把卡片作为常驻(resident)渲染在摘要行下方,上限为 `CHAT_SEARCH_MAX_LINES`(8)—— 与 `BashRow` 对其终端卡片采取的姿态相同。两个工具名共用同一行,因为推导出的 `kind` 决定形态,第二个组件只会重复它。(该常驻姿态与当前的 terminal/diff 卡片一致;一个单独的后续 PR 会统一整行折叠/展开交互并一次性翻转所有常驻卡片 —— 不在本 PR 范围内。)
|
||||
- **generic fallback**(`chat/GenericToolCard` → `chat/ToolRow`)把推导出的 model 作为展开门控的 body 传入,与 `terminal` 用的是同一分支:没有 keyed 行的 `grep`/`glob` 结果(发布应用里没有,因为两者都注册了)仍在行的展开开关后渲染其卡片。
|
||||
- **details panel**(`skeleton/DetailsPanel`)在 Output 段以 primitive 自身的完整高度渲染卡片,保留 JSON Input 段。
|
||||
- **keyed `SearchRow`**(`toolviews/search-row.tsx`)把一个组件同时注册到 `conversation.chat.toolview` keyed hole 的 `grep` 与 `glob` 键下,并把卡片作为常驻(resident)渲染在摘要行下方,上限为 `CHAT_SEARCH_MAX_LINES`(8)—— 与 `BashRow` 对其终端卡片采取的姿态相同。两个工具名共用同一行,因为推导出的 `kind` 决定形态,第二个组件只会重复它。被截断结果的恢复脚注画在卡片下方。因为 keyed 行占据了这个渲染槽,一个没有搜索卡片的已结算调用 —— 出错的搜索(grep/glob 出错时不产出结果视图)、成功的嵌套 `run_code` 子派发(后端不为其计算 `presentationMeta`,故 `resultView` 为 null)、或旧日志的 generic 结果 —— 否则只会显示摘要而丢失内容;该行把这段面向模型的文本作为 fallback body 暴露出来,判据是 `search === null && 已结算`,而非仅凭错误状态。(该常驻姿态与当前的 terminal/diff 卡片一致;一个单独的后续 PR 会统一整行折叠/展开交互并一次性翻转所有常驻卡片 —— 不在本 PR 范围内。)
|
||||
- **generic fallback**(`chat/GenericToolCard` → `chat/ToolRow`)把推导出的 model 作为展开门控的 body 传入,与 `terminal` 用的是同一分支:没有 keyed 行的 `grep`/`glob` 结果(发布应用里没有,因为两者都注册了)仍在行的展开开关后渲染其卡片,并带恢复脚注。
|
||||
- **details panel**(`skeleton/DetailsPanel`)在 Output 段以 primitive 自身的完整高度渲染卡片,恢复脚注画在其下方,保留 JSON Input 段。
|
||||
|
||||
`CHAT_SEARCH_MAX_LINES`(8)是行内上限,为 primitive 默认值的一半(panel 保留默认值),理由与 `CHAT_TERMINAL_MAX_LINES` 相同:chat 流是跨多次调用扫读的摘要表面,panel 是单次调用的阅读表面。
|
||||
|
||||
@@ -55,7 +56,7 @@ Status: implemented
|
||||
|
||||
`packages/client/ui-primitives/tests/search-block.spec.tsx` 以 per-file 100% 覆盖固定组件:两种 kind、折入摘要的截断前总数、空结果分支、逐文件折叠/再展开且不影响邻居、一个文件头与其匹配一起计为一个被截断行、切口落在文件中间时尾部切片恢复其所属文件头、跨两种形态的头/尾上限及其展开控件(含无尾与默认上限的边界),以及复制控件在接受与拒绝的剪贴板路径上写入整个结构化结果。
|
||||
|
||||
`packages/client/ui-conversation/tests/search-card.spec.tsx` 固定每个渲染点的接线:`searchCardModel` 对两种 kind 的推导、截断信号、替换标题,以及每个 null 分支(运行中、无视图、generic、terminal、未知卡片);通过 `GenericToolCard` 的展开门控 matches 与 paths body,对照非搜索的 args-JSON body;`SearchRow` 对两种 kind 的常驻卡片、它与摘要行运行状态的一致、替换标题优先级,以及一个组件在 `grep` 与 `glob` 两个键下的 keyed 注册;以及 details panel 的 Output 段对两种 kind,对照非搜索的压平形态。`packages/client/ui-conversation/src/*` 在覆盖排除清单上,因此该文件不受 gate 压力。`packages/client/connection/src/client/fixture.ts` 新增一个发出 `kind: 'matches'` 的 `grep` turn 与一个发出 `kind: 'paths'` 的 `glob` turn 作为 `resultView`,两者都截断,驱动 built-boot snapshot 与实时 `?fixture` 服务。
|
||||
`packages/client/ui-conversation/tests/search-card.spec.tsx` 固定每个渲染点的接线:`searchCardModel` 对两种 kind 的推导、截断信号、替换标题、仅在截断时暴露的恢复文本,以及每个 null 分支(运行中、无视图、generic、terminal、未知卡片、本版本无法编译的 `kind`、以及一个形态缺失/错误的已知 kind);通过 `GenericToolCard` 的展开门控 matches 与 paths body(含恢复脚注),对照非搜索的 args-JSON body;`SearchRow` 对两种 kind 的常驻卡片、它的恢复脚注、它对出错搜索与已结算无卡片结果两者的 fallback body、它与摘要行运行状态的一致、替换标题优先级,以及一个组件在 `grep` 与 `glob` 两个键下的 keyed 注册;以及 details panel 的 Output 段对两种 kind(含恢复脚注),对照非搜索的压平形态。`packages/client/ui-conversation/src/*` 在覆盖排除清单上,因此该文件不受 gate 压力。`packages/client/connection/src/client/fixture.ts` 新增一个发出 `kind: 'matches'` 的 `grep` turn(三个文件、十二行超过行内上限、`truncated` 且带溢出恢复脚注,因此在组装快照里同时演练头/尾上限与恢复脚注)与一个发出 `kind: 'paths'` 的 `glob` turn,两者都驱动 built-boot snapshot 与实时 `?fixture` 服务。`apps/web/tests/search-card.snapshot.ts` 是仓库契约要求的组装输出检查:它通过 keyless fixture 传输启动真实构建的 `client.js` bundle,打开 fixture 会话,并把 grep 卡片的组装形态——kind、截断摘要、头/尾切片及其展开控件——固定在 `apps/web/tests/snapshots/search-card/` 下,因此一个损坏的 SearchRow 注册或被丢弃的卡片会让一个 golden 失败,而 built-boot smoke(按契约只测启动)无法捕获它。
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
Reference in New Issue
Block a user