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. `` 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 ` ` with no `[aria-expanded]` and no ``, every `` child is a source ``, and ` ` numbers 1..N contiguously. `packages/client/ui-conversation/tests/web-card.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.
+
+## Related
+
+- [Web result card](2026-07-30-web-result-card.md) — the `card: 'web'` render-intent arm and `presentationMeta` route this card consumes; the source of the capped-once list.
diff --git a/.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.zh.md b/.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.zh.md
new file mode 100644
index 0000000000..0b3ef09c9f
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.zh.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.md) | 中文
+
+## Problem
+
+`web_search` 结果卡片(`WebBlock`,`packages/client/ui-primitives/src/WebBlock.tsx`)此前用首尾折叠渲染它的来源列表:超过 `maxSources` 数量(详情面板为 16,聊天行经由 `CHAT_WEB_MAX_SOURCES` 为 8)时,它画出前 `ceil(max/2)` 条来源、一个 `… 其余 N 条来源` 展开按钮,再画出末尾 `max - ceil(max/2)` 条,与 `TerminalBlock` 的输出上限一致。用户阅读该卡片时看到 `来源列表已截断`,会以为前端丢弃了它正持有的来源。
+
+其实并没有。seam(`capSources`,`packages/web/web/src/index.ts`)把 provider 的来源裁剪到工具的 `searchMaxResults` 上限(默认 8)并置位 `truncated`,而这一份被裁剪过一次的列表同时喂给面向模型的 render 文本与卡片的 `presentationMeta`。卡片持有的来源绝不会多于模型所见。因此这个折叠隐藏的正是用户本有权完整查看的来源——并且在默认上限为 8、面板上限为 16 时,它几乎从不触发,只留下 `truncated` 提示,却无从展开任何内容。
+
+## Decision
+
+`WebBlock` 的 search 分支把它收到的每一条来源都渲染进单个 ``,不做首尾切片、不设展开按钮、也不带 `maxSources` prop。`.sources`(`WebBlock.module.css`)获得一个固定的 `max-height` 与 `overflow-y: auto`,因此长于卡片高度的列表在原地滚动,而非撑大卡片或隐藏行。该高度是卡片几何形状的一个设计常量,因此放在 CSS 里,而非插件配置字段。
+
+模型侧不变:seam 仍在 `searchMaxResults` 处封顶来源,面向模型的 render 文本未动,`truncated` 标志及其 `来源列表已截断` 指示保留。模型所见与卡片所示仍是同一份列表——只不过卡片把它全部展示、可滚动,而非折叠中段。
+
+`CHAT_WEB_MAX_SOURCES` 与该 primitive 的 `DEFAULT_WEB_MAX_SOURCES` 被移除:有了滚动,聊天行与详情面板展示同一份完整列表,仅以各自的容器高度区分。`` 仍钉住每条来源从 1 起算的引用序号;没有了折叠造成的间断,这些序号如今就是连续的。
+
+## Alternatives considered
+
+**提高 `searchMaxResults`(或让它无上限),使更多来源同时抵达模型与卡片。** 被用户否决:它改变了模型侧行为(每个请求的上下文纳入更多来源、更多 token),并打破了模型可见来源与前端可见来源相同这一不变量。指令很明确——保留上限与截断,加一个滚动条。
+
+**保留首尾折叠,仅对展开区域加滚动。** 否决:一个关注点上两套重叠机制。一旦整份列表始终渲染,折叠的算术、展开/折叠状态与那个按钮都是死重;仅靠滚动即可约束高度。
+
+**把滚动高度做成插件配置字段。** 否决:该高度约束的是卡片在屏幕上的几何形状,而非部署策略,因此依据 [web-card-model](2026-07-30-web-result-card.md) 对 `CHAT_WEB_MAX_SOURCES` 的先例,它作为设计常量属于 CSS。
+
+## Testing
+
+`packages/client/ui-primitives/tests/web-block.spec.tsx` 删去折叠相关用例(首尾切片、点击展开、折叠尾部编号、展开器不计入编号、仅首部、默认上限),并新增:一个 30 条来源的卡片渲染出全部 30 个 ` `,无 `[aria-expanded]`、无 ``,每个 `` 子元素都是一条来源 ``,且 ` ` 从 1 到 N 连续编号。`packages/client/ui-conversation/tests/web-card.spec.tsx` 删去 `CHAT_WEB_MAX_SOURCES` 上限断言;WebRow 展开测试仍断言卡片展示每一个来源字段。`packages/web/tool-web` 的测试不变——模型侧未曾移动。
+
+## Related
+
+- [Web result card](2026-07-30-web-result-card.md) —— 本卡片消费的 `card: 'web'` 渲染意图分支与 `presentationMeta` 路由;那份裁剪过一次的列表的来源。
diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx
index fa4be6179a..c299f43bda 100644
--- a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx
+++ b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx
@@ -30,7 +30,6 @@ import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
import { CHAT_DIFF_MAX_LINES, type DiffCardModel } from '../contract/diff-card-model.ts'
import { CHAT_READ_MAX_LINES, type ReadCardModel } from '../contract/read-card-model.ts'
import { CHAT_SEARCH_MAX_LINES, type SearchCardModel } from '../contract/search-card-model.ts'
-import { CHAT_WEB_MAX_SOURCES } from '../contract/web-card-model.ts'
import { terminalBlockLabels, type TerminalCardModel } from '../contract/terminal-card-model.ts'
import type { ToolRowState, ToolRowVariant } from '../contract/tool-call-model.ts'
import { DisclosureRow } from './DisclosureRow.tsx'
@@ -276,7 +275,7 @@ export function ToolRow({
>
)
: webBody !== null
- ?
+ ?
: isThink
? {body}
: (
diff --git a/packages/client/ui-conversation/src/client/contract/web-card-model.ts b/packages/client/ui-conversation/src/client/contract/web-card-model.ts
index f2b15e023a..270b387e62 100644
--- a/packages/client/ui-conversation/src/client/contract/web-card-model.ts
+++ b/packages/client/ui-conversation/src/client/contract/web-card-model.ts
@@ -15,16 +15,6 @@
import type { WebBlockProps } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ToolCallBlock } from './tool-call-model.ts'
-/**
- * Sources the chat row's web body shows before collapsing the middle — half
- * the primitive's own default, which the details panel keeps. A chat row is a
- * summary surface inside the message flow: the flow must stay scannable across
- * many calls, while the details panel is the single-call reading surface. A
- * design constant of this UI's row geometry, not a deployment choice, so it is
- * fixed here rather than a plugin Config field.
- */
-export const CHAT_WEB_MAX_SOURCES = 8
-
/**
* Derive the web-card props for a tool call, or null when this call is not a
* web card and belongs on the generic path.
diff --git a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx
index 01c55c5fae..1114236143 100644
--- a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx
+++ b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx
@@ -180,13 +180,12 @@ function OutputBody({ material, cwd, t }: { material: CallMaterial; cwd: string
)
}
const web = webCardModel(material.block)
- // Full source-list allowance here (the panel is the single-call reading
- // surface); the chat rows cap it at CHAT_WEB_MAX_SOURCES. Below the card the
- // panel also renders the flattened result content — the model-visible text
- // the card does not carry verbatim (a web_fetch card shows only the URL and
- // status, so its fetched body lives only here; a search card's answer and
- // sources are structured, so the flattened form repeats them as the raw text
- // the model saw).
+ // The card shows every source the tool returned (the same list the model saw),
+ // scrolling within its own capped height. Below the card the panel also renders
+ // the flattened result content — the model-visible text the card does not carry
+ // verbatim (a web_fetch card shows only the URL and status, so its fetched body
+ // lives only here; a search card's answer and sources are structured, so the
+ // flattened form repeats them as the raw text the model saw).
if (web !== null) {
const settled = 'kind' in material.block ? material.block : null
const body = settled === null ? '' : resultText(settled)
diff --git a/packages/client/ui-conversation/tests/web-card.spec.tsx b/packages/client/ui-conversation/tests/web-card.spec.tsx
index a60c16f77c..c58600113b 100644
--- a/packages/client/ui-conversation/tests/web-card.spec.tsx
+++ b/packages/client/ui-conversation/tests/web-card.spec.tsx
@@ -17,7 +17,7 @@ import type {
import type { ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import type { SelectionTarget, ToolRowOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
-import { CHAT_WEB_MAX_SOURCES, webCardModel } from '../src/client/contract/web-card-model.ts'
+import { webCardModel } from '../src/client/contract/web-card-model.ts'
import { createChatStore } from '../src/client/stores.ts'
import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx'
import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
@@ -135,8 +135,7 @@ describe('chat row web body', () => {
fireEvent.click(view.container.querySelector('[data-expandable]')!)
}
- it('the WebRow collapses to the summary row, expanding to the search card capped tighter than the panel', () => {
- expect(CHAT_WEB_MAX_SOURCES).toBeLessThan(16)
+ it('the WebRow collapses to the summary row, expanding to the full search card', () => {
const view = render( )
// Collapsed: the summary row alone, no card in the DOM.
expect(view.getByText('Search')).toBeTruthy()
diff --git a/packages/client/ui-primitives/src/WebBlock.module.css b/packages/client/ui-primitives/src/WebBlock.module.css
index 8aad5872ba..6533c3ccb6 100644
--- a/packages/client/ui-primitives/src/WebBlock.module.css
+++ b/packages/client/ui-primitives/src/WebBlock.module.css
@@ -1,7 +1,8 @@
/* Geometry mirrors CodeBlock/TerminalBlock (12px radius, code-block surface,
16px vertical margin) so a web card, a terminal card, and a fenced code block
- read as one family. A source list is prose, not aligned output, so it wraps
- normally rather than scrolling horizontally like a terminal card's output. */
+ read as one family. A source list is prose, not aligned output, so each row
+ wraps horizontally rather than scrolling sideways like a terminal card; the
+ list as a whole scrolls vertically within a capped height (see .sources). */
.block {
--dsl-web-radius: 12px;
@@ -27,13 +28,20 @@
margin-bottom: 0;
}
-/* The citation list: ordered so each source reads as a numbered reference. */
+/* The citation list: ordered so each source reads as a numbered reference. The
+ whole list — the sources the tool returned, matching what the model saw —
+ renders here; a max-height caps the card so a long list scrolls in place
+ rather than growing the card unbounded. The height is a design constant of the
+ card's geometry, not a deployment choice, so it lives here rather than a plugin
+ config field. */
.sources {
margin: 0;
padding-left: 20px;
display: flex;
flex-direction: column;
gap: 10px;
+ max-height: 320px;
+ overflow-y: auto;
}
.source {
@@ -65,26 +73,6 @@
font: var(--dsw-font-xs-13);
}
-.expandItem {
- list-style: none;
-}
-
-.expand {
- display: block;
- width: 100%;
- padding: 0;
- border: none;
- background-color: transparent;
- color: var(--dsw-alias-label-tertiary);
- cursor: pointer;
- font: inherit;
- text-align: left;
-}
-
-.expand:hover {
- color: var(--dsw-alias-label-secondary);
-}
-
.truncated {
margin-top: 8px;
color: var(--dsw-alias-label-tertiary);
diff --git a/packages/client/ui-primitives/src/WebBlock.tsx b/packages/client/ui-primitives/src/WebBlock.tsx
index 804ff17dfa..a5a8645b3d 100644
--- a/packages/client/ui-primitives/src/WebBlock.tsx
+++ b/packages/client/ui-primitives/src/WebBlock.tsx
@@ -9,22 +9,15 @@
// allowlist MarkdownText applies to untrusted assistant-authored links (it also
// permits mailto, excluded here); an unparseable or non-http URL renders as
// plain text. Geometry, radius, and fonts mirror CodeBlock/TerminalBlock so a
-// web card reads as one family with them; a long source list caps at maxSources
-// with a head/tail collapse using the same arithmetic as TerminalBlock's output
-// cap.
+// web card reads as one family with them; the whole source list renders inside a
+// fixed-height scroll container (its `.sources` max-height), so a long list
+// scrolls in place rather than growing the card. The list matches what the model
+// saw: the tool already cut it to the source cap, and `truncated` reports that.
-import { useCallback, useState } from 'react'
import clsx from 'clsx'
import { MarkdownText } from './markdown/MarkdownText.tsx'
import css from './WebBlock.module.css'
-/**
- * Sources shown before the height cap collapses the middle of a citation list.
- * Matches TerminalBlock's default output budget so both cards cut a long body
- * at the same place; the chat row narrows it through the maxSources prop.
- */
-export const DEFAULT_WEB_MAX_SOURCES = 16
-
/**
* One citeable source drawn in a search card: the projection of the contract's
* `WebSource`, with the optional fields kept optional so a provider that
@@ -50,8 +43,6 @@ export interface WebSearchBlockProps {
sources: WebSourceView[]
/** True when the tool cut the source list to its result cap. */
truncated: boolean
- /** Sources shown before the middle collapses (default {@link DEFAULT_WEB_MAX_SOURCES}). */
- maxSources?: number | undefined
/** Extra class merged onto the wrapper (callers position; this component draws). */
className?: string | undefined
}
@@ -65,13 +56,6 @@ export interface WebFetchBlockProps {
statusCode: number
/** True when the provider or the output cap cut the fetched content. */
truncated: boolean
- /**
- * Accepted and ignored, so both card kinds take one uniform prop set (a fetch
- * card has no source list to cap) — the same way TerminalBlock accepts one
- * `maxLines` across its arms. Lets a render site spread `maxSources` onto
- * either kind without a per-kind conditional.
- */
- maxSources?: number | undefined
/** Extra class merged onto the wrapper (callers position; this component draws). */
className?: string | undefined
}
@@ -137,9 +121,9 @@ function SafeLink({ url, label, className }: { url: string; label: string; class
/**
* One source row in a search card: the safe link plus its snippet and date. The
- * ` ` pins the source's original 1-based position, so a collapsed list
- * whose tail is drawn after the head still numbers each source by its real
- * citation index rather than by its position in the visible subset.
+ * ` ` pins the source's 1-based citation index explicitly rather than
+ * relying on the ``'s implicit numbering, so a row reads by its real index
+ * even inside the scroll container.
* @param props.source - the source to render.
* @param props.ordinal - the source's 1-based position in the full list.
* @returns the source list item.
@@ -159,21 +143,12 @@ function SourceItem({ source, ordinal }: { source: WebSourceView; ordinal: numbe
}
/**
- * The search card body: the answer over the capped source list.
+ * The search card body: the answer over the full source list, which scrolls in
+ * place once it exceeds the `.sources` container height.
* @param props - see {@link WebSearchBlockProps}.
* @returns the search card element.
*/
-function WebSearchBlock({ answer, sources, truncated, maxSources = DEFAULT_WEB_MAX_SOURCES, className }: WebSearchBlockProps) {
- const [expanded, setExpanded] = useState(false)
- const onToggle = useCallback(() => { setExpanded(value => !value) }, [])
- const hidden = sources.length - maxSources
- const capped = hidden > 0 && !expanded
- // Same split arithmetic as TerminalBlock's output cap, so a long body's head
- // and tail slices agree between the two cards.
- const headCount = Math.ceil(maxSources / 2)
- const tailCount = maxSources - headCount
- const head = capped ? sources.slice(0, headCount) : sources
- const tail = capped ? sources.slice(sources.length - tailCount) : []
+function WebSearchBlock({ answer, sources, truncated, className }: WebSearchBlockProps) {
// A provider may legitimately return no answer and no sources; the chat WebRow
// does not show the raw result content, so without this the user would see an
// empty card. Mirror the backend's `No results found.` render text.
@@ -187,27 +162,7 @@ function WebSearchBlock({ answer, sources, truncated, maxSources = DEFAULT_WEB_M
未找到结果
) : (
- {head.map((source, index) => )}
- {hidden > 0 && (
-
-
- {expanded ? '收起' : `… 其余 ${hidden} 条来源`}
-
-
- )}
- {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 (no marker-less expander).
const ol = view.container.querySelector('ol')!
- // Every direct child is an (no bare child — invalid HTML).
expect([...ol.children].every(child => child.tagName === 'LI')).toBe(true)
})
- it('renders the head slice alone when the cap leaves no tail', () => {
- const view = render( )
- expect([...view.container.querySelectorAll('[class^="_sourceLink_"]')].map(n => n.textContent)).toEqual(['Source 0'])
- expect(view.getByRole('button', { name: '展开其余 4 条来源' })).toBeTruthy()
- })
-
- it('caps at the documented default when maxSources is absent', () => {
- const view = render( )
- expect(view.container.querySelectorAll('[class^="_source_"]')).toHaveLength(DEFAULT_WEB_MAX_SOURCES)
- expect(view.getByRole('button', { name: '展开其余 1 条来源' })).toBeTruthy()
+ it('numbers every source by its 1-based citation index via ', () => {
+ const view = render( )
+ const items = [...view.container.querySelectorAll('li[class^="_source_"]')]
+ expect(items.map(li => li.getAttribute('value'))).toEqual(['1', '2', '3', '4'])
})
})