Merge remote-tracking branch 'origin/feat/read-presenter' into feat/web-read-card

# Conflicts:
#	packages/client/connection/src/client/fixture.ts
#	packages/client/ui-conversation/src/client/apply.ts
#	packages/client/ui-conversation/src/client/chat/GenericToolCard.module.css
#	packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx
#	packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css
#	packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx
#	packages/client/ui-conversation/tests/chat-apply.spec.tsx
#	packages/client/ui-primitives/src/index.ts
This commit is contained in:
Chinesezjc
2026-07-31 13:23:39 +08:00
138 changed files with 3702 additions and 447 deletions

View File

@@ -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

View File

@@ -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 `<li value>`, and the expand control is a marker-less `<li>` so the `<ol>` 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 `<ol>` (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.

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
纯 React 原子组件(零 cordisStateDot、ic_ds_* 图标、Button/Pill/Menu/Modal/Input、markdown 家族MessageText/MarkdownText/JsonBlock、只读 JsonTree 检查器、`useAnchoredMaxHeight` hook把底部锚定的浮层高度收敛到锚点上方的视口空间并在 resize、scroll 与调用方提供的依赖变化时重新测量),以及 TerminalBlock。契约api-contracts v3 §8。
纯 React 原子组件(零 cordisStateDot、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 的切分算术)处折叠为头部/尾部;折叠的尾部通过 `<li value>` 保留每个 source 原始的引用编号,展开控件是无 marker 的 `<li>`,使 `<ol>` 保持为合法 HTML。当一次 search 合法地返回无 answer 且无 source 时,卡片显示一个明确的空状态提示,而不是空的 `<ol>`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。

View File

@@ -1,9 +1,11 @@
// Modal: controlled full-viewport dialog (create-workspace and similar).
// Fixed overlay in the React tree (no react-dom portal) so ui-primitives
// stays free of a react-dom dependency; mask tokens match figma 451:18655.
// The overlay portals to this document's body so ancestor stacking contexts
// cannot leave sticky page controls above the mask. This is still an in-page
// WebUI dialog; it never creates or targets another browser/native window.
import { useEffect } from 'react'
import type { ReactNode } from 'react'
import { createPortal } from 'react-dom'
import clsx from 'clsx'
import { IconCloseOutline16 } from './icons/index.tsx'
import css from './Modal.module.css'
@@ -17,6 +19,7 @@ import css from './Modal.module.css'
* @param props.description - optional supporting sentence under the title.
* @param props.children - body (inputs, etc.).
* @param props.footer - action row (Cancel / Create).
* @param props.contentClassName - optional class for a scrollable content region.
* @param props.headless - render children directly in the card (no default
* header/close/body chrome) for dialogs whose figma frame owns its own
* header structure; mask, card, Escape, and aria-label remain.
@@ -25,7 +28,7 @@ import css from './Modal.module.css'
* @returns null when closed; otherwise the overlay tree.
*/
export function Modal({
open, onClose, title, closeLabel = 'Close', description, children, footer, className, headless = false,
open, onClose, title, closeLabel = 'Close', description, children, footer, className, contentClassName, headless = false,
}: {
open: boolean
onClose: () => void
@@ -35,6 +38,7 @@ export function Modal({
children?: ReactNode
footer?: ReactNode
className?: string
contentClassName?: string
headless?: boolean
}) {
useEffect(() => {
@@ -48,7 +52,7 @@ export function Modal({
if (!open) return null
return (
return createPortal((
<div className={css.root} role="presentation">
<div className={css.mask} aria-hidden="true" onClick={onClose} />
<div
@@ -61,7 +65,7 @@ export function Modal({
? children
: (
<>
<div className={css.content}>
<div className={clsx(css.content, contentClassName)}>
<div className={css.header}>
<h2 className={css.title}>{title}</h2>
<button type="button" className={css.close} aria-label={closeLabel} onClick={onClose}>
@@ -78,5 +82,5 @@ export function Modal({
)}
</div>
</div>
)
), document.body)
}

View File

@@ -0,0 +1,73 @@
.confirmation {
width: min(440px, 100%);
max-height: calc(100vh - 48px);
overflow: hidden;
}
.confirmationContent {
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
}
@supports (height: 100dvh) {
.confirmation {
max-height: calc(100dvh - 48px);
}
}
.warning {
display: flex;
align-items: flex-start;
gap: 10px;
color: var(--dsw-alias-label-secondary);
font-size: 14px;
line-height: 22px;
}
.warning p {
margin: 0;
}
.warningIcon {
flex: none;
margin-top: 2px;
color: var(--dsw-alias-state-error-primary);
}
.acknowledgement {
display: flex;
align-items: flex-start;
gap: 10px;
margin-top: 20px;
color: var(--dsw-alias-label-primary);
font-size: 14px;
line-height: 22px;
cursor: pointer;
}
.acknowledgement input {
flex: none;
width: 16px;
height: 16px;
margin: 3px 0 0;
accent-color: var(--dsw-alias-button-primary-fill);
cursor: pointer;
}
.acknowledgement input:focus-visible {
outline: 2px solid var(--dsw-alias-border-l4);
outline-offset: 2px;
}
.acknowledgement input:disabled {
cursor: default;
}
.modalAction {
min-width: 72px;
}
.confirmAction {
min-width: 136px;
}

View File

@@ -0,0 +1,80 @@
/**
* Controlled risk acknowledgement dialog shared by product surfaces that
* must gate a sensitive action behind an explicit checkbox.
*/
import { Button } from './Button.tsx'
import { IconWarningOutline16 } from './icons/index.tsx'
import { Modal } from './Modal.tsx'
import css from './RiskConfirmation.module.css'
export interface RiskConfirmationProps {
open: boolean
title: string
description: string
acknowledgeLabel: string
cancelLabel: string
confirmLabel: string
acknowledged: boolean
disabled?: boolean
onAcknowledgedChange: (acknowledged: boolean) => void
onCancel: () => void
onConfirm: () => void
}
/**
* Render one in-page confirmation whose primary action is unavailable until
* the caller-controlled acknowledgement is checked.
*/
export function RiskConfirmation({
open,
title,
description,
acknowledgeLabel,
cancelLabel,
confirmLabel,
acknowledged,
disabled = false,
onAcknowledgedChange,
onCancel,
onConfirm,
}: RiskConfirmationProps) {
return (
<Modal
open={open}
onClose={onCancel}
title={title}
className={css.confirmation ?? ''}
contentClassName={css.confirmationContent ?? ''}
footer={(
<>
<Button variant="outline" className={css.modalAction} onClick={onCancel}>
{cancelLabel}
</Button>
<Button
variant="primary"
className={css.confirmAction}
disabled={disabled || !acknowledged}
onClick={onConfirm}
>
{confirmLabel}
</Button>
</>
)}
>
<div className={css.warning}>
<IconWarningOutline16 size={18} className={css.warningIcon} />
<p>{description}</p>
</div>
<label className={css.acknowledgement}>
<input
type="checkbox"
checked={acknowledged}
disabled={disabled}
autoFocus
onChange={(event) => { onAcknowledgedChange(event.currentTarget.checked) }}
/>
<span>{acknowledgeLabel}</span>
</label>
</Modal>
)
}

View File

@@ -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;
}

View File

@@ -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 <span className={className}>{label}</span>
return (
<a className={className} href={href} target="_blank" rel="noopener noreferrer">
{label}
</a>
)
}
/**
* One source row in a search card: the safe link plus its snippet and date. The
* `<li value>` pins the source's original 1-based position, so a collapsed list
* whose tail is drawn after the head still numbers each source by its real
* citation index rather than by its position in the visible subset.
* @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 (
<li className={css.source} value={ordinal}>
<SafeLink url={source.url} label={linkLabel(source.url, source.title)} className={css.sourceLink} />
{source.snippet !== undefined && source.snippet !== '' && (
<div className={css.snippet}>{source.snippet}</div>
)}
{source.publishedAt !== undefined && source.publishedAt !== '' && (
<div className={css.published}>{source.publishedAt}</div>
)}
</li>
)
}
/**
* 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 (
<div className={clsx(css.block, className)} data-web="search">
{answer !== undefined && answer !== '' && (
<div className={css.answer}><MarkdownText text={answer} /></div>
)}
{empty ? (
<div className={css.empty}></div>
) : (
<ol className={css.sources}>
{head.map((source, index) => <SourceItem key={index} source={source} ordinal={index + 1} />)}
{hidden > 0 && (
<li className={css.expandItem}>
<button
type="button"
className={css.expand}
aria-expanded={expanded}
aria-label={expanded ? '收起来源' : `展开其余 ${hidden} 条来源`}
onClick={onToggle}
>
{expanded ? '收起' : `… 其余 ${hidden} 条来源`}
</button>
</li>
)}
{tail.map((source, index) => (
<SourceItem
key={sources.length - tailCount + index}
source={source}
ordinal={sources.length - tailCount + index + 1}
/>
))}
</ol>
)}
{truncated && <div className={css.truncated}></div>}
</div>
)
}
/**
* 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 (
<div className={clsx(css.block, css.fetch, className)} data-web="fetch">
<SafeLink url={url} label={url} className={css.fetchUrl} />
<div className={css.fetchMeta}>
<span className={css.status}>HTTP {statusCode}</span>
{truncated && <span className={css.truncated}></span>}
</div>
</div>
)
}
/**
* 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' ? <WebSearchBlock {...props} /> : <WebFetchBlock {...props} />
}

View File

@@ -13,6 +13,8 @@ export type { MenuEntry, MenuItem, MenuSeparator, MenuLabel } from './Menu.tsx'
export { useAnchoredMaxHeight } from './useAnchoredMaxHeight.ts'
export { HoverCard } from './HoverCard.tsx'
export { Modal } from './Modal.tsx'
export { RiskConfirmation } from './RiskConfirmation.tsx'
export type { RiskConfirmationProps } from './RiskConfirmation.tsx'
export { ConnectionBanner } from './ConnectionBanner.tsx'
export { FishLogo } from './FishLogo.tsx'
export { BrandWordmark } from './BrandWordmark.tsx'
@@ -24,6 +26,8 @@ export { TerminalBlock, DEFAULT_TERMINAL_MAX_LINES } from './TerminalBlock.tsx'
export type { TerminalBlockProps, TerminalBlockLabels } from './TerminalBlock.tsx'
export { ReadBlock, DEFAULT_READ_MAX_LINES } from './ReadBlock.tsx'
export type { ReadBlockProps, ReadBlockLine } from './ReadBlock.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'

View File

@@ -324,12 +324,17 @@ describe('Modal', () => {
<Modal open={false} onClose={onClose} title="Create new workspace">body</Modal>)
expect(screen.queryByRole('dialog')).toBeNull()
rerender(
<Modal open onClose={onClose} title="Create new workspace" closeLabel="Configure later" description="Name it." footer={<button type="button">Create</button>}>
<Modal open onClose={onClose} title="Create new workspace" closeLabel="Configure later" description="Name it." contentClassName="scrolling-content" footer={<button type="button">Create</button>}>
<input aria-label="name" />
</Modal>)
expect(screen.getByRole('dialog', { name: 'Create new workspace' })).toBeDefined()
const dialog = screen.getByRole('dialog', { name: 'Create new workspace' })
expect(dialog).toBeDefined()
// The full-page layer escapes caller stacking contexts but remains in
// this document/current WebUI window.
expect(dialog.parentElement?.parentElement).toBe(document.body)
expect(screen.getByRole('button', { name: 'Configure later' })).toBeDefined()
expect(screen.getByText('Name it.')).toBeDefined()
expect(screen.getByText('Name it.').parentElement?.className).toContain('scrolling-content')
fireEvent.keyDown(document, { key: 'a' })
expect(onClose).not.toHaveBeenCalled()
fireEvent.keyDown(document, { key: 'Escape' })

View File

@@ -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(<WebBlock kind="search" answer="**Answer** text" sources={sources(2)} truncated={false} />)
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(<WebBlock kind="search" sources={sources(1)} truncated={false} />)
expect(view.container.querySelector('[class^="_answer_"]')).toBeNull()
const empty = render(<WebBlock kind="search" answer="" sources={sources(1)} truncated={false} />)
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(<WebBlock kind="search" sources={[]} truncated={false} />)
expect(view.getByText('未找到结果')).toBeTruthy()
// The empty note replaces the source list, not an empty <ol>.
expect(view.container.querySelector('ol')).toBeNull()
})
it('shows the source list, not the empty note, when a source is present', () => {
const view = render(<WebBlock kind="search" sources={sources(1)} truncated={false} />)
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(<WebBlock kind="search" answer="Just an answer" sources={[]} truncated={false} />)
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(<WebBlock kind="search" truncated={false} sources={[
{ url: 'https://example.com/a', title: 'Titled' },
{ url: 'https://plain.example.org/b' },
{ url: 'https://empty.example.net/c', title: '' },
]} />)
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(<WebBlock kind="search" truncated={false} sources={[
{ url: 'file:///etc/passwd' },
]} />)
expect(view.getByText('file:///etc/passwd')).toBeTruthy()
})
it('renders a source as a safe external anchor for an http(s) url', () => {
const view = render(<WebBlock kind="search" truncated={false} sources={[
{ url: 'https://example.com/a', title: 'Titled' },
]} />)
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(<WebBlock kind="search" truncated={false} sources={[
{ url: 'javascript:alert(1)', title: 'Dangerous' },
{ url: 'not a url' },
]} />)
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(<WebBlock kind="search" truncated={false} sources={[
{ url: 'https://a.example.com', title: 'A', snippet: 'excerpt', publishedAt: '2026-07-01' },
{ url: 'https://b.example.com', title: 'B', snippet: '', publishedAt: '' },
{ url: 'https://c.example.com', title: 'C' },
]} />)
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(<WebBlock kind="search" sources={sources(1)} truncated />)
expect(on.getByText('来源列表已截断')).toBeTruthy()
cleanup()
const off = render(<WebBlock kind="search" sources={sources(1)} truncated={false} />)
expect(off.queryByText('来源列表已截断')).toBeNull()
})
it('renders every source and no expand control under the cap', () => {
const view = render(<WebBlock kind="search" sources={sources(4)} truncated={false} maxSources={4} />)
expect(view.container.querySelectorAll('[class^="_source_"]')).toHaveLength(4)
expect(view.container.querySelector('[aria-expanded]')).toBeNull()
})
it('slices head and tail over the cap and expands on click', () => {
const view = render(<WebBlock kind="search" sources={sources(10)} truncated={false} maxSources={4} />)
// maxSources 4: head = ceil(4/2) = 2, tail = 4 - 2 = 2, 6 hidden.
expect([...view.container.querySelectorAll('[class^="_sourceLink_"]')].map(n => n.textContent))
.toEqual(['Source 0', 'Source 1', 'Source 8', 'Source 9'])
const toggle = view.getByRole('button', { name: '展开其余 6 条来源' })
expect(toggle.getAttribute('aria-expanded')).toBe('false')
expect(toggle.textContent).toBe('… 其余 6 条来源')
fireEvent.click(toggle)
expect(view.container.querySelectorAll('[class^="_source_"]')).toHaveLength(10)
const collapse = view.getByRole('button', { name: '收起来源' })
expect(collapse.getAttribute('aria-expanded')).toBe('true')
expect(collapse.textContent).toBe('收起')
fireEvent.click(collapse)
expect(view.container.querySelectorAll('[class^="_source_"]')).toHaveLength(4)
})
it('numbers a collapsed tail by each source original position, not its visible slot', () => {
// maxSources 4 over 10 sources: the tail is sources 8 and 9, which must read
// as citations 9 and 10 (via <li value>), not renumbered 3 and 4.
const view = render(<WebBlock kind="search" sources={sources(10)} truncated={false} maxSources={4} />)
const items = [...view.container.querySelectorAll('li[class^="_source_"]')]
expect(items.map(li => li.getAttribute('value'))).toEqual(['1', '2', '9', '10'])
})
it('keeps the expander out of the ordered-list numbering', () => {
// The expander is a marker-less <li>, so it is valid inside <ol> and does not
// consume a citation number between the head and tail sources.
const view = render(<WebBlock kind="search" sources={sources(10)} truncated={false} maxSources={4} />)
const ol = view.container.querySelector('ol')!
// Every direct child is an <li> (no bare <button> child — invalid HTML).
expect([...ol.children].every(child => child.tagName === 'LI')).toBe(true)
})
it('renders the head slice alone when the cap leaves no tail', () => {
const view = render(<WebBlock kind="search" sources={sources(5)} truncated={false} maxSources={1} />)
expect([...view.container.querySelectorAll('[class^="_sourceLink_"]')].map(n => n.textContent)).toEqual(['Source 0'])
expect(view.getByRole('button', { name: '展开其余 4 条来源' })).toBeTruthy()
})
it('caps at the documented default when maxSources is absent', () => {
const view = render(<WebBlock kind="search" sources={sources(DEFAULT_WEB_MAX_SOURCES + 1)} truncated={false} />)
expect(view.container.querySelectorAll('[class^="_source_"]')).toHaveLength(DEFAULT_WEB_MAX_SOURCES)
expect(view.getByRole('button', { name: '展开其余 1 条来源' })).toBeTruthy()
})
})
describe('WebBlock fetch card', () => {
it('renders the fetched url as a safe external anchor and its HTTP status', () => {
const view = render(<WebBlock kind="fetch" url="https://example.com/page" statusCode={200} truncated={false} />)
const anchor = view.getByText('https://example.com/page') as HTMLAnchorElement
expect(anchor.tagName).toBe('A')
expect(anchor.getAttribute('href')).toBe('https://example.com/page')
expect(anchor.getAttribute('target')).toBe('_blank')
expect(anchor.getAttribute('rel')).toBe('noopener noreferrer')
expect(view.getByText('HTTP 200')).toBeTruthy()
})
it('renders a non-http fetch url as plain text with no href', () => {
const view = render(<WebBlock kind="fetch" url="file:///etc/passwd" statusCode={200} truncated={false} />)
const label = view.getByText('file:///etc/passwd')
expect(label.tagName).toBe('SPAN')
expect(label.getAttribute('href')).toBeNull()
})
it('shows the truncated indicator only when the content was cut', () => {
const on = render(<WebBlock kind="fetch" url="https://example.com" statusCode={200} truncated />)
expect(on.getByText('内容已截断')).toBeTruthy()
cleanup()
const off = render(<WebBlock kind="fetch" url="https://example.com" statusCode={200} truncated={false} />)
expect(off.queryByText('内容已截断')).toBeNull()
})
it('carries a non-200 status verbatim', () => {
const view = render(<WebBlock kind="fetch" url="https://example.com/missing" statusCode={404} truncated={false} />)
expect(view.getByText('HTTP 404')).toBeTruthy()
})
})