Status: implemented
English | 中文
The web_search result card (WebBlock, packages/client/ui-primitives/src/WebBlock.tsx) rendered its source list with a head/tail collapse: past a maxSources count (16 in the details panel, 8 in the chat row via CHAT_WEB_MAX_SOURCES) it drew the first ceil(max/2) sources, an … 其余 N 条来源 expand button, then the last max - ceil(max/2), mirroring TerminalBlock's output cap. A user reading the card saw 来源列表已截断 and assumed the frontend had dropped sources it was holding.
It had not. The seam (capSources, packages/web/web/src/index.ts) cuts the provider's sources to the tool's searchMaxResults bound (default 8) and sets truncated, and that one capped list feeds both the model-facing render text and the card's presentationMeta. The card never holds more sources than that one cut produced. So the collapse was hiding sources the user was entitled to see in full — and, with the default bound at 8 and the panel cap at 16, it almost never even triggered, leaving only the truncated note with no way to reveal anything.
WebBlock's search arm renders every source it receives in one <ol className={css.sources}>, with no head/tail slicing, no expand button, and no maxSources prop. .sources (WebBlock.module.css) gets a fixed max-height and overflow-y: auto, so a list longer than the card height scrolls in place rather than growing the card or hiding rows. The height is a design constant of the card geometry, so it lives in CSS, not a plugin config field.
The model side is unchanged: the seam still caps sources at searchMaxResults, the model-facing render text is untouched, and the truncated flag and its 来源列表已截断 indicator stay. The card draws the list the seam produced, in full and scrollable, instead of collapsing its middle.
That list is the one the model reads as long as nothing downstream of the tool rewrites the result content alone. A deployment mounting dsh-spill-policy breaks that correspondence for an oversized result: tools/post-execute replaces the model-facing content with a preview plus a spill locator and leaves presentationMeta whole, so the card still draws every source while the model reads a bounded excerpt. The card's contract is therefore the view it receives, not the model's context.
CHAT_WEB_MAX_SOURCES and the primitive's DEFAULT_WEB_MAX_SOURCES are removed: with scroll, the chat row and the details panel show the same full list, differentiated only by their container height. <li value={ordinal}> still pins each source's 1-based citation index; without the collapse gap the ordinals are now simply contiguous.
Making the list a scroll container also makes its padding-left a correctness constraint, not spacing. A scroll container clips inline-start overflow and offers no way to scroll it back, and ::marker is right-aligned to the content edge, so a marker wider than the padding silently loses its leading digits — at the list's 20px the two-digit markers rendered as 0. and 1. where 10. and 11. belonged. searchMaxResults is an unbounded positive integer, so the padding is sized in em against the list's own font — the one a marker inherits — to hold a three-digit marker (999. measures 2.35em in the app font stack) and keeps the gap the one-digit case already had.
Raise searchMaxResults (or make it unbounded) so more sources reach both the model and the card. Rejected by the user: it changes model-side behavior (more sources into every request's context, more tokens) and widens the gap between what the model reads and what the card draws.
Keep the head/tail collapse and add scroll only to the expanded region. Rejected: two overlapping mechanisms for one concern. Once the whole list is always rendered, the collapse arithmetic, the expand/collapse state, and the button are dead weight; scroll alone bounds the height.
Make the scroll height a plugin config field. Rejected: the height bounds the card's on-screen geometry, not a deployment policy, so it belongs in WebBlock.module.css alongside the radius, surface, and margin that the web result card frontend note already fixes there as this card's geometry.
Every source the tool returned is always in the DOM, so no source the view carries is hidden behind an interaction. The card's height is bounded regardless of source count, and a list taller than the container scrolls in place. The cost is that the scroll affordance depends on the platform's scrollbar rendering: an overlay-scrollbar system (macOS default) shows no persistent bar when the pointer is away, so a capped list relies on the 来源列表已截断 note plus a clipped last row to signal there is more. WebSearchBlockProps/WebFetchBlockProps lose their maxSources prop and the primitive loses DEFAULT_WEB_MAX_SOURCES, so any future caller renders the full list by construction rather than by passing a large cap.
packages/client/ui-primitives/tests/web-block.client.spec.tsx drops the collapse cases (head/tail slice, expand-on-click, collapsed-tail numbering, expander-out-of-numbering, head-alone, default cap) and adds: a 30-source card renders all 30 <li> with no [aria-expanded] and no <button>, every <ol> child is a source <li>, and <li value> numbers 1..N contiguously. packages/client/ui-tool/tests/web-card.client.spec.tsx drops the CHAT_WEB_MAX_SOURCES cap assertion; the WebRow expansion test still asserts the card shows every source field. The packages/web/tool-web tests are unchanged — the model side did not move.
jsdom resolves no CSS Modules layout, so it reports scrollHeight === clientHeight for every element and cannot witness the scroll at all. The geometry is pinned in the assembled browser instead, by apps/web/tests/web-search-round.e2e.ts: its deterministic search double returns 12 provider results, each with a title, a citation snippet, and a date. That first pins the seam's cap end to end in a real composition — the shipped searchMaxResults keeps 8, the model-visible render text carries the 8 kept titles and none of the 4 dropped URLs plus (Showing the first 8 sources. Refine the query for more.), and meta.truncated is true. A case after the aria golden then expands the web_search row and asserts on the card's <ol>: 8 <li>, no <button> anywhere in the card, the 来源列表已截断 indicator visible, and computed max-height: 320px with overflow-y: auto over scrollHeight 574 against clientHeight 320. A further case measures a 999. marker in the list's own inherited font and requires the computed padding-left to be at least that wide, so the marker room the scroll container cannot clip back is pinned against the widest marker rather than against one fixture's source count. Neither the recorded stream nor the aria golden moved: replay is a positional cursor over the fixture's assistant/chunk entries and the search double is a separate local endpoint the provider reaches by fetch, while the card is collapsed at capture time so its <ol> is out of the DOM and the summary row carries no source count.
- Web result card — the
card: 'web'render-intent arm andpresentationMetaroute this card consumes; the source of the capped-once list. - Web result card frontend — owns
WebBlock, the singleweb-card-modelderivation, and the render sites that draw the card; this note replaces the source-list collapse it specified, and its other decisions (one component for both kinds, the http(s) link allowlist, the single derivation, the resident posture) stand.