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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
107
packages/client/ui-primitives/src/DiffBlock.module.css
Normal file
107
packages/client/ui-primitives/src/DiffBlock.module.css
Normal 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);
|
||||
}
|
||||
196
packages/client/ui-primitives/src/DiffBlock.tsx
Normal file
196
packages/client/ui-primitives/src/DiffBlock.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
80
packages/client/ui-primitives/src/RiskConfirmation.tsx
Normal file
80
packages/client/ui-primitives/src/RiskConfirmation.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
133
packages/client/ui-primitives/src/WebBlock.module.css
Normal file
133
packages/client/ui-primitives/src/WebBlock.module.css
Normal 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;
|
||||
}
|
||||
242
packages/client/ui-primitives/src/WebBlock.tsx
Normal file
242
packages/client/ui-primitives/src/WebBlock.tsx
Normal 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} />
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) }}>
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user