From a2ec6cefc2c5d9d194f6d4b94a7d986d2f79c10e Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 3 Aug 2026 15:38:26 +0800 Subject: [PATCH 01/11] fix(client-web): scroll the web_search source card instead of collapsing Replace the WebBlock search card's head/tail collapse and expand button with a fixed-height scroll container that lists every source the tool returned. The model-facing side is unchanged: the seam still caps sources at searchMaxResults and the truncated indicator stays, so model-visible and frontend-visible sources remain identical. Remove CHAT_WEB_MAX_SOURCES and DEFAULT_WEB_MAX_SOURCES: with scroll, the chat row and details panel show the same full list. --- ...6-08-03-web-search-source-scroll.i18n.yaml | 6 ++ .../2026-08-03-web-search-source-scroll.md | 35 +++++++++ .../2026-08-03-web-search-source-scroll.zh.md | 35 +++++++++ .../src/client/chat/ToolRow.tsx | 3 +- .../src/client/contract/web-card-model.ts | 10 --- .../src/client/skeleton/DetailsPanel.tsx | 13 ++-- .../ui-conversation/tests/web-card.spec.tsx | 5 +- .../ui-primitives/src/WebBlock.module.css | 34 +++------ .../client/ui-primitives/src/WebBlock.tsx | 67 +++-------------- packages/client/ui-primitives/src/index.ts | 2 +- .../ui-primitives/tests/web-block.spec.tsx | 73 +++++-------------- 11 files changed, 127 insertions(+), 156 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.md create mode 100644 .agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.zh.md diff --git a/.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.i18n.yaml b/.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.i18n.yaml new file mode 100644 index 0000000000..801d72dd39 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.md +2026-08-03-web-search-source-scroll.md: 9c1cb772fe768af833477f5f87c2804cbcaf068c +2026-08-03-web-search-source-scroll.zh.md: 0b3ef09c9f204ccbc034c509918053af4cf30821 diff --git a/.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.md b/.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.md new file mode 100644 index 0000000000..9c1cb772fe --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.md @@ -0,0 +1,35 @@ +# Agent Note: Web search source card scrolls instead of collapsing + +Status: implemented + +English | [中文](2026-08-03-web-search-source-scroll.zh.md) + +## Problem + +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 the model saw. 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. + +## Decision + +`WebBlock`'s search arm renders every source it receives in one `
    `, 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. What the model sees and what the card shows remain the same list — the card just shows all of it, scrollable, instead of collapsing the middle. + +`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. `
  1. ` still pins each source's 1-based citation index; without the collapse gap the ordinals are now simply contiguous. + +## Alternatives considered + +**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 breaks the invariant that model-visible and frontend-visible sources are identical. The instruction was explicit — keep the cap and the truncation, add a scrollbar. + +**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 per [web-card-model](2026-07-30-web-result-card.md)'s precedent for `CHAT_WEB_MAX_SOURCES` it belongs in CSS as a design constant. + +## Testing + +`packages/client/ui-primitives/tests/web-block.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 `
  2. ` with no `[aria-expanded]` and no ` -
  3. - )} - {tail.map((source, index) => ( - - ))} + {sources.map((source, index) => )}
)} {truncated &&
来源列表已截断
} diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index b19147a1cc..79bae60c36 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -32,7 +32,7 @@ export { SearchBlock, DEFAULT_SEARCH_MAX_LINES } from './SearchBlock.tsx' export type { SearchBlockProps, SearchMatchesBlockProps, SearchPathsBlockProps, SearchFileGroup, SearchBlockLineMatch, } from './SearchBlock.tsx' -export { WebBlock, DEFAULT_WEB_MAX_SOURCES } from './WebBlock.tsx' +export { WebBlock } from './WebBlock.tsx' export type { WebBlockProps, WebSearchBlockProps, WebFetchBlockProps, WebSourceView } from './WebBlock.tsx' export { CodeBlock } from './markdown/CodeBlock.tsx' export type { CodeBlockProps } from './markdown/CodeBlock.tsx' diff --git a/packages/client/ui-primitives/tests/web-block.spec.tsx b/packages/client/ui-primitives/tests/web-block.spec.tsx index d681ce753b..c26d0a6916 100644 --- a/packages/client/ui-primitives/tests/web-block.spec.tsx +++ b/packages/client/ui-primitives/tests/web-block.spec.tsx @@ -1,19 +1,19 @@ // @vitest-environment jsdom // WebBlock: both kinds of the web card. The search card's answer, its citation // list with the title-or-hostname label fallback and optional snippet/date, the -// source-list height cap and its expand control, and the truncated indicator; -// the fetch card's linked URL, status, and truncation. Safe-link attributes on -// both kinds: an http(s) URL becomes an external anchor (target/rel), any other -// URL renders as plain text with no href. +// full source list rendered inside a scroll container, and the truncated +// indicator; the fetch card's linked URL, status, and truncation. Safe-link +// attributes on both kinds: an http(s) URL becomes an external anchor +// (target/rel), any other URL renders as plain text with no href. import { afterEach, describe, expect, it } from 'vitest' -import { cleanup, fireEvent, render } from '@testing-library/react' -import { DEFAULT_WEB_MAX_SOURCES, WebBlock } from '../src/index.ts' +import { cleanup, render } from '@testing-library/react' +import { WebBlock } from '../src/index.ts' import type { WebSourceView } from '../src/index.ts' afterEach(cleanup) -/** `count` sources with sequential hostnames, so the cap slices read distinctly. */ +/** `count` sources with sequential hostnames, so each row reads distinctly. */ function sources(count: number): WebSourceView[] { return Array.from({ length: count }, (_value, index) => ({ url: `https://site-${index}.example.com/page`, @@ -123,58 +123,23 @@ describe('WebBlock search card', () => { expect(off.queryByText('来源列表已截断')).toBeNull() }) - it('renders every source and no expand control under the cap', () => { - const view = render() - expect(view.container.querySelectorAll('[class^="_source_"]')).toHaveLength(4) + it('renders every source with no expand control, in one scroll container', () => { + // The card shows the whole list the tool returned — the same sources the + // model saw — with no head/tail collapse and no expand button; a long list + // scrolls within the .sources container instead. + const view = render() + expect(view.container.querySelectorAll('li[class^="_source_"]')).toHaveLength(30) expect(view.container.querySelector('[aria-expanded]')).toBeNull() - }) - - it('slices head and tail over the cap and expands on click', () => { - const view = render() - // maxSources 4: head = ceil(4/2) = 2, tail = 4 - 2 = 2, 6 hidden. - expect([...view.container.querySelectorAll('[class^="_sourceLink_"]')].map(n => n.textContent)) - .toEqual(['Source 0', 'Source 1', 'Source 8', 'Source 9']) - const toggle = view.getByRole('button', { name: '展开其余 6 条来源' }) - expect(toggle.getAttribute('aria-expanded')).toBe('false') - expect(toggle.textContent).toBe('… 其余 6 条来源') - - fireEvent.click(toggle) - expect(view.container.querySelectorAll('[class^="_source_"]')).toHaveLength(10) - const collapse = view.getByRole('button', { name: '收起来源' }) - expect(collapse.getAttribute('aria-expanded')).toBe('true') - expect(collapse.textContent).toBe('收起') - - fireEvent.click(collapse) - expect(view.container.querySelectorAll('[class^="_source_"]')).toHaveLength(4) - }) - - it('numbers a collapsed tail by each source original position, not its visible slot', () => { - // maxSources 4 over 10 sources: the tail is sources 8 and 9, which must read - // as citations 9 and 10 (via
  • ), not renumbered 3 and 4. - const view = render() - const items = [...view.container.querySelectorAll('li[class^="_source_"]')] - expect(items.map(li => li.getAttribute('value'))).toEqual(['1', '2', '9', '10']) - }) - - it('keeps the expander out of the ordered-list numbering', () => { - // The expander is a marker-less
  • , so it is valid inside
      and does not - // consume a citation number between the head and tail sources. - const view = render() + expect(view.container.querySelector('button')).toBeNull() + // Every direct child of the
        is a source
      1. (no marker-less expander). const ol = view.container.querySelector('ol')! - // Every direct child is an
      2. (no bare