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

@@ -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>