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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user