Polish web chat presentation

This commit is contained in:
Yichen Jiang
2026-07-30 19:30:58 +08:00
parent 3921610f5b
commit b121adcf1a
23 changed files with 201 additions and 87 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: ba315a02563596a680bc1849c07b8dec3cfdab21
README.zh.md: c0ecfb42765a3a539fb2aa470133f86b36ca2f57

View File

@@ -6,7 +6,7 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/
## Markdown rendering
`MarkdownText` renders GFM from untrusted assistant output through React elements. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders image alt text without loading remote resources; `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars).
`MarkdownText` renders GFM from untrusted assistant output through React elements. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders image alt text without loading remote resources; `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. `JsonBlock` renders a bounded JSON disclosure with design-system chevrons, `aria-expanded`, and an optional semantic collapsed-state icon. Element spacing, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars).
## Terminal output

View File

@@ -6,7 +6,8 @@
## Markdown 渲染
`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM。它会省略原始 HTML使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并只渲染图片 alt 文本而不加载远程资源;`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki
`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM。它会省略原始 HTML使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并只渲染图片 alt 文本而不加载远程资源;`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。`JsonBlock` 会渲染一个有界的 JSON 展开区,其中带有设计系统的 V 形箭头、`aria-expanded`,并可选配折叠态语义图标。元素间距、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki
## 终端输出
`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)。

View File

@@ -685,6 +685,18 @@ export const IconChecklistOutline14 = ({ size = 14, className }: IconProps) => (
</svg>
)
/** Text document glyph for context-disclosure rows. */
export const IconTextOutline14 = ({ size = 14, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M4.083 1.167h4.55c.31 0 .606.123.825.342l2.7 2.7c.219.219.342.516.342.825v6.05c0 .966-.784 1.75-1.75 1.75H4.083a1.75 1.75 0 0 1-1.75-1.75V2.917c0-.967.784-1.75 1.75-1.75Zm4.084 1.166H4.083a.583.583 0 0 0-.583.584v8.166c0 .323.261.584.583.584h6.667a.583.583 0 0 0 .583-.584V5.25H9.917a1.75 1.75 0 0 1-1.75-1.75V2.333Zm1.166.825 1.175 1.175h-.591a.583.583 0 0 1-.584-.583v-.592ZM5.25 7h4.667v1.167H5.25V7Zm0 2.333h4.667V10.5H5.25V9.333Z"
fill="currentColor"
/>
</svg>
)
/** ic_ds_List_Pen_outline_16 */
export const IconListPenOutline16 = ({ size = 16, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">

View File

@@ -24,6 +24,7 @@ export { TerminalBlock, DEFAULT_TERMINAL_MAX_LINES } from './TerminalBlock.tsx'
export type { TerminalBlockProps } from './TerminalBlock.tsx'
export { CodeBlock } from './markdown/CodeBlock.tsx'
export { JsonBlock } from './markdown/JsonBlock.tsx'
export type { JsonBlockProps } from './markdown/JsonBlock.tsx'
export { MarkdownText } from './markdown/MarkdownText.tsx'
export { MessageText } from './markdown/MessageText.tsx'
export { extractMarkdownPlainText } from './markdown/plain-text.ts'

View File

@@ -3,22 +3,54 @@
}
.toggle {
font-size: 12px;
line-height: 18px;
display: inline-flex;
align-items: center;
min-height: 24px;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-secondary);
padding: 2px 6px;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
border-radius: 6px;
}
.toggle:hover {
background: var(--dsw-alias-interactive-bg-hover);
.leading {
position: relative;
flex: none;
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-right: 6px;
color: var(--dsw-alias-label-tertiary);
}
.iconIdle {
display: inline-flex;
opacity: 1;
transition: opacity 100ms ease;
}
.chevronHover {
position: absolute;
inset: 0;
margin: auto;
opacity: 0;
transition: opacity 100ms ease;
}
.toggle:hover .iconIdle {
opacity: 0;
}
.toggle:hover .chevronHover {
opacity: 1;
}
.body {
margin: 4px 0 0;
margin: 4px 0 0 22px;
padding: 8px;
max-height: 200px;
overflow: auto;

View File

@@ -1,15 +1,28 @@
// JsonBlock: collapsible JSON block (conversation side; independent from the RPC panel's PayloadJson to avoid cross-panel coupling).
// JsonBlock: accessible JSON disclosure row (conversation side; independent
// from the RPC panel's PayloadJson to avoid cross-panel coupling).
import { useMemo, useState } from 'react'
import { useMemo, useState, type ReactNode } from 'react'
import { IconChevronDownOutline14, IconChevronRightOutline14 } from '../icons/index.tsx'
import css from './JsonBlock.module.css'
const MAX_CHARS = 20_000
export function JsonBlock({ label, payload, defaultOpen = false }: {
/** Props for the compact JSON disclosure used in conversation content. */
export interface JsonBlockProps {
label: string
payload: unknown
defaultOpen?: boolean
}) {
/** Semantic glyph shown while collapsed; hover previews the disclosure chevron. */
collapsedIcon?: ReactNode
}
/** Render a bounded, pretty-printed JSON disclosure. */
export function JsonBlock({
label,
payload,
defaultOpen = false,
collapsedIcon,
}: JsonBlockProps) {
const [open, setOpen] = useState(defaultOpen)
const body = useMemo(() => {
if (!open) return ''
@@ -23,10 +36,26 @@ export function JsonBlock({ label, payload, defaultOpen = false }: {
}
return s.length > MAX_CHARS ? `${s.slice(0, MAX_CHARS)}\n… 已截断,共 ${s.length} 字符` : s
}, [open, payload])
const leading = open
? <IconChevronDownOutline14 />
: collapsedIcon === undefined
? <IconChevronRightOutline14 />
: (
<>
<span className={css.iconIdle}>{collapsedIcon}</span>
<IconChevronRightOutline14 className={css.chevronHover} />
</>
)
return (
<div className={css.root}>
<button type="button" className={css.toggle} onClick={() => { setOpen(v => !v) }}>
{open ? '▾' : '▸'} {label}
<button
type="button"
className={css.toggle}
aria-expanded={open}
onClick={() => { setOpen(v => !v) }}
>
<span className={css.leading} aria-hidden="true">{leading}</span>
<span>{label}</span>
</button>
{open && <pre className={css.body}>{body}</pre>}
</div>

View File

@@ -14,8 +14,8 @@ const icons = Object.fromEntries(
const iconNames = Object.keys(icons)
describe('ic_ds_ icon set', () => {
it('exports the full P-I set (45 deepsuite + 14 figma extracts + the hand-authored sparkle)', () => {
expect(iconNames.length).toBe(60)
it('exports the full P-I set (45 deepsuite + 14 figma extracts + 2 hand-authored glyphs)', () => {
expect(iconNames.length).toBe(61)
})
it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', (name) => {

View File

@@ -124,12 +124,17 @@ describe('MarkdownText', () => {
})
describe('JsonBlock', () => {
it('collapsed by default; toggle reveals pretty-printed payload', () => {
render(<JsonBlock label="args" payload={{ a: 1 }} />)
it('collapsed by default; accessible toggle reveals pretty-printed payload', () => {
render(<JsonBlock label="args" payload={{ a: 1 }} collapsedIcon={<span data-testid="json-icon">T</span>} />)
const toggle = screen.getByRole('button', { name: 'args' })
expect(toggle.getAttribute('aria-expanded')).toBe('false')
expect(screen.getByTestId('json-icon')).toBeDefined()
expect(screen.queryByText(/"a": 1/)).toBeNull()
fireEvent.click(screen.getByRole('button', { name: /args/ }))
fireEvent.click(toggle)
expect(toggle.getAttribute('aria-expanded')).toBe('true')
expect(screen.getByText(/"a": 1/)).toBeDefined()
fireEvent.click(screen.getByRole('button', { name: /args/ }))
fireEvent.click(toggle)
expect(toggle.getAttribute('aria-expanded')).toBe('false')
expect(screen.queryByText(/"a": 1/)).toBeNull()
})