Răsfoiți Sursa

fix(web-search-card): finish search bot-review round (import, docs)

Complete the stalled review pass: drop the unused useMemo import (rows flatten
inline), add SearchBlock to the ui-primitives README (both languages) with a
Search results section, and re-record the doc pairings. Search card behavior
and tests unchanged (313 pass).
Chinesezjc 2 luni în urmă
părinte
comite
b832f46eff

+ 2 - 2
.agents/notes/implemented/feature/2026-07-30-web-search-card.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .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
+2026-07-30-web-search-card.md: 1dff5ae5a4d789b1e57fcaef349959764583fbdf
+2026-07-30-web-search-card.zh.md: 09d38066bf16923655b27a30c717d97ccbe434bb

+ 4 - 4
.agents/notes/implemented/feature/2026-07-30-web-search-card.md

@@ -22,7 +22,7 @@ The component's contract:
 
 - **Grouped matches, collapsible per file.** Each file is a header row (a bold path plus its match count, the whole row the collapse control) followed by its `lineNumber: line` rows. Collapsing a group drops its match rows from the flattened list and from the height cap's arithmetic, but never from the copy text.
 - **Flat path list.** The paths shape renders one path per row, no headers.
-- **A capped indicator.** When `truncated`, 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} 个路径`).
+- **A capped indicator.** When `truncated`, the banner summary folds the pre-cap total in — `显示 X / 共 N 处匹配 · K 个文件` for grep, `显示 X / 共 N 个路径` for glob — so the card never presents a capped page as the complete result; a reader who wants the rest follows the spill locator in the model-facing text, exactly as the model does. When not `truncated` the summary is a plain structural count (`{n} 处匹配 · {m} 个文件`, or `{n} 个路径`).
 - **No soft wrapping.** Result rows are `white-space: pre` inside a horizontally scrolling box, so a long match line or a deep path scrolls sideways rather than folding.
 - **Height cap with an expand control.** More than `DEFAULT_SEARCH_MAX_LINES` (16) rows shows a head/tail slice with a button reporting the hidden count, the same shape and arithmetic as `TerminalBlock`.
 - **Copy.** The copy control writes the whole structured result — every file and match, or every path — regardless of the height cap or which groups are collapsed, so the clipboard carries the result rather than what the card happens to be showing.
@@ -33,7 +33,7 @@ Geometry, radius, and fonts mirror `CodeBlock` and `TerminalBlock`, so a search
 
 Three sites consume the derivation, mirroring the terminal card's placement exactly:
 
-- **The keyed `SearchRow`** (`toolviews/search-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 keyed `SearchRow`** (`toolviews/search-row.tsx`) registers ONE component under both `grep` and `glob` in the `conversation.chat.toolview` keyed hole, and renders the card RESIDENT under the summary row, capped at `CHAT_SEARCH_MAX_LINES` (8) — the same posture `BashRow` takes for its terminal card. Both tool names get the same row because the derived `kind` decides the shape, so a second component would duplicate it. (This resident posture matches the current terminal/diff cards; a separate later PR unifies the whole-row collapse/expand interaction and flips all resident cards at once — out of scope here.)
 - **The generic fallback** (`chat/GenericToolCard` → `chat/ToolRow`) threads the derived model as an expand-gated body, the same arm `terminal` uses: a `grep`/`glob` result with no keyed row (none in the shipped app, since both are registered) still renders its card behind the row's expand toggle.
 - **The details panel** (`skeleton/DetailsPanel`) renders the card at the primitive's own full height in the Output section, keeping the JSON Input section.
 
@@ -45,7 +45,7 @@ Three sites consume the derivation, mirroring the terminal card's placement exac
 
 **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.
+**Reuse `TerminalBlock` or `CodeBlock`.** Rejected: neither models per-file collapsible groups or a folded capped-result summary, 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
 
@@ -53,7 +53,7 @@ Three sites consume the derivation, mirroring the terminal card's placement exac
 
 ## 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-primitives/tests/search-block.spec.tsx` pins the component at per-file 100%: both kinds, the folded pre-cap total in the summary, the empty arm, per-file collapse/re-expand without touching neighbours, a file header counting as one capped row alongside its matches, the tail slice restoring its owning file header when the cut falls mid-file, the head/tail cap and its expand control across both shapes and the no-tail and default-cap edges, and the copy control writing the whole structured result on the accepted and refused clipboard paths.
 
 `packages/client/ui-conversation/tests/search-card.spec.tsx` pins the wiring at every render site: `searchCardModel`'s derivation for both kinds, the truncation signal, the replacement title, and each null arm (running, no views, generic, terminal, unknown card); the chat row's expand-gated matches and paths bodies through `GenericToolCard` against the non-search args-JSON body; `SearchRow`'s resident card for both kinds, its agreement with the summary row's run state, the replacement-title precedence, and the keyed registration under both `grep` and `glob` with one component; and the details panel's Output section for both kinds against the non-search flattened form. `packages/client/ui-conversation/src/*` sits on the coverage exclude list, so this file is written against no gate pressure. `packages/client/connection/src/client/fixture.ts` gains a `grep` turn emitting `kind: 'matches'` and a `glob` turn emitting `kind: 'paths'` as `resultView`, both truncated, driving the built-boot snapshot and the live `?fixture` server.
 

+ 4 - 4
.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md

@@ -22,7 +22,7 @@ Status: implemented
 
 - **按文件分组的匹配,逐文件可折叠。** 每个文件是一个头行(加粗路径加它的匹配计数,整行即折叠控件),后面跟它的 `lineNumber: line` 行。折叠一个组会把它的匹配行从压平列表和高度上限的算术里去掉,但绝不从复制文本里去掉。
 - **扁平路径列表。** paths 形态每行一个路径,无头行。
-- **截断指示。** `truncated` 时,横幅摘要旁一个 pill 显示 `已截断 · 共 {total}`,因此卡片绝不把一个被截断的页面呈现为完整结果 —— 想要其余部分的读者跟随面向模型文本里的溢出定位符,与模型的做法完全一致。横幅摘要是一个朴素的结构计数(`{n} 处匹配 · {m} 个文件`,或 `{n} 个路径`)。
+- **截断指示。** `truncated` 时,横幅摘要把截断前总数折入 —— grep 为 `显示 X / 共 N 处匹配 · K 个文件`,glob 为 `显示 X / 共 N 个路径` —— 因此卡片绝不把一个被截断的页面呈现为完整结果;想要其余部分的读者跟随面向模型文本里的溢出定位符,与模型的做法完全一致。未 `truncated` 时摘要是一个朴素的结构计数(`{n} 处匹配 · {m} 个文件`,或 `{n} 个路径`)。
 - **不软换行。** 结果行在一个横向滚动的盒子里 `white-space: pre`,因此一条长匹配行或一个深路径横向滚动而不折叠。
 - **带展开控件的高度上限。** 超过 `DEFAULT_SEARCH_MAX_LINES`(16)行时显示一个头/尾切片,中间一个按钮报告被隐藏的行数,形状和算术与 `TerminalBlock` 相同。
 - **复制。** 复制控件写入整个结构化结果 —— 每个文件与匹配,或每个路径 —— 无关高度上限或哪些组被折叠,因此剪贴板携带的是结果本身,而不是卡片此刻恰好显示的内容。
@@ -33,7 +33,7 @@ Status: implemented
 
 三个渲染点消费该推导,与终端卡片的落位完全一致:
 
-- **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 范围内。)
+- **keyed `SearchRow`**(`toolviews/search-row.tsx`)把一个组件同时注册到 `conversation.chat.toolview` keyed hole 的 `grep` 与 `glob` 键下,并把卡片作为常驻(resident)渲染在摘要行下方,上限为 `CHAT_SEARCH_MAX_LINES`(8)—— 与 `BashRow` 对其终端卡片采取的姿态相同。两个工具名共用同一行,因为推导出的 `kind` 决定形态,第二个组件只会重复它。(该常驻姿态与当前的 terminal/diff 卡片一致;一个单独的后续 PR 会统一整行折叠/展开交互并一次性翻转所有常驻卡片 —— 不在本 PR 范围内。)
 - **generic fallback**(`chat/GenericToolCard` → `chat/ToolRow`)把推导出的 model 作为展开门控的 body 传入,与 `terminal` 用的是同一分支:没有 keyed 行的 `grep`/`glob` 结果(发布应用里没有,因为两者都注册了)仍在行的展开开关后渲染其卡片。
 - **details panel**(`skeleton/DetailsPanel`)在 Output 段以 primitive 自身的完整高度渲染卡片,保留 JSON Input 段。
 
@@ -45,7 +45,7 @@ Status: implemented
 
 **加一个 `SearchCallView`,让行在搜索运行时就渲染卡片。** 否决:后端契约刻意没有调用阶段的搜索视图 —— 搜索在 `execute` 前没有匹配或路径。运行中的行只显示摘要,`searchCardModel` 对运行块返回 null,忠实于实际存在的东西。
 
-**复用 `TerminalBlock` 或 `CodeBlock`。** 否决:两者都不建模逐文件可折叠的组或截断 pill,都需要把按文件分组的形态硬塞进去。三个块转而共享几何与字体 token,那是唯一一处一个实现对三者都正确的部分。
+**复用 `TerminalBlock` 或 `CodeBlock`。** 否决:两者都不建模逐文件可折叠的组或折叠式截断摘要,都需要把按文件分组的形态硬塞进去。三个块转而共享几何与字体 token,那是唯一一处一个实现对三者都正确的部分。
 
 ## Consequences
 
@@ -53,7 +53,7 @@ Status: implemented
 
 ## Testing
 
-`packages/client/ui-primitives/tests/search-block.spec.tsx` 以 per-file 100% 覆盖固定组件:两种 kind、带 pre-cap total 的截断 pill、空结果分支、逐文件折叠/再展开且不影响邻居、一个文件头与其匹配一起计为一个被截断行、跨两种形态的头/尾上限及其展开控件(含无尾与默认上限的边界),以及复制控件在接受与拒绝的剪贴板路径上写入整个结构化结果。
+`packages/client/ui-primitives/tests/search-block.spec.tsx` 以 per-file 100% 覆盖固定组件:两种 kind、折入摘要的截断前总数、空结果分支、逐文件折叠/再展开且不影响邻居、一个文件头与其匹配一起计为一个被截断行、切口落在文件中间时尾部切片恢复其所属文件头、跨两种形态的头/尾上限及其展开控件(含无尾与默认上限的边界),以及复制控件在接受与拒绝的剪贴板路径上写入整个结构化结果。
 
 `packages/client/ui-conversation/tests/search-card.spec.tsx` 固定每个渲染点的接线:`searchCardModel` 对两种 kind 的推导、截断信号、替换标题,以及每个 null 分支(运行中、无视图、generic、terminal、未知卡片);通过 `GenericToolCard` 的展开门控 matches 与 paths body,对照非搜索的 args-JSON body;`SearchRow` 对两种 kind 的常驻卡片、它与摘要行运行状态的一致、替换标题优先级,以及一个组件在 `grep` 与 `glob` 两个键下的 keyed 注册;以及 details panel 的 Output 段对两种 kind,对照非搜索的压平形态。`packages/client/ui-conversation/src/*` 在覆盖排除清单上,因此该文件不受 gate 压力。`packages/client/connection/src/client/fixture.ts` 新增一个发出 `kind: 'matches'` 的 `grep` turn 与一个发出 `kind: 'paths'` 的 `glob` turn 作为 `resultView`,两者都截断,驱动 built-boot snapshot 与实时 `?fixture` 服务。
 

+ 24 - 14
packages/client/connection/src/client/fixture.ts

@@ -137,7 +137,7 @@ const TERMINAL_EXIT_STATUS: Record<string, { exitCode: number } | { signal: stri
 }
 
 /**
- * Structured grep result for the search sample (turn 67): matches grouped by
+ * Structured grep result for the search sample (turn 66): 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
@@ -162,33 +162,43 @@ const SEARCH_MATCHES_FIXTURE: { path: string; matches: { lineNumber: number; lin
 ]
 
 /**
- * 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`.
+ * The model-facing grep render text for the sample — what a UI without a search
+ * card shows, attached as the view's `content`. Mirrors the real grep
+ * presenter's shape (see formatGrepOutput in dsh-tool-fs-search): a
+ * `Found X of Y matches` header, the matches grouped under file headers with
+ * `Line N:` rows, then a spill-recovery footer.
  */
 const SEARCH_MATCHES_TEXT = [
-  ...SEARCH_MATCHES_FIXTURE.flatMap(file => [
-    file.path,
-    ...file.matches.map(m => `  Line ${m.lineNumber}: ${m.line}`),
-  ]),
+  'Found 5 of 42 matches',
   '',
-  '(已显示 5 处匹配中的前 5 处,共 42 处;其余见溢出文件)',
+  ...SEARCH_MATCHES_FIXTURE.map(file =>
+    [file.path, ...file.matches.map(m => `Line ${m.lineNumber}: ${m.line}`)].join('\n')),
+  '',
+  '(Full grep result stored at: fixture://spill/grep-66. Read it to see every match.)',
 ].join('\n')
 
 /**
- * Structured glob result for the search sample (turn 68): a flat path list,
+ * Structured glob result for the search sample (turn 67): 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',
+  'packages/client/ui-conversation/src/client/toolviews/search-row.tsx',
+  'packages/client/ui-conversation/src/client/toolviews/search-row.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')
+/**
+ * The model-facing glob render text — the newline-joined path list plus a
+ * spill-recovery footer, mirroring the real glob presenter's shape (see
+ * formatGlobOutput in dsh-tool-fs-search).
+ */
+const SEARCH_PATHS_TEXT = [
+  ...SEARCH_PATHS_FIXTURE,
+  '',
+  '(Showing 5 of 23 paths. Full sorted result stored at: fixture://spill/glob-67. Read it to see every path.)',
+].join('\n')
 
 const DEEPSEEK_REASONING = {
   efforts: [

+ 1 - 1
packages/client/ui-conversation/src/client/apply.ts

@@ -20,7 +20,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 { searchToolview } from './toolviews/search-row.tsx'
 import { ApprovalPanel } from './skeleton/ApprovalPanel.tsx'
 import { todoToolview } from './toolviews/todo-row.tsx'
 import { askQuestionToolview } from './toolviews/ask-question-row.tsx'

+ 14 - 7
packages/client/ui-conversation/src/client/contract/search-card-model.ts

@@ -65,8 +65,10 @@ export interface SearchCardModel {
  * 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).
+ * the compiled variants, a `card: 'search'` view whose `kind` is neither
+ * `matches` nor `paths` (equally untrusted wire data), 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.
  */
@@ -76,10 +78,15 @@ export function searchCardModel(block: ToolCallBlock): SearchCardModel | 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 },
+  if (result.kind === 'matches') {
+    return { title: result.title, card: { kind: 'matches', files: result.files, ...common } }
   }
+  // `kind` rides the same untrusted wire frame as `card`, so a version mismatch
+  // or a loose protocol producer could deliver a `card: 'search'` subtype this
+  // client does not compile. Guard the paths shape explicitly: an unknown kind
+  // falls to the generic path rather than being rendered as a paths card, which
+  // would leave SearchBlock calling `.length`/`.map` on an absent `paths`.
+  // oxlint-disable-next-line typescript/no-unnecessary-condition -- kind is wire data; the compiled union cannot prove this exhaustive.
+  if (result.kind !== 'paths') return null
+  return { title: result.title, card: { kind: 'paths', paths: result.paths, ...common } }
 }

+ 3 - 2
packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css

@@ -101,8 +101,9 @@
   font: var(--dsw-font-xs-13);
 }
 
-/* The terminal card sits directly under its section label, so it drops the
-   primitive's standalone vertical margin; the section owns the spacing. */
+/* A render-intent card (terminal or search) sits directly under its section
+   label, so it drops the primitive's standalone vertical margin; the section
+   owns the spacing. */
 .terminal {
   margin: 0;
 }

+ 0 - 0
packages/client/ui-conversation/src/client/toolviews/search-sample.module.css → packages/client/ui-conversation/src/client/toolviews/search-row.module.css


+ 1 - 1
packages/client/ui-conversation/src/client/toolviews/search-sample.tsx → packages/client/ui-conversation/src/client/toolviews/search-row.tsx

@@ -17,7 +17,7 @@ import { IconSearchOutline16, SearchBlock, StateDot } from '@deepseek-ai/dsh-cli
 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'
+import css from './search-row.module.css'
 
 /** Leading-slot glyph substitution: the search icon yields to the terminal
  *  state semantic (error = red, interrupted = amber). Running keeps the icon —

+ 1 - 1
packages/client/ui-conversation/tests/search-card.spec.tsx

@@ -18,7 +18,7 @@ import { CHAT_SEARCH_MAX_LINES, searchCardModel } from '../src/client/contract/s
 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'
+import { SearchRow, searchToolview } from '../src/client/toolviews/search-row.tsx'
 
 afterEach(cleanup)
 

+ 2 - 2
packages/client/ui-primitives/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
-README.md: 0ef3c20f848b3d331c007911d0837f11cd72c024
-README.zh.md: af94551bfb9e12dbadcef6a96a54f9bf7ea71299
+README.md: c237e63074cc2e62e41d59e93fab3c020151c720
+README.zh.md: 432b5599bda06b0a9799870ae689a3a6834a3f88

Fișier diff suprimat deoarece este prea mare
+ 1 - 1
packages/client/ui-primitives/README.md


Fișier diff suprimat deoarece este prea mare
+ 1 - 1
packages/client/ui-primitives/README.zh.md


+ 2 - 2
packages/client/ui-primitives/src/SearchBlock.module.css

@@ -15,8 +15,8 @@
   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. */
+/* The banner: result summary on the left, the copy control holding its
+   intrinsic width on the right. */
 .header {
   display: flex;
   align-items: center;

+ 30 - 11
packages/client/ui-primitives/src/SearchBlock.tsx

@@ -1,13 +1,14 @@
 // 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
+// banner (result summary that folds the pre-cap total in when the tool capped
+// the result, plus 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 { useCallback, useState, type ReactNode } from 'react'
 import clsx from 'clsx'
 import { writeClipboard } from './clipboard.ts'
 import css from './SearchBlock.module.css'
@@ -39,8 +40,9 @@ export interface SearchFileGroup {
 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.
+   * retained results, not every result the search found. The banner summary
+   * folds the pre-cap `total` in (`显示 X / 共 N …`) 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`). */
@@ -77,7 +79,7 @@ export type SearchBlockProps = SearchMatchesBlockProps | SearchPathsBlockProps
  */
 type SearchRow =
   | { type: 'file'; path: string; count: number; index: number; collapsed: boolean }
-  | { type: 'match'; lineNumber: number; line: string; key: string }
+  | { type: 'match'; lineNumber: number; line: string; key: string; fileIndex: number }
   | { type: 'path'; path: string }
 
 /**
@@ -97,7 +99,7 @@ function copyText(props: SearchBlockProps): string {
 /**
  * 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`.
+ * the banner summary reports against `total` when the result was capped.
  * @param props - the card's props.
  * @returns the retained result count.
  */
@@ -141,7 +143,7 @@ function toRows(props: SearchBlockProps, collapsed: ReadonlySet<number>): Search
     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}` })
+      rows.push({ type: 'match', lineNumber: match.lineNumber, line: match.line, key: `${index}:${match.lineNumber}`, fileIndex: index })
     }
   })
   return rows
@@ -173,7 +175,9 @@ export function SearchBlock(props: SearchBlockProps) {
   const [collapsed, setCollapsed] = useState<ReadonlySet<number>>(() => new Set())
   const [copied, setCopied] = useState(false)
 
-  const rows = useMemo(() => toRows(props, collapsed), [props, collapsed])
+  // `props` is a fresh object each render, so memoizing on it never hits; the
+  // flatten is cheap, so it runs inline keyed on the collapse set instead.
+  const rows = toRows(props, collapsed)
   const shown = shownCount(props)
   const empty = rows.length === 0
   const text = copyText(props)
@@ -204,6 +208,18 @@ export function SearchBlock(props: SearchBlockProps) {
   // 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 head = capped ? rows.slice(0, headLines) : rows
+  const tail = capped ? rows.slice(rows.length - tailLines) : []
+  // When the tail slice begins inside a file's matches, its own header sits
+  // above the cut and is not shown, so those rows could not be attributed to a
+  // file. Restore the owning header at the top of the tail — unless the head
+  // slice already carries it (a single large file), where it would duplicate.
+  const tailLead = tail[0]
+  const tailHeader = tailLead?.type === 'match'
+    && !head.some(row => row.type === 'file' && row.index === tailLead.fileIndex)
+    ? rows.find((row): row is Extract<SearchRow, { type: 'file' }> =>
+      row.type === 'file' && row.index === tailLead.fileIndex)
+    : undefined
 
   const renderRow = (row: SearchRow): ReactNode => {
     if (row.type === 'path') return <div className={css.line}>{row.path}</div>
@@ -242,7 +258,7 @@ export function SearchBlock(props: SearchBlockProps) {
         ? <div className={css.empty}>无结果</div>
         : (
           <div className={css.body}>
-            {(capped ? rows.slice(0, headLines) : rows).map(row => (
+            {head.map(row => (
               <div key={rowKey(row)}>{renderRow(row)}</div>
             ))}
             {hidden > 0 && (
@@ -256,7 +272,10 @@ export function SearchBlock(props: SearchBlockProps) {
                 {expanded ? '收起' : `… 其余 ${hidden} 行`}
               </button>
             )}
-            {capped && rows.slice(rows.length - tailLines).map(row => (
+            {tailHeader !== undefined && (
+              <div key={`tailHeader:${rowKey(tailHeader)}`}>{renderRow(tailHeader)}</div>
+            )}
+            {tail.map(row => (
               <div key={rowKey(row)}>{renderRow(row)}</div>
             ))}
           </div>

+ 19 - 6
packages/client/ui-primitives/tests/search-block.spec.tsx

@@ -1,7 +1,8 @@
 // @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
+// folded truncation summary, the empty arm, per-file collapse/expand, the
+// head/tail height cap and its expand control, the tail slice restoring its
+// owning file header, 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'
@@ -41,9 +42,9 @@ describe('SearchBlock matches kind', () => {
     ]} />)
     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.
+    // The summary counts matches and files, with no folded pre-cap total below the cap.
     expect(view.getByText('3 处匹配 · 2 个文件')).toBeTruthy()
-    expect(view.queryByText(/已截断/u)).toBeNull()
+    expect(view.queryByText(/显示|共/u)).toBeNull()
   })
 
   it('collapses and re-expands a single file group without touching the others', () => {
@@ -64,7 +65,6 @@ describe('SearchBlock matches kind', () => {
   it('folds the pre-cap total into the summary when truncated', () => {
     const view = render(<SearchBlock kind="matches" truncated total={99} files={[group('a.ts', 2)]} />)
     expect(view.getByText('显示 2 / 共 99 处匹配 · 1 个文件')).toBeTruthy()
-    expect(view.queryByText(/已截断/u)).toBeNull()
   })
 })
 
@@ -80,7 +80,6 @@ describe('SearchBlock paths kind', () => {
   it('folds the pre-cap total into the paths summary when truncated', () => {
     const view = render(<SearchBlock kind="paths" truncated total={50} paths={['a', 'b']} />)
     expect(view.getByText('显示 2 / 共 50 个路径')).toBeTruthy()
-    expect(view.queryByText(/已截断/u)).toBeNull()
   })
 })
 
@@ -139,6 +138,20 @@ describe('SearchBlock height cap', () => {
     expect(view.getByRole('button', { name: '展开其余 4 行结果' })).toBeTruthy()
   })
 
+  it('restores the owning file header above a tail slice that begins mid-file', () => {
+    // Two files of 10 matches each → 22 rows. Cap 8: head 4 (a.ts header + 3
+    // matches), tail 4 (last 4 of b.ts, whose header sits above the cut).
+    const view = render(<SearchBlock kind="matches" truncated={false} total={20} maxLines={8} files={[
+      group('a.ts', 10), group('b.ts', 10, 11),
+    ]} />)
+    // The tail's own header is restored so its rows can be attributed to b.ts.
+    expect(fileHeaders(view.container)).toEqual(['a.ts10', 'b.ts10'])
+    expect(lines(view.container)).toEqual([
+      '1: hit 1', '2: hit 2', '3: hit 3',
+      '17: hit 17', '18: hit 18', '19: hit 19', '20: hit 20',
+    ])
+  })
+
   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} />)

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff