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.
This commit is contained in:
18 files changed
+1415
-31
No files matched your search
@@ -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
|
||||
@@ -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.
|
||||
@@ -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` 标签词汇。
|
||||
@@ -136,6 +136,60 @@ const TERMINAL_EXIT_STATUS: Record<string, { exitCode: number } | { signal: stri
|
||||
[TERMINAL_OUTPUT_FIXTURE]: { exitCode: 1 },
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured grep result for the search sample (turn 67): matches grouped by
|
||||
* file, authored inline because the client-side fixture cannot import the tool
|
||||
* that produces the canonical value. `truncated` with a larger `total` than the
|
||||
* retained match count exercises the search card's capped indicator; the file
|
||||
* with more than CHAT_SEARCH_MAX_LINES rows exercises its head/tail height cap.
|
||||
*/
|
||||
const SEARCH_MATCHES_FIXTURE: { path: string; matches: { lineNumber: number; line: string }[] }[] = [
|
||||
{
|
||||
path: 'packages/client/ui-primitives/src/SearchBlock.tsx',
|
||||
matches: [
|
||||
{ lineNumber: 16, line: 'export const DEFAULT_SEARCH_MAX_LINES = 16' },
|
||||
{ lineNumber: 138, line: 'export function SearchBlock(props: SearchBlockProps) {' },
|
||||
{ lineNumber: 141, line: ' const [collapsed, setCollapsed] = useState<ReadonlySet<number>>(() => 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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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<ToolRowVariant, ReactNode> = {
|
||||
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 (
|
||||
<ToolRow
|
||||
@@ -37,11 +39,13 @@ export function GenericToolCard({ toolName, block, cwd, openFile }: ToolRowOwner
|
||||
icon={VARIANT_ICONS[model.variant]}
|
||||
title={model.title}
|
||||
// A terminal presenter's description is the contract's above-card text, so
|
||||
// it outranks the args-derived summary here exactly as it does in BashRow.
|
||||
summary={terminal?.description ?? model.summary}
|
||||
// it outranks the args-derived summary here exactly as it does in BashRow;
|
||||
// a search result view's replacement title outranks it the same way.
|
||||
summary={terminal?.description ?? search?.title ?? model.summary}
|
||||
// Single-file tools never expose an args body — the path link is the only action.
|
||||
body={singleFile ? null : model.body}
|
||||
terminal={terminal}
|
||||
search={search}
|
||||
state={model.state}
|
||||
filePath={model.filePath}
|
||||
onOpenFile={singleFile ? openFile : undefined}
|
||||
|
||||
@@ -175,14 +175,15 @@ button.leading {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* The two block-shaped expanded bodies: the code variant's run_code program
|
||||
through CodeBlock (shiki-highlighted TypeScript) and a terminal card's
|
||||
command output through TerminalBlock. Both are drawn by the shared
|
||||
primitive, so only the row's indentation is this file's concern — the margin
|
||||
also replaces each primitive's own standalone vertical spacing with the
|
||||
flow's row rhythm. */
|
||||
/* The block-shaped expanded bodies: the code variant's run_code program
|
||||
through CodeBlock (shiki-highlighted TypeScript), a terminal card's command
|
||||
output through TerminalBlock, and a search card's grouped matches or path
|
||||
list through SearchBlock. All are drawn by the shared primitive, so only the
|
||||
row's indentation is this file's concern — the margin also replaces each
|
||||
primitive's own standalone vertical spacing with the flow's row rhythm. */
|
||||
.codeBody,
|
||||
.terminalBody {
|
||||
.terminalBody,
|
||||
.searchBody {
|
||||
margin: 4px 0 4px 22px;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,16 +2,17 @@
|
||||
// 16px leading slot (state dot / tool icon, chevron on hover or expanded) + title +
|
||||
// separator dot + FILL-truncated summary. The collapsed row is always one
|
||||
// line; the expanded body is indented gray text, the run_code program through
|
||||
// CodeBlock, or — for a call whose render intent is a terminal card — the
|
||||
// command's own output through TerminalBlock, capped at
|
||||
// CHAT_TERMINAL_MAX_LINES so the message flow stays scannable. Expand state is
|
||||
// component-local view state. File-tool summaries are path links that open
|
||||
// through the host; the row itself is not a details-panel control.
|
||||
// CodeBlock, a call whose render intent is a terminal card through TerminalBlock
|
||||
// (capped at CHAT_TERMINAL_MAX_LINES), or a search card through SearchBlock
|
||||
// (capped at CHAT_SEARCH_MAX_LINES), so the message flow stays scannable. Expand
|
||||
// state is component-local view state. File-tool summaries are path links that
|
||||
// open through the host; the row itself is not a details-panel control.
|
||||
|
||||
import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { CodeBlock, StateDot, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { CodeBlock, SearchBlock, StateDot, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { CHAT_SEARCH_MAX_LINES, type SearchCardModel } from '../contract/search-card-model.ts'
|
||||
import { CHAT_TERMINAL_MAX_LINES, type TerminalCardModel } from '../contract/terminal-card-model.ts'
|
||||
import type { ToolRowState, ToolRowVariant } from '../contract/tool-call-model.ts'
|
||||
import css from './ToolRow.module.css'
|
||||
@@ -33,6 +34,13 @@ export interface ToolRowProps {
|
||||
* expandable (its leading slot never toggles).
|
||||
*/
|
||||
terminal?: TerminalCardModel | null | undefined
|
||||
/**
|
||||
* Search-card material for a call whose render intent is a search card
|
||||
* (derived by `searchCardModel`); it replaces the text body when present.
|
||||
* Null or absent leaves the text body. A call carries at most one card kind,
|
||||
* so `terminal` and `search` are never both present on the same row.
|
||||
*/
|
||||
search?: SearchCardModel | null | undefined
|
||||
state: ToolRowState
|
||||
/** Makes the row itself the expand control instead of only its leading icon. */
|
||||
expandOnRowClick?: boolean | undefined
|
||||
@@ -64,6 +72,7 @@ export function ToolRow({
|
||||
summary,
|
||||
body,
|
||||
terminal,
|
||||
search,
|
||||
state,
|
||||
expandOnRowClick = false,
|
||||
filePath,
|
||||
@@ -71,16 +80,17 @@ export function ToolRow({
|
||||
}: ToolRowProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const terminalBody = terminal ?? null
|
||||
const searchBody = search ?? null
|
||||
// A row that names a single file keeps one interaction (open that path);
|
||||
// args expand is off whether or not the open callback is wired yet. Terminal
|
||||
// args expand is off whether or not the open callback is wired yet. Card
|
||||
// material still expands: only the file variants carry a path, so a terminal
|
||||
// card and a file link never land on the same row.
|
||||
// or search card and a file link never land on the same row.
|
||||
const singleFile = filePath !== undefined
|
||||
const fileLink = singleFile && onOpenFile !== undefined
|
||||
const expandable = (body !== null && !singleFile) || terminalBody !== null
|
||||
const expandable = (body !== null && !singleFile) || terminalBody !== null || searchBody !== null
|
||||
// The text arms take the empty string for a null body: a row expandable
|
||||
// only through its terminal material renders the terminal body instead, so
|
||||
// this substitution never shows.
|
||||
// only through its card material renders that card instead, so this
|
||||
// substitution never shows.
|
||||
const text = body ?? ''
|
||||
const open = expanded && expandable
|
||||
const rowExpands = expandable && expandOnRowClick
|
||||
@@ -164,9 +174,11 @@ export function ToolRow({
|
||||
)}
|
||||
{open && (terminalBody !== null
|
||||
? <TerminalBlock {...terminalBody.card} maxLines={CHAT_TERMINAL_MAX_LINES} className={css.terminalBody} />
|
||||
: variant === 'code'
|
||||
? <CodeBlock code={text} lang="typescript" className={css.codeBody} />
|
||||
: <div className={css.body}>{text}</div>)}
|
||||
: searchBody !== null
|
||||
? <SearchBlock {...searchBody.card} maxLines={CHAT_SEARCH_MAX_LINES} className={css.searchBody} />
|
||||
: variant === 'code'
|
||||
? <CodeBlock code={text} lang="typescript" className={css.codeBody} />
|
||||
: <div className={css.body}>{text}</div>)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<A | B, K>` 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, K extends keyof T> = T extends unknown ? Omit<T, K> : never
|
||||
|
||||
/** The {@link SearchBlockProps} union minus each render site's own fields. */
|
||||
type SearchBlockModelProps = DistributiveOmit<SearchBlockProps, 'maxLines' | 'className'>
|
||||
|
||||
/**
|
||||
* 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 },
|
||||
}
|
||||
}
|
||||
@@ -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 <SearchBlock {...search.card} className={css.terminal} />
|
||||
// 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 <div className={css.empty}>运行中…</div>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 <StateDot state="error" />
|
||||
case 'stopped': return <StateDot state="warning" />
|
||||
default: return <IconSearchOutline16 size={14} />
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<div className={css.card}>
|
||||
<div className={css.root} data-variant="search" data-tool={toolName} data-state={model.state}>
|
||||
<span className={css.leading}>{leadingFor(model.state)}</span>
|
||||
{status !== null && <span className={css.visuallyHidden}>{status}</span>}
|
||||
<span className={css.title}>{model.title}</span>
|
||||
<span className={css.sep} aria-hidden />
|
||||
{/* The result view's replacement title outranks the args-derived
|
||||
summary, matching the terminal card's description precedence. */}
|
||||
<span className={css.summary}>{search?.title ?? model.summary}</span>
|
||||
</div>
|
||||
{search !== null && (
|
||||
<SearchBlock {...search.card} maxLines={CHAT_SEARCH_MAX_LINES} className={css.search} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
},
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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<Extract<ToolResultView, { card: 'search'; kind: 'matches' }>>): 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<Extract<ToolResultView, { card: 'search'; kind: 'paths' }>>): ToolResultView => ({
|
||||
card: 'search', kind: 'paths', paths: ['src/a.ts', 'src/b.ts'], truncated: false, total: 2, ...over,
|
||||
})
|
||||
|
||||
const runningGrep = (over?: Partial<RunningToolCall>): 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>): 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>): 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(<GenericToolCard {...ownerProps(settledGrep(), 'grep')} />)
|
||||
// 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(<GenericToolCard {...ownerProps(settledGlob(), 'glob')} />)
|
||||
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(<GenericToolCard {...ownerProps(settledGrep({
|
||||
resultView: { card: 'generic' },
|
||||
}), 'grep')} />)
|
||||
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(<SearchRow {...rowProps(settledGrep(), 'grep')} />)
|
||||
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(<SearchRow {...rowProps(settledGlob(), 'glob')} />)
|
||||
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(<SearchRow {...rowProps(runningGrep(), 'grep')} />)
|
||||
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(<SearchRow {...rowProps(settledGrep({
|
||||
isError: true, resultView: { card: 'generic' },
|
||||
}), 'grep')} />)
|
||||
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(<SearchRow {...rowProps(settledGrep({
|
||||
resultView: resultMatches({ title: '3 matches in 2 files' }),
|
||||
}), 'grep')} />)
|
||||
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(<SearchRow {...rowProps(settledGrep(), 'grep')} />)
|
||||
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<SessionListState>({ ids: [], byId: {}, current: undefined, phase: 'ready' })
|
||||
const workspaces = createSnapshotStore<WorkspaceListState>({
|
||||
items: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
})
|
||||
return render(
|
||||
<DetailsPanel
|
||||
sessionId={SID}
|
||||
useSession={bindSnapshotSelector({ getSnapshot: () => 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> = {}): 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')
|
||||
})
|
||||
})
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<number>): 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<ReadonlySet<number>>(() => 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 <div className={css.line}>{row.path}</div>
|
||||
if (row.type === 'match') {
|
||||
return (
|
||||
<div className={css.line}>
|
||||
<span className={css.lineNumber}>{row.lineNumber}: </span>
|
||||
{row.line}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={css.fileHeader}
|
||||
aria-expanded={!row.collapsed}
|
||||
onClick={() => { toggleFile(row.index) }}
|
||||
>
|
||||
<span className={css.filePath}>{row.path}</span>
|
||||
<span className={css.fileCount}>{row.count}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={clsx(css.block, className)} data-search={props.kind}>
|
||||
<div className={css.header}>
|
||||
<span className={css.summary}>{summaryText(props, shown)}</span>
|
||||
{truncated && <Pill className={css.truncated}>{`已截断 · 共 ${total}`}</Pill>}
|
||||
{!empty && (
|
||||
<button type="button" className={css.copyButton} onClick={onCopy}>
|
||||
{copied ? '复制成功' : '复制'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{empty
|
||||
? <div className={css.empty}>无结果</div>
|
||||
: (
|
||||
<div className={css.body}>
|
||||
{(capped ? rows.slice(0, headLines) : rows).map(row => (
|
||||
<div key={rowKey(row)}>{renderRow(row)}</div>
|
||||
))}
|
||||
{hidden > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className={css.expand}
|
||||
aria-expanded={expanded}
|
||||
aria-label={expanded ? '收起结果' : `展开其余 ${hidden} 行结果`}
|
||||
onClick={onToggle}
|
||||
>
|
||||
{expanded ? '收起' : `… 其余 ${hidden} 行`}
|
||||
</button>
|
||||
)}
|
||||
{capped && rows.slice(rows.length - tailLines).map(row => (
|
||||
<div key={rowKey(row)}>{renderRow(row)}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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(<SearchBlock kind="matches" truncated={false} total={3} files={[
|
||||
{ path: 'a.ts', matches: [{ lineNumber: 12, line: 'const a = 1' }, { lineNumber: 40, line: 'return a' }] },
|
||||
{ path: 'b.ts', matches: [{ lineNumber: 7, line: 'const b = 2' }] },
|
||||
]} />)
|
||||
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(<SearchBlock kind="matches" truncated={false} total={3} files={[
|
||||
{ path: 'a.ts', matches: [{ lineNumber: 1, line: 'x' }] },
|
||||
{ path: 'b.ts', matches: [{ lineNumber: 2, line: 'y' }] },
|
||||
]} />)
|
||||
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(<SearchBlock kind="matches" truncated total={99} files={[group('a.ts', 2)]} />)
|
||||
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(<SearchBlock kind="paths" truncated={false} total={2} paths={['src/a.ts', 'src/b.ts']} />)
|
||||
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(<SearchBlock kind="paths" truncated total={50} paths={['a', 'b']} />)
|
||||
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(<SearchBlock kind="matches" truncated={false} total={0} files={[]} />)
|
||||
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(<SearchBlock kind="paths" truncated={false} total={0} paths={[]} />)
|
||||
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(<SearchBlock kind="paths" truncated={false} total={4}
|
||||
paths={['a', 'b', 'c', 'd']} maxLines={4} />)
|
||||
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(<SearchBlock kind="paths" truncated={false} total={10} paths={paths} maxLines={4} />)
|
||||
// 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(<SearchBlock kind="matches" truncated={false} total={10}
|
||||
files={[group('a.ts', 10)]} maxLines={4} />)
|
||||
// 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(<SearchBlock kind="paths" truncated={false} total={5}
|
||||
paths={['a', 'b', 'c', 'd', 'e']} maxLines={1} />)
|
||||
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(<SearchBlock kind="paths" truncated={false} total={paths.length} paths={paths} />)
|
||||
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(<SearchBlock kind="matches" truncated total={9} maxLines={2} files={[
|
||||
{ path: 'a.ts', matches: [{ lineNumber: 1, line: 'x' }, { lineNumber: 2, line: 'y' }] },
|
||||
{ path: 'b.ts', matches: [{ lineNumber: 3, line: 'z' }] },
|
||||
]} />)
|
||||
// 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(<SearchBlock kind="paths" truncated={false} total={2} paths={['src/a.ts', 'src/b.ts']} />)
|
||||
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(<SearchBlock kind="paths" truncated={false} total={1} paths={['a']} />)
|
||||
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(<SearchBlock kind="paths" truncated={false} total={0} paths={[]} className="x" />)
|
||||
expect(view.container.firstElementChild?.classList.contains('x')).toBe(true)
|
||||
expect(view.container.firstElementChild?.getAttribute('data-search')).toBe('paths')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user