Merge remote-tracking branch 'origin/master' into mergebot/pr1008

# Conflicts:
#	apps/web/tests/snapshots/cordis-tool-round/ui.expected.md
#	apps/web/tests/snapshots/fresh-round-trip/ui.expected.md
#	apps/web/tests/snapshots/steering/settled.expected.md
#	packages/client/ui-conversation/src/client/chat/ChatView.tsx
#	packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx
#	packages/client/ui-conversation/src/client/chat/ToolRow.tsx
#	packages/client/ui-conversation/src/client/contract/terminal-card-model.ts
#	packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx
#	packages/client/ui-conversation/src/client/toolviews/ask-question-row.tsx
#	packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx
#	packages/client/ui-conversation/src/client/toolviews/todo-row.tsx
#	packages/client/ui-conversation/tests/chat-tool-row.spec.tsx
#	packages/client/ui-conversation/tests/terminal-card.spec.tsx
#	packages/client/ui-trajectory/tests/client-bundle.spec.ts
This commit is contained in:
imccyu
2026-07-31 03:00:08 +08:00
165 changed files with 2544 additions and 1160 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: 4075f0e7472141b5d41fe0f51c1a620eae913bfb
README.zh.md: 7fd9529e597bc473a7c35fc3614f21f84cd19f44

View File

@@ -25,5 +25,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.
- **`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

@@ -24,5 +24,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什么都不传的消费者渲染与本地化之前逐字节一致
- **`TerminalBlock` 不是终端模拟器**它渲染已结束或仍在运行的命令输出而不是交互式会话SGR 颜色与属性会被遵循,进度行所用的行内光标移动同样被遵循——回车、退格、行内擦除、制表位与字符宽度。绝对光标定位、清屏与备用屏幕序列会被剥离。基础 16 色中的洋红与青色没有对应 token保持字面 rgb。

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

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

@@ -20,6 +20,8 @@ import css from './Modal.module.css'
* @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({

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
@@ -39,6 +87,8 @@ export interface TerminalBlockProps {
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

@@ -19,12 +19,14 @@ 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 { 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}