diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.i18n.yaml new file mode 100644 index 0000000000..dae8f18b4d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.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-07-30-web-result-card-frontend.md +2026-07-30-web-result-card-frontend.md: d6f4785e83335ca2dd5295516baf47c845ebf5bd +2026-07-30-web-result-card-frontend.zh.md: ed95cbe39f4f0bf77ba5da64d664705a0841863f diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md new file mode 100644 index 0000000000..d6f4785e83 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md @@ -0,0 +1,49 @@ +# Agent Note: Web result card frontend — rendering the web render intent in the browser + +Status: implemented + +English | [中文](2026-07-30-web-result-card-frontend.zh.md) + +## Problem + +The `web_search` and `web_fetch` tools declare a `card: 'web'` result view ([web result card](2026-07-30-web-result-card.md)): a `kind`-tagged union carrying either the structured cited sources plus an optional provider answer (`kind: 'search'`) or the fetched URL and its HTTP status (`kind: 'fetch'`). That view already reaches the browser — host, connection, and runtime deliver it onto `ConversationSnapshot` as `resultView` — but the Web client ignored it: a completed web call rendered only as its flattened model-facing text, the same lossy render the contract note explains the structured view exists to replace. A `web_search` reached the reader as one free-text markdown line per source rather than a citation list of clickable sources, and a `web_fetch` as its markdown body with no retrieval summary. + +## Decision + +`WebBlock` is a `ui-primitives` component that renders a completed web retrieval, and every Web render site for a web call consumes the `web` render intent through it: the keyed chat tool rows (`web_search`/`web_fetch`), the `GenericToolCard` render-site fallback, and the details panel's Output section. `ui-conversation/src/client/contract/web-card-model.ts` is the single place that turns the snapshot's `resultView` into the component's props, mirroring `terminal-card-model.ts`, so no two sites disagree about what a web call shows. It returns null — the generic path — for a running call (the web card is result-only, since the tools keep a generic pending view), for a settled call whose result view is not a web card including a `card` value this client version does not know (which arrives over the wire and so cannot be trusted to be a compiled variant), for a generic result view (a web tool's error path returns the generic card, whose text the generic path preserves), and for a web card whose `kind` this client version does not know (a newer host's value off the wire, which reading as a fetch would draw as an empty URL and `HTTP undefined`). + +One component draws both kinds, discriminated by `kind`. A `search` shows the answer as markdown above a citation list; each source is a safe external link labelled by its title, or its hostname when the provider gave none, with the snippet and publication date below it, and a `来源列表已截断` indicator when the tool capped the list. A `fetch` shows a compact summary: the linked final URL, its HTTP status, and a `内容已截断` indicator. One component rather than two because both are web retrieval rendered as one card family, which is exactly the reason the contract carries them under one `card` tag with a `kind` discriminant. + +**Links are safe by the http(s) subset of the allowlist MarkdownText applies to untrusted assistant-authored links** — MarkdownText also permits `mailto:`, deliberately excluded here since a retrieval URL is never a mail address. A source or fetch URL becomes a navigable anchor only when its protocol is `http:` or `https:`, with `target="_blank"` and `rel="noopener noreferrer"`; a `javascript:`/`data:`/`file:`/`mailto:` URL or an unparseable string renders as plain text with no href. The result content a web tool returns is model-authored and reaches this component unverified, so it is treated as untrusted exactly as assistant markdown is. The label falls back from title to hostname to the raw URL, so a source always reads as something even when both the title is absent and the URL does not parse. + +**Geometry mirrors CodeBlock/TerminalBlock** (12px radius, code-block surface, 16px vertical margin) so a web card reads as one family with them. A long source list caps at `maxSources` with a head/tail collapse using TerminalBlock's exact split arithmetic (`ceil(max/2)` head lines plus the remaining tail), so a long body's slices agree between the two cards. A source list is prose rather than column-aligned output, so it wraps normally instead of scrolling horizontally the way a terminal card's output does — that is the one deliberate divergence from TerminalBlock. + +The card is **resident** under the summary row in the chat rows, capped at `CHAT_WEB_MAX_SOURCES` (8) — half the primitive's own default of 16, which the details panel keeps — the same summary-surface-versus-reading-surface split `CHAT_TERMINAL_MAX_LINES` draws for the terminal card, and the same resident posture `BashRow` uses. The keyed rows register one `WebRow` component under both `web_search` and `web_fetch`; the row discriminates on the tool name only to pick its icon (search vs. browse) and its title (`Search`/`Fetch`). A web-declaring tool without its own keyed row lands on `GenericToolCard`, which grows the same resident card. The details panel renders the card at the primitive's full source allowance and, below it, the flattened model-visible result content: a `web_fetch` card carries only the URL and status, so its fetched body is readable only here. + +## Consequences + +`WebBlock` reads only the web view's fields, so it stays a pure function of what the render intent carries — no session lookups, replay-safe like the presenters that produce the view, and unlike the terminal card it needs no cwd resolution because a web view carries no path. A UI without the `web` capability (the TUI) still gets the contract's fallback `content`; nothing about the tools' result shape changed. `MarkdownText` is reused for the answer, so the answer's own untrusted-link handling and GFM rendering come for free. + +A separate later PR unifies the whole-row collapse/expand interaction and will flip every resident card (terminal, diff, web) to expand-gated at once; this card follows the current resident convention rather than pre-empting that change. + +## Alternatives considered + +**Two components, one per kind.** Rejected: the two shapes share their card chrome, their safe-link handling, and their truncation indicator, and the contract already expresses their difference as a `kind` discriminant under one `card` tag; two components would duplicate the shared surface and split the safe-link logic. + +**Reparse the model-facing render text instead of consuming the structured view.** Rejected for the same reason the contract note gives: `web_search`'s render collapses each source's fields into one free-text line labelled by title OR hostname, so reparsing cannot recover `{url, title?, snippet?, publishedAt?}`. The structured `resultView` is the only faithful source, which is why the backend PR added it. + +**Render plain anchors without the protocol allowlist.** Rejected: the URL is model-authored and unverified at this seam, so an unfiltered href would let a `javascript:` URL execute on click. The allowlist is the http(s) subset of MarkdownText's (which also permits `mailto:`), so untrusted retrieval links behave identically wherever they render. + +## Testing + +`packages/client/ui-primitives/tests/web-block.spec.tsx` pins the component per-file to the 100% gate: both kinds; the title-or-hostname-or-raw-URL label fallback; the safe-link attributes on both kinds (an http(s) URL becoming an external anchor with `target`/`rel`, a `javascript:`/`file:`/unparseable URL rendering as a plain span with no href); the snippet and date shown or omitted on present/empty/absent; the truncation indicator gated on the flag; and the source-list height cap with its head/tail slice and expand/collapse control including the default cap. + +`packages/client/ui-conversation/tests/web-card.spec.tsx` mirrors `terminal-card.spec.tsx` at every wiring seam: `webCardModel`'s derivation projecting every source field, its truncation and absent-answer arms, the fetch derivation, and each null arm (running, null result view, generic result view, unknown card tag, unknown web `kind`); the keyed `WebRow`'s resident card for both kinds capped tighter than the panel, its summary-row-alone running and failed arms; the `GenericToolCard` fallback growing the resident card for a web-declaring tool and keeping the plain row for a non-web call; the details panel's Output section for both kinds — including a `web_fetch`'s body flattened below its URL/status card — and its flattened fallback for a non-web result; and the keyed registration under both `web_search` and `web_fetch` with one component. That file sits on the coverage `exclude` list (`ui-conversation/src/*`), so a coverage run measures none of it. + +The fixture (`packages/client/connection/src/client/fixture.ts`) adds turns 66 (`web_search`) and 67 (`web_fetch`), authored inline because the client-side fixture cannot import the web tool: turn 66's result view carries an answer and three sources exercising the citation list (a titled source with a snippet and date, a source with no title so its hostname labels the link, and a source with a date but no snippet) with the capped indicator on; turn 67's carries the fetched URL and a 200 status. Both keep a generic pending call view and add the `web` card only at result time, matching the contract's result-only web shape, and are named after the real tools so they hit the keyed `WebRow`. They are ordered before the todo turn (renumbered to 68) for the same reason the terminal turn is: the standing plan retires at the next `turn/start`, so a turn appended after it would empty the dock's plan strip. This drives the built-boot snapshot and a live `?fixture` server. + +## Related + +- [Web result card](2026-07-30-web-result-card.md) — the backend PR that added the `card: 'web'` result arm and made the two tools emit it; this is its deferred frontend consumer. +- [Web terminal card](2026-07-28-web-terminal-card.md) — the precedent this mirrors: a `ui-primitives` block, a single card-model derivation, keyed and fallback chat rows, and a details-panel arm, for the `terminal` render intent. +- [Tagged render-intent union for tool-call presentation](../architecture/2026-07-02-tool-render-intent-union.md) — the `card`-tagged vocabulary; the Web client is now a full consumer of the `web` arm. diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md new file mode 100644 index 0000000000..ed95cbe39f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md @@ -0,0 +1,49 @@ +# Agent Note: Web result 卡片前端 —— 在浏览器渲染 web 渲染意图 + +Status: implemented + +[English](2026-07-30-web-result-card-frontend.md) | 中文 + +## Problem + +`web_search` 和 `web_fetch` 工具声明了 `card: 'web'` result view([web result card](2026-07-30-web-result-card.md)):一个 `kind` 标签联合,携带结构化的被引用 sources 加可选的 provider answer(`kind: 'search'`),或抓取的 URL 及其 HTTP 状态(`kind: 'fetch'`)。该视图早已抵达浏览器 —— host、connection、runtime 将它作为 `resultView` 投递到 `ConversationSnapshot` —— 但 Web 客户端忽略了它:一次已完成的 web 调用只渲染为摊平的模型可见文本,正是契约笔记所解释的、结构化视图要替代的那种有损渲染。`web_search` 到达读者时是每个 source 一行自由文本 markdown,而非可点击 source 的引用列表;`web_fetch` 是它的 markdown 正文,没有检索摘要。 + +## Decision + +`WebBlock` 是一个 `ui-primitives` 组件,渲染一次已完成的 web 检索,web 调用的每个 Web 渲染点都通过它消费 `web` 渲染意图:键控的 chat 工具行(`web_search`/`web_fetch`)、`GenericToolCard` 渲染点兜底,以及详情面板的 Output 区。`ui-conversation/src/client/contract/web-card-model.ts` 是唯一把快照的 `resultView` 转成组件 props 的地方,镜像 `terminal-card-model.ts`,因此没有两个渲染点会对一次 web 调用的显示产生分歧。它返回 null —— 走通用路径 —— 对运行中的调用(web 卡片是 result-only 的,因为工具保留 generic pending 视图)、对 result view 不是 web 卡片的已结算调用(包括本客户端版本不认识的 `card` 值,它经 wire 抵达因而不能被信任为已编译的变体)、对 generic result view(web 工具的错误路径返回 generic 卡片,其文本由通用路径保留)、以及对本客户端版本不认识 `kind` 的 web 卡片(更新的 host 经 wire 发来的值,读作 fetch 会画出空 URL 和 `HTTP undefined`)。 + +一个组件绘制两种 kind,由 `kind` 判别。`search` 把 answer 作为 markdown 显示在引用列表上方;每个 source 是一个安全外链,以其标题为标签,provider 未给标题时以其主机名为标签,下方是 snippet 与发布日期,工具截断列表时显示 `来源列表已截断` 提示。`fetch` 显示一个紧凑摘要:带链接的最终 URL、其 HTTP 状态、以及 `内容已截断` 提示。用一个组件而非两个,因为两者都是渲染为同一卡片族的 web 检索 —— 这正是契约把它们放在一个 `card` 标签下、用 `kind` 判别的原因。 + +**链接的安全性沿用 MarkdownText 对不受信任的 assistant 链接所用 allowlist 的 http(s) 子集。** MarkdownText 还允许 `mailto:`,此处刻意排除,因为检索 URL 绝不会是邮件地址。一个 source 或 fetch URL 仅当其协议为 `http:` 或 `https:` 时才成为可导航锚点,带 `target="_blank"` 和 `rel="noopener noreferrer"`;`javascript:`/`data:`/`file:`/`mailto:` URL 或无法解析的字符串渲染为纯文本、无 href。web 工具返回的 result content 是模型创作的,未经验证抵达本组件,因此像 assistant markdown 一样被当作不受信任处理。标签从标题回退到主机名再回退到原始 URL,因此即便标题缺失且 URL 无法解析,source 也总能读作某个东西。 + +**几何镜像 CodeBlock/TerminalBlock**(12px 圆角、code-block 表面、16px 垂直外边距),使 web 卡片与它们读作一家。长 source 列表在 `maxSources` 处折叠,用 TerminalBlock 完全相同的分割算术做头/尾折叠(`ceil(max/2)` 头部行加剩余尾部),使长正文的切片在两张卡之间一致。source 列表是散文而非按列对齐的输出,所以它正常换行,而不像终端卡片的输出那样横向滚动 —— 这是与 TerminalBlock 唯一刻意的分歧。 + +卡片在 chat 行中**常驻**于摘要行之下,上限 `CHAT_WEB_MAX_SOURCES`(8)—— 原语自身默认 16 的一半,面板保留 16 —— 与 `CHAT_TERMINAL_MAX_LINES` 为终端卡片所画的摘要面对阅读面的同一划分,以及 `BashRow` 所用的同一常驻姿态。键控行把一个 `WebRow` 组件注册在 `web_search` 与 `web_fetch` 两个键下;行仅根据工具名判别以选取其图标(search 对 browse)与标题(`Search`/`Fetch`)。没有自己键控行的 web 声明工具落到 `GenericToolCard`,它长出同一张常驻卡片。详情面板以原语的完整 source 额度渲染卡片,并在其下方渲染摊平的模型可见结果内容:`web_fetch` 卡片只携带 URL 与状态,因此其抓取正文只在此处可读。 + +## Consequences + +`WebBlock` 只读 web view 的字段,因此它是渲染意图所携带内容的纯函数 —— 无会话查找,与产出该视图的 presenter 一样回放安全,且不同于终端卡片它不需要 cwd 解析,因为 web view 不携带路径。没有 `web` 能力的 UI(TUI)仍得到契约的回退 `content`;工具的 result 形状没有任何改变。answer 复用 `MarkdownText`,因此 answer 自身的不受信任链接处理与 GFM 渲染免费获得。 + +一条独立的后续 PR 会统一整行折叠/展开交互,并把每张常驻卡片(terminal、diff、web)一次性翻成 expand-gated;本卡片遵循当前的常驻约定,而非抢先做那次改动。 + +## Alternatives considered + +**两个组件,每种 kind 一个。** 拒绝:两种形状共享卡片外框、安全链接处理、截断提示,而契约已经把它们的差异表达为一个 `card` 标签下的 `kind` 判别;两个组件会重复共享表面并拆分安全链接逻辑。 + +**重解析模型可见的渲染文本,而非消费结构化视图。** 因契约笔记给出的同一理由拒绝:`web_search` 的渲染把每个 source 的字段压缩成一行自由文本、以标题或主机名为标签,所以重解析无法恢复 `{url, title?, snippet?, publishedAt?}`。结构化的 `resultView` 是唯一忠实来源,这正是后端 PR 添加它的原因。 + +**不加协议 allowlist 直接渲染裸锚点。** 拒绝:URL 在此接缝处是模型创作、未经验证的,所以未过滤的 href 会让 `javascript:` URL 在点击时执行。该 allowlist 是 MarkdownText allowlist(它还允许 `mailto:`)的 http(s) 子集,因此不受信任的检索链接无论在何处渲染都行为相同。 + +## Testing + +`packages/client/ui-primitives/tests/web-block.spec.tsx` 把组件钉到 per-file 100% 门槛:两种 kind;标题-或-主机名-或-原始 URL 的标签回退;两种 kind 上的安全链接属性(http(s) URL 成为带 `target`/`rel` 的外链,`javascript:`/`file:`/无法解析的 URL 渲染为无 href 的纯 span);snippet 与日期在存在/为空/缺失时的显示或省略;由标志位控制的截断提示;以及 source 列表高度上限及其头/尾切片与展开/收起控件,含默认上限。 + +`packages/client/ui-conversation/tests/web-card.spec.tsx` 在每个接线接缝镜像 `terminal-card.spec.tsx`:`webCardModel` 的派生投影每个 source 字段、其截断与缺失 answer 的支路、fetch 派生、以及每个 null 支路(运行中、null result view、generic result view、未知 card 标签、未知 web `kind`);键控 `WebRow` 对两种 kind 的常驻卡片、比面板收得更紧、其仅摘要行的运行中与失败支路;`GenericToolCard` 兜底为 web 声明工具长出常驻卡片、并为非 web 调用保持纯行;详情面板 Output 区对两种 kind —— 含 `web_fetch` 正文摊平在其 URL/状态卡片下方 —— 及其对非 web 结果的摊平回退;以及在 `web_search` 与 `web_fetch` 两键下用一个组件的键控注册。该文件位于覆盖率 `exclude` 列表(`ui-conversation/src/*`),因此覆盖率运行不度量它。 + +fixture(`packages/client/connection/src/client/fixture.ts`)添加 turn 66(`web_search`)与 67(`web_fetch`),内联撰写,因为客户端 fixture 无法 import web 工具:turn 66 的 result view 携带一个 answer 与三个 source,演练引用列表(一个带 snippet 与日期的有标题 source、一个无标题因而以主机名标注链接的 source、一个有日期无 snippet 的 source)并开启截断提示;turn 67 携带抓取的 URL 与一个 200 状态。两者都保留 generic pending call view,仅在 result 时添加 `web` 卡片,匹配契约的 result-only web 形状,且以真实工具命名,使其命中键控 `WebRow`。它们被排在 todo turn(重编号为 68)之前,理由与终端 turn 相同:待定计划在下一个 `turn/start` 退休,所以排在其后的 turn 会清空 dock 的 plan strip。这驱动 built-boot snapshot 与一个实时 `?fixture` 服务。 + +## Related + +- [Web result card](2026-07-30-web-result-card.md) —— 添加 `card: 'web'` result 支路并让两个工具发出它的后端 PR;本条是它推迟的前端消费者。 +- [Web terminal card](2026-07-28-web-terminal-card.md) —— 本条所镜像的先例:一个 `ui-primitives` block、一处 card-model 派生、键控与兜底 chat 行、以及一个详情面板支路,用于 `terminal` 渲染意图。 +- [工具调用呈现的标签化 render-intent union](../architecture/2026-07-02-tool-render-intent-union.md) —— `card` 标签词汇;Web 客户端现在是 `web` 支路的完整消费者。 diff --git a/apps/web/tests/built-boot.snapshot.ts b/apps/web/tests/built-boot.snapshot.ts index 0bac8a2eb8..90b27e84cb 100644 --- a/apps/web/tests/built-boot.snapshot.ts +++ b/apps/web/tests/built-boot.snapshot.ts @@ -111,6 +111,18 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn expect(document.querySelector('[data-sample="bash-global"]')).not.toBeNull() }, { timeout: 10_000 }) + // The web render intent reaches the assembled boot graph: the fixture's + // web_search / web_fetch turns render their keyed WebRow cards, proving the + // registration, wire projection, and card rendering survive the real bundle + // path (not just the per-package src benches). The selector pins the KEYED + // WebRow (its own `data-variant="web"` wrapper), not the `[data-web]` attribute + // WebBlock draws — the generic fallback renders the same WebBlock, so a silent + // keyed-registration failure would still satisfy a bare `[data-web]` check. + await waitFor(() => { + expect(document.querySelector('[data-variant="web"][data-tool="web_search"]')).not.toBeNull() + expect(document.querySelector('[data-variant="web"][data-tool="web_fetch"]')).not.toBeNull() + }, { timeout: 10_000 }) + // Every bundle injected its plugin-owned style tag (the loader's CSS path). const styleOwners = [...document.head.querySelectorAll('style[data-plugin]')] .map(style => style.getAttribute('data-plugin')) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 2014dc200e..8a51f359f1 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -137,6 +137,44 @@ const TERMINAL_EXIT_STATUS: Record, 'card' | 'kind'> = { + answer: 'DeepSeek Harness is a plugin-based agent harness on vendored Cordis where **every capability is a plugin**.', + sources: [ + { + url: 'https://github.com/deepseek-ai/deepseek-harness', + title: 'DeepSeek Harness — plugin-based agent harness', + snippet: 'Everything is a plugin: session, tools, agent-loop, and LLM adapters all mount on the same Cordis context.', + publishedAt: '2026-07-01', + }, + { + url: 'https://www.deepseek.com/blog/harness-architecture', + snippet: 'The capability-seam pattern splits each capability into interface, implementation, and consumer packages.', + }, + { + url: 'https://docs.deepseek.com/harness/plugins', + title: 'Writing a harness plugin', + publishedAt: '2026-06-15', + }, + ], + truncated: true, +} + +/** The `web_fetch` result view for fixture turn 67, authored inline for the same reason. */ +const WEB_FETCH_RESULT: Omit, 'card' | 'kind'> = { + url: 'https://www.deepseek.com/blog/harness-architecture', + statusCode: 200, + truncated: false, +} + const DEEPSEEK_REASONING = { efforts: [ { id: 'off', name: 'Off' }, @@ -326,8 +364,20 @@ function buildAlphaLog(): SessionEvent[] { // strip empty and take the todo surfaces' own coverage with it. toolTurn(65, 'bash', '{"command":"pnpm run check","cwd":"/tmp/fixture/deep/nested"}', TERMINAL_OUTPUT_FIXTURE) + // Turns 66-67: the web render intent — a web_search whose result view carries + // structured sources plus an answer (the citation list, one source lacking a + // title so its hostname labels the link, the capped indicator on), and a + // web_fetch whose result view carries the fetched URL and its HTTP status. + // Both keep a generic pending call view and add the `web` card only at + // result time, which is the contract's result-only web shape. Named after + // the real tools so they hit the keyed WebRow registration. Ordered BEFORE + // the todo turn for the same reason turn 65 is: the standing plan retires at + // the next turn/start, so a turn after it would empty the dock's plan strip. + toolTurn(66, 'web_search', '{"query":"deepseek harness architecture"}', 'Search results for deepseek harness architecture.') + toolTurn(67, 'web_fetch', '{"url":"https://www.deepseek.com/blog/harness-architecture"}', '# Harness architecture\n\nEverything is a plugin.') + const todoArgs = JSON.stringify({ todos: fixtureTodos }) - toolTurn(66, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 1 in progress, 1 completed.') + toolTurn(68, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 1 in progress, 1 completed.') // The real tool appends the snapshot mid-execution — between tool/call and // tool/result — so the fixture reproduces that exact ordering (the last // toolTurn events run ... tool/call, tool/result, step/end, turn/end). @@ -366,6 +416,13 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined { return { card: 'generic', title: `Edit ${str(args.file_path)}`, kind: 'edit', rawInput: args } case 'write': return { card: 'generic', title: `Write ${str(args.file_path)}`, kind: 'edit', rawInput: args } + // The web tools keep a GENERIC pending card and add the `web` result card + // only at result time (the contract's result-only web shape); their pending + // kind matches the result kind so a call and its result read as one category. + case 'web_search': + return { card: 'generic', title: `Search ${str(args.query)}`, kind: 'search', rawInput: args } + case 'web_fetch': + return { card: 'generic', title: `Fetch ${str(args.url)}`, kind: 'fetch', rawInput: args } default: return undefined // echo et al: the documented no-view fallback path } @@ -374,6 +431,17 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined { function presentResult(name: string, argsRaw: string, resultText: string): ToolResultView | undefined { const call = presentCall(name, argsRaw) if (call === undefined) return undefined + // The web tools keep a generic pending card, so their result card is chosen + // by tool name rather than by the pending card tag: the structured `web` card + // the frontend consumes. The view carries no `content` copy (per the contract + // and the web-result-card note); a capability-less UI falls back to the raw + // `tool/result` content, which this fixture emits from `resultText`. + if (name === 'web_search') { + return { card: 'web', kind: 'search', ...WEB_SEARCH_RESULT } + } + if (name === 'web_fetch') { + return { card: 'web', kind: 'fetch', ...WEB_FETCH_RESULT } + } switch (call.card) { case 'terminal': // The sample's own exit status, authored beside it: re-parsing the diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index bb6a998779..cfbff2117e 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 packages/client/ui-conversation/README.md -README.md: 726aab444818627dc7ed22ba9a20920d119d1a62 -README.zh.md: c191855aa3607b3ac50c9d1f9f5c86ff0b0e8a76 +README.md: 64440a3d69d78871bdc4777f88bb65c71b02fb69 +README.zh.md: dde2d38d9fae102dbfd49e9c5cacf16385ef3ef8 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 726aab4448..64440a3d69 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -14,7 +14,9 @@ Logged non-user messages render as a default-collapsed `上下文注入` disclos Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is a hover-underline link that opens the file with the host OS default application (`host.openPath`, relative paths resolve against the session cwd). Tool rows are not whole-row click targets and do not open the details panel. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged). Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering. -A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed for this intent alone; a generic tool's content remains panel-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)). +A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed per render intent — the terminal and web cards, each with its own bound; a generic tool's content remains panel-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)). + +A tool call declaring the `web` render intent renders its web retrieval inline, at both conversation render sites, through ui-primitives' `WebBlock`. `contract/web-card-model.ts` is the single derivation from the snapshot's `resultView`, mirroring the terminal card, so the sites cannot disagree about what a web call shows; it yields null — the generic path — for a running call, a non-web result view, a generic result view, a `card` tag this client version does not know, or a web card whose `kind` this client version does not know (a newer host's value, which the wire cannot be trusted to be `search` or `fetch`). The keyed `WebRow` registers one component under both `web_search` and `web_fetch`, discriminating on the tool name only for its icon and title; a web-declaring tool without a keyed row lands on the `GenericToolCard` fallback, which grows the same resident card, and the details panel renders it at the primitive's full source allowance and, below the card, the flattened model-visible result content — a fetch body is readable only there, since its card carries only the URL and status. Rows cap at `CHAT_WEB_MAX_SOURCES` (8) against the panel's 16, the same summary-versus-reading split the terminal card draws ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md)). Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openFile`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index c191855aa3..dde2d38d9f 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -12,7 +12,9 @@ 通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和路径摘要;该路径是悬停下划线链接,点击后通过宿主操作系统的默认应用打开文件(`host.openPath`,相对路径相对会话 cwd 解析)。工具行不再是整行点击目标,也不会打开 details 面板。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行)。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect`、`Mount temporary Plugin` 和 `Unmount temporary Plugin`;mount 行保留 code 变体的可展开源码渲染。 -声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView`/`resultView` 对推导的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null,落回通用路径。因此两个渲染点也都显示卡片的运行状态点,它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片常驻在摘要行下方;由于工具行已不再是详情面板的点击目标,卡片的复制与展开控件就是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`(8),面板为 16,正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出只对该意图开放;通用工具的内容仍然只在面板中呈现([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。 +声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView`/`resultView` 对推导的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null,落回通用路径。因此两个渲染点也都显示卡片的运行状态点,它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片常驻在摘要行下方;由于工具行已不再是详情面板的点击目标,卡片的复制与展开控件就是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`(8),面板为 16,正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出按渲染意图开放——终端卡片与 web 卡片,各有自己的上限;通用工具的内容仍然只在面板中呈现([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。 + +声明 `web` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `WebBlock` 内联渲染其 web 检索。`contract/web-card-model.ts` 是从快照的 `resultView` 推导的唯一位置,镜像终端卡片,因此两个渲染点不可能对一次 web 调用的显示产生分歧;对运行中的调用、非 web 的 result view、generic result view、本客户端版本不认识的 `card` 标签,或本客户端版本不认识 `kind` 的 web 卡片(更新的 host 发来的值,wire 上不可信其为 `search` 或 `fetch`),它返回 null,落回通用路径。键控的 `WebRow` 把一个组件注册在 `web_search` 与 `web_fetch` 两个键下,仅根据工具名判别以选取图标与标题;没有自己键控行的 web 声明工具落到 `GenericToolCard` 兜底,它长出同一张常驻卡片,详情面板则以原语的完整 source 额度渲染它,并在卡片下方渲染摊平的模型可见结果内容——fetch 正文只在此处可读,因为其卡片只携带 URL 和状态。行的上限是 `CHAT_WEB_MAX_SOURCES`(8),面板为 16,与终端卡片所画的摘要面对阅读面的同一划分([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md))。 工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openFile`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);Session 区分在组件内部完成(`useSessions` 读取 `parentId`,bash 示例是第三方姿态的范例)。Trajectory/waterfall 工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 0bfa811741..24c26ab432 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -20,6 +20,7 @@ import { InputBar } from './skeleton/InputBar.tsx' import { ChatView } from './chat/ChatView.tsx' import { StatsLine } from './chat/StatsLine.tsx' import { bashToolviewSample } from './toolviews/bash-sample.tsx' +import { webToolview } from './toolviews/web-row.tsx' import { ApprovalPanel } from './skeleton/ApprovalPanel.tsx' import { todoToolview } from './toolviews/todo-row.tsx' import { askQuestionToolview } from './toolviews/ask-question-row.tsx' @@ -318,6 +319,11 @@ export function apply(ctx: Context): void { // (ToolRow-matching Bash · {description} chrome; scoped badge in child sessions). ctx.plugin(bashToolviewSample) + // The web rows ride the same seam: one WebRow registered under both + // web_search and web_fetch, rendering the completed retrieval's web card + // resident under the summary (a product registration, not a sample). + ctx.plugin(webToolview) + // The todo_write row rides the same seam (a product registration, not a sample). ctx.plugin(todoToolview) diff --git a/packages/client/ui-conversation/src/client/chat/GenericToolCard.module.css b/packages/client/ui-conversation/src/client/chat/GenericToolCard.module.css new file mode 100644 index 0000000000..13a4ef9d62 --- /dev/null +++ b/packages/client/ui-conversation/src/client/chat/GenericToolCard.module.css @@ -0,0 +1,15 @@ +/* The generic card grows a resident web card under its summary row when the + tool declares the `web` render intent but has no keyed row of its own (the + web_search/web_fetch rows register their own WebRow). A column around the + ToolRow keeps the row's own 24px height. */ + +.card { + display: flex; + flex-direction: column; +} + +/* Row indentation matches ToolRow's expanded bodies (16px leading + 6px gap), + and replaces the primitive's standalone vertical margin with the flow's. */ +.web { + margin: 4px 0 4px 22px; +} diff --git a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx index dd2ad0f8d3..d35e7c0721 100644 --- a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx +++ b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx @@ -7,12 +7,14 @@ import type { ReactNode } from 'react' import { IconApiOutline14, IconBrowseOutline16, IconCodeOutline16, IconEditOutline16, IconSearchOutline16, IconSparkle16, - IconThinkOutline14, + IconThinkOutline14, WebBlock, } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps, ToolRowOwnerProps } from '../contract/slots.ts' import { terminalCardModel, terminalFailed } from '../contract/terminal-card-model.ts' +import { CHAT_WEB_MAX_SOURCES, webCardModel } from '../contract/web-card-model.ts' import { toolRowModel, type ToolRowVariant } from '../contract/tool-call-model.ts' import { ToolRow } from './ToolRow.tsx' +import css from './GenericToolCard.module.css' /** Variant leading icons (figma table); all glyphs render at 14 inside the 16px leading box. */ const VARIANT_ICONS: Record = { @@ -34,13 +36,14 @@ export interface GenericToolCardProps extends ToolRowOwnerProps { export function GenericToolCard({ toolName, block, cwd, openFile, inspect, t }: GenericToolCardProps) { const model = toolRowModel(toolName, block, cwd) const terminal = terminalCardModel(block, cwd) + const web = webCardModel(block) // A failing exit status is the terminal card's own error signal (the call // itself settles isError:false), surfaced as the row's red state dot. const state = model.state === 'ok' && terminal !== null && terminalFailed(terminal) ? 'error' : model.state const singleFile = model.filePath !== undefined - return ( + const row = ( ) + // A web-declaring tool without its own keyed row lands here; its card is + // resident under the summary, mirroring WebRow (and BashRow's terminal card). + if (web === null) return row + return ( +
+ {row} + +
+ ) } 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 new file mode 100644 index 0000000000..f2b15e023a --- /dev/null +++ b/packages/client/ui-conversation/src/client/contract/web-card-model.ts @@ -0,0 +1,84 @@ +/** + * Pure derivation of the web-card props from a frozen call slice: the + * `card:'web'` render intent the `web_search`/`web_fetch` tools declare at + * result time arrives on the snapshot as `resultView`, and this is the one + * place that turns it into what {@link WebBlock} draws. Both conversation + * render sites (the chat tool row's resident/expanded body and the details + * panel's Output section) call this, so the sources and fetch summary they + * show are derived once. + * + * The web card is result-only by contract: those tools keep a generic pending + * call view, so there is nothing to derive while the call is still running and + * a running call always takes the generic path. + * @module + */ +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. + * + * The result side supplies the whole card: the sources and answer for a + * `search`, the URL and status for a `fetch`. Cases producing null, all of + * them the documented generic-card default: + * + * - A running call (no `resultView` yet): the web tools keep a generic pending + * card, so nothing web-shaped exists until the call settles. + * - A settled call whose result view is not a web card — including a `card` + * value this UI version does not know, which arrives over the wire and so + * cannot be trusted to be one of the compiled variants, and a generic result + * view (a web tool's error path returns the generic card, whose text the + * generic path preserves). + * - A web card whose `kind` this UI version does not know (a newer host's + * value): the wire cannot be trusted to be `search` or `fetch`, so it takes + * the generic path rather than rendering as a malformed fetch. + * @param block - RunningToolCall or ToolResultNode off the snapshot caches. + * @returns the web-card props, or null for the generic path. + */ +export function webCardModel(block: ToolCallBlock): WebBlockProps | null { + // Running calls have no result view; the web card is result-only. + if (!('kind' in block)) return null + const result = block.resultView + if (result?.card !== 'web') return null + if (result.kind === 'search') { + return { + kind: 'search', + answer: result.answer, + sources: result.sources.map(source => ({ + url: source.url, + title: source.title, + snippet: source.snippet, + publishedAt: source.publishedAt, + })), + truncated: result.truncated, + } + } + // Discriminate `fetch` explicitly rather than treating it as the else of + // `search`: a `kind` this UI version does not know arrives over the wire from + // a newer host, and reading it as a fetch would draw an empty URL and + // `HTTP undefined`. It takes the generic path, the same wire-boundary default + // an unknown `card` tag takes above. The static union narrows `kind` to + // `'fetch'` here, but the runtime value is off the wire, so the guard and its + // null fallthrough are load-bearing despite the type. + // oxlint-disable-next-line typescript/no-unnecessary-condition + if (result.kind === 'fetch') { + return { + kind: 'fetch', + url: result.url, + statusCode: result.statusCode, + truncated: result.truncated, + } + } + return null +} diff --git a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css index 143174fe42..b4e85912ab 100644 --- a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css @@ -106,3 +106,9 @@ .terminal { margin: 0; } + +/* Same rule for the web card: it sits under the section label, so the section + owns the spacing rather than the primitive's own vertical margin. */ +.web { + margin: 0; +} diff --git a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx index ade1b7d85e..ba07870e70 100644 --- a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx @@ -7,11 +7,12 @@ // share the store seat exists for) and derives the call material from the // session snapshot — no data of its own. -import { CodeBlock, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives' +import { CodeBlock, TerminalBlock, WebBlock } from '@deepseek-ai/dsh-client-ui-primitives' import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' import type { DetailsSlotProps } from '../contract/slots.ts' import { terminalBlockLabels, terminalCardModel } from '../contract/terminal-card-model.ts' +import { webCardModel } from '../contract/web-card-model.ts' import { resultText, type ToolCallBlock } from '../contract/tool-call-model.ts' import css from './DetailsPanel.module.css' @@ -127,8 +128,10 @@ export function DetailsPanel({ useSession, useSessions, sessionId, useStore, clo * The Output section's body for the selected call. A terminal-card call — a * shell command's call/result views — renders through the shared TerminalBlock * at the primitive's own full height allowance, so column-aligned output keeps - * its alignment and scrolls sideways instead of folding. Every other call, and - * a running call with no terminal card yet, keeps the flattened text form. + * its alignment and scrolls sideways instead of folding. A web-card call — a + * `web_search`/`web_fetch` result — renders through WebBlock at its own full + * source-list allowance. Every other call, and a running call with no card + * yet, keeps the flattened text form. * @param props.material - the selected call's material from {@link materialFor}. * @param props.cwd - the session workspace root, resolving the terminal view's cwd. * @param props.t - the panel's locale seat, passed down as a plain prop. @@ -148,6 +151,24 @@ 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). + if (web !== null) { + const settled = 'kind' in material.block ? material.block : null + const body = settled === null ? '' : resultText(settled) + return ( + <> + + {body !== '' &&
{body}
} + + ) + } // A settled call always carries the result node the flattened form needs; // the running shape has no result to flatten. if (!('kind' in material.block)) return
{t('details.running')}
diff --git a/packages/client/ui-conversation/src/client/toolviews/web-row.module.css b/packages/client/ui-conversation/src/client/toolviews/web-row.module.css new file mode 100644 index 0000000000..0b1519e218 --- /dev/null +++ b/packages/client/ui-conversation/src/client/toolviews/web-row.module.css @@ -0,0 +1,95 @@ +/* Web toolview: same geometry/tokens as ToolRow (figma icon · summary), plus + the web card the row stacks under its summary line, mirroring the bash row's + resident terminal card. */ + +/* Summary line over the web card; the summary row keeps its own 24px height, + so the card is a column around it rather than a change to it. */ +.card { + display: flex; + flex-direction: column; +} + +/* Row indentation matches ToolRow's expanded bodies (16px leading + 6px gap), + and replaces the primitive's standalone vertical margin with the flow's. */ +.web { + margin: 4px 0 4px 22px; +} + +.root { + position: relative; /* sweep-glare overlay anchor */ + overflow: hidden; + display: flex; + align-items: center; + height: 24px; + min-width: 0; +} + +/* Running sweep glare — same deepsuite ShimmerText pattern as ToolRow. */ +.root[data-state='running']::after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 300px; + background: linear-gradient( + 90deg, + transparent 0%, + color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%, + transparent 100% + ); + animation: dsh-web-row-sweep 2.6s ease-out infinite; + pointer-events: none; +} + +@keyframes dsh-web-row-sweep { + 0% { left: -300px; } + 90%, 100% { left: 100%; } +} + +.leading { + flex: none; + width: 16px; + height: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + margin-right: 6px; + color: var(--dsw-alias-label-tertiary); +} + +.title { + flex: none; + font-size: 14px; + line-height: 24px; + color: var(--dsw-alias-label-secondary); +} + +.sep { + flex: none; + width: 2px; + height: 2px; + border-radius: 1px; + margin: 0 8px; + background: var(--dsw-alias-label-caption); +} + +.summary { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 14px; + line-height: 24px; + color: var(--dsw-alias-label-tertiary); +} + +.visuallyHidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} diff --git a/packages/client/ui-conversation/src/client/toolviews/web-row.tsx b/packages/client/ui-conversation/src/client/toolviews/web-row.tsx new file mode 100644 index 0000000000..b86c523a26 --- /dev/null +++ b/packages/client/ui-conversation/src/client/toolviews/web-row.tsx @@ -0,0 +1,92 @@ +// Web toolview registrant: third-party posture over the keyed toolview hole +// (ctx.slots.register + ToolRowProps only — never imports the chat domain). +// Registered under BOTH web_search and web_fetch, since both declare the one +// `web` render intent and render through the one WebBlock family; the row +// discriminates on the toolName only to pick its icon and title. +// +// A web tool declares the `web` render intent at result time, so this row +// renders the completed retrieval through WebBlock resident below its summary, +// the same posture BashRow uses for the terminal card: no expand control on the +// row itself, not a details-panel target, and the block's own expander keeps a +// long source list from taking over the message flow (CHAT_WEB_MAX_SOURCES is +// passed as maxSources — the chat flow's tighter cap over the block's default +// of 16). Until the call settles there is no web card (the tools keep a generic +// pending view), so a running row is the summary line alone. + +import type { Context } from 'cordis' +import { IconBrowseOutline16, IconSearchOutline16, StateDot, WebBlock } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ToolRowProps } from '../contract/slots.ts' +import { CHAT_WEB_MAX_SOURCES, webCardModel } from '../contract/web-card-model.ts' +import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts' +import css from './web-row.module.css' + +/** web_fetch reads one URL; web_search queries. Titles are figma literals. */ +const WEB_TITLES: Record = { + web_search: 'Search', + web_fetch: 'Fetch', +} + +/** Leading icon per tool, yielding to the state semantic while failed/stopped. */ +function leadingFor(toolName: string, state: ToolRowState) { + switch (state) { + case 'error': return + case 'stopped': return + // Running keeps the icon — the row sweep carries the in-flight signal. + default: return toolName === 'web_fetch' ? : + } +} + +/** Visually hidden status — StateDot is aria-hidden; AT needs a text label. */ +function stateStatus(state: ToolRowState): string | null { + switch (state) { + case 'running': return '运行中' + case 'error': return '失败' + case 'stopped': return '已停止' + default: return null + } +} + +/** + * Web row: icon + Search/Fetch · {summary} in the shared ToolRow chrome, with + * the completed retrieval's web card resident below it. The summary row is not + * a details-panel control (tool rows stopped being one), so the card's own + * links and expander are the row's only interactions. + */ +export function WebRow({ toolName, block }: ToolRowProps) { + const model = toolRowModel(toolName, block) + const web = webCardModel(block) + const status = stateStatus(model.state) + return ( +
+
+ {leadingFor(toolName, model.state)} + {status !== null && {status}} + {WEB_TITLES[toolName] ?? model.title} + + {model.summary} +
+ {web !== null && ( + + )} +
+ ) +} + +/** + * The web rows as a plain registrant plugin, riding the same load-order seam as + * the bash sample: `inject: ['conversation']` guarantees the chat entry (and + * with it the 'conversation.chat.toolview' declaration) is on the ledger. One + * WebRow component registers under both web tool names. + */ +export const webToolview = { + name: 'web-toolview', + inject: ['slots', 'conversation'], + /** + * Register the web row under both web tool names' keyed toolview holes. + * @param ctx - registrant context (disposal rides ctx.effect inside slots.register). + */ + apply(ctx: Context): void { + ctx.slots.register({ name: 'conversation.chat.toolview', key: 'web_search' }, WebRow) + ctx.slots.register({ name: 'conversation.chat.toolview', key: 'web_fetch' }, WebRow) + }, +} diff --git a/packages/client/ui-conversation/tests/chat-apply.spec.tsx b/packages/client/ui-conversation/tests/chat-apply.spec.tsx index 3ba318ff74..a8e0e06aec 100644 --- a/packages/client/ui-conversation/tests/chat-apply.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-apply.spec.tsx @@ -84,12 +84,13 @@ describe('apply wiring', () => { await b.runtime.dispose() }) - it('mounts the bash sample and the product rows as keyed entries through the load-order seam', async () => { + it('mounts the bash sample, the web rows, and the product rows as keyed entries through the load-order seam', async () => { const b = await bench() // Every registrant plugin's inject: ['slots', 'conversation'] resolved — the - // service being present implies the chat entry declared the hole first. + // service being present implies the chat entry declared the hole first. The + // web rows register one component under both web tool names. const entries = b.slots.entries('conversation.chat.toolview') - expect(entries.map(e => e.options.key)).toEqual(['bash', 'todo_write', 'ask_user_question']) + expect(entries.map(e => e.options.key)).toEqual(['bash', 'web_search', 'web_fetch', 'todo_write', 'ask_user_question']) // Stats stick with the composer (not inside ChatView). expect(b.slots.entries('conversation.composer.dock').map(e => e.options.id)).toEqual(['stats']) await b.runtime.dispose() diff --git a/packages/client/ui-conversation/tests/web-card.spec.tsx b/packages/client/ui-conversation/tests/web-card.spec.tsx new file mode 100644 index 0000000000..cea95e61f6 --- /dev/null +++ b/packages/client/ui-conversation/tests/web-card.spec.tsx @@ -0,0 +1,270 @@ +// @vitest-environment jsdom +// The web render intent on the web side: the pure webCardModel derivation over +// resultView, and the conversation render sites that consume it — the keyed +// WebRow (registered under both web_search and web_fetch), the GenericToolCard +// render-site fallback, and the details panel's Output section. Mirrors +// terminal-card.spec.tsx: model derivation + null arms, both kinds, the chat +// row's resident card, the panel arm, and the keyed registration. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, render } from '@testing-library/react' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { + ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState, +} from '@deepseek-ai/dsh-client-runtime/client' +import type { ToolResultView } from '@deepseek-ai/dsh-client-connection/client' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' +import type { SelectionTarget, ToolRowOwnerProps, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { CHAT_WEB_MAX_SOURCES, 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' +import { WebRow, webToolview } from '../src/client/toolviews/web-row.tsx' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' +import { zh } from '../src/client/locales.ts' + +afterEach(cleanup) + +const SID = 's1' as SessionId + +/** Locale seat for the card render sites (GenericToolCard, DetailsPanel), as the sibling suites build it. */ +const t = makeTranslate(zh, commonZh) + +const SEARCH_ARGS = '{"query":"deepseek harness"}' +const FETCH_ARGS = '{"url":"https://example.com/page"}' + +/** A web_search result view; overrides tune the sources / answer / truncation. */ +const resultSearch = (over?: Partial>): ToolResultView => ({ + card: 'web', kind: 'search', truncated: false, + answer: 'A short answer.', + sources: [ + { url: 'https://example.com/a', title: 'Titled', snippet: 'excerpt', publishedAt: '2026-07-01' }, + { url: 'https://plain.example.org/b' }, + ], + ...over, +}) + +/** A web_fetch result view. */ +const resultFetch = (over?: Partial>): ToolResultView => ({ + card: 'web', kind: 'fetch', url: 'https://example.com/page', statusCode: 200, truncated: false, ...over, +}) + +const runningSearch = (over?: Partial): RunningToolCall => ({ + callId: 'c1', name: 'web_search', argsRaw: SEARCH_ARGS, + turn: 1, step: 1, time: 1_000, callView: { card: 'generic', title: 'Search', kind: 'search' }, ...over, +}) + +const settledSearch = (over?: Partial): ToolResultNode => ({ + kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1', + call: { name: 'web_search', argsRaw: SEARCH_ARGS }, + callTime: 1_000, + content: [{ type: 'text', text: 'search text' }], isError: false, + callView: { card: 'generic', title: 'Search', kind: 'search' }, resultView: resultSearch(), ...over, +}) + +const settledFetch = (over?: Partial): ToolResultNode => ({ + kind: 'tool-result', seq: 11, time: 2_000, callId: 'c2', + call: { name: 'web_fetch', argsRaw: FETCH_ARGS }, + callTime: 1_000, + content: [{ type: 'text', text: 'fetch body' }], isError: false, + callView: { card: 'generic', title: 'Fetch', kind: 'fetch' }, resultView: resultFetch(), ...over, +}) + +describe('webCardModel', () => { + it('derives a search card from the result view, projecting every source field', () => { + expect(webCardModel(settledSearch())).toEqual({ + kind: 'search', + answer: 'A short answer.', + truncated: false, + sources: [ + { url: 'https://example.com/a', title: 'Titled', snippet: 'excerpt', publishedAt: '2026-07-01' }, + { url: 'https://plain.example.org/b', title: undefined, snippet: undefined, publishedAt: undefined }, + ], + }) + }) + + it('carries the search truncation flag and an absent answer', () => { + const model = webCardModel(settledSearch({ resultView: { card: 'web', kind: 'search', truncated: true, sources: [] } })) + expect(model).toEqual({ kind: 'search', answer: undefined, truncated: true, sources: [] }) + }) + + it('derives a fetch card from the result view', () => { + expect(webCardModel(settledFetch())).toEqual({ + kind: 'fetch', url: 'https://example.com/page', statusCode: 200, truncated: false, + }) + expect(webCardModel(settledFetch({ resultView: resultFetch({ statusCode: 404, truncated: true }) }))) + .toEqual({ kind: 'fetch', url: 'https://example.com/page', statusCode: 404, truncated: true }) + }) + + it('returns null for a running call, since the web card is result-only', () => { + expect(webCardModel(runningSearch())).toBeNull() + // Even a running call that somehow carried a web call view stays generic: + // the derivation reads resultView only. + expect(webCardModel(runningSearch({ callView: null }))).toBeNull() + }) + + it('returns null for a settled call whose result view is not a web card', () => { + expect(webCardModel(settledSearch({ resultView: null }))).toBeNull() + expect(webCardModel(settledSearch({ resultView: { card: 'generic' } }))).toBeNull() + // A card tag this UI version does not know arrives over the wire; the + // documented generic-card default takes it, not a crash. + const future = { card: 'chart', kind: 'search' } as unknown as ToolResultView + expect(webCardModel(settledSearch({ resultView: future }))).toBeNull() + // A web card whose kind this UI version does not know (a newer host's + // value) also takes the generic path, not a malformed fetch. + const futureKind = { card: 'web', kind: 'timeline' } as unknown as ToolResultView + expect(webCardModel(settledSearch({ resultView: futureKind }))).toBeNull() + }) +}) + +describe('chat row web body', () => { + const ownerProps = (block: RunningToolCall | ToolResultNode, toolName: string): ToolRowOwnerProps => ({ + callId: block.callId, toolName, block, openFile: vi.fn(), + }) + // WebRow reads only toolName/block off the full runtime share; the standard + // kit is unused, so the cast supplies the owner slice alone (as BashRow's + // tests do for the terminal card). + const rowProps = (block: RunningToolCall | ToolResultNode, toolName: string): ToolRowProps => + ownerProps(block, toolName) as unknown as ToolRowProps + + it('the WebRow renders the search card resident under the summary, capped tighter than the panel', () => { + expect(CHAT_WEB_MAX_SOURCES).toBeLessThan(16) + const view = render() + // The summary row plus the resident card, without any expand gesture on the row itself. + expect(view.getByText('Search')).toBeTruthy() + expect(view.getByText('Titled')).toBeTruthy() + expect(view.getByText('excerpt')).toBeTruthy() + // hostname fallback for the source with no title + expect(view.getByText('plain.example.org')).toBeTruthy() + }) + + it('the WebRow renders the fetch card resident, titled Fetch', () => { + const view = render() + expect(view.getByText('Fetch')).toBeTruthy() + // The url shows in the summary row and as the card's link; scope to the card. + const card = view.container.querySelector('[data-web="fetch"]') + expect(card?.querySelector('a')?.getAttribute('href')).toBe('https://example.com/page') + expect(view.getByText('HTTP 200')).toBeTruthy() + }) + + it('a running web call is the summary row alone (no card until it settles)', () => { + const view = render() + expect(view.getByText('Search')).toBeTruthy() + expect(view.queryByText('Titled')).toBeNull() + expect(view.container.querySelector('[data-web]')).toBeNull() + }) + + it('a failed web call keeps the summary row without the card', () => { + const view = render() + expect(view.getByText('Search')).toBeTruthy() + expect(view.container.querySelector('[data-web]')).toBeNull() + // The row reflects the error state so the summary line still reads as failed. + expect(view.container.querySelector('[data-state="error"]')).not.toBeNull() + }) + + it('the GenericToolCard fallback also renders a resident web card for a web-declaring tool', () => { + // A web-declaring tool without its own keyed row lands on the fallback; its + // card is resident there too. + const view = render() + expect(view.getByText('Titled')).toBeTruthy() + expect(view.container.querySelector('[data-web="search"]')).not.toBeNull() + }) + + it('the GenericToolCard fallback keeps the plain row for a non-web call', () => { + const view = render() + expect(view.container.querySelector('[data-web]')).toBeNull() + }) +}) + +describe('DetailsPanel web Output section', () => { + function mount(snapshot: ConversationSnapshot, selection: SelectionTarget | null) { + localStorage.clear() + const chat = createChatStore().create() + if (selection !== null) chat.actions.select(selection) + const sessions = createSnapshotStore({ ids: [], byId: {}, current: undefined, phase: 'ready' }) + const workspaces = createSnapshotStore({ + items: [], state: 'idle', phase: 'ready', error: null, + baselinesReady: true, recentWorkspaceId: undefined, + }) + return render( + snapshot, subscribe: () => () => {} })} + useSessions={bindSnapshotSelector(sessions)} + useWorkspaces={bindSnapshotSelector(workspaces)} + useInput={(() => { throw new Error('unused') })} + inputActions={{ setDraft: () => {}, submit: () => {} }} + useProjection={(() => undefined)} + useStore={bindSnapshotSelector(chat)} + actions={chat.actions} + closeDetails={vi.fn()} + t={t} + />, + ) + } + + function snapshot(over: Partial = {}): ConversationSnapshot { + return { + sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(), + pending: [], queue: [], running: false, composerPhase: 'active', removed: false, + openState: 'open', openError: null, hasMore: false, loadingOlder: false, + promptError: null, blank: false, lastAgentError: null, ...over, + } + } + + it('renders the search card at full source allowance', () => { + const view = mount(snapshot({ nodes: [settledSearch()] }), { turnSeq: 10, callId: 'c1', toolName: 'web_search' }) + expect(view.getByText('Titled')).toBeTruthy() + expect(view.getByText('excerpt')).toBeTruthy() + // The Input JSON section survives beside it. + expect(view.getByText(/"query"/)).toBeTruthy() + }) + + it('renders the fetch card and keeps the fetched body below it', () => { + const view = mount(snapshot({ nodes: [settledFetch()] }), { turnSeq: 11, callId: 'c2', toolName: 'web_fetch' }) + const card = view.container.querySelector('[data-web="fetch"]') + expect(card?.querySelector('a')?.getAttribute('href')).toBe('https://example.com/page') + expect(view.getByText('HTTP 200')).toBeTruthy() + // The card is a summary (URL + status only); the panel is the single-call + // reading surface, so the fetched body still renders below the card. + const output = view.getByText('输出').closest('section') + expect(output?.querySelector('pre')?.textContent).toContain('fetch body') + }) + + it('a non-web result keeps the flattened pre form', () => { + const view = mount(snapshot({ + nodes: [settledSearch({ callView: null, resultView: null })], + }), { turnSeq: 10, callId: 'c1', toolName: 'web_search' }) + expect(view.container.querySelector('[data-web]')).toBeNull() + const output = view.getByText('输出').closest('section') + expect(output?.querySelector('pre')?.textContent).toContain('search text') + }) +}) + +describe('web toolview registration', () => { + it('registers one WebRow under both web_search and web_fetch', () => { + const registered: { key: string; component: unknown }[] = [] + const ctx = { + slots: { + register: (options: { name: string; key: string }, component: unknown) => { + registered.push({ key: options.key, component }) + return () => {} + }, + }, + } as unknown as import('cordis').Context + webToolview.apply(ctx) + expect(registered.map(r => r.key)).toEqual(['web_search', 'web_fetch']) + // One component under both keys, not two thin rows. + expect(registered[0]?.component).toBe(WebRow) + expect(registered[1]?.component).toBe(WebRow) + // The load-order seam the render site depends on. + expect(webToolview.inject).toEqual(['slots', 'conversation']) + }) +}) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index c440159ba6..c665d9a754 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 packages/client/ui-primitives/README.md -README.md: 4075f0e7472141b5d41fe0f51c1a620eae913bfb -README.zh.md: 7fd9529e597bc473a7c35fc3614f21f84cd19f44 +README.md: 5406d501eade2881491b3d157edddaa03f235a35 +README.zh.md: 18c7cefa7cdd631405f34f18e7c4c3369c65bc88 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 4075f0e747..5406d501ea 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/Input, the markdown family (MessageText/MarkdownText/JsonBlock), the read-only JsonTree inspector, the `useAnchoredMaxHeight` hook that clamps a bottom-anchored overlay to the viewport space above its anchor (re-measured on resize, scroll, and a caller-supplied dependency), and TerminalBlock. Contract: api-contracts v3 §8. +Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/Input, the markdown family (MessageText/MarkdownText/JsonBlock), the read-only JsonTree inspector, the `useAnchoredMaxHeight` hook that clamps a bottom-anchored overlay to the viewport space above its anchor (re-measured on resize, scroll, and a caller-supplied dependency), TerminalBlock, and WebBlock. Contract: api-contracts v3 §8. ## Markdown rendering @@ -12,6 +12,10 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/ `TerminalBlock` renders a shell command as a terminal surface: one prompt row per line of the command (the shortened `cwd` label on the first row only, since the view knows one working directory and a `cd` moves later lines elsewhere, then that line), the command's output, a status pill for a non-zero exit code or a terminating signal, and a copy control that writes the raw `output` prop. A run-state `StateDot` marks the call once, on the first row, out of flow in a gutter the card reserves as its own left padding, so the dot sits inside the card box yet left of the prompt text. It reaches three of `StateDot`'s states — the chase while `running`, red for the same exit status that renders the pill, green otherwise — so a card states whether its command is still running rather than leaving that to be inferred from the presence of output; it carries one visually hidden text label because `StateDot` is `aria-hidden`. One dot regardless of line count is deliberate: the exit status is the whole call's, so a dot per line would claim a per-line outcome the view does not carry. Command text is `white-space: pre`, so repeated spaces, tabs, and an indented continuation render verbatim while the row stays single-line and ellipsizes. ANSI escape sequences are parsed with the `anser` runtime dependency into React spans; cursor movements replay into a per-line column buffer before inert controls are stripped, since carriage return and backspace only MOVE the cursor: `100%` + CR + `OK` alone shows `OK0%`, while the `\x1b[K` a spinner writes with its redraw erases the tail so `100%\r\x1b[KOK` shows `OK`. Erase-in-line is honored in all three parameter forms, the cursor advances by terminal columns (8-column tab stops, two for emoji and CJK, none for a combining mark), and SGR state is normalized per cell as a terminal stores it, threading across lines and closing at the state the line ended in; basic-16 foreground colors map onto `--dsw-*` tokens, while 256-palette and truecolor values pass through as literal rgb. Output keeps `white-space: pre` with horizontal scrolling, so column-aligned output holds its alignment instead of soft-wrapping, and collapses to a head slice plus a tail slice past `maxLines` (default 16, the TUI transcript's split arithmetic) behind an expand button. Rationale: [the web terminal card note](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md). +## Web retrieval + +`WebBlock` renders a completed web retrieval, one component for both kinds of the `web` render intent (discriminated by `kind`). A `search` shows an optional provider answer (through `MarkdownText`) above an ordered citation list: each source is a safe external link labelled by its title, or its hostname, falling back to the raw URL when the URL does not parse or has no hostname (a `file:`/`data:` URL) so a label is never blank; its snippet and publication date render below it. Only http(s) URLs become anchors (`target`/`rel` set) — the http(s) subset of the allowlist `MarkdownText` applies to untrusted links (it also permits `mailto:`, excluded here); any other URL renders as plain text. A long list caps at `maxSources` (default 16, the TerminalBlock split arithmetic) with a head/tail collapse; the collapsed tail keeps each source's original citation number via `
  • `, and the expand control is a marker-less `
  • ` so the `
      ` stays valid HTML. When a search legitimately returns no answer and no sources, the card shows an explicit empty-state note rather than a blank `
        ` (the chat row does not surface the raw result content). A `fetch` shows a compact summary: the linked final URL and its HTTP status. Both mark a capped retrieval. Rationale: [the web result card note](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md). + ## Model Experience None, as the package renders pure React atoms in the browser; nothing here reaches a model request. @@ -25,5 +29,5 @@ None; this package neither assembles nor sends a provider request. - **Glyph-level icons are redrawn approximations** — the fish logo (and the sparkle held by ui-conversation) come from font glyphs whose vector geometry is not exportable from the local design data; hand-authored recreations stand in until an exact export path exists. - **Pill and Input have no design source** — both atoms are self-defined; the sidebar search field and view-tab strip that resemble them are consumer-owned compositions, not these atoms. - **StateDot `Active` variant is a hidden placeholder in the design** — not implemented; the four shipped states (done/warning/ongoing/error) are the complete P-I surface. -- **User-facing copy localizes through label props, defaulting to the original Chinese literals** — the atoms are zero-cordis and cannot reach `ctx.locale`, so `TerminalBlock` (`labels`), `JsonTree` (`labels`), `CodeBlock` (`copyLabel`/`copiedLabel`), `MarkdownText` (`codeLabels`), `JsonBlock` (`truncatedLabel`), `ConnectionBanner` (`label`), and `Modal` (`closeLabel`) take their copy as optional props with the previous hardcoded strings as defaults. Localized plugins pass dictionary-driven labels from their own `t` seat; a consumer that passes nothing renders exactly the pre-localization output. +- **User-facing copy localizes through label props, defaulting to the original Chinese literals** — the atoms are zero-cordis and cannot reach `ctx.locale`, so `TerminalBlock` (`labels`), `JsonTree` (`labels`), `CodeBlock` (`copyLabel`/`copiedLabel`), `MarkdownText` (`codeLabels`), `JsonBlock` (`truncatedLabel`), `ConnectionBanner` (`label`), and `Modal` (`closeLabel`) take their copy as optional props with the previous hardcoded strings as defaults. Localized plugins pass dictionary-driven labels from their own `t` seat; a consumer that passes nothing renders exactly the pre-localization output. `WebBlock` does not yet follow this pattern: its source expand/collapse controls, source-list and fetch truncation notes, and empty-search note stay inline Chinese, pending the same label-prop treatment. - **`TerminalBlock` is not a terminal emulator** — it renders settled or still-running command output, not an interactive session: SGR color and attributes are honored, and so are the in-line cursor movements a progress line uses — carriage return, backspace, erase-in-line, tab stops and character width. Absolute cursor positioning, screen clearing, and alternate-screen sequences are stripped. Basic-16 magenta and cyan have no token equivalent and stay literal rgb. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 7fd9529e59..18c7cefa7c 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -纯 React 原子组件(零 cordis):StateDot、ic_ds_* 图标、Button/Pill/Menu/Modal/Input、markdown 家族(MessageText/MarkdownText/JsonBlock)、只读 JsonTree 检查器、`useAnchoredMaxHeight` hook(把底部锚定的浮层高度收敛到锚点上方的视口空间,并在 resize、scroll 与调用方提供的依赖变化时重新测量),以及 TerminalBlock。契约:api-contracts v3 §8。 +纯 React 原子组件(零 cordis):StateDot、ic_ds_* 图标、Button/Pill/Menu/Modal/Input、markdown 家族(MessageText/MarkdownText/JsonBlock)、只读 JsonTree 检查器、`useAnchoredMaxHeight` hook(把底部锚定的浮层高度收敛到锚点上方的视口空间,并在 resize、scroll 与调用方提供的依赖变化时重新测量),以及 TerminalBlock 与 WebBlock。契约:api-contracts v3 §8。 ## Markdown 渲染 @@ -11,6 +11,10 @@ `TerminalBlock` 将一条 shell 命令渲染为终端表层:命令的每一行各占一个提示行(缩短后的 `cwd` 标签只出现在第一行,因为视图只知道一个工作目录,而一个 `cd` 就会让后面的行去到别处,标签之后是该行)、命令输出、非零退出码或终止信号对应的状态胶囊,以及写入原始 `output` prop 的复制控件。一枚运行状态 `StateDot` 为整次调用标记一次,位于第一行,以脱离文档流的方式落在卡片以自身左内边距预留的落区中,因此它位于卡片盒之内、提示文字之左。它用到 `StateDot` 的三种状态——`running` 期间为追逐动画,与渲染状态胶囊相同的退出状态为红色,其余为绿色——因此卡片直接陈述其命令是否仍在运行,而不是让人从有无输出中推断;由于 `StateDot` 是 `aria-hidden`,它携带一处视觉隐藏的文本标签。无论多少行都只有一枚状态点是有意为之:退出状态属于整次调用,因此每行一枚就会声称一个视图并不携带的逐行结果。命令文本使用 `white-space: pre`,因此重复空格、制表符与缩进续行都原样呈现,同时该行仍保持单行并以省略号截断。ANSI 转义序列通过运行时依赖 `anser` 解析为 React span;光标移动在剥除无显示意义控制符之前先重放进逐行的列缓冲,因为回车与退格**只移动**光标:单是 `100%` 加回车再加 `OK` 显示为 `OK0%`,而 spinner 随重绘写出的 `\x1b[K` 会擦掉尾巴,因此 `100%\r\x1b[KOK` 显示为 `OK`。行内擦除的三种参数形式都被遵循,光标按终端列推进(8 列制表位;emoji 与 CJK 占两列;组合标记不占列),SGR 状态按单元格归一化存储,与终端一致,并跨行延续、在行结束时的状态处收束;基础 16 色前景色映射到 `--dsw-*` token,而 256 色板与真彩色值按字面 rgb 透传。输出保持 `white-space: pre` 并支持横向滚动,因此按列对齐的输出保留其对齐而不会软换行;超过 `maxLines`(默认 16,与 TUI 转录相同的切分算法)时折叠为头部切片加尾部切片,由展开按钮控制。原理:[Web 终端卡片笔记](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)。 +## Web 检索 + +`WebBlock` 渲染一次已完成的 web 检索,用一个组件绘制 `web` 渲染意图的两种 kind(由 `kind` 判别)。`search` 在有序引用列表上方显示可选的 provider answer(通过 `MarkdownText`):每个 source 是一个安全外链,以其标题为标签,或以其主机名为标签,当 URL 无法解析或没有主机名(`file:`/`data:` URL)时回退到原始 URL,因此标签绝不为空;其下渲染 snippet 与发布日期。只有 http(s) URL 会成为锚点(设置 `target`/`rel`)——这是 `MarkdownText` 对不受信任链接所用 allowlist 的 http(s) 子集(该 allowlist 还允许 `mailto:`,此处排除);任何其他 URL 渲染为纯文本。长列表在 `maxSources`(默认 16,即 TerminalBlock 的切分算术)处折叠为头部/尾部;折叠的尾部通过 `
      1. ` 保留每个 source 原始的引用编号,展开控件是无 marker 的 `
      2. `,使 `
          ` 保持为合法 HTML。当一次 search 合法地返回无 answer 且无 source 时,卡片显示一个明确的空状态提示,而不是空的 `
            `(chat 行不呈现原始 result content)。`fetch` 显示一个紧凑摘要:带链接的最终 URL 及其 HTTP 状态。两者都会标记一次被截断的检索。原理:[Web result 卡片笔记](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md)。 + ## 模型体验 无。该包(package)在浏览器中渲染纯 React 原子组件;这里没有任何内容进入模型请求。 @@ -24,5 +28,5 @@ - **字形级图标是重新绘制的近似版本**:鱼形标志(以及 ui-conversation 持有的闪光图标)来自字体字形,而本地设计数据无法导出其矢量几何;在获得精确导出路径前,使用手工重建版本代替。 - **Pill 与 Input 没有设计来源**:两个原子组件均自行定义;与其相似的侧边栏搜索字段和视图标签条由消费方组合,不是这些原子组件。 - **StateDot 的 `Active` 变体是设计中的隐藏占位符**:尚未实现;已交付的四种状态(done/warning/ongoing/error)构成完整的 P-I 表层。 -- **面向用户的文案经 label props 本地化,默认值为原中文字面量**:这些原子组件是 zero-cordis 的,拿不到 `ctx.locale`,因此 `TerminalBlock`(`labels`)、`JsonTree`(`labels`)、`CodeBlock`(`copyLabel`/`copiedLabel`)、`MarkdownText`(`codeLabels`)、`JsonBlock`(`truncatedLabel`)、`ConnectionBanner`(`label`)和 `Modal`(`closeLabel`)都把文案作为可选 props 接收,默认值即此前的硬编码字符串。已本地化的插件用自己的 `t` 席位传入字典驱动的 label;什么都不传的消费者渲染与本地化之前逐字节一致。 +- **面向用户的文案经 label props 本地化,默认值为原中文字面量**:这些原子组件是 zero-cordis 的,拿不到 `ctx.locale`,因此 `TerminalBlock`(`labels`)、`JsonTree`(`labels`)、`CodeBlock`(`copyLabel`/`copiedLabel`)、`MarkdownText`(`codeLabels`)、`JsonBlock`(`truncatedLabel`)、`ConnectionBanner`(`label`)和 `Modal`(`closeLabel`)都把文案作为可选 props 接收,默认值即此前的硬编码字符串。已本地化的插件用自己的 `t` 席位传入字典驱动的 label;什么都不传的消费者渲染与本地化之前逐字节一致。`WebBlock` 尚未跟进这一模式:它的来源展开/收起控件、来源列表与 fetch 截断提示、以及空搜索提示仍是内联中文,待同样的 label-prop 处理。 - **`TerminalBlock` 不是终端模拟器**:它渲染已结束或仍在运行的命令输出,而不是交互式会话:SGR 颜色与属性会被遵循,进度行所用的行内光标移动同样被遵循——回车、退格、行内擦除、制表位与字符宽度。绝对光标定位、清屏与备用屏幕序列会被剥离。基础 16 色中的洋红与青色没有对应 token,保持字面 rgb。 diff --git a/packages/client/ui-primitives/src/WebBlock.module.css b/packages/client/ui-primitives/src/WebBlock.module.css new file mode 100644 index 0000000000..8aad5872ba --- /dev/null +++ b/packages/client/ui-primitives/src/WebBlock.module.css @@ -0,0 +1,133 @@ +/* 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. */ + +.block { + --dsl-web-radius: 12px; + + margin: 16px 0; + padding: 12px 14px; + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-markdown-code-block); + border-radius: var(--dsl-web-radius); +} + +/* The provider answer reads as body prose above the citation list; its own + MarkdownText margins are trimmed so the list sits tight under it. */ +.answer { + margin-bottom: 8px; +} + +.answer > :global(div) > :first-child { + margin-top: 0; +} + +.answer > :global(div) > :last-child { + margin-bottom: 0; +} + +/* The citation list: ordered so each source reads as a numbered reference. */ +.sources { + margin: 0; + padding-left: 20px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.source { + min-width: 0; +} + +.sourceLink { + color: var(--dsw-alias-state-business-primary); + font-size: 14px; + line-height: 20px; + word-break: break-word; +} + +.sourceLink:hover { + text-decoration: underline; +} + +.snippet { + margin-top: 2px; + color: var(--dsw-alias-label-secondary); + font-size: 13px; + line-height: 19px; + word-break: break-word; +} + +.published { + margin-top: 2px; + color: var(--dsw-alias-label-tertiary); + 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); + font: var(--dsw-font-xs-13); +} + +.empty { + color: var(--dsw-alias-label-secondary); + font: var(--dsw-font-xs-13); +} + +/* The fetch card is a compact summary: the URL over a status/truncation row. */ +.fetch { + display: flex; + flex-direction: column; + gap: 6px; +} + +.fetchUrl { + color: var(--dsw-alias-state-business-primary); + font-family: var(--ds-font-family-code); + font-size: 13px; + line-height: 19px; + word-break: break-all; +} + +.fetchUrl:hover { + text-decoration: underline; +} + +.fetchMeta { + display: flex; + align-items: baseline; + gap: 12px; +} + +.status { + color: var(--dsw-alias-label-secondary); + font: var(--dsw-font-xs-13); +} + +/* The fetch card's truncation note sits inline beside the status, so it drops + the search card's top margin. */ +.fetch .truncated { + margin-top: 0; +} diff --git a/packages/client/ui-primitives/src/WebBlock.tsx b/packages/client/ui-primitives/src/WebBlock.tsx new file mode 100644 index 0000000000..804ff17dfa --- /dev/null +++ b/packages/client/ui-primitives/src/WebBlock.tsx @@ -0,0 +1,242 @@ +// WebBlock: the surface for a completed web retrieval. One component draws both +// kinds of the `web` render intent, discriminated by `kind`: a `search` shows an +// optional provider answer above a citation list of sources (each a safe +// external link labelled by its title, or its hostname when the provider gave +// none, with the snippet and publication date below it), and a `fetch` shows a +// compact retrieval summary (the linked final URL and its HTTP status). Both +// mark a capped retrieval. Every link is a same-origin-safe external anchor: +// only http(s) URLs become anchors (target/rel set) — the http(s) subset of the +// 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. + +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 + * returned only a URL still renders (its hostname becomes the label). + */ +export interface WebSourceView { + /** The source URL; becomes a safe external link when it is http(s). */ + url: string + /** The source title; when absent the URL's hostname labels the link. */ + title?: string | undefined + /** A short excerpt or summary shown under the link. */ + snippet?: string | undefined + /** Publication/crawl timestamp, a provider-supplied string shown under the link. */ + publishedAt?: string | undefined +} + +/** A `web_search` card: an optional answer over a capped citation list. */ +export interface WebSearchBlockProps { + kind: 'search' + /** The provider-generated answer, rendered as markdown above the sources. */ + answer?: string | undefined + /** The cited sources, in provider order. */ + 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 +} + +/** A `web_fetch` card: the retrieval summary for one fetched URL. */ +export interface WebFetchBlockProps { + kind: 'fetch' + /** The final URL after allowed redirects; becomes a safe external link when http(s). */ + url: string + /** HTTP status code of the fetched response. */ + 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 +} + +/** A completed web retrieval card, discriminated by `kind`. */ +export type WebBlockProps = WebSearchBlockProps | WebFetchBlockProps + +/** + * The URL to link to, or undefined when the URL must render as plain text. Only + * http(s) becomes a navigable external anchor, so a `javascript:`/`data:`/`file:` + * URL or an unparseable string never reaches the DOM as an href. This is the + * http(s) subset of the allowlist MarkdownText applies to untrusted links — + * MarkdownText also permits `mailto:`, deliberately excluded here since a + * retrieval URL is never a mail address. + * @param url - the source or fetch URL, from tool result content. + * @returns the href to use, or undefined for plain text. + */ +function safeHref(url: string): string | undefined { + try { + const { protocol } = new URL(url) + return protocol === 'http:' || protocol === 'https:' ? url : undefined + } catch { + return undefined + } +} + +/** + * The link's visible label: the title when the provider gave one, otherwise the + * URL's hostname, falling back to the raw URL when it does not parse OR parses + * to an empty hostname (a `file:`/`data:`/`javascript:` URL), so a label is + * never blank. + * @param url - the source URL. + * @param title - the provider title, if any. + * @returns the label text. + */ +function linkLabel(url: string, title: string | undefined): string { + if (title !== undefined && title !== '') return title + try { + const { hostname } = new URL(url) + return hostname === '' ? url : hostname + } catch { + return url + } +} + +/** + * A single URL rendered as a safe external anchor, or as plain text when the + * URL is not an http(s) link. + * @param props.url - the URL to render. + * @param props.label - the visible label. + * @param props.className - class for the anchor or the plain span. + * @returns the anchor or span element. + */ +function SafeLink({ url, label, className }: { url: string; label: string; className?: string | undefined }) { + const href = safeHref(url) + if (href === undefined) return {label} + return ( + + {label} + + ) +} + +/** + * One source row in a search card: the safe link plus its snippet and date. The + * `
          1. ` 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. + * @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. + */ +function SourceItem({ source, ordinal }: { source: WebSourceView; ordinal: number }) { + return ( +
          2. + + {source.snippet !== undefined && source.snippet !== '' && ( +
            {source.snippet}
            + )} + {source.publishedAt !== undefined && source.publishedAt !== '' && ( +
            {source.publishedAt}
            + )} +
          3. + ) +} + +/** + * The search card body: the answer over the capped source list. + * @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) : [] + // 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. + const empty = (answer === undefined || answer === '') && sources.length === 0 + return ( +
            + {answer !== undefined && answer !== '' && ( +
            + )} + {empty ? ( +
            未找到结果
            + ) : ( +
              + {head.map((source, index) => )} + {hidden > 0 && ( +
            1. + +
            2. + )} + {tail.map((source, index) => ( + + ))} +
            + )} + {truncated &&
            来源列表已截断
            } +
            + ) +} + +/** + * The fetch card body: the linked URL and its HTTP status. + * @param props - see {@link WebFetchBlockProps}. + * @returns the fetch card element. + */ +function WebFetchBlock({ url, statusCode, truncated, className }: WebFetchBlockProps) { + return ( +
            + +
            + HTTP {statusCode} + {truncated && 内容已截断} +
            +
            + ) +} + +/** + * Render a completed web retrieval as a structured card. + * @param props - see {@link WebBlockProps}; `kind` selects the search or fetch body. + * @returns the web card element. + */ +export function WebBlock(props: WebBlockProps) { + return props.kind === 'search' ? : +} diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index e1dcde0c80..e191112d7b 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -22,6 +22,8 @@ export { JsonTree } from './JsonTree.tsx' export type { JsonTreeProps, JsonTreeLabels } from './JsonTree.tsx' export { TerminalBlock, DEFAULT_TERMINAL_MAX_LINES } from './TerminalBlock.tsx' export type { TerminalBlockProps, TerminalBlockLabels } from './TerminalBlock.tsx' +export { WebBlock, DEFAULT_WEB_MAX_SOURCES } 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' export { JsonBlock } from './markdown/JsonBlock.tsx' diff --git a/packages/client/ui-primitives/tests/web-block.spec.tsx b/packages/client/ui-primitives/tests/web-block.spec.tsx new file mode 100644 index 0000000000..d681ce753b --- /dev/null +++ b/packages/client/ui-primitives/tests/web-block.spec.tsx @@ -0,0 +1,211 @@ +// @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. + +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 type { WebSourceView } from '../src/index.ts' + +afterEach(cleanup) + +/** `count` sources with sequential hostnames, so the cap slices read distinctly. */ +function sources(count: number): WebSourceView[] { + return Array.from({ length: count }, (_value, index) => ({ + url: `https://site-${index}.example.com/page`, + title: `Source ${index}`, + })) +} + +describe('WebBlock search card', () => { + it('renders the answer above the citation list', () => { + const view = render() + expect(view.getByText('Answer')).toBeTruthy() + expect(view.getByText('Source 0')).toBeTruthy() + expect(view.getByText('Source 1')).toBeTruthy() + }) + + it('omits the answer block when there is no answer', () => { + const view = render() + expect(view.container.querySelector('[class^="_answer_"]')).toBeNull() + const empty = render() + expect(empty.container.querySelector('[class^="_answer_"]')).toBeNull() + }) + + it('shows the empty-state note when a search returns no answer and no sources', () => { + const view = render() + expect(view.getByText('未找到结果')).toBeTruthy() + // The empty note replaces the source list, not an empty
              . + expect(view.container.querySelector('ol')).toBeNull() + }) + + it('shows the source list, not the empty note, when a source is present', () => { + const view = render() + expect(view.container.querySelector('ol')).toBeTruthy() + expect(view.queryByText('未找到结果')).toBeNull() + }) + + it('shows the source list when an empty source list still carries an answer', () => { + const view = render() + expect(view.getByText('Just an answer')).toBeTruthy() + expect(view.queryByText('未找到结果')).toBeNull() + }) + + it('labels a source by its title, and by hostname when the title is absent', () => { + const view = render() + expect(view.getByText('Titled')).toBeTruthy() + // No title / empty title: the hostname labels the link. + expect(view.getByText('plain.example.org')).toBeTruthy() + expect(view.getByText('empty.example.net')).toBeTruthy() + }) + + it('labels a source by the raw url when it parses to an empty hostname', () => { + // file:/data:/javascript: URLs parse but have no hostname; the label must + // fall back to the raw URL so it is never blank (and the link stays plain + // text since the protocol is not http(s)). + const view = render() + expect(view.getByText('file:///etc/passwd')).toBeTruthy() + }) + + it('renders a source as a safe external anchor for an http(s) url', () => { + const view = render() + const anchor = view.getByText('Titled') as HTMLAnchorElement + expect(anchor.tagName).toBe('A') + expect(anchor.getAttribute('href')).toBe('https://example.com/a') + expect(anchor.getAttribute('target')).toBe('_blank') + expect(anchor.getAttribute('rel')).toBe('noopener noreferrer') + }) + + it('renders a non-http url as plain text with no href, and its raw text label when unparseable', () => { + const view = render() + const unsafe = view.getByText('Dangerous') + expect(unsafe.tagName).toBe('SPAN') + expect(unsafe.getAttribute('href')).toBeNull() + // An unparseable url is not a link and cannot yield a hostname, so its raw + // text is the label. + const raw = view.getByText('not a url') + expect(raw.tagName).toBe('SPAN') + }) + + it('shows a source snippet and publication date when present, and omits them when absent or empty', () => { + const view = render() + expect(view.getByText('excerpt')).toBeTruthy() + expect(view.getByText('2026-07-01')).toBeTruthy() + // The empty-string and absent arms both draw nothing beyond the link. + expect(view.container.querySelectorAll('[class^="_snippet_"]')).toHaveLength(1) + expect(view.container.querySelectorAll('[class^="_published_"]')).toHaveLength(1) + }) + + it('shows the truncated indicator only when the list was capped by the tool', () => { + const on = render() + expect(on.getByText('来源列表已截断')).toBeTruthy() + cleanup() + const off = render() + 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) + 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
            1. ), 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
            2. , so it is valid inside
                and does not + // consume a citation number between the head and tail sources. + const view = render() + const ol = view.container.querySelector('ol')! + // Every direct child is an
              1. (no bare