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

@@ -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', () => {