From 71adea8ba492f30835851a28b5fb02758a541894 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 30 Jul 2026 17:42:59 +0800 Subject: [PATCH] feat(web): render grep/glob search output as a search card Consume the card:'search' result view (matches grouped by file for grep, a path list for glob) the search backend PR added. SearchBlock (ui-primitives) draws both kinds via the kind discriminant with a per-file collapse, a truncation pill, a height cap matching TerminalBlock, and a copy control; search-card-model is the single resultView derivation; a keyed SearchRow registers under grep and glob with the card resident under its summary. The generic fallback and the details panel are search-aware. Fixture gains grep and glob turns for the built-boot snapshot. --- .../2026-07-30-web-search-card.i18n.yaml | 6 + .../feature/2026-07-30-web-search-card.md | 64 ++++ .../feature/2026-07-30-web-search-card.zh.md | 64 ++++ .../client/connection/src/client/fixture.ts | 89 +++++- .../ui-conversation/src/client/apply.ts | 5 + .../src/client/chat/GenericToolCard.tsx | 8 +- .../src/client/chat/ToolRow.module.css | 15 +- .../src/client/chat/ToolRow.tsx | 40 ++- .../src/client/contract/search-card-model.ts | 85 ++++++ .../src/client/skeleton/DetailsPanel.tsx | 11 +- .../client/toolviews/search-sample.module.css | 95 ++++++ .../src/client/toolviews/search-sample.tsx | 91 ++++++ .../ui-conversation/tests/chat-apply.spec.tsx | 9 +- .../tests/search-card.spec.tsx | 276 ++++++++++++++++++ .../ui-primitives/src/SearchBlock.module.css | 125 ++++++++ .../client/ui-primitives/src/SearchBlock.tsx | 263 +++++++++++++++++ packages/client/ui-primitives/src/index.ts | 4 + .../ui-primitives/tests/search-block.spec.tsx | 196 +++++++++++++ 18 files changed, 1415 insertions(+), 31 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-30-web-search-card.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-30-web-search-card.md create mode 100644 .agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md create mode 100644 packages/client/ui-conversation/src/client/contract/search-card-model.ts create mode 100644 packages/client/ui-conversation/src/client/toolviews/search-sample.module.css create mode 100644 packages/client/ui-conversation/src/client/toolviews/search-sample.tsx create mode 100644 packages/client/ui-conversation/tests/search-card.spec.tsx create mode 100644 packages/client/ui-primitives/src/SearchBlock.module.css create mode 100644 packages/client/ui-primitives/src/SearchBlock.tsx create mode 100644 packages/client/ui-primitives/tests/search-block.spec.tsx diff --git a/.agents/notes/implemented/feature/2026-07-30-web-search-card.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-search-card.i18n.yaml new file mode 100644 index 0000000000..a0b66d0f87 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-web-search-card.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-search-card.md +2026-07-30-web-search-card.md: 38d8b2f10b5b5b4f9b1d5c43a726877159737440 +2026-07-30-web-search-card.zh.md: 1d1f371d2fa846219ea8cc434727b5354508b454 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-search-card.md b/.agents/notes/implemented/feature/2026-07-30-web-search-card.md new file mode 100644 index 0000000000..38d8b2f10b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-web-search-card.md @@ -0,0 +1,64 @@ +# Agent Note: Web search card — the grep and glob render intent reaches the browser + +Status: implemented + +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. + +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, and a `card` value this client version does not know. + +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. + +One component draws both shapes, discriminated by `kind`, because `grep` and `glob` are the same visual object — a search result. `SearchMatchesBlockProps` (`kind: 'matches'`) and `SearchPathsBlockProps` (`kind: 'paths'`) keep each shape's fields required rather than a single interface with everything optional. The component flattens whichever shape it holds into one list of render rows — a file header row plus its match rows for the matches shape, one path row per path for the paths shape — so the height cap counts a file header as one row exactly as a match line or a path, and the head/tail slice arithmetic is `TerminalBlock`'s (`ceil(max/2)` head, the remainder tail), so a long search result and a long command output cut at the same place across the two cards. + +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`, a pill reads `已截断 · 共 {total}` beside the banner summary, 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. The banner summary is a plain structural count (`{n} 处匹配 · {m} 个文件`, or `{n} 个路径`). +- **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. + +Geometry, radius, and fonts mirror `CodeBlock` and `TerminalBlock`, so a search card reads as one family with them; `white-space: pre` plus horizontal scroll is the shared deliberate divergence. + +### Render sites + +Three sites consume the derivation, mirroring the terminal card's placement exactly: + +- **The keyed `SearchRow`** (`toolviews/search-sample.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. + +`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. + +## Alternatives considered + +**Two card components, one per tool.** Rejected: `grep` and `glob` are the same visual object discriminated only by `kind`, so two components would duplicate the banner, the height cap, the copy control, and the no-wrap geometry. One component switching on `kind` is what the backend's single `card: 'search'` view is for. + +**A `SearchCallView` so the row renders a card while the search runs.** Rejected: the backend contract deliberately has no call-time search view — a search has no matches or paths before `execute`. The running row shows its summary alone, and `searchCardModel` returns null for a running block, which is faithful to what exists. + +**Reuse `TerminalBlock` or `CodeBlock`.** Rejected: neither models per-file collapsible groups or a truncation pill, and both would need the grouped-matches shape bolted on. The three blocks share their geometry and font tokens instead, which is the only part where one implementation is correct for all. + +## Consequences + +`SearchBlock` reads only the search view's fields, so it stays a pure function of what the render intent carries — no session lookups, replay-safe like the presenters that produce the view. A UI without the search capability still gets the bridge's fenced fallback; nothing about the tool's result shape changed. Extending `ToolRow` with a `search` body prop adds one arm beside `terminal`; a call carries at most one card kind, so the two are never both present on a row. + +## Testing + +`packages/client/ui-primitives/tests/search-block.spec.tsx` pins the component at per-file 100%: both kinds, the truncation pill with its pre-cap total, the empty arm, per-file collapse/re-expand without touching neighbours, a file header counting as one capped row alongside its matches, 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. + +## Related + +- [Search render intent — grep and glob emit a structured search card](2026-07-30-search-render-card.md) — the backend contract and its two producers; this is its named web-consumer follow-up. +- [Web terminal card](2026-07-28-web-terminal-card.md) — the precedent this mirrors: a tool's render intent reaches the browser through a `ui-primitives` block, a single `contract/*-card-model.ts` derivation, and the same three render sites. +- [Tagged render-intent union for tool-call presentation](../architecture/2026-07-02-tool-render-intent-union.md) — the `card`-tagged vocabulary both cards consume. diff --git a/.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md new file mode 100644 index 0000000000..1d1f371d2f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md @@ -0,0 +1,64 @@ +# Agent Note:Web 搜索卡片 —— grep 与 glob 的 render intent 到达浏览器 + +Status: implemented + +[English](2026-07-30-web-search-card.md) | 中文 + +## 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 前端,只有那段预格式化文本。 + +这正是 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` 值。 + +与终端卡片的不对称是刻意的,继承自后端契约:`terminalCardModel` 同时读 `callView` 和 `resultView`,因为命令、cwd、description 在调用时就存在;`searchCardModel` 只读 `resultView`,因为搜索的匹配或路径只在执行后存在。因此运行中的搜索行只显示摘要,没有卡片。 + +一个组件绘制两种形态,用 `kind` 区分,因为 `grep` 和 `glob` 是同一个视觉对象 —— 一个搜索结果。`SearchMatchesBlockProps`(`kind: 'matches'`)和 `SearchPathsBlockProps`(`kind: 'paths'`)让每种形态的字段保持必填,而不是所有字段都可选的单一接口。组件把它持有的形态压平成一个渲染行列表 —— matches 形态是一个文件头行加它的匹配行,paths 形态是每个路径一行 —— 于是高度上限把一个文件头当作一行来计,与一条匹配行或一个路径相同,头/尾切片算术就是 `TerminalBlock` 的(`ceil(max/2)` 头,其余为尾),因此一个长搜索结果和一段长命令输出在两张卡片间在同一处截断。 + +组件契约: + +- **按文件分组的匹配,逐文件可折叠。** 每个文件是一个头行(加粗路径加它的匹配计数,整行即折叠控件),后面跟它的 `lineNumber: line` 行。折叠一个组会把它的匹配行从压平列表和高度上限的算术里去掉,但绝不从复制文本里去掉。 +- **扁平路径列表。** paths 形态每行一个路径,无头行。 +- **截断指示。** `truncated` 时,横幅摘要旁一个 pill 显示 `已截断 · 共 {total}`,因此卡片绝不把一个被截断的页面呈现为完整结果 —— 想要其余部分的读者跟随面向模型文本里的溢出定位符,与模型的做法完全一致。横幅摘要是一个朴素的结构计数(`{n} 处匹配 · {m} 个文件`,或 `{n} 个路径`)。 +- **不软换行。** 结果行在一个横向滚动的盒子里 `white-space: pre`,因此一条长匹配行或一个深路径横向滚动而不折叠。 +- **带展开控件的高度上限。** 超过 `DEFAULT_SEARCH_MAX_LINES`(16)行时显示一个头/尾切片,中间一个按钮报告被隐藏的行数,形状和算术与 `TerminalBlock` 相同。 +- **复制。** 复制控件写入整个结构化结果 —— 每个文件与匹配,或每个路径 —— 无关高度上限或哪些组被折叠,因此剪贴板携带的是结果本身,而不是卡片此刻恰好显示的内容。 + +几何、圆角、字体镜像 `CodeBlock` 与 `TerminalBlock`,因此搜索卡片与它们读作同一族;`white-space: pre` 加横向滚动是它们共享的刻意分歧。 + +### 渲染点 + +三个渲染点消费该推导,与终端卡片的落位完全一致: + +- **keyed `SearchRow`**(`toolviews/search-sample.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 段。 + +`CHAT_SEARCH_MAX_LINES`(8)是行内上限,为 primitive 默认值的一半(panel 保留默认值),理由与 `CHAT_TERMINAL_MAX_LINES` 相同:chat 流是跨多次调用扫读的摘要表面,panel 是单次调用的阅读表面。 + +## Alternatives considered + +**两个卡片组件,每个工具一个。** 否决:`grep` 与 `glob` 是仅由 `kind` 区分的同一视觉对象,两个组件会重复横幅、高度上限、复制控件与不换行几何。一个按 `kind` 分支的组件正是后端那个单一 `card: 'search'` 视图的用途。 + +**加一个 `SearchCallView`,让行在搜索运行时就渲染卡片。** 否决:后端契约刻意没有调用阶段的搜索视图 —— 搜索在 `execute` 前没有匹配或路径。运行中的行只显示摘要,`searchCardModel` 对运行块返回 null,忠实于实际存在的东西。 + +**复用 `TerminalBlock` 或 `CodeBlock`。** 否决:两者都不建模逐文件可折叠的组或截断 pill,都需要把按文件分组的形态硬塞进去。三个块转而共享几何与字体 token,那是唯一一处一个实现对三者都正确的部分。 + +## Consequences + +`SearchBlock` 只读搜索视图的字段,因此保持为 render intent 所携内容的纯函数 —— 无会话查询,与产生该视图的 presenter 一样可重放。没有搜索能力的 UI 仍得到 bridge 的围栏回退;工具的结果形态没有任何改变。给 `ToolRow` 扩一个 `search` body prop 只在 `terminal` 旁加一个分支;一次调用至多携带一种卡片,因此两者绝不同时出现在一行。 + +## Testing + +`packages/client/ui-primitives/tests/search-block.spec.tsx` 以 per-file 100% 覆盖固定组件:两种 kind、带 pre-cap total 的截断 pill、空结果分支、逐文件折叠/再展开且不影响邻居、一个文件头与其匹配一起计为一个被截断行、跨两种形态的头/尾上限及其展开控件(含无尾与默认上限的边界),以及复制控件在接受与拒绝的剪贴板路径上写入整个结构化结果。 + +`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` 服务。 + +## Related + +- [Search render intent —— grep 与 glob 发出结构化搜索卡片](2026-07-30-search-render-card.md) —— 后端契约与它的两个生产者;本 note 是它指名的 web 消费者后续。 +- [Web 终端卡片](2026-07-28-web-terminal-card.md) —— 本 note 镜像的先例:工具的 render intent 通过一个 `ui-primitives` 块、一个 `contract/*-card-model.ts` 推导、以及同样的三个渲染点到达浏览器。 +- [工具调用呈现的标签化 render-intent 联合](../architecture/2026-07-02-tool-render-intent-union.md) —— 两张卡片都消费的 `card` 标签词汇。 diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index cb79a6b9a2..6941be9dff 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -136,6 +136,60 @@ const TERMINAL_EXIT_STATUS: Record>(() => new Set())' }, + ], + }, + { + path: 'packages/client/ui-conversation/src/client/contract/search-card-model.ts', + matches: [ + { lineNumber: 24, line: 'export const CHAT_SEARCH_MAX_LINES = 8' }, + { lineNumber: 60, line: 'export function searchCardModel(block: ToolCallBlock): SearchCardModel | null {' }, + ], + }, +] + +/** + * The model-facing grep render text for the sample, grouped under file headers + * with `Line N:` rows and a spill footer — what a UI without a search card + * shows, attached as the view's `content`. + */ +const SEARCH_MATCHES_TEXT = [ + ...SEARCH_MATCHES_FIXTURE.flatMap(file => [ + file.path, + ...file.matches.map(m => ` Line ${m.lineNumber}: ${m.line}`), + ]), + '', + '(已显示 5 处匹配中的前 5 处,共 42 处;其余见溢出文件)', +].join('\n') + +/** + * Structured glob result for the search sample (turn 68): a flat path list, + * truncated with a larger `total` so the path card shows its capped indicator. + */ +const SEARCH_PATHS_FIXTURE = [ + 'packages/client/ui-primitives/src/SearchBlock.tsx', + 'packages/client/ui-primitives/src/SearchBlock.module.css', + 'packages/client/ui-conversation/src/client/contract/search-card-model.ts', + 'packages/client/ui-conversation/src/client/toolviews/search-sample.tsx', + 'packages/client/ui-conversation/src/client/toolviews/search-sample.module.css', +] + +/** The model-facing glob render text: the newline-joined path list plus a spill footer. */ +const SEARCH_PATHS_TEXT = [...SEARCH_PATHS_FIXTURE, '', '(共 23 个路径,已显示前 5 个)'].join('\n') + const DEEPSEEK_REASONING = { efforts: [ { id: 'off', name: 'Off' }, @@ -296,8 +350,18 @@ function buildAlphaLog(): SessionEvent[] { // strip empty and take the todo surfaces' own coverage with it. toolTurn(65, 'bash', '{"command":"pnpm run check","cwd":"/tmp/fixture/deep/nested"}', TERMINAL_OUTPUT_FIXTURE) + // Turns 66-67: the search card's two shapes. `grep` emits a `card: 'search'` + // `kind: 'matches'` result view (grouped-by-file matches, truncated with a + // larger `total`), `glob` emits `kind: '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(66, 'grep', '{"pattern":"SEARCH_MAX_LINES","path":"packages/client"}', SEARCH_MATCHES_TEXT) + toolTurn(67, 'glob', '{"pattern":"**/SearchBlock*","path":"packages/client"}', SEARCH_PATHS_TEXT) + const todoArgs = JSON.stringify({ todos: fixtureTodos }) - toolTurn(66, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 1 in progress, 1 completed.') + toolTurn(68, '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). @@ -336,6 +400,13 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined { return { card: 'generic', title: `Edit ${str(args.file_path)}`, kind: 'edit', rawInput: args } case 'write': return { card: 'generic', title: `Write ${str(args.file_path)}`, kind: 'edit', rawInput: args } + // A search call stays a generic card (kind: 'search'): the structured + // matches/paths exist only after execute, so the search card is result-time + // only (presentResult builds it). This mirrors the real grep/glob presenters. + case 'grep': + return { card: 'generic', title: `Grep ${str(args.pattern)}`, kind: 'search', rawInput: args } + case 'glob': + return { card: 'generic', title: `Glob ${str(args.pattern)}`, kind: 'search', rawInput: args } default: return undefined // echo et al: the documented no-view fallback path } @@ -344,6 +415,22 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined { function presentResult(name: string, argsRaw: string, resultText: string): ToolResultView | undefined { const call = presentCall(name, argsRaw) if (call === undefined) return undefined + // Search is result-time only: the call stays a generic search card, and the + // result view carries the structured shape the card renders, with the + // model-facing text as `content` for a UI without a search card. `total` + // exceeds the retained count so the card shows its capped indicator. + if (name === 'grep') { + return { + card: 'search', kind: 'matches', files: SEARCH_MATCHES_FIXTURE, + truncated: true, total: 42, content: text(resultText), + } + } + if (name === 'glob') { + return { + card: 'search', kind: 'paths', paths: SEARCH_PATHS_FIXTURE, + truncated: true, total: 23, content: text(resultText), + } + } switch (call.card) { case 'terminal': // The sample's own exit status, authored beside it: re-parsing the diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 7f3aeb38cc..7e714fd8d8 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -19,6 +19,7 @@ import { InputBar } from './skeleton/InputBar.tsx' import { ChatView } from './chat/ChatView.tsx' import { StatsLine } from './chat/StatsLine.tsx' import { bashToolviewSample } from './toolviews/bash-sample.tsx' +import { searchToolview } from './toolviews/search-sample.tsx' import { ApprovalPanel } from './skeleton/ApprovalPanel.tsx' import { todoToolview } from './toolviews/todo-row.tsx' import { todoDockEntry } from './skeleton/TodoPanel.tsx' @@ -254,6 +255,10 @@ export function apply(ctx: Context): void { // (ToolRow-matching Bash · {description} chrome; scoped badge in child sessions). ctx.plugin(bashToolviewSample) + // The grep/glob search row rides the same seam: one component registered + // under both tool names, since both declare the same search render intent. + ctx.plugin(searchToolview) + // The todo_write row rides the same seam (a product registration, not a sample). ctx.plugin(todoToolview) diff --git a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx index ce55d84f57..8bfb700218 100644 --- a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx +++ b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx @@ -10,6 +10,7 @@ import { IconThinkOutline14, } from '@deepseek-ai/dsh-client-ui-primitives' import type { ToolRowOwnerProps } from '../contract/slots.ts' +import { searchCardModel } from '../contract/search-card-model.ts' import { terminalCardModel } from '../contract/terminal-card-model.ts' import { toolRowModel, type ToolRowVariant } from '../contract/tool-call-model.ts' import { ToolRow } from './ToolRow.tsx' @@ -29,6 +30,7 @@ const VARIANT_ICONS: Record = { export function GenericToolCard({ toolName, block, cwd, openFile }: ToolRowOwnerProps) { const model = toolRowModel(toolName, block, cwd) const terminal = terminalCardModel(block, cwd) + const search = searchCardModel(block) const singleFile = model.filePath !== undefined return ( - : variant === 'code' - ? - :
{text}
)} + : searchBody !== null + ? + : variant === 'code' + ? + :
{text}
)} ) } diff --git a/packages/client/ui-conversation/src/client/contract/search-card-model.ts b/packages/client/ui-conversation/src/client/contract/search-card-model.ts new file mode 100644 index 0000000000..8373dc2ddd --- /dev/null +++ b/packages/client/ui-conversation/src/client/contract/search-card-model.ts @@ -0,0 +1,85 @@ +/** + * Pure derivation of the search-card props from a frozen call slice: the + * `card:'search'` render intent the `grep` and `glob` tools declare arrives on + * the snapshot as `resultView`, and this is the one place that turns it into + * what {@link SearchBlock} draws. Both conversation render sites (the chat tool + * row's resident body and the details panel's Output section) call this, so the + * grouped matches or the path list they show are derived once. + * + * The search card is result-time only: a search call has no matches or paths + * before `execute`, so its pending state stays a `GenericCallView` + * ({@link module:@deepseek-ai/dsh-tools/src/presentation}). This derivation + * therefore reads only `resultView` and returns null for a still-running call, + * unlike the terminal card whose call view carries the command before + * execution. + * @module + */ +import type { SearchBlockProps } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ToolCallBlock } from './tool-call-model.ts' + +/** + * Distributive `Omit`: a plain `Omit` keeps only the keys common to + * both members, which would drop the `files`/`paths` discriminated fields. + * Distributing over the naked type parameter `T` preserves each shape. + */ +type DistributiveOmit = T extends unknown ? Omit : never + +/** The {@link SearchBlockProps} union minus each render site's own fields. */ +type SearchBlockModelProps = DistributiveOmit + +/** + * Result rows the chat row's resident search body shows before collapsing the + * middle — half the primitive's own default, which the details panel keeps. A + * chat row is a summary surface inside the message flow: the flow must stay + * scannable across many calls, while the details panel is the single-call + * reading surface. A design constant of this UI's row geometry, not a + * deployment choice, so it is fixed here rather than a plugin Config field. + */ +export const CHAT_SEARCH_MAX_LINES = 8 + +/** + * The {@link SearchBlock} props this derivation owns. Held as a nested object + * (`card`) so a render site spreads exactly the primitive's own surface and can + * never leak a neighbouring field into it. `maxLines`/`className` belong to each + * render site. + */ +export interface SearchCardModel { + /** + * The props {@link SearchBlock} draws, minus each render site's own + * `maxLines`/`className`. + */ + card: SearchBlockModelProps + /** + * The result view's replacement title, which the presentation contract lets a + * search tool set at settle time. Absent when the presenter supplied none; a + * row then keeps its args-derived summary. + */ + title: string | undefined +} + +/** + * Derive the search-card props for a tool call, or null when this call is not a + * search card and belongs on the generic path. + * + * Only the result side matters: the search card carries no call-time state, so + * a still-running call (no result view) is null, as is a settled call whose + * result view is not a search card — including a `card` value this UI version + * does not know, which arrives over the wire and cannot be trusted to be one of + * the compiled variants, and a generic result a `grep`/`glob` failure or nested + * `run_code` dispatch produces (its text keeps the generic path). + * @param block - RunningToolCall or ToolResultNode off the snapshot caches. + * @returns the search-card props, or null for the generic path. + */ +export function searchCardModel(block: ToolCallBlock): SearchCardModel | null { + // Running: no result view exists yet, and a search card is result-only. + if (!('kind' in block)) return null + const result = block.resultView?.card === 'search' ? block.resultView : null + if (result === null) return null + const common = { truncated: result.truncated, total: result.total } + return { + title: result.title, + card: result.kind === 'matches' + ? { kind: 'matches', files: result.files, ...common } + : { kind: 'paths', paths: result.paths, ...common }, + } +} diff --git a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx index 9fc5a04ff6..e164d955b9 100644 --- a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx @@ -7,10 +7,11 @@ // share the store seat exists for) and derives the call material from the // session snapshot — no data of its own. -import { CodeBlock, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives' +import { CodeBlock, SearchBlock, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives' import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' import type { DetailsSlotProps } from '../contract/slots.ts' +import { searchCardModel } from '../contract/search-card-model.ts' import { terminalCardModel } from '../contract/terminal-card-model.ts' import type { ToolCallBlock } from '../contract/tool-call-model.ts' import css from './DetailsPanel.module.css' @@ -127,8 +128,10 @@ export function DetailsPanel({ useSession, useSessions, sessionId, useStore, clo * The Output section's body for the selected call. A terminal-card call — a * shell command's call/result views — renders through the shared TerminalBlock * at the primitive's own full height allowance, so column-aligned output keeps - * its alignment and scrolls sideways instead of folding. Every other call, and - * a running call with no terminal card yet, keeps the flattened text form. + * its alignment and scrolls sideways instead of folding. A search-card call — + * a `grep`/`glob` result view — renders through the shared SearchBlock at the + * same full height allowance. Every other call, and a running call with no card + * yet, keeps the flattened text form. * @param props.material - the selected call's material from {@link materialFor}. * @param props.cwd - the session workspace root, resolving the terminal view's cwd. * @returns the Output section's body element. @@ -147,6 +150,8 @@ function OutputBody({ material, cwd }: { material: CallMaterial; cwd: string | u ) } + const search = searchCardModel(material.block) + if (search !== null) return // A settled call always carries the result node the flattened form needs; // the running shape has no result to flatten. if (!('kind' in material.block)) return
运行中…
diff --git a/packages/client/ui-conversation/src/client/toolviews/search-sample.module.css b/packages/client/ui-conversation/src/client/toolviews/search-sample.module.css new file mode 100644 index 0000000000..5c4eb1f7db --- /dev/null +++ b/packages/client/ui-conversation/src/client/toolviews/search-sample.module.css @@ -0,0 +1,95 @@ +/* Search toolview: same geometry/tokens as ToolRow and BashRow (figma + Search · summary), plus the search card the row stacks resident under its + summary line. */ + +/* Summary line over the search card; the summary row keeps its own 24px + height, so the card is a column around it rather than a change to it. */ +.card { + display: flex; + flex-direction: column; +} + +/* Row indentation matches ToolRow's expanded bodies (16px leading + 6px gap), + and replaces the primitive's standalone vertical margin with the flow's. */ +.search { + margin: 4px 0 4px 22px; +} + +.root { + position: relative; /* sweep-glare overlay anchor */ + overflow: hidden; + display: flex; + align-items: center; + height: 24px; + min-width: 0; +} + +/* Running sweep glare — same deepsuite ShimmerText pattern as ToolRow / BashRow. */ +.root[data-state='running']::after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 300px; + background: linear-gradient( + 90deg, + transparent 0%, + color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%, + transparent 100% + ); + animation: dsh-search-row-sweep 2.6s ease-out infinite; + pointer-events: none; +} + +@keyframes dsh-search-row-sweep { + 0% { left: -300px; } + 90%, 100% { left: 100%; } +} + +.leading { + flex: none; + width: 16px; + height: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + margin-right: 6px; + color: var(--dsw-alias-label-tertiary); +} + +.title { + flex: none; + font-size: 14px; + line-height: 24px; + color: var(--dsw-alias-label-secondary); +} + +.sep { + flex: none; + width: 2px; + height: 2px; + border-radius: 1px; + margin: 0 8px; + background: var(--dsw-alias-label-caption); +} + +.summary { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 14px; + line-height: 24px; + color: var(--dsw-alias-label-tertiary); +} + +.visuallyHidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} diff --git a/packages/client/ui-conversation/src/client/toolviews/search-sample.tsx b/packages/client/ui-conversation/src/client/toolviews/search-sample.tsx new file mode 100644 index 0000000000..d717132d72 --- /dev/null +++ b/packages/client/ui-conversation/src/client/toolviews/search-sample.tsx @@ -0,0 +1,91 @@ +// Search toolview registrant: the keyed toolview hole (ctx.slots.register + +// ToolRowProps only — never imports the chat domain). One SearchRow component +// registered under both `grep` and `glob`, since both tools declare the same +// `card: 'search'` render intent and render as one visual object; the row reads +// the `kind` discriminant off the derived model to draw grouped matches or a +// path list. Product chrome matches ToolRow / BashRow (Search · {summary}). +// +// A search call declares its render intent result-time only, so this row's +// search card is resident below the summary rather than expand-gated: the row +// itself has no expand control, and the card's own copy, per-file collapse, and +// head/tail expand are the row's only interactions. CHAT_SEARCH_MAX_LINES is +// passed as `maxLines` — the chat flow's tighter cap over the block's own +// default of 16 — so a large result stays bounded in the message flow. + +import type { Context } from 'cordis' +import { IconSearchOutline16, SearchBlock, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ToolRowProps } from '../contract/slots.ts' +import { CHAT_SEARCH_MAX_LINES, searchCardModel } from '../contract/search-card-model.ts' +import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts' +import css from './search-sample.module.css' + +/** Leading-slot glyph substitution: the search icon yields to the terminal + * state semantic (error = red, interrupted = amber). Running keeps the icon — + * the row sweep carries the in-flight signal. */ +function leadingFor(state: ToolRowState) { + switch (state) { + case 'error': return + case 'stopped': return + default: return + } +} + +/** Visually hidden status — StateDot is aria-hidden; assistive technology needs a text label. */ +function stateStatus(state: ToolRowState): string | null { + switch (state) { + case 'running': return '运行中' + case 'error': return '失败' + case 'stopped': return '已停止' + default: return null + } +} + +/** + * Search row: icon + Search · {summary} in the shared ToolRow chrome, with the + * completed search's card resident below it. The summary row is not a + * details-panel control, so the card's copy, per-file collapse, and expand + * controls are the row's only interactions. Registered under both `grep` and + * `glob`; the derived model's `kind` decides the card shape. + */ +export function SearchRow({ toolName, block }: ToolRowProps) { + const model = toolRowModel(toolName, block) + const search = searchCardModel(block) + const status = stateStatus(model.state) + return ( +
+
+ {leadingFor(model.state)} + {status !== null && {status}} + {model.title} + + {/* The result view's replacement title outranks the args-derived + summary, matching the terminal card's description precedence. */} + {search?.title ?? model.summary} +
+ {search !== null && ( + + )} +
+ ) +} + +/** + * The search toolview as a plain registrant plugin. `inject` carries the + * load-order seam: requiring the conversation service guarantees the chat entry + * (and with it the 'conversation.chat.toolview' declaration) is registered. + * The one component registers under both keys, since `grep` and `glob` are the + * same visual object discriminated only by the result view's `kind`. + */ +export const searchToolview = { + name: 'search-toolview', + inject: ['slots', 'conversation'], + /** + * Register the search row into the chat view's keyed toolview hole under both + * the `grep` and `glob` tool names. + * @param ctx - registrant context (disposal rides ctx.effect inside slots.register). + */ + apply(ctx: Context): void { + ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep' }, SearchRow) + ctx.slots.register({ name: 'conversation.chat.toolview', key: 'glob' }, SearchRow) + }, +} diff --git a/packages/client/ui-conversation/tests/chat-apply.spec.tsx b/packages/client/ui-conversation/tests/chat-apply.spec.tsx index d7b9125b34..261e87890e 100644 --- a/packages/client/ui-conversation/tests/chat-apply.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-apply.spec.tsx @@ -80,12 +80,13 @@ describe('apply wiring', () => { await b.runtime.dispose() }) - it('mounts the bash sample and the todo row as keyed entries through the load-order seam', async () => { + it('mounts the bash sample, the search row (grep + glob), and the todo row as keyed entries through the load-order seam', async () => { const b = await bench() - // Both registrant plugins' inject: ['slots', 'conversation'] resolved — the - // service being present implies the chat entry declared the hole first. + // All registrant plugins' inject: ['slots', 'conversation'] resolved — the + // service being present implies the chat entry declared the hole first. The + // one search row registers under both grep and glob. const entries = b.slots.entries('conversation.chat.toolview') - expect(entries.map(e => e.options.key)).toEqual(['bash', 'todo_write']) + expect(entries.map(e => e.options.key)).toEqual(['bash', 'grep', 'glob', 'todo_write']) // Stats stick with the composer (not inside ChatView). expect(b.slots.entries('conversation.composer.dock').map(e => e.options.id)).toEqual(['stats']) await b.runtime.dispose() diff --git a/packages/client/ui-conversation/tests/search-card.spec.tsx b/packages/client/ui-conversation/tests/search-card.spec.tsx new file mode 100644 index 0000000000..c728b5b1a3 --- /dev/null +++ b/packages/client/ui-conversation/tests/search-card.spec.tsx @@ -0,0 +1,276 @@ +// @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. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render } from '@testing-library/react' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-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 type { SelectionTarget, ToolRowOwnerProps, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { CHAT_SEARCH_MAX_LINES, searchCardModel } from '../src/client/contract/search-card-model.ts' +import { createChatStore } from '../src/client/stores.ts' +import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx' +import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx' +import { SearchRow, searchToolview } from '../src/client/toolviews/search-sample.tsx' + +afterEach(cleanup) + +/** The rendered search card's kind attribute, so a render site cannot silently drop it. */ +function searchKindOf(container: HTMLElement): string | null { + return container.querySelector('[data-search]')?.getAttribute('data-search') ?? null +} + +/** The rendered result rows of the search card, one string per visible row. */ +function searchRows(container: HTMLElement): string[] { + return [...container.querySelectorAll('[data-search] [class^="_line_"]')].map(row => row.textContent ?? '') +} + +const SID = 's1' as SessionId + +const GREP_ARGS = '{"pattern":"foo","path":"src"}' +const GLOB_ARGS = '{"pattern":"**/*.ts","path":"src"}' + +/** A grep result view: matches grouped by file. */ +const resultMatches = (over?: Partial>): ToolResultView => ({ + card: 'search', kind: 'matches', + files: [ + { path: 'a.ts', matches: [{ lineNumber: 12, line: 'const foo = 1' }, { lineNumber: 40, line: 'return foo' }] }, + { path: 'b.ts', matches: [{ lineNumber: 7, line: 'foo()' }] }, + ], + truncated: false, total: 3, ...over, +}) + +/** A glob result view: a flat path list. */ +const resultPaths = (over?: Partial>): ToolResultView => ({ + card: 'search', kind: 'paths', paths: ['src/a.ts', 'src/b.ts'], truncated: false, total: 2, ...over, +}) + +const runningGrep = (over?: Partial): RunningToolCall => ({ + callId: 'c1', name: 'grep', argsRaw: GREP_ARGS, + turn: 1, step: 1, time: 1_000, callView: { card: 'generic', title: 'Grep foo', kind: 'search' }, ...over, +}) + +const settledGrep = (over?: Partial): ToolResultNode => ({ + kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1', + call: { name: 'grep', argsRaw: GREP_ARGS }, + callTime: 1_000, + content: [{ type: 'text', text: 'a.ts\n Line 12: const foo = 1' }], isError: false, + callView: { card: 'generic', title: 'Grep foo', kind: 'search' }, resultView: resultMatches(), ...over, +}) + +const settledGlob = (over?: Partial): ToolResultNode => ({ + kind: 'tool-result', seq: 11, time: 2_000, callId: 'c2', + call: { name: 'glob', argsRaw: GLOB_ARGS }, + callTime: 1_000, + content: [{ type: 'text', text: 'src/a.ts\nsrc/b.ts' }], isError: false, + callView: { card: 'generic', title: 'Glob **/*.ts', kind: 'search' }, resultView: resultPaths(), ...over, +}) + +describe('searchCardModel', () => { + it('derives a matches card from the grep result view', () => { + expect(searchCardModel(settledGrep())).toEqual({ + title: undefined, + card: { + kind: 'matches', + files: [ + { path: 'a.ts', matches: [{ lineNumber: 12, line: 'const foo = 1' }, { lineNumber: 40, line: 'return foo' }] }, + { path: 'b.ts', matches: [{ lineNumber: 7, line: 'foo()' }] }, + ], + truncated: false, total: 3, + }, + }) + }) + + it('derives a paths card from the glob result view, carrying the truncation signal', () => { + expect(searchCardModel(settledGlob({ resultView: resultPaths({ truncated: true, total: 20 }) }))).toEqual({ + title: undefined, + card: { kind: 'paths', paths: ['src/a.ts', 'src/b.ts'], truncated: true, total: 20 }, + }) + }) + + it('carries the result view\'s replacement title when the presenter sets one', () => { + expect(searchCardModel(settledGrep({ resultView: resultMatches({ title: '3 matches' }) }))?.title).toBe('3 matches') + // Without one it is absent, so the row keeps its args-derived summary. + expect(searchCardModel(settledGrep())?.title).toBeUndefined() + }) + + it('returns null for every non-search call: running, no views, generic, terminal, unknown cards', () => { + // A search card is result-time only: a running call has no result view yet. + expect(searchCardModel(runningGrep())).toBeNull() + expect(searchCardModel(settledGrep({ callView: null, resultView: null }))).toBeNull() + // A generic result settles a search call as a generic card (grep/glob failure + // or a nested run_code dispatch), which keeps the generic path. + expect(searchCardModel(settledGrep({ resultView: { card: 'generic' } }))).toBeNull() + // A terminal result view is a different card entirely. + expect(searchCardModel(settledGrep({ resultView: { card: 'terminal', output: 'x' } }))).toBeNull() + // A card tag this UI version does not know arrives over the wire; the + // documented generic-card default takes it, not a crash. + const future = { card: 'chart' } as unknown as ToolResultView + expect(searchCardModel(settledGrep({ resultView: future }))).toBeNull() + }) +}) + +describe('chat row search body (GenericToolCard fallback)', () => { + const ownerProps = (block: RunningToolCall | ToolResultNode, toolName: string): ToolRowOwnerProps => ({ + callId: 'c1', toolName, block, openFile: vi.fn(), + }) + + it('the expanded body is the grouped matches, capped tighter than the panel', () => { + expect(CHAT_SEARCH_MAX_LINES).toBeLessThan(16) + const view = render() + // Collapsed: the one-line summary row only, no card. + expect(view.queryByText(/const foo = 1/)).toBeNull() + fireEvent.click(view.container.querySelector('button')!) + expect(searchRows(view.container)).toContain('12: const foo = 1') + expect(view.getByText('a.ts')).toBeTruthy() + expect(searchKindOf(view.container)).toBe('matches') + // The args JSON body the generic path would have shown is gone. + expect(view.queryByText(/"pattern"/)).toBeNull() + }) + + it('the glob fallback expands to the flat path card', () => { + const view = render() + fireEvent.click(view.container.querySelector('button')!) + expect(view.getByText('src/a.ts')).toBeTruthy() + expect(searchKindOf(view.container)).toBe('paths') + }) + + it('a non-search result keeps the args-JSON text body', () => { + const view = render() + fireEvent.click(view.container.querySelector('button')!) + expect(view.getByText(/"pattern"/)).toBeTruthy() + expect(searchKindOf(view.container)).toBeNull() + }) +}) + +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) + + it('renders the grep card resident under the summary row, without an expand gesture', () => { + const view = render() + expect(view.getByText('Search')).toBeTruthy() + 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. + expect(view.getByText('复制')).toBeTruthy() + }) + + it('renders the glob path card resident', () => { + const view = render() + expect(view.getByText('src/a.ts')).toBeTruthy() + expect(searchKindOf(view.container)).toBe('paths') + }) + + 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. + expect(searchKindOf(runningView.container)).toBeNull() + cleanup() + const errorView = render() + expect(errorView.container.querySelector('[data-variant="search"]')?.getAttribute('data-state')).toBe('error') + }) + + it('shows the result view\'s replacement title instead of the args summary', () => { + const view = render() + expect(view.getByText('3 matches in 2 files')).toBeTruthy() + }) + + it('keeps the args-derived summary when the result view has no title', () => { + const view = render() + expect(view.getByText('foo')).toBeTruthy() + }) + + it('registers the one row component under both grep and glob keys', () => { + const registered: { key: unknown; component: unknown }[] = [] + const ctx = { + slots: { + register: (options: { name: string; key: string }, component: unknown) => { + registered.push({ key: options.key, component }) + }, + }, + } as never + searchToolview.apply(ctx) + expect(registered.map(r => r.key)).toEqual(['grep', 'glob']) + // One component, two keys. + expect(registered[0]!.component).toBe(SearchRow) + expect(registered[1]!.component).toBe(SearchRow) + expect(searchToolview.inject).toEqual(['slots', 'conversation']) + }) +}) + +describe('DetailsPanel Output section (search)', () => { + function mount(snapshot: ConversationSnapshot, selection: SelectionTarget | null) { + localStorage.clear() + const chat = createChatStore().create() + 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, + baselinesReady: true, recentWorkspaceId: undefined, + }) + return render( + snapshot, subscribe: () => () => {} })} + useSessions={bindSnapshotSelector(sessions)} + useWorkspaces={bindSnapshotSelector(workspaces)} + useInput={(() => { throw new Error('unused') })} + inputActions={{ setDraft: () => {}, submit: () => {} }} + useProjection={(() => undefined)} + useStore={bindSnapshotSelector(chat)} + actions={chat.actions} + closeDetails={vi.fn()} + />, + ) + } + + function snapshot(over: Partial = {}): ConversationSnapshot { + return { + sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(), + pending: [], queue: [], running: false, composerPhase: 'active', removed: false, + openState: 'open', openError: null, hasMore: false, loadingOlder: false, + promptError: null, blank: false, lastAgentError: null, ...over, + } + } + + const grepTarget: SelectionTarget = { turnSeq: 10, callId: 'c1', toolName: 'grep' } + const globTarget: SelectionTarget = { turnSeq: 11, callId: 'c2', toolName: 'glob' } + + it('renders the grep matches card at full height, keeping the JSON Input section', () => { + const view = mount(snapshot({ nodes: [settledGrep()] }), grepTarget) + expect(view.getByText(/"pattern"/)).toBeTruthy() + expect(searchRows(view.container)).toContain('12: const foo = 1') + expect(searchKindOf(view.container)).toBe('matches') + }) + + it('renders the glob path card', () => { + const view = mount(snapshot({ nodes: [settledGlob()] }), globTarget) + expect(view.getByText('src/a.ts')).toBeTruthy() + expect(searchKindOf(view.container)).toBe('paths') + }) + + it('a non-search result keeps the flattened pre form', () => { + const view = mount(snapshot({ + nodes: [settledGrep({ callView: null, resultView: null })], + }), grepTarget) + expect(searchKindOf(view.container)).toBeNull() + const output = view.getByText('Output').closest('section') + expect(output?.querySelector('pre')?.textContent).toContain('const foo = 1') + }) +}) diff --git a/packages/client/ui-primitives/src/SearchBlock.module.css b/packages/client/ui-primitives/src/SearchBlock.module.css new file mode 100644 index 0000000000..79902de6a3 --- /dev/null +++ b/packages/client/ui-primitives/src/SearchBlock.module.css @@ -0,0 +1,125 @@ +/* Geometry mirrors CodeBlock and TerminalBlock (12px radius, code-block + surface + banner row, markdown code-block font) so a search card reads as one + family with them. The deliberate divergence they share: the result rows keep + `white-space: pre` and scroll horizontally, because folding a long match line + or path destroys the alignment a reader scans by. */ + +.block { + --dsl-search-radius: 12px; + --dsl-search-line-height: 22px; + + position: relative; + margin: 16px 0; + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-markdown-code-block); + border-radius: var(--dsl-search-radius); +} + +/* The banner: result summary on the left, the truncation pill and copy control + holding their intrinsic width on the right. */ +.header { + display: flex; + align-items: center; + gap: 12px; + padding: 9px 14px; + background: var(--dsw-alias-markdown-code-block-banner); + border-top-left-radius: var(--dsl-search-radius); + border-top-right-radius: var(--dsl-search-radius); +} + +.summary { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font: var(--dsw-font-xs-13); + color: var(--dsw-alias-label-secondary); +} + +.truncated { + flex: none; + color: var(--dsw-alias-state-business-primary); +} + +.copyButton { + flex: none; + background-color: transparent; + border: none; + padding: 0; + margin: 0; + color: var(--dsw-alias-label-secondary); + cursor: pointer; + font: var(--dsw-font-xs-13); +} + +.body { + padding: 8px 14px 12px 0; + font: var(--dsw-font-markdown-code-block); + overflow-x: auto; + overflow-y: hidden; +} + +/* No wrapping: a match line or a path keeps its content on one row and scrolls + sideways instead of folding. */ +.line { + min-height: var(--dsl-search-line-height); + padding-left: 14px; + white-space: pre; +} + +/* The 1-based line number ahead of a grep match line, dimmed so the match text + stays the salient content. */ +.lineNumber { + color: var(--dsw-alias-label-tertiary); +} + +/* A file group's header: a bold path label plus its match count, the whole row + the collapse control. */ +.fileHeader { + display: flex; + align-items: baseline; + gap: 8px; + width: 100%; + min-height: var(--dsl-search-line-height); + padding: 0 14px; + border: none; + background-color: transparent; + cursor: pointer; + font: inherit; + text-align: left; +} + +.filePath { + min-width: 0; + font-weight: 600; + color: var(--dsw-alias-label-primary); + white-space: pre; +} + +.fileCount { + flex: none; + color: var(--dsw-alias-label-tertiary); +} + +.expand { + display: block; + width: 100%; + padding: 0 14px; + border: none; + background-color: transparent; + color: var(--dsw-alias-label-tertiary); + cursor: pointer; + font: inherit; + text-align: left; +} + +.expand:hover { + color: var(--dsw-alias-label-secondary); +} + +.empty { + padding: 12px 14px; + font: var(--dsw-font-markdown-code-block); + color: var(--dsw-alias-label-tertiary); +} diff --git a/packages/client/ui-primitives/src/SearchBlock.tsx b/packages/client/ui-primitives/src/SearchBlock.tsx new file mode 100644 index 0000000000..98d1edc808 --- /dev/null +++ b/packages/client/ui-primitives/src/SearchBlock.tsx @@ -0,0 +1,263 @@ +// SearchBlock: the search surface for a completed content or path search — a +// banner (result count + a truncation pill when the tool capped the result + +// a copy control), then either grep matches grouped by file (each file a bold +// path header with its `lineNumber: line` rows, the group collapsible) or a +// flat glob path list. Both shapes flatten to one list of rows the height cap +// slices head/tail over, and neither soft-wraps: a long match line or path +// scrolls horizontally instead of folding. Geometry mirrors CodeBlock and +// TerminalBlock so a search card reads as one family with them. + +import { useCallback, useMemo, useState, type ReactNode } from 'react' +import clsx from 'clsx' +import { writeClipboard } from './clipboard.ts' +import { Pill } from './Pill.tsx' +import css from './SearchBlock.module.css' + +/** + * Result rows shown before the height cap collapses the middle. Matches + * {@link DEFAULT_TERMINAL_MAX_LINES} so a search card and a terminal card cut a + * long result at the same place. + */ +export const DEFAULT_SEARCH_MAX_LINES = 16 + +/** One matched line inside a {@link SearchFileGroup}: its 1-based line number and text. */ +export interface SearchBlockLineMatch { + /** 1-based line number of the match within its file. */ + lineNumber: number + /** The matched line text, as the tool surfaced it. */ + line: string +} + +/** One file's grouped matches, in first-seen file order. */ +export interface SearchFileGroup { + /** The file the matches belong to (the display path). */ + path: string + /** The file's matched lines, in output order. */ + matches: SearchBlockLineMatch[] +} + +/** Fields both search shapes carry (the render site positions; this component draws). */ +interface SearchBlockCommon { + /** + * Whether the tool capped the inline result: the shape carries only the + * retained results, not every result the search found. A truncation pill is + * shown so the card never presents a capped result as complete. + */ + truncated: boolean + /** Total results the search found before capping (equals the retained count when not `truncated`). */ + total: number + /** Height cap in rows before the middle collapses (default {@link DEFAULT_SEARCH_MAX_LINES}). */ + maxLines?: number | undefined + /** Extra class merged onto the wrapper. */ + className?: string | undefined +} + +/** Props for the grouped-matches (`grep`) shape. */ +export interface SearchMatchesBlockProps extends SearchBlockCommon { + kind: 'matches' + /** Matched lines grouped by file, in first-seen file order. */ + files: SearchFileGroup[] +} + +/** Props for the flat-path (`glob`) shape. */ +export interface SearchPathsBlockProps extends SearchBlockCommon { + kind: 'paths' + /** The discovered paths, in the tool's result order (the retained page when `truncated`). */ + paths: string[] +} + +/** {@link SearchBlock} props: one card, two `kind`-discriminated shapes. */ +export type SearchBlockProps = SearchMatchesBlockProps | SearchPathsBlockProps + +/** + * One flattened render row. A matches card produces a `file` header row per + * group followed by a `match` row per retained line while the group is + * expanded; a paths card produces one `path` row per path. The height cap + * counts these rows uniformly, so a file header costs one row exactly as a + * match line or a path does. + */ +type SearchRow = + | { type: 'file'; path: string; count: number; index: number; collapsed: boolean } + | { type: 'match'; lineNumber: number; line: string; key: string } + | { type: 'path'; path: string } + +/** + * The plain-text form the copy control writes: the whole structured result + * 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. + * @param props - the card's props. + * @returns the copyable text, or the empty string for an empty result. + */ +function copyText(props: SearchBlockProps): string { + if (props.kind === 'paths') return props.paths.join('\n') + return props.files + .map(file => [file.path, ...file.matches.map(m => `${m.lineNumber}: ${m.line}`)].join('\n')) + .join('\n\n') +} + +/** + * Number of retained results the card holds: the matched-line count across all + * files for a matches card, the path count for a paths card. This is the count + * the truncation pill reports against `total`. + * @param props - the card's props. + * @returns the retained result count. + */ +function shownCount(props: SearchBlockProps): number { + return props.kind === 'paths' + ? props.paths.length + : props.files.reduce((sum, file) => sum + file.matches.length, 0) +} + +/** + * The banner summary: the structural count of the retained result. The + * truncation pill beside it carries the capped-vs-complete signal, so this + * stays a plain count of what the card holds. + * @param props - the card's props. + * @param shown - the retained result count from {@link shownCount}. + * @returns the summary text. + */ +function summaryText(props: SearchBlockProps, shown: number): string { + return props.kind === 'paths' + ? `${shown} 个路径` + : `${shown} 处匹配 · ${props.files.length} 个文件` +} + +/** + * Flatten a card's shape into its render rows, dropping a collapsed file + * group's match rows. + * @param props - the card's props. + * @param collapsed - the set of collapsed file-group indices (matches only). + * @returns the flattened rows in output order. + */ +function toRows(props: SearchBlockProps, collapsed: ReadonlySet): SearchRow[] { + if (props.kind === 'paths') return props.paths.map((path): SearchRow => ({ type: 'path', path })) + const rows: SearchRow[] = [] + props.files.forEach((file, index) => { + const isCollapsed = collapsed.has(index) + rows.push({ type: 'file', path: file.path, count: file.matches.length, index, collapsed: isCollapsed }) + if (isCollapsed) return + for (const match of file.matches) { + rows.push({ type: 'match', lineNumber: match.lineNumber, line: match.line, key: `${index}:${match.lineNumber}` }) + } + }) + return rows +} + +/** + * A stable React key for a flattened render row: the group-scoped match key, a + * file-index-scoped header key, or the path itself. Rows of different types + * never collide, since each key carries its type prefix or the group index. + * @param row - the flattened row. + * @returns the key. + */ +function rowKey(row: SearchRow): string { + switch (row.type) { + case 'match': return `match:${row.key}` + case 'file': return `file:${row.index}` + case 'path': return `path:${row.path}` + } +} + +/** + * Render a completed search as a grouped-matches or flat-path card. + * @param props - see {@link SearchBlockProps}. + * @returns the search block element. + */ +export function SearchBlock(props: SearchBlockProps) { + const { truncated, total, maxLines = DEFAULT_SEARCH_MAX_LINES, className } = props + const [expanded, setExpanded] = useState(false) + const [collapsed, setCollapsed] = useState>(() => new Set()) + const [copied, setCopied] = useState(false) + + const rows = useMemo(() => toRows(props, collapsed), [props, collapsed]) + const shown = shownCount(props) + const empty = rows.length === 0 + const text = copyText(props) + + const onCopy = useCallback(() => { + if (copied) return + void writeClipboard(text).then((ok) => { + if (!ok) return + setCopied(true) + window.setTimeout(() => { setCopied(false) }, 1000) + }) + }, [copied, text]) + + const onToggle = useCallback(() => { setExpanded(value => !value) }, []) + + const toggleFile = useCallback((index: number) => { + setCollapsed((prev) => { + const next = new Set(prev) + if (next.has(index)) next.delete(index) + else next.add(index) + return next + }) + }, []) + + const hidden = rows.length - maxLines + const capped = hidden > 0 && !expanded + // Same split arithmetic as TerminalBlock (and the TUI transcript's collapsed + // tool card), so a long result's head and tail slices agree across surfaces. + const headLines = Math.ceil(maxLines / 2) + const tailLines = maxLines - headLines + + const renderRow = (row: SearchRow): ReactNode => { + if (row.type === 'path') return
{row.path}
+ if (row.type === 'match') { + return ( +
+ {row.lineNumber}: + {row.line} +
+ ) + } + return ( + + ) + } + + return ( +
+
+ {summaryText(props, shown)} + {truncated && {`已截断 · 共 ${total}`}} + {!empty && ( + + )} +
+ {empty + ?
无结果
+ : ( +
+ {(capped ? rows.slice(0, headLines) : rows).map(row => ( +
{renderRow(row)}
+ ))} + {hidden > 0 && ( + + )} + {capped && rows.slice(rows.length - tailLines).map(row => ( +
{renderRow(row)}
+ ))} +
+ )} +
+ ) +} diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index aa674f7a1a..2a53f67c1c 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -22,6 +22,10 @@ export { JsonTree } from './JsonTree.tsx' export type { JsonTreeProps } from './JsonTree.tsx' export { TerminalBlock, DEFAULT_TERMINAL_MAX_LINES } from './TerminalBlock.tsx' export type { TerminalBlockProps } from './TerminalBlock.tsx' +export { SearchBlock, DEFAULT_SEARCH_MAX_LINES } from './SearchBlock.tsx' +export type { + SearchBlockProps, SearchMatchesBlockProps, SearchPathsBlockProps, SearchFileGroup, SearchBlockLineMatch, +} from './SearchBlock.tsx' export { CodeBlock } from './markdown/CodeBlock.tsx' export { JsonBlock } from './markdown/JsonBlock.tsx' export { MarkdownText } from './markdown/MarkdownText.tsx' diff --git a/packages/client/ui-primitives/tests/search-block.spec.tsx b/packages/client/ui-primitives/tests/search-block.spec.tsx new file mode 100644 index 0000000000..37da021663 --- /dev/null +++ b/packages/client/ui-primitives/tests/search-block.spec.tsx @@ -0,0 +1,196 @@ +// @vitest-environment jsdom +// SearchBlock: both kinds (grouped grep matches and a flat glob path list), the +// truncation pill, the empty arm, per-file collapse/expand, the head/tail height +// cap and its expand control, and the copy control writing the whole structured +// result on both the accepted and refused clipboard paths. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { DEFAULT_SEARCH_MAX_LINES, SearchBlock } from '../src/index.ts' +import type { SearchFileGroup } from '../src/index.ts' + +afterEach(cleanup) + +beforeEach(() => { + vi.useRealTimers() +}) + +/** The rendered result rows, one string per visible row (CSS-module class prefix). */ +function lines(container: HTMLElement): string[] { + return [...container.querySelectorAll('[class^="_line_"]')].map(row => row.textContent ?? '') +} + +/** The file-group header rows, one string per header (path + count concatenated). */ +function fileHeaders(container: HTMLElement): string[] { + return [...container.querySelectorAll('[class^="_fileHeader_"]')].map(row => row.textContent ?? '') +} + +/** `count` numbered match lines under one file, without a terminating newline. */ +function group(path: string, count: number, from = 1): SearchFileGroup { + return { + path, + matches: Array.from({ length: count }, (_v, i) => ({ lineNumber: from + i, line: `hit ${from + i}` })), + } +} + +describe('SearchBlock matches kind', () => { + it('renders each file as a header group with its matched lines', () => { + const view = render() + expect(fileHeaders(view.container)).toEqual(['a.ts2', 'b.ts1']) + expect(lines(view.container)).toEqual(['12: const a = 1', '40: return a', '7: const b = 2']) + // The summary counts matches and files, no truncation pill under the cap. + expect(view.getByText('3 处匹配 · 2 个文件')).toBeTruthy() + expect(view.queryByText(/已截断/u)).toBeNull() + }) + + it('collapses and re-expands a single file group without touching the others', () => { + const view = render() + const [headerA] = view.container.querySelectorAll('[class^="_fileHeader_"]') + expect(headerA!.getAttribute('aria-expanded')).toBe('true') + fireEvent.click(headerA!) + // a.ts collapsed: its match row is gone, b.ts's stays. + expect(headerA!.getAttribute('aria-expanded')).toBe('false') + expect(lines(view.container)).toEqual(['2: y']) + fireEvent.click(headerA!) + expect(lines(view.container)).toEqual(['1: x', '2: y']) + }) + + it('shows the truncation pill with the pre-cap total', () => { + const view = render() + expect(view.getByText('已截断 · 共 99')).toBeTruthy() + expect(view.getByText('2 处匹配 · 1 个文件')).toBeTruthy() + }) +}) + +describe('SearchBlock paths kind', () => { + it('renders a flat path list with a path-count summary', () => { + const view = render() + expect(lines(view.container)).toEqual(['src/a.ts', 'src/b.ts']) + expect(view.getByText('2 个路径')).toBeTruthy() + // No file-group headers in the paths shape. + expect(fileHeaders(view.container)).toEqual([]) + }) + + it('shows the truncation pill with the pre-cap total', () => { + const view = render() + expect(view.getByText('已截断 · 共 50')).toBeTruthy() + }) +}) + +describe('SearchBlock empty arm', () => { + it('shows the placeholder and no copy control for an empty matches result', () => { + const view = render() + expect(view.getByText('无结果')).toBeTruthy() + expect(view.queryByText('复制')).toBeNull() + expect(view.getByText('0 处匹配 · 0 个文件')).toBeTruthy() + }) + + it('shows the placeholder for an empty paths result', () => { + const view = render() + expect(view.getByText('无结果')).toBeTruthy() + expect(view.queryByText('复制')).toBeNull() + }) +}) + +describe('SearchBlock height cap', () => { + it('renders every row and no expand control under the cap', () => { + const view = render() + expect(lines(view.container)).toHaveLength(4) + expect(view.container.querySelector('[aria-label^="展开"]')).toBeNull() + }) + + it('slices head and tail over the cap and expands on click', () => { + const paths = Array.from({ length: 10 }, (_v, i) => `p${i + 1}`) + const view = render() + // maxLines 4: head = ceil(4/2) = 2, tail = 2, 6 hidden. + expect(lines(view.container)).toEqual(['p1', 'p2', 'p9', 'p10']) + const toggle = view.getByRole('button', { name: '展开其余 6 行结果' }) + expect(toggle.textContent).toBe('… 其余 6 行') + fireEvent.click(toggle) + expect(lines(view.container)).toHaveLength(10) + const collapse = view.getByRole('button', { name: '收起结果' }) + expect(collapse.textContent).toBe('收起') + fireEvent.click(collapse) + expect(lines(view.container)).toEqual(['p1', 'p2', 'p9', 'p10']) + }) + + it('counts a file header as one capped row alongside its matches', () => { + // One file with 10 matches → 11 rows (header + 10). Cap 4: head 2, tail 2. + const view = render() + // Head takes the header then the first match; tail takes the last two matches. + expect(lines(view.container)).toEqual(['1: hit 1', '9: hit 9', '10: hit 10']) + expect(fileHeaders(view.container)).toEqual(['a.ts10']) + expect(view.getByRole('button', { name: '展开其余 7 行结果' })).toBeTruthy() + }) + + it('renders the head slice alone when the cap leaves no tail', () => { + const view = render() + expect(lines(view.container)).toEqual(['a']) + expect(view.getByRole('button', { name: '展开其余 4 行结果' })).toBeTruthy() + }) + + it('caps at the documented default when maxLines is absent', () => { + const paths = Array.from({ length: DEFAULT_SEARCH_MAX_LINES + 1 }, (_v, i) => `p${i}`) + const view = render() + expect(lines(view.container)).toHaveLength(DEFAULT_SEARCH_MAX_LINES) + expect(view.getByRole('button', { name: '展开其余 1 行结果' })).toBeTruthy() + }) +}) + +describe('SearchBlock copy', () => { + it('copies the whole structured matches result, not the collapsed or capped view', async () => { + vi.useFakeTimers() + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }) + const view = render() + // Collapse a group and leave the cap in place: the clipboard still gets it all. + fireEvent.click(view.container.querySelector('[class^="_fileHeader_"]')!) + fireEvent.click(screen.getByRole('button', { name: '复制' })) + expect(writeText).toHaveBeenCalledWith('a.ts\n1: x\n2: y\n\nb.ts\n3: z') + await act(async () => { await Promise.resolve() }) + expect(screen.getByRole('button', { name: '复制成功' })).toBeTruthy() + // A second click while the ok label shows is a no-op. + fireEvent.click(screen.getByRole('button', { name: '复制成功' })) + expect(writeText).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1000) + expect(screen.getByRole('button', { name: '复制' })).toBeTruthy() + }) + + it('copies the newline-joined path list for the paths shape', async () => { + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }) + render() + fireEvent.click(screen.getByRole('button', { name: '复制' })) + expect(writeText).toHaveBeenCalledWith('src/a.ts\nsrc/b.ts') + expect(await screen.findByRole('button', { name: '复制成功' })).toBeTruthy() + }) + + it('does not claim success when the host refuses the write', async () => { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, value: { writeText: vi.fn().mockRejectedValue(new Error('denied')) }, + }) + render() + fireEvent.click(screen.getByRole('button', { name: '复制' })) + await act(async () => { await Promise.resolve() }) + expect(screen.getByRole('button', { name: '复制' })).toBeTruthy() + expect(screen.queryByRole('button', { name: '复制成功' })).toBeNull() + }) + + it('merges className onto the wrapper and tags the wrapper with the kind', () => { + const view = render() + expect(view.container.firstElementChild?.classList.contains('x')).toBe(true) + expect(view.container.firstElementChild?.getAttribute('data-search')).toBe('paths') + }) +})