mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
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.
This commit is contained in:
@@ -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
|
||||
@@ -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 `<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. 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. `<li value={ordinal}>` 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 `<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-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.
|
||||
@@ -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 分支把它收到的每一条来源都渲染进单个 `<ol className={css.sources}>`,不做首尾切片、不设展开按钮、也不带 `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` 被移除:有了滚动,聊天行与详情面板展示同一份完整列表,仅以各自的容器高度区分。`<li value={ordinal}>` 仍钉住每条来源从 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 个 `<li>`,无 `[aria-expanded]`、无 `<button>`,每个 `<ol>` 子元素都是一条来源 `<li>`,且 `<li value>` 从 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` 路由;那份裁剪过一次的列表的来源。
|
||||
@@ -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
|
||||
? <WebBlock {...webBody} maxSources={CHAT_WEB_MAX_SOURCES} className={css.webBody} />
|
||||
? <WebBlock {...webBody} className={css.webBody} />
|
||||
: isThink
|
||||
? <div className={css.thinkBody}>{body}</div>
|
||||
: (
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(<WebRow {...rowProps(settledSearch(), 'web_search')} />)
|
||||
// Collapsed: the summary row alone, no card in the DOM.
|
||||
expect(view.getByText('Search')).toBeTruthy()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
* `<li value>` 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.
|
||||
* `<li value>` pins the source's 1-based citation index explicitly rather than
|
||||
* relying on the `<ol>`'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
|
||||
<div className={css.empty}>未找到结果</div>
|
||||
) : (
|
||||
<ol className={css.sources}>
|
||||
{head.map((source, index) => <SourceItem key={index} source={source} ordinal={index + 1} />)}
|
||||
{hidden > 0 && (
|
||||
<li className={css.expandItem}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.expand}
|
||||
aria-expanded={expanded}
|
||||
aria-label={expanded ? '收起来源' : `展开其余 ${hidden} 条来源`}
|
||||
onClick={onToggle}
|
||||
>
|
||||
{expanded ? '收起' : `… 其余 ${hidden} 条来源`}
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
{tail.map((source, index) => (
|
||||
<SourceItem
|
||||
key={sources.length - tailCount + index}
|
||||
source={source}
|
||||
ordinal={sources.length - tailCount + index + 1}
|
||||
/>
|
||||
))}
|
||||
{sources.map((source, index) => <SourceItem key={index} source={source} ordinal={index + 1} />)}
|
||||
</ol>
|
||||
)}
|
||||
{truncated && <div className={css.truncated}>来源列表已截断</div>}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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(<WebBlock kind="search" sources={sources(4)} truncated={false} maxSources={4} />)
|
||||
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(<WebBlock kind="search" sources={sources(30)} truncated={false} />)
|
||||
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(<WebBlock kind="search" sources={sources(10)} truncated={false} maxSources={4} />)
|
||||
// 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 <li value>), not renumbered 3 and 4.
|
||||
const view = render(<WebBlock kind="search" sources={sources(10)} truncated={false} maxSources={4} />)
|
||||
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 <li>, so it is valid inside <ol> and does not
|
||||
// consume a citation number between the head and tail sources.
|
||||
const view = render(<WebBlock kind="search" sources={sources(10)} truncated={false} maxSources={4} />)
|
||||
expect(view.container.querySelector('button')).toBeNull()
|
||||
// Every direct child of the <ol> is a source <li> (no marker-less expander).
|
||||
const ol = view.container.querySelector('ol')!
|
||||
// Every direct child is an <li> (no bare <button> 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(<WebBlock kind="search" sources={sources(5)} truncated={false} maxSources={1} />)
|
||||
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(<WebBlock kind="search" sources={sources(DEFAULT_WEB_MAX_SOURCES + 1)} truncated={false} />)
|
||||
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 <li value>', () => {
|
||||
const view = render(<WebBlock kind="search" sources={sources(4)} truncated={false} />)
|
||||
const items = [...view.container.querySelectorAll('li[class^="_source_"]')]
|
||||
expect(items.map(li => li.getAttribute('value'))).toEqual(['1', '2', '3', '4'])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user