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
@@ -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
@@ -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>}
@@ -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 } }
}
@@ -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);
}
@@ -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>
@@ -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);
}
@@ -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>
)
}