Polish web chat presentation
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)。
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user