fix(web-search-card): surface truncation recovery, widen cardless fallback, validate wire shape, fix tail-cap

Address the ds-review-bot findings on the search card:

- searchCardModel dropped the result view's `content`, so a capped search's
  `Full … stored at: <locator>` recovery footer vanished from the UI (the card
  replaces the raw text). Thread it through as `SearchCardModel.recovery` and
  render it below the card at all three sites, only when truncated.
- SearchRow's fallback body was gated on `state === 'error'`, so a settled
  non-error call with no card (a successful nested run_code sub-dispatch, a
  legacy generic result) showed only its summary with content lost. Widen it to
  any settled call with `search === null`.
- searchCardModel trusted the `files`/`paths` shape the host wire schema only
  string-checks; a malformed known-kind frame would crash SearchBlock. Validate
  the full shape and fall to the generic path on mismatch.
- SearchBlock's restored tail file header added a row without consuming a tail
  slot, exceeding maxLines by one and overstating the hidden count. Make it
  consume a slot so the visible count holds at maxLines and `hidden` stays exact.

Correct the fixture JSDoc (now genuinely exceeds the row cap) and the Agent Note
recovery-text claim, sync the ui-conversation bilingual README with the search
row, and add an assembled keyless snapshot (apps/web/tests/search-card.snapshot.ts)
that pins the grep card's shape from the built bundles.
This commit is contained in:
Chinesezjc
2026-07-30 22:40:07 +08:00
parent c45cf2ae53
commit e830ed7939
19 changed files with 470 additions and 42 deletions

View File

@@ -159,6 +159,15 @@ const SEARCH_MATCHES_FIXTURE: { path: string; matches: { lineNumber: number; lin
{ lineNumber: 60, line: 'export function searchCardModel(block: ToolCallBlock): SearchCardModel | null {' },
],
},
{
path: 'packages/client/ui-conversation/src/client/toolviews/search-row.tsx',
matches: [
{ lineNumber: 71, line: 'export function SearchRow({ toolName, block }: ToolRowProps) {' },
{ lineNumber: 73, line: ' const search = searchCardModel(block)' },
{ lineNumber: 90, line: ' <SearchBlock {...search.card} maxLines={CHAT_SEARCH_MAX_LINES} className={css.search} />' },
{ lineNumber: 113, line: " ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep' }, SearchRow)" },
],
},
]
/**
@@ -169,7 +178,7 @@ const SEARCH_MATCHES_FIXTURE: { path: string; matches: { lineNumber: number; lin
* `Line N:` rows, then a spill-recovery footer.
*/
const SEARCH_MATCHES_TEXT = [
'Found 5 of 42 matches',
'Found 9 of 42 matches',
'',
...SEARCH_MATCHES_FIXTURE.map(file =>
[file.path, ...file.matches.map(m => `Line ${m.lineNumber}: ${m.line}`)].join('\n')),

View File

@@ -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-conversation/README.md
README.md: fc466190a744a1c13094ca6ebf62755d5bf49c98
README.zh.md: f6fbff9c1e5d005b64e928680bbf401d94e4ce79
README.md: e5e006b761020b8dfaf25eac191d1745326ae1f6
README.zh.md: 3b19e2b5a0e9cc764bad74671e16c4428452e61f

View File

@@ -16,6 +16,8 @@ Generic tool rows classify the built-in bash, read, search, write, edit, and run
A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed for this intent alone; a generic tool's content remains panel-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)).
A `grep`/`glob` call declaring the `search` render intent renders its result inline, at the same render sites, through ui-primitives' `SearchBlock` — grep's matches grouped by file (each a collapsible header of `lineNumber: line` rows), glob's flat path list. `contract/search-card-model.ts` is the single derivation from the snapshot's `resultView`; unlike the terminal card it reads no `callView`, since a search has no matches or paths before `execute`, so a running search shows its summary alone. It yields null — the generic path — for any non-search result view, a `card` or `kind` this client version does not compile, and (because those ride the untrusted wire frame) a known kind whose `files`/`paths` is malformed. The keyed `SearchRow`, registered under both `grep` and `glob` since the derived `kind` decides the shape, carries the card resident below its summary; the render-site fallback keeps it behind the expand control. Both cap at `CHAT_SEARCH_MAX_LINES` (8) against the panel's 16. A capped search drops rows from the card, but the locator to the rest — grep/glob's `Full … stored at …` footer — lives only in the result text, so the derivation surfaces that as a recovery footer below the card when (and only when) the result was truncated; a settled call with no card at all (an errored search, a nested `run_code` sub-dispatch, a legacy generic result) falls back to its flattened result text so nothing is lost behind a bare summary ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md)).
Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openFile`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders).
The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`<done>/<total> 已完成 · <active item>` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: 10` — between Goal and Queue — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus `"<done>/<total> tasks · <n> in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included.

View File

@@ -14,6 +14,8 @@
声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView``resultView` 对推导的唯一位置因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null落回通用路径。因此两个渲染点也都显示卡片的运行状态点它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片常驻在摘要行下方;由于工具行已不再是详情面板的点击目标,卡片的复制与展开控件就是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`8面板为 16正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出只对该意图开放通用工具的内容仍然只在面板中呈现[决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。
声明 `search` 渲染意图的 `grep``glob` 调用,会在同样的渲染点上通过 ui-primitives 的 `SearchBlock` 内联渲染其结果——grep 的匹配按文件分组(每个是一个可折叠的头,下辖 `lineNumber: line`glob 是扁平路径列表。`contract/search-card-model.ts` 是从快照的 `resultView` 推导的唯一位置;与终端卡片不同,它不读 `callView`,因为搜索在 `execute` 前没有匹配或路径,所以运行中的搜索只显示摘要。对任何非搜索的结果视图、当前客户端版本无法编译的 `card``kind`、以及(因为这些都与不可信的 wire 帧同行)一个 `files``paths` 格式错误的已知 kind它都返回 null落回通用路径。键控的 `SearchRow` 因推导出的 `kind` 决定形态而同时注册在 `grep``glob` 下,把卡片常驻在摘要行下方;渲染点兜底行则把它保持在展开控件之后。两者上限都是 `CHAT_SEARCH_MAX_LINES`8面板为 16。被截断的搜索会从卡片里丢掉一些行但通往其余部分的定位符——grep/glob 的 `Full … stored at …` 脚注——只存在于结果文本里,因此推导在(且仅在)结果被截断时把它作为恢复脚注画在卡片下方;一个完全没有卡片的已结算调用(出错的搜索、嵌套 `run_code` 子派发、旧日志的 generic 结果)则回退到其压平后的结果文本,从而不让任何内容丢失在一个光秃秃的摘要之后([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md))。
工具行同样是 slot独立工具环`ToolViewRegistry``ctx.toolviews`outlet已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位Session scopekey 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps``callId``toolName``block``openFile``ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seamapply 在聊天注册后挂载 ConversationService因此服务存在即可保证 slot 已声明Session 区分在组件内部完成(`useSessions` 读取 `parentId`bash 示例是第三方姿态的范例。Trajectory/waterfall 工具视图 slot 共享此形状并随各自的渲染点落地RendersCheck 会拒绝没有任何渲染方的声明)。
审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。侧边栏通过 manager 跟踪的 `waitingApproval` 列表位未实例化会话同样点亮镜像该阻塞状态其优先级高于运行中圆环直至问题解决。未决等待完全离开消息流问题ui-question与审批ApprovalPanel都经编辑器接管作答不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数key 缺席即隐藏 chipchip 打开 Menu 原语下拉kebab-case 预设名渲染为 Title Case 标签(与 `/permission` popup 的显示变换孪生),选中会经由输入栏注入的 `command` 回调提交 `/permission <preset>` 命令行。

View File

@@ -125,6 +125,17 @@
margin: 4px 0 4px 22px;
}
/* The recovery footer for a capped search: the result text (its `Full … stored
at …` locator) below the card in the muted tone, since the card holds only the
retained rows. Same column indent as the card body. */
.searchRecovery {
margin: 4px 0 4px 22px;
white-space: pre-wrap;
overflow-wrap: anywhere;
font: var(--dsw-font-xs-13);
color: var(--dsw-alias-label-tertiary);
}
/* Indented to the body's own column so the description reads as the card's
heading rather than as another summary row, and sits tight against the card
below it. Its own rule: grouping it with a body would put description

View File

@@ -137,7 +137,16 @@ export function ToolRow({
{terminalBody !== null
? <TerminalBlock {...terminalBody.card} maxLines={CHAT_TERMINAL_MAX_LINES} className={css.terminalBody} />
: searchBody !== null
? <SearchBlock {...searchBody.card} maxLines={CHAT_SEARCH_MAX_LINES} className={css.searchBody} />
? (
<>
<SearchBlock {...searchBody.card} maxLines={CHAT_SEARCH_MAX_LINES} className={css.searchBody} />
{/* A capped search's recovery locator lives only in the result
text; show it below the card so the dropped rows survive. */}
{searchBody.recovery !== undefined && (
<div className={css.searchRecovery}>{searchBody.recovery}</div>
)}
</>
)
: variant === 'code'
? <CodeBlock code={text} lang="typescript" className={css.codeBody} />
: <div className={css.body}>{text}</div>}

View File

@@ -12,9 +12,15 @@
* therefore reads only `resultView` and returns null for a still-running call,
* unlike the terminal card whose call view carries the command before
* execution.
*
* A capped result also carries a recovery locator (grep/glob's `Full … stored
* at …` footer) that lives only in the view's `content` text, not in the
* structured matches/paths. Since both render sites replace the raw result with
* the card, this derivation surfaces that text as {@link SearchCardModel.recovery}
* so the one path to the dropped rows is not lost.
* @module
*/
import type { SearchBlockProps } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SearchBlockProps, SearchFileGroup } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ToolCallBlock } from './tool-call-model.ts'
/**
@@ -55,6 +61,54 @@ export interface SearchCardModel {
* row then keeps its args-derived summary.
*/
title: string | undefined
/**
* The model-facing result text (the view's `content`, flattened), surfaced
* only when the search was capped. The card renders the retained matches or
* paths, but the recovery locator a capped result carries — grep/glob's
* `Full … stored at: <locator>` footer, the one way to reach the rows the cap
* dropped — lives only in this text. A UI that replaces the raw result with
* the card would otherwise lose it. Absent when the result was not capped
* (the card holds every result) or the presenter supplied no content.
*/
recovery: string | undefined
}
/**
* Whether every file group in a matches view is structurally valid: the wire
* frame carries `kind` and `card` as strings the host schema checks, but not the
* grouped shape, so a version mismatch or loose producer could deliver
* `kind: 'matches'` with a missing or malformed `files`. Rendering that would
* crash {@link SearchBlock} at `.reduce`/`.map`; an invalid shape falls to the
* generic path instead.
* @param files - the candidate `files` field off the untrusted result view.
* @returns whether `files` is a valid {@link SearchFileGroup} array.
*/
function isValidFiles(files: unknown): files is SearchFileGroup[] {
return Array.isArray(files) && files.every(file =>
typeof file === 'object' && file !== null
&& typeof (file as { path?: unknown }).path === 'string'
&& Array.isArray((file as { matches?: unknown }).matches)
&& (file as { matches: unknown[] }).matches.every(match =>
typeof match === 'object' && match !== null
&& typeof (match as { lineNumber?: unknown }).lineNumber === 'number'
&& typeof (match as { line?: unknown }).line === 'string'))
}
/**
* Flatten a result view's `content` blocks to their text, joined by newlines.
* The search views carry `content` (the model-facing result text) so a UI
* without a search card can show it; here it is the source of the truncation
* recovery footer. Non-text blocks (a search result carries none) are skipped.
* @param content - the result view's optional content blocks.
* @returns the joined text, or undefined when absent or empty.
*/
function flattenContent(content: readonly { type: string; text?: string }[] | undefined): string | undefined {
if (content === undefined) return undefined
const text = content
.filter((block): block is { type: 'text'; text: string } => block.type === 'text' && typeof block.text === 'string')
.map(block => block.text)
.join('\n')
return text === '' ? undefined : text
}
/**
@@ -78,8 +132,17 @@ 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 }
// The recovery footer only matters when the tool capped the result: an
// uncapped card holds every match/path, so its content adds nothing the card
// does not already show. When capped, the content's `Full … stored at …`
// locator is the only path to the dropped rows, so surface it.
const recovery = result.truncated ? flattenContent(result.content) : undefined
if (result.kind === 'matches') {
return { title: result.title, card: { kind: 'matches', files: result.files, ...common } }
// `files` rides the untrusted wire frame: the host schema checks `card`/`kind`
// strings but not the grouped shape, so validate it before SearchBlock, which
// would crash on a missing/malformed `files`. An invalid shape falls to generic.
if (!isValidFiles(result.files)) return null
return { title: result.title, recovery, 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
@@ -88,5 +151,8 @@ export function searchCardModel(block: ToolCallBlock): SearchCardModel | null {
// 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 } }
// `paths` is likewise unchecked by the wire schema; a known kind with a
// missing/malformed array would crash the paths card at `.map`.
if (!Array.isArray(result.paths) || !result.paths.every((path): path is string => typeof path === 'string')) return null
return { title: result.title, recovery, card: { kind: 'paths', paths: result.paths, ...common } }
}

View File

@@ -107,3 +107,14 @@
.terminal {
margin: 0;
}
/* The recovery footer for a capped search: the result text (its `Full … stored
at …` locator) below the card in the muted tone, since the card holds only the
retained rows. */
.searchRecovery {
margin: 6px 0 0;
white-space: pre-wrap;
overflow-wrap: anywhere;
color: var(--dsw-alias-label-tertiary);
font: var(--dsw-font-xs-13);
}

View File

@@ -130,8 +130,9 @@ export function DetailsPanel({ useSession, useSessions, sessionId, useStore, clo
* at the primitive's own full height allowance, so column-aligned output keeps
* 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.
* same full height allowance, with a capped search's recovery footer below it.
* 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.
@@ -151,7 +152,18 @@ function OutputBody({ material, cwd }: { material: CallMaterial; cwd: string | u
)
}
const search = searchCardModel(material.block)
if (search !== null) return <SearchBlock {...search.card} className={css.terminal} />
if (search !== null) {
return (
<>
<SearchBlock {...search.card} className={css.terminal} />
{/* A capped search's recovery locator lives only in the result text;
show it below the card so the dropped rows stay reachable. */}
{search.recovery !== undefined && (
<div className={css.searchRecovery}>{search.recovery}</div>
)}
</>
)
}
// 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>

View File

@@ -104,3 +104,14 @@
font: var(--dsw-font-xs-13);
color: var(--dsw-alias-state-error-primary);
}
/* The recovery footer for a capped search: the model-facing result text (its
`Full … stored at …` locator) shown below the card in the muted tone, since
the card holds only the retained rows. Same column indent as the card body. */
.recovery {
margin: 4px 0 4px 22px;
white-space: pre-wrap;
overflow-wrap: anywhere;
font: var(--dsw-font-xs-13);
color: var(--dsw-alias-label-tertiary);
}

View File

@@ -42,11 +42,14 @@ function stateStatus(state: ToolRowState): string | null {
/**
* A settled result's text, flattened from its content blocks, for the arm that
* shows a failure the search card cannot: grep/glob have no `presentResult` on
* an error result, so an errored search has no card, and the keyed row is not a
* details-panel target. Without this the failure — a bad pattern, a missing
* path, a nested run_code dispatch that returned no card — would read as a bare
* red dot with the model-facing error text nowhere on screen.
* shows a result the search card cannot. Two cases reach it: an errored search
* (grep/glob emit no `presentResult` on an error result, so an errored search
* has no card), and a settled call whose result view is not a search card at all
* a nested `run_code` sub-dispatch (the backend computes no presentationMeta
* for it, so `resultView` is null) or a legacy generic result. In both the keyed
* SearchRow owns the render slot, so without this arm the model-facing text would
* have nowhere to go: an errored search would read as a bare red dot, and a
* successful cardless result would show only its summary with its content lost.
* @param block - the frozen call slice.
* @returns the result text, or null for a running call or an empty result.
*/
@@ -63,18 +66,24 @@ function errorText(block: ToolRowProps['block']): string | 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.
* completed search's card resident below it, and — when the result was capped —
* the recovery footer below the card. 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)
// An errored search has no card (grep/glob return no presentResult on error);
// surface its result text so the failure is more than a red dot.
const failure = search === null && model.state === 'error' ? errorText(block) : null
// A settled call with no search card — an errored search (grep/glob emit no
// result view on error), a successful nested run_code sub-dispatch, or a
// legacy generic result — has its model-facing text nowhere else to go, since
// the keyed SearchRow owns this render slot. Surface it as the fallback body.
// A running call ('kind' absent) has no result to flatten; errorText returns
// null for it, so the arm stays closed until settle.
const settled = 'kind' in block
const fallback = search === null && settled ? errorText(block) : null
return (
<div className={css.card}>
<div className={css.root} data-variant="search" data-tool={toolName} data-state={model.state}>
@@ -89,7 +98,11 @@ export function SearchRow({ toolName, block }: ToolRowProps) {
{search !== null && (
<SearchBlock {...search.card} maxLines={CHAT_SEARCH_MAX_LINES} className={css.search} />
)}
{failure !== null && <div className={css.failure}>{failure}</div>}
{/* A capped search drops rows from the card; its recovery locator (the
`Full … stored at …` footer) lives only in the result text, so show it
below the card so the one path to the dropped rows survives. */}
{search?.recovery !== undefined && <div className={css.recovery}>{search.recovery}</div>}
{fallback !== null && <div className={css.failure}>{fallback}</div>}
</div>
)
}

View File

@@ -77,6 +77,7 @@ describe('searchCardModel', () => {
it('derives a matches card from the grep result view', () => {
expect(searchCardModel(settledGrep())).toEqual({
title: undefined,
recovery: undefined,
card: {
kind: 'matches',
files: [
@@ -91,6 +92,7 @@ describe('searchCardModel', () => {
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,
recovery: undefined,
card: { kind: 'paths', paths: ['src/a.ts', 'src/b.ts'], truncated: true, total: 20 },
})
})
@@ -115,6 +117,55 @@ describe('searchCardModel', () => {
const future = { card: 'chart' } as unknown as ToolResultView
expect(searchCardModel(settledGrep({ resultView: future }))).toBeNull()
})
it('returns null for a card:search view whose kind this version does not compile', () => {
// `kind` rides the same untrusted wire frame as `card`; a subtype this client
// does not know must fall to the generic path, never render as a paths card
// that would crash SearchBlock on an absent `paths`.
const futureKind = {
card: 'search', kind: 'future', truncated: false, total: 0,
} as unknown as ToolResultView
expect(searchCardModel(settledGrep({ resultView: futureKind }))).toBeNull()
})
it('returns null for a known kind whose structured shape is missing or malformed', () => {
// The host wire schema checks the `card`/`kind` strings but not the grouped
// shape, so a version mismatch could deliver kind:'matches' with no `files`
// (or kind:'paths' with no `paths`). Rendering that crashes SearchBlock at
// `.reduce`/`.map`; the derivation drops to the generic path instead.
const noFiles = { card: 'search', kind: 'matches', truncated: false, total: 0 } as unknown as ToolResultView
expect(searchCardModel(settledGrep({ resultView: noFiles }))).toBeNull()
const badFile = {
card: 'search', kind: 'matches', truncated: false, total: 1,
files: [{ path: 'a.ts', matches: [{ lineNumber: 'x', line: 1 }] }],
} as unknown as ToolResultView
expect(searchCardModel(settledGrep({ resultView: badFile }))).toBeNull()
const noPaths = { card: 'search', kind: 'paths', truncated: false, total: 0 } as unknown as ToolResultView
expect(searchCardModel(settledGlob({ resultView: noPaths }))).toBeNull()
const badPaths = {
card: 'search', kind: 'paths', truncated: false, total: 1, paths: [42],
} as unknown as ToolResultView
expect(searchCardModel(settledGlob({ resultView: badPaths }))).toBeNull()
})
it('surfaces the recovery text only when the result was capped', () => {
const recovery = 'a.ts\n 12: const foo = 1\n\n(Full grep result stored at: spill://grep-1. Read it to see every match.)'
// Capped: the content (its `Full … stored at …` locator) rides through so the
// dropped rows stay reachable.
const capped = searchCardModel(settledGrep({
resultView: resultMatches({ truncated: true, total: 42, content: [{ type: 'text', text: recovery }] }),
}))
expect(capped?.recovery).toBe(recovery)
// Not capped: the card holds every match, so the content adds nothing and is
// dropped.
const whole = searchCardModel(settledGrep({
resultView: resultMatches({ truncated: false, content: [{ type: 'text', text: recovery }] }),
}))
expect(whole?.recovery).toBeUndefined()
// Capped but the presenter attached no content: nothing to surface.
const noContent = searchCardModel(settledGrep({ resultView: resultMatches({ truncated: true, total: 42 }) }))
expect(noContent?.recovery).toBeUndefined()
})
})
describe('chat row search body (GenericToolCard fallback)', () => {
@@ -150,6 +201,16 @@ describe('chat row search body (GenericToolCard fallback)', () => {
expect(view.getByText(/"pattern"/)).toBeTruthy()
expect(searchKindOf(view.container)).toBeNull()
})
it('the expanded body shows the recovery footer below a capped card', () => {
const recovery = 'a.ts\n 12: const foo = 1\n\n(Full grep result stored at: spill://grep-1. Read it to see every match.)'
const view = render(<GenericToolCard {...ownerProps(settledGrep({
resultView: resultMatches({ truncated: true, total: 42, content: [{ type: 'text', text: recovery }] }),
}), 'grep')} />)
fireEvent.click(view.container.querySelector('button')!)
expect(searchKindOf(view.container)).toBe('matches')
expect(view.getByText(/Full grep result stored at: spill:\/\/grep-1/)).toBeTruthy()
})
})
describe('SearchRow keyed card', () => {
@@ -195,6 +256,34 @@ describe('SearchRow keyed card', () => {
expect(view.getByText('grep: invalid regular expression')).toBeTruthy()
})
it('surfaces the result text for a settled non-error call with no card', () => {
// A successful nested run_code sub-dispatch (backend computes no
// presentationMeta, so resultView is null) or a legacy generic result settles
// with search === null and state ok. The keyed SearchRow owns the slot, so
// without the widened arm the content would be lost behind a bare summary.
const view = render(<SearchRow {...rowProps(settledGrep({
isError: false, resultView: null,
content: [{ type: 'text', text: 'nested run_code output line' }],
}), 'grep')} />)
expect(view.container.querySelector('[data-variant="search"]')?.getAttribute('data-state')).toBe('ok')
expect(searchKindOf(view.container)).toBeNull()
expect(view.getByText('nested run_code output line')).toBeTruthy()
})
it('renders the recovery footer below the card when the search was capped', () => {
const recovery = 'a.ts\n 12: const foo = 1\n\n(Full grep result stored at: spill://grep-1. Read it to see every match.)'
const view = render(<SearchRow {...rowProps(settledGrep({
resultView: resultMatches({ truncated: true, total: 42, content: [{ type: 'text', text: recovery }] }),
}), 'grep')} />)
expect(searchKindOf(view.container)).toBe('matches')
expect(view.getByText(/Full grep result stored at: spill:\/\/grep-1/)).toBeTruthy()
})
it('shows no recovery footer for an uncapped search', () => {
const view = render(<SearchRow {...rowProps(settledGrep(), 'grep')} />)
expect(view.container.textContent).not.toMatch(/stored at/)
})
it('falls back to the error name/code when an errored result has no text block', () => {
const view = render(<SearchRow {...rowProps(settledGrep({
isError: true, resultView: null, content: [],
@@ -284,6 +373,15 @@ describe('DetailsPanel Output section (search)', () => {
expect(searchKindOf(view.container)).toBe('paths')
})
it('renders the recovery footer below the card for a capped search', () => {
const recovery = 'src/a.ts\nsrc/b.ts\n\n(Showing 2 of 23 paths. Full sorted result stored at: spill://glob-7.)'
const view = mount(snapshot({
nodes: [settledGlob({ resultView: resultPaths({ truncated: true, total: 23, content: [{ type: 'text', text: recovery }] }) })],
}), globTarget)
expect(searchKindOf(view.container)).toBe('paths')
expect(view.getByText(/Full sorted result stored at: spill:\/\/glob-7/)).toBeTruthy()
})
it('a non-search result keeps the flattened pre form', () => {
const view = mount(snapshot({
nodes: [settledGrep({ callView: null, resultView: null })],

View File

@@ -209,17 +209,23 @@ export function SearchBlock(props: SearchBlockProps) {
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) : []
const naturalTail = 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 tailLead = naturalTail[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
// The restored header is itself a row. Left extra it would push the card to
// maxLines + 1 and overstate `hidden` by one, so it consumes a tail slot: drop
// the tail's first row (the match whose header this is) for it. Visible rows
// hold at maxLines and `hidden` stays exact; the dropped match joins the
// hidden middle.
const tail = tailHeader === undefined ? naturalTail : naturalTail.slice(1)
const renderRow = (row: SearchRow): ReactNode => {
if (row.type === 'path') return <div className={css.line}>{row.path}</div>

View File

@@ -140,16 +140,20 @@ describe('SearchBlock height cap', () => {
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).
// matches), tail 4. The tail begins mid-b.ts, so its header is restored —
// and, being a row itself, it consumes one tail slot rather than pushing the
// card to 9 rows: the tail keeps its last 3 matches, total visible = 8.
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',
'18: hit 18', '19: hit 19', '20: hit 20',
])
// Visible rows hold at maxLines (2 headers + 6 matches = 8), so the hidden
// count stays exact: 22 8 = 14.
expect(view.getByRole('button', { name: '展开其余 14 行结果' })).toBeTruthy()
})
it('caps at the documented default when maxLines is absent', () => {