Merge remote-tracking branch 'origin/master' into worktree-session-hover-menu-fix

# Conflicts:
#	apps/web/tests/workspace-management.e2e.ts
This commit is contained in:
creatixchu
2026-07-31 15:14:05 +08:00
1191 changed files with 45284 additions and 5704 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: 0ef3c20f848b3d331c007911d0837f11cd72c024
README.zh.md: af94551bfb9e12dbadcef6a96a54f9bf7ea71299
README.md: 58be01d56a85c66a144df3f8054840961e987403
README.zh.md: 2efbec77e64d664553e93b5a8f8dcd2ec7fce49e

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, DiffBlock, and WebBlock. Contract: api-contracts v3 §8.
## Markdown rendering
@@ -12,6 +12,14 @@ 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).
## Diff rendering
`DiffBlock` renders a file mutation as an inline diff surface: one bold path header per file, the removed lines (`- `, error token) above the added lines (`+ `, success token), a `⋯` gap before a same-file second hunk, and a dim `└ +A -R · N file(s)` footer. Lines are `white-space: pre` with horizontal scrolling, so a source line holds its indentation instead of soft-wrapping, and the body collapses to a head slice plus a tail slice past `maxLines` (default 16, `TerminalBlock`'s split arithmetic) behind an expand button. A create (`oldText: null`) has no removed side. The copy control writes the prefixed diff text (path headers, `- `/`+ ` lines, the gap) so a multi-file copy stays attributable, and floats in the top-right corner rather than on a banner row of its own. Geometry mirrors `CodeBlock`/`TerminalBlock`. The `+`/`-` block form mirrors the TUI transcript's diff card so a diff reads the same across front ends. Rationale: [the web diff card note](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-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 +33,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.
- **This package's user-facing copy is inline Chinese, not localized** — the atoms are zero-cordis and so cannot reach `ctx.locale`; `TerminalBlock`'s exit-code and signal pills, its copy and expand controls, and `CodeBlock`'s copy control are all hardcoded. This matches the repo-wide state the locale package records (only the Settings surface is translated); extracting these into the `zh`/`en` dictionaries needs a localization channel for zero-cordis atoms and belongs to that repo-wide extraction.
- **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、DiffBlock以及 WebBlock。契约api-contracts v3 §8。
## Markdown 渲染
@@ -11,6 +11,14 @@
`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)。
## Diff 渲染
`DiffBlock` 将一次文件改动渲染为内联 diff 表层:每个文件一个粗体路径头、删除行(`- `error token在新增行`+ `success token之上、同文件第二个 hunk 前一个 `⋯` gap以及暗色 `└ +A -R · N file(s)` 页脚。各行使用 `white-space: pre` 并横向滚动,因此源码行保留其缩进而不软换行;超过 `maxLines`(默认 16`TerminalBlock` 相同的切分算法)时折叠为头部切片加尾部切片,由展开按钮控制。新建(`oldText: null`)没有删除侧。复制控件写入带前缀的 diff 文本(路径头、`- `/`+ ` 行、gap使多文件复制保持可归属并浮在右上角而非占据自己的 banner 行。几何镜像 `CodeBlock`/`TerminalBlock``+`/`-` 块形式镜像 TUI 转录的 diff 卡片,使 diff 在两个前端读起来一致。原理:[Web diff 卡片笔记](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-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 +32,5 @@
- **字形级图标是重新绘制的近似版本**:鱼形标志(以及 ui-conversation 持有的闪光图标)来自字体字形,而本地设计数据无法导出其矢量几何;在获得精确导出路径前,使用手工重建版本代替。
- **Pill 与 Input 没有设计来源**:两个原子组件均自行定义;与其相似的侧边栏搜索字段和视图标签条由消费方组合,不是这些原子组件。
- **StateDot 的 `Active` 变体是设计中的隐藏占位符**尚未实现已交付的四种状态done/warning/ongoing/error构成完整的 P-I 表层。
- **本包面向用户的文案是内联中文,未做本地化**:这些原子组件是 zero-cordis 的,因此拿不到 `ctx.locale``TerminalBlock` 的退出码与信号胶囊、它的复制与展开控件,以及 `CodeBlock` 的复制控件全部硬编码。这与 locale 包记录的全仓现状一致(只有 Settings 表面做了翻译);把它们抽取进 `zh`/`en` 字典需要为 zero-cordis 原子组件提供一条本地化通道,属于那次全仓抽取的范围
- **面向用户的文案经 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,5 +1,5 @@
// DeepSeek Harness brand wordmark (figma 356:14644, exact extract): whale +
// "deepseek" letterforms + HARNESS badge plate in one svg. Native 182x24.
// "deepseek-official" letterforms + HARNESS badge plate in one svg. Native 182x24.
// Ink rides currentColor; the badge text is knocked out in the inverted
// label color so the plate stays legible in both themes.

View File

@@ -8,9 +8,14 @@ import css from './ConnectionBanner.module.css'
/**
* Render the reconnecting banner.
* @param props.reconnecting - true while the connection is in backoff/retry.
* @param props.label - banner text; the owner passes localized copy (this
* package is cordis-free, so copy arrives via props).
* @returns the banner, or null when connected.
*/
export function ConnectionBanner({ reconnecting }: { reconnecting: boolean }) {
export function ConnectionBanner({ reconnecting, label = '连接已断开,正在重连…' }: {
reconnecting: boolean
label?: string | undefined
}) {
if (!reconnecting) return null
return <div className={css.banner}></div>
return <div className={css.banner}>{label}</div>
}

View File

@@ -0,0 +1,107 @@
/* Geometry mirrors CodeBlock/TerminalBlock (12px radius, code-block surface +
banner row, markdown code-block font) so a diff card reads as one family with
a fenced block and a terminal card. The deliberate divergence, shared with
TerminalBlock: the body keeps `white-space: pre` and scrolls horizontally,
because folding a source line destroys the indentation a diff is read by. */
.block {
--dsl-diff-radius: 12px;
--dsl-diff-line-height: 22px;
position: relative;
margin: 16px 0;
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-markdown-code-block);
border-radius: var(--dsl-diff-radius);
}
/* The copy control floats in the top-right corner over the body, so the card
has no empty banner row above its first diff line (the TUI diff card has no
banner either — only the footer). The block is position: relative, so this
anchors to the card. */
.copyButton {
position: absolute;
top: 8px;
right: 12px;
z-index: 1;
background-color: transparent;
border: none;
padding: 0;
margin: 0;
color: var(--dsw-alias-label-secondary);
cursor: pointer;
font: var(--dsw-font-xs-13);
}
.body {
padding: 12px 14px;
font: var(--dsw-font-markdown-code-block);
overflow-x: auto;
overflow-y: hidden;
}
/* No wrapping, no word-break: a diff is read by its indentation. */
.line {
min-height: var(--dsl-diff-line-height);
white-space: pre;
}
/* A file header: the path in the primary tone, set apart by weight. The copy
button floats over this first row's top-right corner, so reserve space at the
line's end for it — a long path scrolls under the button otherwise, and the
button's hit area would eat clicks on the path's tail. */
.path {
color: var(--dsw-alias-label-primary);
font-weight: 600;
padding-right: 56px;
}
/* A same-file second hunk's separator (a scattered edit), in the dim tone. */
.gap {
color: var(--dsw-alias-label-tertiary);
}
/* The diff's own meaning-carrying colors: removed on the error token, added on
the success token. A `- `/`+ ` prefix is drawn here so a copied line and the
shown line agree, and so the sign reads without relying on color alone. */
.del::before {
content: '- ';
color: var(--dsw-alias-state-error-primary);
}
.del {
color: var(--dsw-alias-state-error-primary);
}
.add::before {
content: '+ ';
color: var(--dsw-alias-state-success-primary);
}
.add {
color: var(--dsw-alias-state-success-primary);
}
.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);
}
/* The change summary, dim under the body: `└ +A -R · N file(s)`, the same
footer the TUI transcript's diff card draws. */
.footer {
padding: 0 14px 12px;
font: var(--dsw-font-markdown-code-block);
color: var(--dsw-alias-label-tertiary);
}

View File

@@ -0,0 +1,196 @@
// DiffBlock: the inline-diff surface for a file mutation (write/edit) — a copy
// control over one or more per-file hunks, each a bold path header followed by
// the removed block (`-`, error color) and the added block (`+`, success
// color), with a dim `└ +A -R · N file(s)` footer. The +/- block form mirrors
// the TUI transcript's diff card (packages/ui/tui: diffLines) so a diff reads
// the same across front ends: the removed side is the old text in full, the
// added side the new text in full, both split on the same terminator rule, and
// the footer counts distinct paths on both ends. Output never soft-wraps — an
// aligned source line keeps its indentation and scrolls horizontally instead of
// folding. Colors resolve through --dsw-* tokens; geometry mirrors CodeBlock.
import { useCallback, useMemo, useState } from 'react'
import clsx from 'clsx'
import { writeClipboard } from './clipboard.ts'
import css from './DiffBlock.module.css'
/**
* Output lines shown before the height cap collapses the middle. Matches
* {@link DEFAULT_TERMINAL_MAX_LINES} so a diff card and a terminal card cut a
* long body at the same place.
*/
export const DEFAULT_DIFF_MAX_LINES = 16
/**
* One file's change, in the shape {@link DiffBlock} draws. Structurally the
* render-intent contract's `FileDiff`, redeclared here so this primitive stays
* free of the tool contract (the terminal card's decoupling, applied to diffs).
*/
export interface DiffHunk {
/** The changed file's path, drawn verbatim as the hunk's header (the tool's model-facing path). */
path: string
/** Prior content, or `null` for a new file / an overwrite (nothing on the removed side). */
oldText: string | null
/** Content after the change (the added side). */
newText: string
}
export interface DiffBlockProps {
/** One entry per applied hunk, in file order; empty renders nothing. */
diffs: DiffHunk[]
/** Height cap in body lines before the middle collapses (default {@link DEFAULT_DIFF_MAX_LINES}). */
maxLines?: number | undefined
/** Extra class merged onto the wrapper (callers position; this component draws). */
className?: string | undefined
}
/** A single rendered body line and its role, so the height cap slices a flat list. */
interface DiffRow {
kind: 'path' | 'del' | 'add' | 'gap'
text: string
}
/** Local exhaustiveness helper — this package does not depend on `dsh-llm`. */
/* v8 ignore next 3 -- closed-union backstop; only reached if a row kind is forged */
function assertNever(value: never): never {
throw new Error(`unreachable diff row kind: ${String(value)}`)
}
/** The dim class per row kind (path/gap chrome vs the diff's own +/- colors). */
const ROW_CLASS: Record<DiffRow['kind'], string | undefined> = {
path: css.path,
del: css.del,
add: css.add,
gap: css.gap,
}
/**
* Flatten the hunks into the body's rows plus the footer counts. A path header
* opens each new file; a same-file second hunk (a scattered edit) opens with a
* `⋯` gap instead of repeating the path. Every old-side line counts toward
* `removed` and every new-side line toward `added`. The file count is of
* DISTINCT paths, matching the TUI diff card's footer, so two hunks in one file
* read as `1 file` on both front ends.
* @param diffs - the hunks to render.
* @returns the body rows, the +/- totals, and the distinct-file count.
*/
function buildRows(diffs: DiffHunk[]): { rows: DiffRow[]; added: number; removed: number; files: number } {
const rows: DiffRow[] = []
const paths = new Set<string>()
let added = 0
let removed = 0
let prevPath: string | undefined
for (const diff of diffs) {
paths.add(diff.path)
if (diff.path !== prevPath) rows.push({ kind: 'path', text: diff.path })
else rows.push({ kind: 'gap', text: '⋯' })
prevPath = diff.path
if (diff.oldText !== null) {
for (const line of contentLines(diff.oldText)) {
rows.push({ kind: 'del', text: line })
removed++
}
}
for (const line of contentLines(diff.newText)) {
rows.push({ kind: 'add', text: line })
added++
}
}
return { rows, added, removed, files: paths.size }
}
/**
* Split a side's text into its content lines. Empty text is zero lines (a full
* deletion's `newText` or a create's absent `oldText` side draws nothing), and a
* single trailing newline is a line terminator rather than an extra empty line —
* the same terminator rule TerminalBlock applies to command output. An interior
* blank line (a genuine `\n\n`) survives.
* @param text - the removed or added side's text.
* @returns the content lines, without the terminating newline.
*/
function contentLines(text: string): string[] {
if (text === '') return []
const body = text.endsWith('\n') ? text.slice(0, -1) : text
return body.split('\n')
}
/**
* The diff text a reader copies: each row's `-`/`+`/path/gap prefix and its
* content, exactly what the card shows. The removed and added blocks are the
* change; the path headers keep a multi-file copy attributable.
* @param rows - the flattened body rows.
* @returns the diff as plain text.
*/
function copyText(rows: DiffRow[]): string {
return rows.map((row) => {
switch (row.kind) {
case 'del': return `- ${row.text}`
case 'add': return `+ ${row.text}`
case 'path': return row.text
case 'gap': return row.text
/* v8 ignore next -- closed-union backstop; only reached if a row kind is forged */
default: return assertNever(row.kind)
}
}).join('\n')
}
/**
* Render a file mutation as an inline diff surface.
* @param props - see {@link DiffBlockProps}.
* @returns the diff block element.
*/
export function DiffBlock({ diffs, maxLines = DEFAULT_DIFF_MAX_LINES, className }: DiffBlockProps) {
const { rows, added, removed, files } = useMemo(() => buildRows(diffs), [diffs])
const [expanded, setExpanded] = useState(false)
const [copied, setCopied] = useState(false)
const onCopy = useCallback(() => {
if (copied) return
void writeClipboard(copyText(rows)).then((ok) => {
if (!ok) return
setCopied(true)
window.setTimeout(() => { setCopied(false) }, 1000)
})
}, [copied, rows])
const onToggle = useCallback(() => { setExpanded(value => !value) }, [])
if (rows.length === 0) return null
const hidden = rows.length - maxLines
const capped = hidden > 0 && !expanded
// Same split arithmetic as TerminalBlock and the TUI transcript's collapsed
// card, so a body's head and tail slices agree across the front ends.
const headLines = Math.ceil(maxLines / 2)
const tailLines = maxLines - headLines
const head = capped ? rows.slice(0, headLines) : rows
const tail = capped ? rows.slice(rows.length - tailLines) : []
return (
<div className={clsx(css.block, className)} data-diff="">
<button type="button" className={css.copyButton} onClick={onCopy}>
{copied ? '复制成功' : '复制'}
</button>
<div className={css.body}>
{head.map((row, index) => (
<div key={index} className={clsx(css.line, ROW_CLASS[row.kind])}>{row.text}</div>
))}
{hidden > 0 && (
<button
type="button"
className={css.expand}
aria-expanded={expanded}
aria-label={expanded ? '收起差异' : `展开其余 ${hidden} 行差异`}
onClick={onToggle}
>
{expanded ? '收起' : `… 其余 ${hidden}`}
</button>
)}
{tail.map((row, index) => (
<div key={index} className={clsx(css.line, ROW_CLASS[row.kind])}>{row.text}</div>
))}
</div>
<div className={css.footer}> +{added} -{removed} · {files} file{files === 1 ? '' : 's'}</div>
</div>
)
}

View File

@@ -1,5 +1,5 @@
import clsx from 'clsx'
import { useEffect, useId, useRef, useState } from 'react'
import { useEffect, useId, useMemo, useRef, useState } from 'react'
import type {
KeyboardEvent as ReactKeyboardEvent,
MouseEvent as ReactMouseEvent,
@@ -14,16 +14,64 @@ import css from './JsonTree.module.css'
const OBJECT_PREVIEW_LIMIT = 4
const ARRAY_PREVIEW_LIMIT = 5
const PREVIEW_DEPTH_LIMIT = 2
const VALUE_COPY_MENU_ITEMS: readonly MenuEntry[] = [
{ id: 'value', label: 'Copy value' },
{ id: 'json', label: 'Copy JSON' },
{ id: 'path', label: 'Copy property path' },
]
const OBJECT_COPY_MENU_ITEMS: readonly MenuEntry[] = [
{ id: 'prettyJson', label: 'Copy pretty JSON' },
{ id: 'json', label: 'Copy compact JSON' },
{ id: 'path', label: 'Copy property path' },
]
/**
* Display copy for the tree's copy affordance; the owner passes localized
* labels (this package is cordis-free, so copy arrives via props). Every
* field defaults to the current built-in value, so existing consumers render
* unchanged.
*/
export interface JsonTreeLabels {
/** Menu item: copy the raw primitive value. */
copyValue: string
/** Menu item: copy the value as compact JSON (primitive rows). */
copyJson: string
/** Menu item: copy the property path. */
copyPath: string
/** Menu item: copy the value as pretty-printed JSON. */
copyPrettyJson: string
/** Menu item: copy the value as compact JSON (object rows). */
copyCompactJson: string
/** Copy-button state label after a successful copy. */
copied: string
/** Copy-button state label after a failed copy. */
copyFailed: string
/** Expander aria label while expanded. */
collapseNode: string
/** Expander aria label while collapsed. */
expandNode: string
/** Copy-button tooltip, given the current action label. */
copyButtonTitle: (action: string) => string
}
const DEFAULT_LABELS: JsonTreeLabels = {
copyValue: 'Copy value',
copyJson: 'Copy JSON',
copyPath: 'Copy property path',
copyPrettyJson: 'Copy pretty JSON',
copyCompactJson: 'Copy compact JSON',
copied: 'Copied',
copyFailed: 'Copy failed',
collapseNode: 'Collapse JSON node',
expandNode: 'Expand JSON node',
copyButtonTitle: action => `${action}; right-click for copy options`,
}
function valueCopyMenuItems(labels: JsonTreeLabels): readonly MenuEntry[] {
return [
{ id: 'value', label: labels.copyValue },
{ id: 'json', label: labels.copyJson },
{ id: 'path', label: labels.copyPath },
]
}
function objectCopyMenuItems(labels: JsonTreeLabels): readonly MenuEntry[] {
return [
{ id: 'prettyJson', label: labels.copyPrettyJson },
{ id: 'json', label: labels.copyCompactJson },
{ id: 'path', label: labels.copyPath },
]
}
type JsonPath = readonly (number | string)[]
@@ -193,6 +241,7 @@ function NodeField({
interface JsonTreeNodeProps {
field?: string
initialExpanded: boolean
labels: JsonTreeLabels
lastElement: boolean
onClaimTabStop: (id: string) => void
onRowHover: (row: HTMLElement, target: RowTarget) => void
@@ -204,6 +253,7 @@ interface JsonTreeNodeProps {
function JsonTreeNode({
field,
initialExpanded,
labels,
lastElement,
onClaimTabStop,
onRowHover,
@@ -279,7 +329,7 @@ function JsonTreeNode({
className={clsx(css.expander, expanded ? css.collapseIcon : css.expandIcon)}
data-json-expander
role="button"
aria-label={expanded ? 'Collapse JSON node' : 'Expand JSON node'}
aria-label={expanded ? labels.collapseNode : labels.expandNode}
aria-expanded={expanded}
aria-controls={expanded ? contentsId : undefined}
tabIndex={tabStopId === nodeId ? 0 : -1}
@@ -298,6 +348,7 @@ function JsonTreeNode({
field={key}
value={item}
path={[...path, Array.isArray(value) ? index : key]}
labels={labels}
lastElement={index === entries.length - 1}
initialExpanded={false}
tabStopId={tabStopId}
@@ -344,6 +395,8 @@ export interface JsonTreeProps {
copyable?: boolean
/** Whether the top-level object or array is always expanded. */
expandTopLevel?: boolean
/** Localized display copy; omitted fields keep the built-in defaults. */
labels?: Partial<JsonTreeLabels> | undefined
}
/**
@@ -357,7 +410,12 @@ export function JsonTree({
className,
copyable = true,
expandTopLevel = true,
labels,
}: JsonTreeProps) {
const copyLabels = useMemo<JsonTreeLabels>(
() => (labels === undefined ? DEFAULT_LABELS : { ...DEFAULT_LABELS, ...labels }),
[labels],
)
const rootEntries = entriesOf(data)
const firstExpandableIndex = rootEntries.findIndex(([, value]) => (
isExpandableValue(value) && entriesOf(value).length > 0
@@ -486,10 +544,10 @@ export function JsonTree({
const copyTargetIsObject = typeof copyTarget?.value === 'object' && copyTarget.value !== null
const defaultCopyMode = copyTargetIsObject ? 'prettyJson' : 'value'
const copyTitle = copyState === 'copied'
? 'Copied'
? copyLabels.copied
: copyState === 'failed'
? 'Copy failed'
: copyTargetIsObject ? 'Copy pretty JSON' : 'Copy value'
? copyLabels.copyFailed
: copyTargetIsObject ? copyLabels.copyPrettyJson : copyLabels.copyValue
return (
<div
@@ -525,6 +583,7 @@ export function JsonTree({
field={key}
value={value}
path={[Array.isArray(data) ? index : key]}
labels={copyLabels}
lastElement={index === rootEntries.length - 1}
initialExpanded={false}
tabStopId={tabStopId}
@@ -543,6 +602,7 @@ export function JsonTree({
<JsonTreeNode
value={data}
path={[]}
labels={copyLabels}
lastElement
initialExpanded
tabStopId={tabStopId}
@@ -570,7 +630,7 @@ export function JsonTree({
data-json-copy-button
data-state={copyState}
aria-label={copyTitle}
title={`${copyTitle}; right-click for copy options`}
title={copyLabels.copyButtonTitle(copyTitle)}
onClick={() => void copy(defaultCopyMode)}
onContextMenu={(event) => {
event.preventDefault()
@@ -584,7 +644,7 @@ export function JsonTree({
: <IconCopyOutline16 size={12} />}
</button>
)}
items={copyTargetIsObject ? OBJECT_COPY_MENU_ITEMS : VALUE_COPY_MENU_ITEMS}
items={copyTargetIsObject ? objectCopyMenuItems(copyLabels) : valueCopyMenuItems(copyLabels)}
onSelect={(id) => {
void copy(id as 'json' | 'path' | 'prettyJson' | 'value')
copyMenuOpenRef.current = false

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'
@@ -13,22 +15,30 @@ import css from './Modal.module.css'
* @param props.open - whether the dialog is showing.
* @param props.onClose - Escape or mask click.
* @param props.title - dialog heading (aria-label in every mode).
* @param props.closeLabel - accessible close-button label.
* @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.
* @param props.closeLabel - close-button aria label; the owner passes
* localized copy (this package is cordis-free, so copy arrives via props).
* @returns null when closed; otherwise the overlay tree.
*/
export function Modal({ open, onClose, title, description, children, footer, className, headless = false }: {
export function Modal({
open, onClose, title, closeLabel = 'Close', description, children, footer, className, contentClassName, headless = false,
}: {
open: boolean
onClose: () => void
title: string
closeLabel?: string
description?: string
children?: ReactNode
footer?: ReactNode
className?: string
contentClassName?: string
headless?: boolean
}) {
useEffect(() => {
@@ -42,7 +52,7 @@ export function Modal({ open, onClose, title, description, children, footer, cla
if (!open) return null
return (
return createPortal((
<div className={css.root} role="presentation">
<div className={css.mask} aria-hidden="true" onClick={onClose} />
<div
@@ -55,10 +65,10 @@ export function Modal({ open, onClose, title, description, children, footer, cla
? 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="Close" onClick={onClose}>
<button type="button" className={css.close} aria-label={closeLabel} onClick={onClose}>
<IconCloseOutline16 size={14} />
</button>
</div>
@@ -72,5 +82,5 @@ export function Modal({ open, onClose, title, description, children, footer, cla
)}
</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

@@ -7,6 +7,10 @@
.block {
--dsl-terminal-radius: 12px;
--dsl-terminal-line-height: 22px;
/* Rebindable by consumers (CodeBlock's --dsl-code-block-content-font
pattern): a surface wanting the smaller code size rebinds this together
with --dsl-terminal-line-height on its own container. */
--dsl-terminal-font: var(--dsw-font-markdown-code-block);
/* The card's own left inset, holding the run-state dot in a column of its own
so it never competes with the commands for horizontal space. */
--dsl-terminal-gutter: 30px;
@@ -22,26 +26,49 @@
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-markdown-code-block);
border-radius: var(--dsl-terminal-radius);
/* Clip the banner to the card's own radius: when a consumer adds a border,
the banner's equal corner radius no longer nests inside it and leaves a
notch at the corner. Nothing inside renders out of the box. */
overflow: hidden;
}
/* Top-aligned: the status pill and copy control stay on the first prompt row
however many command lines the card carries. */
/* The status pill and copy control top-align to the FIRST prompt row (their
heights are capped to the prompt line, so on a multi-line command they sit
with the first command instead of floating mid-banner). */
.header {
display: flex;
align-items: flex-start;
gap: 12px;
/* Pulled back across the card's gutter padding so the banner background and
its top-left radius span the FULL surface, then re-inset by the same amount
so the prompt text and the dot keep their positions. A plain block child
only reaches the content box, which left the gutter column painted in the
body color and drew the card's top-left corner in it — invisible in the
light theme, where banner and body share a token, and visible in the dark
one, where they do not. */
/* Pulled back across the card's gutter padding so the banner spans the FULL
surface, then re-inset by the same amount so the prompt text and the dot
keep their positions. The banner shares the card's own surface (no banner
token): the l2 divider below is the section boundary. */
margin-left: calc(-1 * var(--dsl-terminal-gutter));
padding: 9px 14px 9px var(--dsl-terminal-gutter);
background: var(--dsw-alias-markdown-code-block-banner);
border-top-left-radius: var(--dsl-terminal-radius);
border-top-right-radius: var(--dsl-terminal-radius);
/* A long multi-line command scrolls inside the banner (same cap as the
IN/OUT card's sections) instead of pushing the output off screen. */
max-height: 150px;
overflow-y: auto;
}
/* Banner scrollbar floats off the card edge like the output's. */
.header::-webkit-scrollbar-thumb {
border: 2px solid transparent;
background-clip: padding-box;
border-radius: 6px;
}
.header::-webkit-scrollbar-track {
margin: 6px;
}
/* Full-width l2 hairline between the command banner and the body — the same
divider the IN/OUT card draws between its sections. A running card is
banner-only, so it draws none. */
.block:not([data-running]) .header {
border-bottom: 1px solid var(--dsw-alias-border-l2);
}
/* One row per command line. The prompt column is the only element allowed to
@@ -51,7 +78,7 @@
flex-direction: column;
min-width: 0;
flex: 1;
font: var(--dsw-font-markdown-code-block);
font: var(--dsl-terminal-font);
}
.promptLine {
@@ -100,27 +127,59 @@
white-space: pre;
}
/* Capped to the prompt's line height (Pill's own 24px height would exceed a
smaller-font prompt row and stretch the banner). Sticky against the
banner's own scroll so the pill and the copy control stay in reach while a
long command scrolls underneath. */
.status {
flex: none;
position: sticky;
top: 0;
height: var(--dsl-terminal-line-height);
color: var(--dsw-alias-state-error-primary);
}
.copyButton {
flex: none;
background-color: transparent;
position: sticky;
top: 0;
/* Card surface, not transparent: the control is sticky over the banner's
own scroll, so scrolled command text must not bleed through it. */
background-color: var(--dsw-alias-markdown-code-block);
border: none;
padding: 0;
margin: 0;
color: var(--dsw-alias-label-secondary);
cursor: pointer;
font: var(--dsw-font-xs-13);
line-height: var(--dsl-terminal-line-height);
}
/* Vertical scrolling lives on the OUTPUT, not the card root: a root scroller
would run its scrollbar over the banner (and the copy control), while here
the banner stays pinned and the bar sits inside the output's right padding.
Unset, the max-height is none and the auto overflow never engages. */
.output {
max-height: var(--dsl-terminal-output-max-height, none);
padding: 12px 14px 12px 0;
font: var(--dsw-font-markdown-code-block);
font: var(--dsl-terminal-font);
overflow-x: auto;
overflow-y: hidden;
overflow-y: auto;
}
/* Both output scrollbars (vertical cap, horizontal pre overflow) float 2px
off the card edge: a transparent border clips the thumb inward so it never
hugs the rounded corner. */
.output::-webkit-scrollbar-thumb {
border: 2px solid transparent;
background-clip: padding-box;
border-radius: 6px;
}
/* Track end-margins keep the thumb's travel out of the card's rounded
corners in both directions. */
.output::-webkit-scrollbar-track {
margin: 6px;
}
/* No wrapping, no word-break: alignment is the payload of terminal output. */
@@ -147,6 +206,6 @@
.empty {
padding: 12px 14px 12px 0;
font: var(--dsw-font-markdown-code-block);
font: var(--dsl-terminal-font);
color: var(--dsw-alias-label-tertiary);
}

View File

@@ -20,6 +20,54 @@ import css from './TerminalBlock.module.css'
*/
export const DEFAULT_TERMINAL_MAX_LINES = 16
/**
* Display copy for the terminal surface; the owner passes localized labels
* (this package is cordis-free, so copy arrives via props). Every field
* defaults to the current built-in value, so existing consumers render
* unchanged.
*/
export interface TerminalBlockLabels {
/** Status pill text for a signal-terminated command. */
signal: (signal: string) => string
/** Status pill text for a non-zero exit code. */
exitCode: (exitCode: number) => string
/** Run-state text while the command is still running. */
running: string
/** Run-state text for a signal or non-zero-exit settle. */
failed: string
/** Run-state text for a clean settle. */
done: string
/** Copy-button idle label. */
copy: string
/** Copy-button label during the post-copy confirmation window. */
copied: string
/** Placeholder when a settled command produced no visible output. */
noOutput: string
/** Collapse-toggle aria label while expanded. */
collapseAria: string
/** Collapse-toggle text while expanded. */
collapse: string
/** Expand-toggle aria label while capped, given the hidden line count. */
expandAria: (hidden: number) => string
/** Expand-toggle text while capped, given the hidden line count. */
expand: (hidden: number) => string
}
const DEFAULT_LABELS: TerminalBlockLabels = {
signal: signal => `信号 ${signal}`,
exitCode: exitCode => `退出码 ${exitCode}`,
running: '运行中',
failed: '失败',
done: '已完成',
copy: '复制',
copied: '复制成功',
noOutput: '无输出',
collapseAria: '收起输出',
collapse: '收起',
expandAria: hidden => `展开其余 ${hidden} 行输出`,
expand: hidden => `… 其余 ${hidden}`,
}
export interface TerminalBlockProps {
/** The command line, rendered verbatim after the prompt label. */
command: string
@@ -35,10 +83,12 @@ export interface TerminalBlockProps {
signal?: string | undefined
/** The command is still running: the block shows the prompt line alone. */
running?: boolean | undefined
/** Height cap in output lines before the middle collapses (default {@link DEFAULT_TERMINAL_MAX_LINES}). */
/** Height cap in output lines before the middle collapses (default {@link DEFAULT_TERMINAL_MAX_LINES}); Infinity disables the cap. */
maxLines?: number | undefined
/** Extra class merged onto the wrapper (callers position; this component draws). */
className?: string | undefined
/** Localized display copy; omitted fields keep the built-in defaults. */
labels?: Partial<TerminalBlockLabels> | undefined
}
/**
@@ -63,11 +113,16 @@ function promptLabel(cwd: string, home: string | undefined): string {
* distinction the bash tool's own exit-status markers draw.
* @param exitCode - settled exit code, when known.
* @param signal - settled terminating signal name, when known.
* @param labels - display copy for the pill text.
* @returns the pill text, or undefined for a clean exit.
*/
function statusText(exitCode: number | undefined, signal: string | undefined): string | undefined {
if (signal !== undefined) return `信号 ${signal}`
if (exitCode !== undefined && exitCode !== 0) return `退出码 ${exitCode}`
function statusText(
exitCode: number | undefined,
signal: string | undefined,
labels: TerminalBlockLabels,
): string | undefined {
if (signal !== undefined) return labels.signal(signal)
if (exitCode !== undefined && exitCode !== 0) return labels.exitCode(exitCode)
return undefined
}
@@ -84,16 +139,18 @@ function statusText(exitCode: number | undefined, signal: string | undefined): s
* @param running - the command has not settled.
* @param exitCode - settled exit code, when known.
* @param signal - settled terminating signal name, when known.
* @param labels - display copy for the text label.
* @returns the dot's state and its text label, since the dot is aria-hidden.
*/
function runState(
running: boolean,
exitCode: number | undefined,
signal: string | undefined,
labels: TerminalBlockLabels,
): { state: StateDotState; label: string } {
if (running) return { state: 'ongoing', label: '运行中' }
if (statusText(exitCode, signal) !== undefined) return { state: 'error', label: '失败' }
return { state: 'done', label: '已完成' }
if (running) return { state: 'ongoing', label: labels.running }
if (statusText(exitCode, signal, labels) !== undefined) return { state: 'error', label: labels.failed }
return { state: 'done', label: labels.done }
}
/**
@@ -123,7 +180,12 @@ export function TerminalBlock({
running = false,
maxLines = DEFAULT_TERMINAL_MAX_LINES,
className,
labels,
}: TerminalBlockProps) {
const copy = useMemo<TerminalBlockLabels>(
() => (labels === undefined ? DEFAULT_LABELS : { ...DEFAULT_LABELS, ...labels }),
[labels],
)
const text = output ?? ''
// A command's output ends with a newline; that terminator is not an extra
// blank line to draw or to count against the height cap. The check runs on the
@@ -155,8 +217,8 @@ export function TerminalBlock({
const onToggle = useCallback(() => { setExpanded(value => !value) }, [])
const status = statusText(exitCode, signal)
const state = runState(running, exitCode, signal)
const status = statusText(exitCode, signal, copy)
const state = runState(running, exitCode, signal, copy)
// A multi-line command gets one prompt row per line, so a two-command shell
// snippet reads as the two commands it is instead of collapsing into one
// ellipsized row. A trailing newline is a terminator, not an empty command.
@@ -205,12 +267,12 @@ export function TerminalBlock({
{status !== undefined && <Pill className={css.status}>{status}</Pill>}
{!running && !empty && (
<button type="button" className={css.copyButton} onClick={onCopy}>
{copied ? '复制成功' : '复制'}
{copied ? copy.copied : copy.copy}
</button>
)}
</div>
{!running && (empty
? <div className={css.empty}></div>
? <div className={css.empty}>{copy.noOutput}</div>
: (
<div className={css.output}>
{(capped ? lines.slice(0, headLines) : lines).map((line, index) => (
@@ -221,10 +283,10 @@ export function TerminalBlock({
type="button"
className={css.expand}
aria-expanded={expanded}
aria-label={expanded ? '收起输出' : `展开其余 ${hidden} 行输出`}
aria-label={expanded ? copy.collapseAria : copy.expandAria(hidden)}
onClick={onToggle}
>
{expanded ? '收起' : `… 其余 ${hidden}`}
{expanded ? copy.collapse : copy.expand(hidden)}
</button>
)}
{capped && lines.slice(lines.length - tailLines).map((line, index) => (

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,18 +13,26 @@ 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'
export { Tooltip } from './Tooltip.tsx'
export type { TooltipSide } from './Tooltip.tsx'
export { JsonTree } from './JsonTree.tsx'
export type { JsonTreeProps } from './JsonTree.tsx'
export type { JsonTreeProps, JsonTreeLabels } from './JsonTree.tsx'
export { TerminalBlock, DEFAULT_TERMINAL_MAX_LINES } from './TerminalBlock.tsx'
export type { TerminalBlockProps } from './TerminalBlock.tsx'
export type { TerminalBlockProps, TerminalBlockLabels } from './TerminalBlock.tsx'
export { DiffBlock, DEFAULT_DIFF_MAX_LINES } from './DiffBlock.tsx'
export type { DiffBlockProps, DiffHunk } from './DiffBlock.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'
export { MarkdownText } from './markdown/MarkdownText.tsx'
export type { MarkdownCodeLabels } from './markdown/MarkdownText.tsx'
export { MessageText } from './markdown/MessageText.tsx'
export { extractMarkdownPlainText } from './markdown/plain-text.ts'
export type { MarkdownPlainTextMode, MarkdownPlainTextOptions } from './markdown/plain-text.ts'

View File

@@ -17,9 +17,13 @@ export interface CodeBlockProps {
lang?: string | undefined
/** Extra class merged onto the wrapper (callers position; this component draws). */
className?: string | undefined
/** Copy-button idle label; the owner passes localized copy (this package is cordis-free, so copy arrives via props). */
copyLabel?: string | undefined
/** Copy-button label during the post-copy confirmation window. */
copiedLabel?: string | undefined
}
export function CodeBlock({ code, lang, className }: CodeBlockProps) {
export function CodeBlock({ code, lang, className, copyLabel = '复制', copiedLabel = '复制成功' }: CodeBlockProps) {
const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code
const html = useMemo(() => highlightToHtml(trimmed, lang), [trimmed, lang])
const rootRef = useRef<HTMLDivElement>(null)
@@ -55,7 +59,7 @@ export function CodeBlock({ code, lang, className }: CodeBlockProps) {
<div className={css.infostring}>{lang ?? ''}</div>
<div className={css.action}>
<button type="button" className={css.copyButton} onClick={onCopy}>
{copied ? '复制成功' : '复制'}
{copied ? copiedLabel : copyLabel}
</button>
</div>
</div>

View File

@@ -5,10 +5,17 @@ import css from './JsonBlock.module.css'
const MAX_CHARS = 20_000
export function JsonBlock({ label, payload, defaultOpen = false }: {
/** Default truncation footer; the owner passes a localized formatter. */
function defaultTruncatedLabel(total: number): string {
return `… 已截断,共 ${total} 字符`
}
export function JsonBlock({ label, payload, defaultOpen = false, truncatedLabel = defaultTruncatedLabel }: {
label: string
payload: unknown
defaultOpen?: boolean
/** Footer appended when the body exceeds the char cap, given the full length (this package is cordis-free, so copy arrives via props). */
truncatedLabel?: ((total: number) => string) | undefined
}) {
const [open, setOpen] = useState(defaultOpen)
const body = useMemo(() => {
@@ -21,8 +28,8 @@ export function JsonBlock({ label, payload, defaultOpen = false }: {
} catch {
s = String(payload)
}
return s.length > MAX_CHARS ? `${s.slice(0, MAX_CHARS)}\n… 已截断,共 ${s.length} 字符` : s
}, [open, payload])
return s.length > MAX_CHARS ? `${s.slice(0, MAX_CHARS)}\n${truncatedLabel(s.length)}` : s
}, [open, payload, truncatedLabel])
return (
<div className={css.root}>
<button type="button" className={css.toggle} onClick={() => { setOpen(v => !v) }}>

View File

@@ -1,4 +1,4 @@
import { isValidElement } from 'react'
import { isValidElement, useMemo } from 'react'
import ReactMarkdown from 'react-markdown'
import type { Components, UrlTransform } from 'react-markdown'
import remarkGfm from 'remark-gfm'
@@ -24,8 +24,16 @@ function sanitizeUrl(url: string): string {
const safeUrl: UrlTransform = url => sanitizeUrl(url)
/** Copy-button labels forwarded to fence CodeBlocks (this package is cordis-free, so copy arrives via props). */
export interface MarkdownCodeLabels {
/** Copy-button idle label. */
copyLabel?: string | undefined
/** Copy-button label during the post-copy confirmation window. */
copiedLabel?: string | undefined
}
/** Build the component table; while `streaming`, fences render the plain arm (see CodeBlock). */
function buildComponents(streaming: boolean): Components {
function buildComponents(streaming: boolean, codeLabels?: MarkdownCodeLabels): Components {
return {
a: ({ href = '', children }) => {
const safeHref = sanitizeUrl(href)
@@ -62,7 +70,14 @@ function buildComponents(streaming: boolean): Components {
// keeps the stock <pre> rather than guessing.
if (typeof raw !== 'string') return <pre>{children}</pre>
const lang = /language-([\w-]+)/.exec(child?.props.className ?? '')?.[1]
return <CodeBlock code={raw} lang={streaming ? undefined : lang} />
return (
<CodeBlock
code={raw}
lang={streaming ? undefined : lang}
copyLabel={codeLabels?.copyLabel}
copiedLabel={codeLabels?.copiedLabel}
/>
)
},
}
}
@@ -73,15 +88,29 @@ const streamingComponents = buildComponents(true)
/**
* Render untrusted assistant-authored Markdown as semantic React elements.
* @param props - Markdown source text preserved by the session projection;
* `streaming` renders fences plain (highlighting lands on the finalize swap).
* `streaming` renders fences plain (highlighting lands on the finalize swap);
* `codeLabels` forwards localized copy-button labels to fence CodeBlocks —
* pass a reference-stable object (memoized per locale revision), because the
* component table memoizes on its identity and a fresh literal per render
* would rebuild it every streaming chunk.
* @returns A GFM document with raw HTML, relative links, unsafe protocols, and remote images disabled.
*/
export function MarkdownText({ text, streaming = false }: { text: string; streaming?: boolean }) {
export function MarkdownText({ text, streaming = false, codeLabels }: {
text: string
streaming?: boolean
codeLabels?: MarkdownCodeLabels | undefined
}) {
// The label-free tables stay module-level singletons so the common case
// keeps referential stability across renders without a hook.
const components = useMemo(() => {
if (codeLabels === undefined) return streaming ? streamingComponents : staticComponents
return buildComponents(streaming, codeLabels)
}, [streaming, codeLabels])
return (
<div className={css.markdown}>
<ReactMarkdown
remarkPlugins={remarkPlugins}
components={streaming ? streamingComponents : staticComponents}
components={components}
urlTransform={safeUrl}
>
{text}

View File

@@ -386,11 +386,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" 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,182 @@
// @vitest-environment jsdom
// DiffBlock: the per-file hunk rows (path header, removed block, added block),
// the same-file second-hunk gap separator, the `+A -R · N file(s)` footer and
// its singular/plural, the head/tail height cap and its expand control, the
// empty-diffs null render, and the copy control writing the prefixed diff text
// on both the accepted and the refused clipboard paths. writeClipboard's own
// return contract is pinned in terminal-block.spec.tsx (the shared seam), so
// only its DOM consequence is asserted here.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { DEFAULT_DIFF_MAX_LINES, DiffBlock, type DiffHunk } from '../src/index.ts'
afterEach(cleanup)
beforeEach(() => {
vi.useRealTimers()
})
/** The rendered body rows, one string per visible line (CSS-module class prefix). */
function bodyRows(container: HTMLElement): string[] {
return [...container.querySelectorAll('[class*="_line_"]')].map(row => row.textContent ?? '')
}
/** Only the changed rows (add/del), excluding the path header and gap chrome. */
function changeRows(container: HTMLElement): string[] {
return [...container.querySelectorAll('[class*="_del_"], [class*="_add_"]')].map(row => row.textContent ?? '')
}
/** `count` numbered added lines as one hunk's newText. */
function added(count: number): string {
return Array.from({ length: count }, (_v, i) => `line ${i + 1}`).join('\n')
}
describe('DiffBlock structure', () => {
it('renders a create as a path header and an added block (no removed side)', () => {
const diffs: DiffHunk[] = [{ path: 'notes/new.txt', oldText: null, newText: 'hello\nworld' }]
const { container } = render(<DiffBlock diffs={diffs} />)
expect(screen.getByText('notes/new.txt')).toBeTruthy()
// No removed rows: both change lines are added.
expect(changeRows(container)).toEqual(['hello', 'world'])
expect(container.querySelectorAll('[class*="_del_"]').length).toBe(0)
expect(container.querySelectorAll('[class*="_add_"]').length).toBe(2)
})
it('renders an edit as a removed block above an added block', () => {
const diffs: DiffHunk[] = [{ path: 'a.ts', oldText: 'old', newText: 'new' }]
const { container } = render(<DiffBlock diffs={diffs} />)
expect(container.querySelectorAll('[class*="_del_"]').length).toBe(1)
expect(container.querySelectorAll('[class*="_add_"]').length).toBe(1)
expect(changeRows(container)).toEqual(['old', 'new'])
})
it('opens a same-file second hunk with a gap instead of repeating the path', () => {
const diffs: DiffHunk[] = [
{ path: 'a.ts', oldText: 'x', newText: 'y' },
{ path: 'a.ts', oldText: 'p', newText: 'q' },
]
const { container } = render(<DiffBlock diffs={diffs} />)
// One path header, one gap row.
expect(container.querySelectorAll('[class*="_path_"]').length).toBe(1)
expect(container.querySelectorAll('[class*="_gap_"]').length).toBe(1)
})
it('opens a new file with its own path header', () => {
const diffs: DiffHunk[] = [
{ path: 'a.ts', oldText: 'x', newText: 'y' },
{ path: 'b.ts', oldText: 'p', newText: 'q' },
]
const { container } = render(<DiffBlock diffs={diffs} />)
expect(container.querySelectorAll('[class*="_path_"]').length).toBe(2)
expect(container.querySelectorAll('[class*="_gap_"]').length).toBe(0)
})
it('renders nothing for empty diffs', () => {
const { container } = render(<DiffBlock diffs={[]} />)
expect(container.firstChild).toBeNull()
})
it('treats a trailing newline as a terminator, not an extra blank line', () => {
// A create whose newText ends in a newline is one added line, not two, and
// the footer counts one — the phantom `+ ` empty line the naive split drew.
const { container } = render(<DiffBlock diffs={[{ path: 'n.txt', oldText: null, newText: 'hello\n' }]} />)
expect(changeRows(container)).toEqual(['hello'])
expect(screen.getByText('└ +1 -0 · 1 file')).toBeTruthy()
})
it('renders a full deletion as removed-only with no phantom added line', () => {
// newText '' is zero added lines: an empty string must contribute nothing.
const { container } = render(<DiffBlock diffs={[{ path: 'gone.ts', oldText: 'a\nb', newText: '' }]} />)
expect(container.querySelectorAll('[class*="_add_"]').length).toBe(0)
expect(screen.getByText('└ +0 -2 · 1 file')).toBeTruthy()
})
it('keeps a genuine interior blank line', () => {
const { container } = render(<DiffBlock diffs={[{ path: 'a.ts', oldText: null, newText: 'x\n\ny' }]} />)
expect(container.querySelectorAll('[class*="_add_"]').length).toBe(3)
})
})
describe('DiffBlock footer', () => {
it('counts added and removed lines and one file', () => {
const diffs: DiffHunk[] = [{ path: 'a.ts', oldText: 'a\nb', newText: 'c' }]
render(<DiffBlock diffs={diffs} />)
expect(screen.getByText('└ +1 -2 · 1 file')).toBeTruthy()
})
it('pluralizes the distinct-file count', () => {
const diffs: DiffHunk[] = [
{ path: 'a.ts', oldText: null, newText: 'x' },
{ path: 'b.ts', oldText: null, newText: 'y' },
]
render(<DiffBlock diffs={diffs} />)
expect(screen.getByText('└ +2 -0 · 2 files')).toBeTruthy()
})
})
describe('DiffBlock height cap', () => {
it('shows head and tail with an expand control past the cap, then all lines expanded', () => {
// One added line over the default cap forces the collapse.
const diffs: DiffHunk[] = [{ path: 'a.ts', oldText: null, newText: added(DEFAULT_DIFF_MAX_LINES) }]
// The path header counts as a row, so a body of maxLines added lines plus
// the header is one over the cap.
const { container } = render(<DiffBlock diffs={diffs} />)
const toggle = screen.getByRole('button', { name: /展开其余/ })
expect(toggle.getAttribute('aria-expanded')).toBe('false')
// Collapsed shows fewer rows than the full body.
const collapsedCount = bodyRows(container).length
expect(collapsedCount).toBeLessThan(DEFAULT_DIFF_MAX_LINES + 1)
fireEvent.click(toggle)
expect(screen.getByRole('button', { name: '收起差异' }).getAttribute('aria-expanded')).toBe('true')
expect(bodyRows(container).length).toBeGreaterThan(collapsedCount)
})
it('shows no expand control at or under the cap', () => {
const diffs: DiffHunk[] = [{ path: 'a.ts', oldText: null, newText: added(4) }]
render(<DiffBlock diffs={diffs} maxLines={16} />)
expect(screen.queryByRole('button', { name: /展开其余|收起差异/ })).toBeNull()
})
})
describe('DiffBlock copy', () => {
it('copies the prefixed diff text and flips the label on success', async () => {
vi.useFakeTimers()
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
const diffs: DiffHunk[] = [
{ path: 'a.ts', oldText: 'old', newText: 'new' },
{ path: 'a.ts', oldText: 'p', newText: 'q' },
]
render(<DiffBlock diffs={diffs} />)
const copy = screen.getByRole('button', { name: '复制' })
await act(async () => { fireEvent.click(copy) })
// Path header, del/add prefixes, and the same-file gap all reach the clipboard.
expect(writeText).toHaveBeenCalledWith('a.ts\n- old\n+ new\n⋯\n- p\n+ q')
expect(screen.getByRole('button', { name: '复制成功' })).toBeTruthy()
await act(async () => { await vi.advanceTimersByTimeAsync(1000) })
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
})
it('keeps the label on a refused clipboard write', async () => {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText: vi.fn().mockRejectedValue(new Error('denied')) },
})
render(<DiffBlock diffs={[{ path: 'a.ts', oldText: null, newText: 'x' }]} />)
const copy = screen.getByRole('button', { name: '复制' })
await act(async () => { fireEvent.click(copy) })
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
})
it('ignores a second click while the copied label is showing', async () => {
vi.useFakeTimers()
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
render(<DiffBlock diffs={[{ path: 'a.ts', oldText: null, newText: 'x' }]} />)
const copy = screen.getByRole('button', { name: '复制' })
await act(async () => { fireEvent.click(copy) })
await act(async () => { fireEvent.click(screen.getByRole('button', { name: '复制成功' })) })
expect(writeText).toHaveBeenCalledTimes(1)
})
})

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()
})
})