refactor(client): unify Context browser detail rendering into one section-card system

Every expanded element body is now a uniform stack of Section cards
(labeled head + body): prose carries the Raw/MD switch in the head, tool
calls/params/images/raw JSON share the same frame, and one blocks→sections
pipeline serves user/assistant/tool-result/injection bodies. Replaces the
floating-switch, bare-pre, and per-kind card idioms; drops the dead
lc-br-blk/lc-br-pre/lc-br-md/lc-br-rich-inline styles and the unused
RichCard/inHead/dim parameters. Element-row tool tags lose their inverted
chip and share the subtle injection-form tag style. Test bed: function
components now receive props.children like real React.
This commit is contained in:
bowenliang123 committed 2026-08-23 04:56:17 +08:00
1 parent af8f695854
commit 271f4e3395
8 files changed
+274 -335

No files matched your search

+200 -210
View File
@@ -6,8 +6,9 @@
* step picker + composition bar
* → six category sections (accordion: dot, label, element count, tokens)
* → element rows (one per message / prompt / tool schema)
* → the element's ACTUAL content, rendered per kind (text, reasoning,
* tool call + result, injection notice, system prompt, JSON schema).
* → the element's ACTUAL content as a uniform stack of SECTION cards
* (Section: labeled head + body) — prose, tool calls, parameter
* rows, image grids, and raw JSON all share that one frame.
*
* Data sources: the `contextTimeline` projection (structure + token prices,
* per-step reconstruction via the removed-node archive), the `contextHeaders`
@@ -29,7 +30,7 @@ import { makeNodeText } from './nodes'
import { imageRefOf, makeImageCard } from './images'
import type { ImageKit } from './images'
import { makeRichText } from './richText'
import type { RichKit, RichMode } from './richText'
import type { RichKit } from './richText'
import type { StackedBarProps } from './stackedBar'
import type { ImageLoader, ImageRefLike } from '../services'
@@ -187,19 +188,73 @@ function ParamRow(props: {
)
}
/**
* Section — the ONE detail chrome of the Context browser: a labeled card
* (`lc-ts-card`) whose head carries the section title on the left and the
* extras on the right (a count badge and/or the Raw/MD switch), with the
* body below. Every expanded element renders as a stack of these — prose,
* tool calls, parameter rows, image grids, and raw JSON all share the same
* frame, so the reader scans one repeating anatomy instead of a different
* layout per content kind.
*/
function Section(props: {
label: string
/** Head class for identifier-style titles (tool-call names: mono, larger). */
labelClass?: string
/** Count badge at the head's right edge (omitted when undefined). */
count?: number
/** Head actions (the Raw/MD switch), between the title and the count. */
actions?: ReactNS.ReactNode
children: ReactNS.ReactNode
}): ReactNS.ReactElement {
return (
<div className="lc-ts-card">
<div className="lc-ts-card-head">
<b className={props.labelClass}>{props.label}</b>
{props.actions ?? null}
{props.count !== undefined ? <span className="lc-ts-card-count">{props.count}</span> : null}
</div>
{props.children}
</div>
)
}
/**
* TextSection — a prose Section (system prompt, message/injection/summary
* body, thinking, answer, tool description): the head's right edge carries
* the Raw/MD switch and the body follows the per-card mode.
*/
function TextSection(props: { label: string; text: string; rich: RichKit }): ReactNS.ReactElement {
const { rich } = props
const [mode, setMode] = rich.useRichMode()
return (
<Section label={props.label} actions={<rich.RichSwitch mode={mode} onPick={setMode} />}>
<rich.RichText text={props.text} mode={mode} />
</Section>
)
}
/** RawSection — a dimmed text Section without a view switch (structured payloads: tool results, raw JSON). */
function RawSection(props: { label: string; text: string }): ReactNS.ReactElement {
return (
<Section label={props.label}>
<pre className="lc-ts-desc-body lc-br-dim">{props.text}</pre>
</Section>
)
}
/**
* The full body of one expanded tool row: description, a parsed parameter
* table (when the schema carries one), and the raw JSON behind a toggle.
* Owns its own open/closed state for the JSON so two expanded tools stay
* independent. The description card carries the shared raw/markdown view
* toggle in its head (per-card state via `rich.useRichMode`).
* independent.
*/
function ToolSchema(props: {
description: string | undefined
schema: unknown
/** Rich-text toggle kit (description card: raw <pre> vs markdown). */
/** Rich-text toggle kit (description section: raw <pre> vs markdown). */
rich: RichKit
/** Localized labels for the card titles, empty-state line, and toggle. */
/** Localized labels for the section titles, empty-state line, and toggle. */
labels: {
desc: string
title: string
@@ -210,7 +265,6 @@ function ToolSchema(props: {
}): ReactNS.ReactElement {
const { rich } = props
const [jsonOpen, setJsonOpen] = React.useState(false)
const [descMode, setDescMode] = rich.useRichMode()
const params = React.useMemo(() => paramsOf(props.schema), [props.schema])
// Pull `properties` + `required` out of the parameter object as plain
// values (avoids re-deriving the same shape in every row).
@@ -237,27 +291,12 @@ function ToolSchema(props: {
return (
<>
{props.description !== undefined ? (
<div className="lc-ts-card">
<div className="lc-ts-card-head">
<b>{props.labels.desc}</b>
<rich.RichSwitch mode={descMode} onPick={setDescMode} inHead />
</div>
<rich.RichText
text={props.description}
mode={descMode}
rawClass="lc-ts-desc-body"
mdClass="lc-ts-desc-md"
/>
</div>
<TextSection label={props.labels.desc} text={props.description} rich={rich} />
) : null}
{params !== null && rows.length > 0 ? (
<div className="lc-ts-card">
<div className="lc-ts-card-head">
<b>{props.labels.title}</b>
<span className="lc-ts-card-count">{rows.length}</span>
</div>
<Section label={props.labels.title} count={rows.length}>
{rows.map(r => <ParamRow key={r.name} name={r.name} schema={r.schema} required={r.required} />)}
</div>
</Section>
) : params !== null ? (
<div className="lc-ts-params-empty">{props.labels.empty}</div>
) : null}
@@ -268,63 +307,99 @@ function ToolSchema(props: {
className="lc-ts-json-toggle"
onClick={() => { setJsonOpen(o => !o) }}
>{(jsonOpen ? '▾ ' : '▸ ') + (jsonOpen ? props.labels.hide : props.labels.show)}</button>
{jsonOpen ? <pre className="lc-br-pre lc-br-dim">{schemaJson}</pre> : null}
{jsonOpen ? <pre className="lc-ts-desc-body lc-br-dim">{schemaJson}</pre> : null}
</div>
) : null}
</>
)
}
/** One raw content block (text/reasoning/tool-result/image/…), rendered defensively. */
function RawBlocks(props: { blocks: readonly unknown[]; mode: RichMode; rich: RichKit; img: ImageKit }): ReactNS.ReactElement {
const { rich, img } = props
return (
<>
{props.blocks.map((b, i) => {
const blk = b !== null && typeof b === 'object' ? b as { type?: string; text?: unknown; content?: unknown } : null
if (blk !== null
&& (blk.type === 'text' || blk.type === 'reasoning') && typeof blk.text === 'string') {
// Reasoning stays raw in either mode (plain model trace, not
// prose); text blocks follow the card's view mode.
if (blk.type === 'reasoning') {
return <pre key={i} className="lc-br-pre lc-br-dim">{blk.text}</pre>
}
return <rich.RichText key={i} text={blk.text} mode={props.mode} />
}
const image = imageRefOf(b)
if (image !== null) return <img.Card key={i} attachment={image} load={img.load} />
if (blk !== null && blk.type === 'tool-result' && Array.isArray(blk.content)) {
return <RawBlocks key={i} blocks={blk.content as unknown[]} mode={props.mode} rich={rich} img={img} />
}
return <pre key={i} className="lc-br-pre lc-br-dim">{JSON.stringify(b, null, 2)}</pre>
})}
</>
)
/** Localized section labels for one element body. */
interface DetailLabels {
thinking: string
answer: string
content: string
result: string
summary: string
images: string
other: string
}
/**
* One assistant-message block (thinking / answer) as its own card: the
* title row carries the block label plus the block's OWN raw/markdown
* switch (per-card state via `rich.useRichMode`, mirroring the tool
* description card), so a mixed reply's parts toggle independently.
* Render one message's content blocks as a uniform Section stack. Both
* block vocabularies normalize here — raw durable blocks (`type`: text /
* reasoning / tool-call / tool-result / image, arguments) and the
* conversation snapshot's assistant blocks (`kind`: text / reasoning /
* tool-call / image, argsRaw). Consecutive images group into one grid
* section; `richable` bodies (messages, injections) carry the Raw/MD
* switch while tool-result payloads stay raw (structured data, not prose)
* and dim; nested tool-result blocks flatten into the same flow.
*/
function BlockCard(props: { label: string; text: string; rich: RichKit }): ReactNS.ReactElement {
const { rich } = props
const [mode, setMode] = rich.useRichMode()
return (
<div className="lc-ts-card">
<div className="lc-ts-card-head">
<b>{props.label}</b>
<rich.RichSwitch mode={mode} onPick={setMode} inHead />
</div>
<rich.RichText
text={props.text}
mode={mode}
rawClass="lc-ts-desc-body"
mdClass="lc-ts-desc-md"
/>
</div>
)
function BlocksBody(props: {
blocks: readonly unknown[]
richable: boolean
/** Section label for prose text blocks (answer / content / result). */
textLabel: string
rich: RichKit
img: ImageKit
labels: DetailLabels
}): ReactNS.ReactElement {
const { rich, img, labels } = props
const out: ReactNS.ReactNode[] = []
let images: ImageRefLike[] = []
const flushImages = (): void => {
if (images.length === 0) return
const group = images
images = []
out.push(
<Section key={'img' + String(out.length)} label={labels.images} count={group.length}>
<div className="lc-att-grid">
{group.map((a, i) => <img.Card key={`${a.attachmentId}:${i}`} attachment={a} load={img.load} />)}
</div>
</Section>,
)
}
for (const b of props.blocks) {
const image = imageRefOf(b)
if (image !== null) { images.push(image); continue }
flushImages()
const blk = b !== null && typeof b === 'object'
? b as { type?: unknown; kind?: unknown; text?: unknown; name?: unknown; argsRaw?: unknown; arguments?: unknown; content?: unknown }
: null
const blockKind = blk !== null
? typeof blk.type === 'string' ? blk.type : typeof blk.kind === 'string' ? blk.kind : ''
: ''
if ((blockKind === 'text' || blockKind === 'reasoning') && typeof blk?.text === 'string') {
const label = blockKind === 'reasoning' ? labels.thinking : props.textLabel
out.push(props.richable
? <TextSection key={out.length} label={label} text={blk.text} rich={rich} />
: <RawSection key={out.length} label={label} text={blk.text} />)
continue
}
if (blockKind === 'tool-call') {
out.push(<ToolCallCard
key={out.length}
name={typeof blk?.name === 'string' ? blk.name : '?'}
argsRaw={blk?.argsRaw ?? blk?.arguments}
/>)
continue
}
if (blockKind === 'tool-result' && Array.isArray(blk?.content)) {
out.push(<BlocksBody
key={out.length}
blocks={blk.content as unknown[]}
richable={false}
textLabel={labels.result}
rich={rich}
img={img}
labels={labels}
/>)
continue
}
out.push(<RawSection key={out.length} label={labels.other} text={JSON.stringify(b, null, 2)} />)
}
flushImages()
return <>{out}</>
}
/**
@@ -385,27 +460,27 @@ function blockSummaryOf(conv: ConversationNodeLike | undefined): string | null {
}
/**
* One tool call as its own card, mirroring the tool-definition parameter
* card: the head names the call target (with the parsed argument count),
* the body lists the arguments as name/value rows. Arguments that are not
* a parseable JSON object fall back to the raw payload inside the same
* card. Shared by assistant tool-call blocks (`→`) and the call half of a
* tool result (`←`).
* One tool call as a Section, mirroring the tool-definition parameter
* card: the head names the call target (mono, with the parsed argument
* count), the body lists the arguments as name/value rows. Arguments that
* are not a parseable JSON object fall back to the raw payload inside the
* same card. Shared by assistant tool-call blocks (`→`) and the call half
* of a tool result (`←`).
*/
function ToolCallCard(props: { name: string; argsRaw: unknown; arrow?: string }): ReactNS.ReactElement {
const args = React.useMemo(() => parseArgs(props.argsRaw), [props.argsRaw])
return (
<div className="lc-ts-card">
<div className="lc-ts-card-head">
<b className="lc-ts-call-name">{(props.arrow ?? '→') + ' ' + props.name}</b>
{args !== null ? <span className="lc-ts-card-count">{Object.keys(args).length}</span> : null}
</div>
<Section
label={(props.arrow ?? '→') + ' ' + props.name}
labelClass="lc-ts-call-name"
count={args !== null ? Object.keys(args).length : undefined}
>
{args !== null
? Object.keys(args).map(k => <CallArgRow key={k} name={k} value={args[k]} />)
: typeof props.argsRaw === 'string' && props.argsRaw !== ''
? <pre className="lc-br-pre lc-br-dim">{props.argsRaw}</pre>
? <pre className="lc-ts-desc-body lc-br-dim">{props.argsRaw}</pre>
: null}
</div>
</Section>
)
}
@@ -423,138 +498,57 @@ function CallArgRow(props: { name: string; value: unknown }): ReactNS.ReactEleme
)
}
/** The actual content of one surface element, joined from the conversation snapshot. */
/**
* The actual content of one surface element, joined from the conversation
* snapshot — rendered as a uniform Section stack (see BlocksBody); the
* element row's open body wraps the stack in `lc-br-content`.
*/
function NodeContent(props: {
node: SurfaceNode
conv: ConversationNodeLike | undefined
hint: string
rich: RichKit
img: ImageKit
labels: { thinking: string; answer: string; images: string; other: string }
labels: DetailLabels
}): ReactNS.ReactElement {
const { node, conv, rich, img } = props
// The raw/markdown toggle serves the prose bodies (user / injection text,
// compaction summaries); assistant thinking/answer blocks each carry
// their own per-card switch (BlockCard); tool results stay raw
// (structured payload, not prose).
const richable = node.cat === 'user' || node.cat === 'assistant' || node.cat === 'inject'
const wrap = (render: (mode: RichMode) => ReactNS.ReactNode): ReactNS.ReactElement =>
richable
? <rich.RichCard render={render} />
: <div className="lc-br-content">{render('raw')}</div>
const { node, conv, rich, img, labels } = props
if (conv === undefined) {
// The join missed (node outside the loaded window): the 80-char preview
// still shows as a plain content section, with the window note below.
if (node.text === undefined || node.text === '') {
return <div className="lc-br-content"><div className="lc-br-note">{props.hint}</div></div>
return <div className="lc-br-note">{props.hint}</div>
}
return wrap(mode => (
return (
<>
<rich.RichText text={node.text ?? ''} mode={mode} />
<TextSection label={labels.content} text={node.text} rich={rich} />
<div className="lc-br-note">{props.hint}</div>
</>
))
)
}
if (conv.kind === 'assistant' && Array.isArray(conv.blocks)) {
// Thinking, answer, and tool-call blocks render as SEPARATE cards (the
// prose cards own a raw/markdown switch, the call card parses its
// arguments into rows); images keep their raw form.
return (
<div className="lc-br-content">
{conv.blocks.map((b, i) => {
const blk = b as { kind?: string; text?: unknown; name?: string; argsRaw?: unknown }
if (blk.kind === 'text' && typeof blk.text === 'string') {
return <BlockCard key={i} label={props.labels.answer} text={blk.text} rich={rich} />
}
if (blk.kind === 'reasoning' && typeof blk.text === 'string') {
return <BlockCard key={i} label={props.labels.thinking} text={blk.text} rich={rich} />
}
if (blk.kind === 'tool-call') {
return <ToolCallCard key={i} name={blk.name ?? '?'} argsRaw={blk.argsRaw} />
}
const image = imageRefOf(b)
if (image !== null) return <img.Card key={i} attachment={image} load={img.load} />
return null
})}
</div>
)
return <BlocksBody blocks={conv.blocks} richable textLabel={labels.answer} rich={rich} img={img} labels={labels} />
}
if (conv.kind === 'tool-result') {
return (
<div className="lc-br-content">
<>
{conv.call != null
? <ToolCallCard arrow="←" name={conv.call.name} argsRaw={conv.call.argsRaw} />
: null}
{Array.isArray(conv.content) ? <RawBlocks blocks={conv.content} mode="raw" rich={rich} img={img} /> : null}
</div>
{Array.isArray(conv.content)
? <BlocksBody blocks={conv.content} richable={false} textLabel={labels.result} rich={rich} img={img} labels={labels} />
: null}
</>
)
}
if (conv.kind === 'compaction') {
return wrap(mode => (
<>
{typeof conv.summary === 'string' && conv.summary !== ''
? <rich.RichText text={conv.summary} mode={mode} />
: null}
</>
))
return typeof conv.summary === 'string' && conv.summary !== ''
? <TextSection label={labels.summary} text={conv.summary} rich={rich} />
: <></>
}
if (Array.isArray(conv.content)) {
const blocks = conv.content as unknown[]
// User messages get the card layout: prose in the toggle-served text
// card, durable image refs as attachment cards (thumbnail + name + dims
// + size), and anything else as a raw JSON card. A text-only message
// renders exactly as before — one rich card and nothing else.
if (node.cat !== 'user') {
return wrap(mode => <RawBlocks blocks={blocks} mode={mode} rich={rich} img={img} />)
}
const texts: string[] = []
const images: ImageRefLike[] = []
const others: unknown[] = []
for (const b of blocks) {
const blk = b as { type?: string; text?: unknown } | null
if (blk !== null && typeof blk === 'object' && blk.type === 'text' && typeof blk.text === 'string') {
texts.push(blk.text)
continue
}
const image = imageRefOf(b)
if (image !== null) { images.push(image); continue }
others.push(b)
}
return (
<div className="lc-br-content">
{texts.length > 0 ? (
// Un-padded RichCard: the outer lc-br-content already owns the
// body indent, so the text card aligns with the attachment cards
// instead of sitting one indent deeper.
<rich.RichCard className="lc-br-rich-inline" render={mode => (
<>
{texts.map((text, i) => <rich.RichText key={i} text={text} mode={mode} />)}
</>
)} />
) : null}
{images.length > 0 ? (
<div className="lc-ts-card">
<div className="lc-ts-card-head">
<b>{props.labels.images}</b>
<span className="lc-ts-card-count">{images.length}</span>
</div>
<div className="lc-att-grid">
{images.map((a, i) => <img.Card key={`${a.attachmentId}:${i}`} attachment={a} load={img.load} />)}
</div>
</div>
) : null}
{others.length > 0 ? (
<div className="lc-ts-card">
<div className="lc-ts-card-head">
<b>{props.labels.other}</b>
<span className="lc-ts-card-count">{others.length}</span>
</div>
{others.map((b, i) => <pre key={i} className="lc-br-pre lc-br-dim">{JSON.stringify(b, null, 2)}</pre>)}
</div>
) : null}
</div>
)
return <BlocksBody blocks={conv.content} richable textLabel={labels.content} rich={rich} img={img} labels={labels} />
}
return <div className="lc-br-content"><div className="lc-br-note">{props.hint}</div></div>
return <div className="lc-br-note">{props.hint}</div>
}
/** Group an assembled surface's nodes by category (for per-category counts). */
@@ -731,23 +725,23 @@ export function makeContextBrowser(
}
const toggleElem = (key: string) => { setOpenElem(openElem === key ? null : key) }
/** One expandable element row (preview line; content when open). */
/** One expandable element row (preview line; the open body is the
* uniform indented section stack — `lc-br-content`). */
const elemRow = (
key: string, tag: string | null, preview: string,
tokens: number, time: number | undefined, body: ReactNS.ReactNode,
inv = false,
) => {
const open = openElem === key
return (
<div key={key} className={'lc-br-elem' + (open ? ' lc-br-elem-on' : '')}>
<button type="button" className="lc-br-elem-row" onClick={() => { toggleElem(key) }}>
<span className={'lc-br-chev' + (open ? ' lc-br-chev-on' : '')}>{'▸'}</span>
{tag !== null ? <span className={'lc-br-tag' + (inv ? ' lc-br-tag-inv' : '')}>{tag}</span> : null}
{tag !== null ? <span className="lc-br-tag">{tag}</span> : null}
<span className="lc-br-preview">{preview}</span>
{time !== undefined ? <span className="lc-br-time">{fmtTime(time)}</span> : null}
<span className="lc-br-tokens">{'≈' + fmt(tokens)}</span>
</button>
{open ? body : null}
{open ? <div className="lc-br-content">{body}</div> : null}
</div>
)
}
@@ -758,7 +752,7 @@ export function makeContextBrowser(
const system = view.header.system
if (system === undefined) return null
return elemRow('sys', null, system.replace(/\s+/g, ' ').trim().slice(0, 80), breakdown.system, undefined,
<rich.RichCard render={mode => <rich.RichText text={system} mode={mode} />} />)
<TextSection label={catLabel('system')} text={system} rich={rich} />)
}
if (c === 'tools') {
if (view.header === null) return <div className="lc-br-note">{t(headers === null ? 'browser.noHeader' : 'browser.noEpoch')}</div>
@@ -776,9 +770,7 @@ export function makeContextBrowser(
}
return view.header.tools.slice().sort((a, b) => b.tokens - a.tokens).map((tool: HeaderTool) => {
return elemRow('tool:' + tool.name, null, tool.name, tool.tokens, undefined,
<div className="lc-br-content">
<ToolSchema description={tool.description} schema={tool.schema} rich={rich} labels={labels} />
</div>)
<ToolSchema description={tool.description} schema={tool.schema} rich={rich} labels={labels} />)
})
}
// Surface-node categories carry per-element timestamps; list them
@@ -786,16 +778,12 @@ export function makeContextBrowser(
const nodes = (byCat[c as Category] ?? []).slice().reverse()
return nodes.map((n) => {
// Tag/preview split: the compact chip carries the compact fact (tool
// name, injection form), the preview line carries the text — each
// fact shown once. Tool-bearing rows (tool results, assistant turns
// with calls) lead with an INVERTED breadcrumb chip naming the
// tool(s) so they scan apart from prose rows.
// name, injection form) — one shared subtle chip style — and the
// preview line carries the text, each fact shown once.
let tag: string | null = null
let inv = false
let preview = nodeText(n)
if (n.cat === 'tool') {
tag = (n.tool ?? '?') + (n.err ? ' ⚠' : '')
inv = true
// A call that summarizes itself (bash's `description`, the path
// of an edit/read call) previews with that line in the collapsed
// row; the generic result label only when the call says nothing.
@@ -805,7 +793,6 @@ export function makeContextBrowser(
// then carries the reply text, or the first call's own summary
// (description / target path) for a text-less turn.
tag = n.calls.join(' › ')
inv = true
preview = (n.text !== undefined && n.text !== '' ? n.text : null)
?? blockSummaryOf(bySeq.get(n.seq))
?? t('node.empty')
@@ -825,12 +812,15 @@ export function makeContextBrowser(
conv={bySeq.get(n.seq)}
rich={rich}
img={{ Card: ImageCard, load: props.loadImage }}
// Localized block/section titles (thinking / answer / image and
// other-content cards), handed in by the parent so the body
// stays a pure function of props.
// Localized section titles (thinking / answer / content /
// result / summary / image and other-content cards), handed in
// by the parent so the body stays a pure function of props.
labels={{
thinking: t('block.thinking'),
answer: t('block.answer'),
content: t('block.content'),
result: t('block.result'),
summary: t('block.summary'),
images: t('attach.images'),
other: t('attach.other'),
}}
@@ -840,7 +830,7 @@ export function makeContextBrowser(
hint={bySeq.get(n.seq) === undefined && awaiting
? t('browser.loading')
: t('browser.noContent')}
/>, inv)
/>)
})
}
+21 -57
View File
@@ -1,17 +1,16 @@
/**
* RichText — the raw/markdown view switch shared by the Context browser's
* detail cards (system prompt, tool description, user/assistant message and
* injection bodies).
* RichText — the raw/markdown view shared by the Context browser's detail
* section cards (system prompt, tool description, message and injection
* bodies).
*
* `RichCard` owns the per-card mode state and places a small segmented
* switch (the same pill chrome as the trend chart's 步骤/轮次 control) at
* the card's top-right corner; `RichText` renders one text block in the
* handed-down mode — rendered markdown via the harness's shared
* MarkdownText renderer (the default; GFM, sanitized, resolved from the
* platform module table — zero plugin-side markdown dependency) or the raw
* <pre>. The mode crosses component boundaries explicitly through render
* props/props (no React context), and each card keeps its own state so two
* open details switch independently.
* Every detail section wears the same card chrome (`lc-ts-card` + head —
* see browser.tsx's Section); the Raw/MD segmented switch always sits at
* the head's right edge (`RichSwitch`, per-card state via `useRichMode`),
* and `RichText` renders one text block in the handed-down mode — rendered
* markdown via the harness's shared MarkdownText renderer (the default;
* GFM, sanitized, resolved from the platform module table — zero
* plugin-side markdown dependency) or the raw <pre>. Both bodies share the
* section text chrome (`lc-ts-desc-body` / `lc-ts-desc-md`).
*/
import type * as ReactNS from 'react'
@@ -24,27 +23,10 @@ export type RichMode = 'raw' | 'md'
export interface RichKit {
/** One text block in the given mode: raw <pre> or rendered markdown. */
RichText: (props: {
text: string
mode: RichMode
/** Chrome of the raw block (defaults to the browser's `lc-br-pre`). */
rawClass?: string
/** Chrome of the markdown box (defaults to `lc-br-md`). */
mdClass?: string
}) => ReactNS.ReactElement
/**
* Detail card wrapper: renders the mode switch as a small segmented pill
* on the card's top-right edge and hands the current mode to its `render`
* prop (an explicit prop, not function-children, so the mode hand-off is
* visible in the props object).
*/
RichCard: (props: {
className?: string
render: (mode: RichMode) => ReactNS.ReactNode
}) => ReactNS.ReactElement
/** The segmented switch alone, for cards that place it in their own head row. */
RichSwitch: (props: { mode: RichMode; onPick: (mode: RichMode) => void; inHead?: boolean }) => ReactNS.ReactElement
/** Per-card mode state for bodies that place the switch themselves. */
RichText: (props: { text: string; mode: RichMode }) => ReactNS.ReactElement
/** The segmented switch, placed at a section head's right edge. */
RichSwitch: (props: { mode: RichMode; onPick: (mode: RichMode) => void }) => ReactNS.ReactElement
/** Per-card mode state for the section that places the switch. */
useRichMode: () => [RichMode, (mode: RichMode) => void]
}
@@ -62,7 +44,7 @@ export function makeRichText(kit: ViewKit): RichKit {
// Two-option pill mirroring the trend chart's 步骤/轮次 control: one
// segment per view, the active segment highlighted; each segment's
// tooltip spells its view out (localized).
function RichSwitch(props: { mode: RichMode; onPick: (mode: RichMode) => void; inHead?: boolean }): ReactNS.ReactElement {
function RichSwitch(props: { mode: RichMode; onPick: (mode: RichMode) => void }): ReactNS.ReactElement {
const seg = (m: RichMode, label: string, tip: string) => (
<button
type="button"
@@ -72,37 +54,19 @@ export function makeRichText(kit: ViewKit): RichKit {
>{label}</button>
)
return (
<span className={'lc-rich-seg' + (props.inHead === true ? ' lc-rich-seg-head' : '')}>
<span className="lc-rich-seg">
{seg('raw', t('rich.raw'), t('rich.toRaw'))}
{seg('md', t('rich.md'), t('rich.toMd'))}
</span>
)
}
function RichText(props: {
text: string
mode: RichMode
rawClass?: string
mdClass?: string
}): ReactNS.ReactElement {
function RichText(props: { text: string; mode: RichMode }): ReactNS.ReactElement {
if (props.mode === 'md') {
return <div className={props.mdClass ?? 'lc-br-md'}><MarkdownText text={props.text} /></div>
return <div className="lc-ts-desc-md"><MarkdownText text={props.text} /></div>
}
return <pre className={props.rawClass ?? 'lc-br-pre'}>{props.text}</pre>
return <pre className="lc-ts-desc-body">{props.text}</pre>
}
function RichCard(props: {
className?: string
render: (mode: RichMode) => ReactNS.ReactNode
}): ReactNS.ReactElement {
const [mode, setMode] = useRichMode()
return (
<div className={props.className ?? 'lc-br-content'}>
<RichSwitch mode={mode} onPick={setMode} />
{props.render(mode)}
</div>
)
}
return { RichText, RichCard, RichSwitch, useRichMode }
return { RichText, RichSwitch, useRichMode }
}
+2
View File
@@ -105,6 +105,7 @@ export const DICT_ZH: Record<string, string> = {
// mirroring the harness's own block + locale vocabulary).
'block.thinking': '思考',
'block.answer': '回答',
'block.content': '内容', 'block.result': '结果', 'block.summary': '摘要',
// Attachment cards in a message detail: image refs render as thumbnail
// cards (name + one labeled row per known fact — raw dims, sent dims +
// size, estimated token cost), anything unrecognized falls into the
@@ -221,6 +222,7 @@ export const DICT_EN: Record<string, string> = {
// mirroring the harness's own block + locale vocabulary).
'block.thinking': 'Reasoning',
'block.answer': 'Response',
'block.content': 'Content', 'block.result': 'Result', 'block.summary': 'Summary',
// Attachment cards in a message detail: image refs render as thumbnail
// cards (name + one labeled row per known fact — raw dims, sent dims +
// size, estimated token cost), anything unrecognized falls into the
+27 -45
View File
@@ -350,30 +350,20 @@
.lc-br-elem-on { background: var(--dsw-alias-interactive-bg-active); }
.lc-br-elem-row { display: flex; align-items: center; gap: 8px; width: 100%; border: 0; background: transparent; color: var(--dsw-alias-label-primary); font: inherit; font-size: 12px; padding: 5px 6px; cursor: pointer; text-align: left; border-radius: 6px; transition: background-color 120ms ease; }
.lc-br-elem-row:hover { background: var(--dsw-alias-interactive-bg-hover); }
/* The ONE element-row chip (tool name, injection form): subtle secondary
label on the layer-1 surface — the same tag for every row kind. */
.lc-br-tag { flex: none; font-size: 11px; color: var(--dsw-alias-label-secondary); background: var(--dsw-alias-bg-layer-1); border: 1px solid var(--dsw-alias-border-l1); border-radius: 4px; padding: 0 6px; max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Inverted tool-name breadcrumb (tool results + assistant turns with
calls): label/surface tokens swap, so it reads black-on-white in light
mode and white-on-black in dark mode; mono — these are identifiers. */
.lc-br-tag-inv { color: var(--dsw-alias-bg-layer-1); background: var(--dsw-alias-label-primary); border-color: transparent; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.lc-br-preview { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.lc-br-time { flex: none; color: var(--dsw-alias-label-secondary); font-size: 11px; }
.lc-br-content { padding: 2px 6px 8px 26px; display: flex; flex-direction: column; gap: 6px; }
.lc-br-pre { margin: 0; padding: 8px 10px; background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l1); border-radius: 6px; color: var(--dsw-alias-label-primary); font-size: 12px; line-height: 1.55; white-space: pre-wrap; word-break: break-word; max-height: 320px; overflow-y: auto; scrollbar-width: thin; }
.lc-br-dim { color: var(--dsw-alias-label-secondary); }
/*
Assistant-message block title: a slim label line above each block
(thinking / answer) so a mixed reply stays scannable — same weight and
tone as the tool-schema card heads, without an extra bordered card.
*/
.lc-br-blk { display: flex; flex-direction: column; gap: 4px; }
.lc-br-blk-label { font-size: 11px; font-weight: 600; color: var(--dsw-alias-label-secondary); }
/*
---- Tool schema body (browser card, tools category). The description
and parameter table share one "card with a small title" chrome — a
labeled bordered block so a reader can scan three stacked sections
without losing track of which is which. The raw JSON sits below as a
collapsed toggle (no card chrome — it's a debug view, not a primary
section). ----
---- Detail sections (the expanded element's content stack). EVERYTHING in
an open element body is a Section: the same card with a labeled head
(title left; count badge / Raw-MD switch right) over a body — prose, tool
calls, parameter rows, image grids, and raw JSON share this one frame.
The raw-schema JSON sits below the cards as a collapsed toggle (no card
chrome — it's a debug view, not a primary section). ----
*/
.lc-ts-card { background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l1); border-radius: 6px; overflow: hidden; }
.lc-ts-card-head { display: flex; align-items: center; gap: 8px; padding: 6px 10px; font-size: 11px; font-weight: 600; color: var(--dsw-alias-label-secondary); border-bottom: 1px solid var(--dsw-alias-border-l1); }
@@ -382,7 +372,10 @@
line (mono: it is an identifier, not prose). */
.lc-ts-call-name { font-size: 13px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.lc-ts-card-count { margin-left: auto; }
.lc-ts-desc-body { margin: 0; padding: 8px 10px; color: var(--dsw-alias-label-primary); font-size: 12px; line-height: 1.55; white-space: pre-wrap; word-break: break-word; max-height: 160px; overflow-y: auto; scrollbar-width: thin; }
/* The ONE text body chrome (raw <pre> and rendered markdown alike): plain
padded text on the section card, internally scrolled past the cap. `dim`
tones down non-prose payloads (reasoning traces, raw JSON). */
.lc-ts-desc-body { margin: 0; padding: 8px 10px; color: var(--dsw-alias-label-primary); font-size: 12px; line-height: 1.55; white-space: pre-wrap; word-break: break-word; max-height: 320px; overflow-y: auto; scrollbar-width: thin; }
/*
Grid: name + type + required chip + description. Columns wrap on narrow
rows; the description cell spans the full second row so a long blurb
@@ -418,21 +411,16 @@
.lc-ts-json-toggle { align-self: flex-start; border: 0; background: transparent; color: var(--dsw-alias-label-secondary); font: inherit; font-size: 11px; padding: 0; cursor: pointer; }
.lc-ts-json-toggle:hover { color: var(--dsw-alias-label-primary); text-decoration: underline; }
/*
---- Raw/markdown view switch (detail cards: system prompt, tool
description, user/assistant message + injection bodies). A segmented
pill mirroring the trend chart's 步骤/轮次 control (`lc-gran`), riding
the card's top-right corner as a self-end item of the content column
(no absolute overlay, so it never covers text); the `in-head` variant
sits at the right end of the tool description card's title row. The
markdown box reuses the raw <pre> chrome minus the pre whitespace. ----
---- Raw/markdown view switch: a segmented pill mirroring the trend
chart's 步骤/轮次 control (`lc-gran`), always at a section head's right
edge. The markdown body shares the raw text chrome minus the pre
whitespace. ----
*/
.lc-rich-seg { align-self: flex-end; display: flex; gap: 2px; background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l1); border-radius: 6px; padding: 1px; }
.lc-rich-seg { margin-left: auto; display: flex; gap: 2px; background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l1); border-radius: 6px; padding: 1px; }
.lc-rich-seg-btn { border: 0; background: transparent; color: var(--dsw-alias-label-secondary); font-size: 11px; line-height: 1; padding: 3px 8px; border-radius: 5px; cursor: pointer; font-family: inherit; }
.lc-rich-seg-btn:hover { color: var(--dsw-alias-label-primary); }
.lc-rich-seg-on, .lc-rich-seg-on:hover { background: var(--dsw-alias-button-primary-fill); color: var(--dsw-alias-label-primary-foreground); }
.lc-rich-seg-head { align-self: auto; margin-left: auto; }
.lc-br-md { padding: 4px 10px; background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l1); border-radius: 6px; color: var(--dsw-alias-label-primary); font-size: 12px; line-height: 1.55; word-break: break-word; max-height: 320px; overflow-y: auto; scrollbar-width: thin; }
.lc-ts-desc-md { padding: 4px 10px; color: var(--dsw-alias-label-primary); font-size: 12px; line-height: 1.55; word-break: break-word; max-height: 160px; overflow-y: auto; scrollbar-width: thin; }
.lc-ts-desc-md { padding: 4px 10px; color: var(--dsw-alias-label-primary); font-size: 12px; line-height: 1.55; word-break: break-word; max-height: 320px; overflow-y: auto; scrollbar-width: thin; }
/*
The shared markdown renderer sizes its root/headings for chat bubbles;
inside a detail card the base size sits one step ABOVE the raw text
@@ -446,26 +434,20 @@
next line sticks to the neighbor line's chip; pin the chip just under
the paragraph line (1.3em + 1px padding ≈ 19px) to reopen the gap.
*/
.lc-br-md > div, .lc-ts-desc-md > div { font-size: 13px; line-height: 1.55; }
.lc-br-md div :is(h1, h2, h3, h4, h5, h6), .lc-ts-desc-md div :is(h1, h2, h3, h4, h5, h6) { font-size: 13px; font-weight: 600; margin: 10px 0 4px; }
.lc-br-md div p, .lc-ts-desc-md div p { margin: 6px 0; }
.lc-br-md div pre, .lc-ts-desc-md div pre { margin: 6px 0; }
.lc-br-md div :not(pre) > code, .lc-ts-desc-md div :not(pre) > code { font-size: 1em !important; line-height: 1.3; padding: 1px 5px; box-decoration-break: clone; }
.lc-br-md div table code, .lc-ts-desc-md div table code { font-size: inherit; }
.lc-ts-desc-md > div { font-size: 13px; line-height: 1.55; }
.lc-ts-desc-md div :is(h1, h2, h3, h4, h5, h6) { font-size: 13px; font-weight: 600; margin: 10px 0 4px; }
.lc-ts-desc-md div p { margin: 6px 0; }
.lc-ts-desc-md div pre { margin: 6px 0; }
.lc-ts-desc-md div :not(pre) > code { font-size: 1em !important; line-height: 1.3; padding: 1px 5px; box-decoration-break: clone; }
.lc-ts-desc-md div table code { font-size: inherit; }
/*
Un-padded RichCard variant: used when the text card is one section
among several (attachment cards) inside an already-indented body.
*/
.lc-br-rich-inline { display: flex; flex-direction: column; gap: 6px; }
/*
---- Attachment cards (image refs in a message detail). The card chrome
reuses the tool-schema sections' `lc-ts-card`/`lc-ts-card-head`; only the
image grid and tile are new. The grid is two EQUAL-WIDTH columns
---- Attachment cards (image refs in a message detail): tiles live in the
shared Section frame (`lc-ts-card`/`lc-ts-card-head`); only the grid and
tile are new. The grid is two EQUAL-WIDTH columns
(minmax(0,…) so long names shrink instead of stretching the track); each
image is a 64px cover tile (the chat history's tile size) beside a name +
dims + size + estimated token cost column. ----
*/
.lc-ts-card .lc-br-pre { margin: 8px 10px; }
.lc-att-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; padding: 8px 10px; }
.lc-att-item { display: flex; gap: 8px; align-items: center; min-width: 0; }
.lc-att-thumb { width: 64px; height: 64px; flex: none; padding: 0; display: flex; align-items: center; justify-content: center; border: 1px solid var(--dsw-alias-border-l1); border-radius: 6px; overflow: hidden; background: var(--dsw-alias-bg-layer-1); cursor: pointer; font: inherit; }
+5 -5
View File
@@ -63,7 +63,7 @@ test('assistant blocks: thinking/answer/tool-call split into cards, prose switch
const badCard = cards[3]
assert.match(textOf(byClass(badCard, 'lc-ts-card-head')[0]), /→ write/, 'fallback card names the tool')
assert.equal(byClass(badCard, 'lc-ts-arg-row').length, 0, 'fallback card has no argument rows')
assert.match(textOf(byClass(badCard, 'lc-br-pre')[0]), /\{bad json/, 'fallback card shows the raw payload')
assert.match(textOf(byClass(badCard, 'lc-ts-desc-body')[0]), /\{bad json/, 'fallback card shows the raw payload')
// Flip the THINKING card to raw: only its own view changes.
byClass(segsOf()[0], 'lc-rich-seg-btn')[0].args[1].onClick()
@@ -142,7 +142,7 @@ test('tool result: the call half renders as a card with name/value argument rows
assert.doesNotMatch(rowText, /node\.toolResult/, 'generic result label replaced')
const toolTag = byClass(toolRow, 'lc-br-tag')[0]
assert.equal(textOf(toolTag), 'bash', 'tool row leads with the tool-name chip')
assert.match(toolTag.args[1].className, /lc-br-tag-inv/, 'tool-name chip is inverted')
assert.ok(!/lc-br-tag-inv/.test(toolTag.args[1].className), 'tool-name chip uses the shared subtle tag style')
brSlots[2][1]('n3') // expand the tool-result element
tr = renderView()
@@ -157,8 +157,8 @@ test('tool result: the call half renders as a card with name/value argument rows
assert.equal(byClass(callCard, 'lc-ts-param-type').length, 0, 'no type column')
assert.match(textOf(tr), /RESULT-TEXT/, 'the result payload still renders below the card')
// The text-less ASSISTANT turns preview the same way: an inverted
// tool-name breadcrumb chip leads, then the call's summary (bash's
// The text-less ASSISTANT turns preview the same way: a tool-name
// breadcrumb chip leads, then the call's summary (bash's
// description, an edit call's target path) — while a turn WITH text
// keeps the text preview (no chip: it made no calls).
brSlots[1][1]('assistant')
@@ -167,7 +167,7 @@ test('tool result: the call half renders as a card with name/value argument rows
assert.equal(asstRows.length, 3, 'all three assistant turns listed (newest first)')
const editTag = byClass(asstRows[0], 'lc-br-tag')[0]
assert.equal(textOf(editTag), 'edit', 'edit turn leads with its tool-name chip')
assert.match(editTag.args[1].className, /lc-br-tag-inv/, 'assistant chip is inverted too')
assert.ok(!/lc-br-tag-inv/.test(editTag.args[1].className), 'assistant chip shares the subtle tag style too')
assert.ok(textOf(asstRows[0]).includes('/tmp/b.ts'), 'edit turn previews the target path')
const asstTag = byClass(asstRows[1], 'lc-br-tag')[0]
assert.equal(textOf(asstTag), 'bash', 'assistant row leads with the tool-name chip')
+6 -6
View File
@@ -93,7 +93,7 @@ test('context browser: picker, accordion, reconstruction, header content, deltas
msgSegBtns[0].args[1].onClick()
tr = renderView()
assert.equal(byClass(tr, 'lc-md-stub').length, 0, 'raw mode drops the markdown renderer')
assert.match(textOf(byClass(tr, 'lc-br-pre')[0]), /first message/, 'raw view carries the message text')
assert.match(textOf(byClass(tr, 'lc-ts-desc-body')[0]), /first message/, 'raw view carries the message text')
assert.match(byClass(tr, 'lc-rich-seg-btn')[0].args[1].className, /lc-rich-seg-on/, 'raw segment lights up')
byClass(tr, 'lc-rich-seg-btn')[1].args[1].onClick()
tr = renderView()
@@ -132,12 +132,12 @@ test('context browser: picker, accordion, reconstruction, header content, deltas
const sysMd = byClass(tr, 'lc-md-stub')
assert.equal(sysMd.length, 1, 'markdown mode renders the prompt through MarkdownText')
assert.equal(textOf(sysMd[0]), 'SYSTEM-PROMPT-TEXT', 'markdown view carries the same source text')
assert.equal(byClass(tr, 'lc-br-body')[0] && byClass(byClass(tr, 'lc-br-body')[0], 'lc-br-pre').length, 0,
assert.equal(byClass(tr, 'lc-br-body')[0] && byClass(byClass(tr, 'lc-br-body')[0], 'lc-ts-desc-body').length, 0,
'markdown mode drops the raw <pre>')
sysSegBtns()[0].args[1].onClick()
tr = renderView()
assert.equal(byClass(tr, 'lc-md-stub').length, 0, 'raw mode drops the markdown renderer')
assert.match(textOf(byClass(tr, 'lc-br-pre')[0]), /SYSTEM-PROMPT-TEXT/, 'raw view shows the prompt')
assert.match(textOf(byClass(tr, 'lc-ts-desc-body')[0]), /SYSTEM-PROMPT-TEXT/, 'raw view shows the prompt')
sysSegBtns()[1].args[1].onClick()
tr = renderView()
assert.equal(byClass(tr, 'lc-md-stub').length, 1, 'switching back restores the markdown view')
@@ -189,7 +189,7 @@ test('context browser: picker, accordion, reconstruction, header content, deltas
'array parameters render their element type')
// Raw JSON is collapsed by default — the toggle is visible but the schema
// string does NOT appear in the rendered text yet.
assert.equal(byClass(tr, 'lc-br-pre').filter(n => /"parameters"/.test(textOf(n))).length, 0,
assert.equal(byClass(tr, 'lc-ts-desc-body').filter(n => /"parameters"/.test(textOf(n))).length, 0,
'raw JSON stays collapsed behind the toggle by default')
const toggle = byClass(tr, 'lc-ts-json-toggle')[0]
assert.ok(toggle, 'JSON toggle button is rendered')
@@ -197,12 +197,12 @@ test('context browser: picker, accordion, reconstruction, header content, deltas
// Expanding the toggle reveals the schema; clicking again collapses it.
toggle.args[1].onClick()
tr = renderView()
assert.match(textOf(byClass(tr, 'lc-br-pre')[0]), /"parameters"/, 'expanding reveals the raw JSON')
assert.match(textOf(byClass(tr, 'lc-ts-desc-body')[0]), /"parameters"/, 'expanding reveals the raw JSON')
const collapseToggle = byClass(tr, 'lc-ts-json-toggle')[0]
assert.match(textOf(collapseToggle), /Collapse|收起/, 'toggle label flips to the hide label')
collapseToggle.args[1].onClick()
tr = renderView()
assert.equal(byClass(tr, 'lc-br-pre').length, 0, 'collapsing the toggle removes the JSON block')
assert.equal(byClass(tr, 'lc-ts-desc-body').length, 0, 'collapsing the toggle removes the JSON block')
// Without the contextHeaders key (older host), those sections degrade to a note.
bed.headersValue = undefined
+7 -7
View File
@@ -244,14 +244,14 @@ export async function bootViewBed() {
if (node === null || typeof node !== 'object') return node
if (node.kind === 'element') {
const [type, props, ...children] = node.args
// The fake createElement keeps children in args; real React puts them
// on props.children (single child as-is, several as an array) — merge
// them back for BOTH component kinds, exactly what React would pass.
const childrenOf = children.length === 0 ? {} : children.length === 1 ? { children: children[0] } : { children }
if (typeof type === 'function' && type.prototype !== undefined && typeof type.prototype.render === 'function') {
const key = path + '/' + (type.name || 'anon') + '#' + fnIdx
// The fake createElement keeps children in args, not props; real React
// puts them on props.children (single child as-is, several as an
// array), which the class instance's render() reads. Constructing with
// `new` runs the real constructor, so props AND the initial state are
// exactly what React would set.
const childrenOf = children.length === 0 ? {} : children.length === 1 ? { children: children[0] } : { children }
// Constructing with `new` runs the real constructor, so props AND
// the initial state are exactly what React would set.
const inst = new type(Object.assign({}, props || {}, childrenOf))
bed.classInstances.set(key, inst)
try {
@@ -272,7 +272,7 @@ export async function bootViewBed() {
bed.hookStates.set(key, currentHooks)
}
hookCursor = 0
return evaluate(type(props), key)
return evaluate(type(Object.assign({}, props || {}, childrenOf)), key)
}
const kids = []
let f = 0
+6 -5
View File
@@ -33,13 +33,14 @@ test('image attachment cards: card layout, metadata degradation, resolveImage th
brSlots[1][1]('user') // openCat('user')
brSlots[2][1]('n1') // expand the user element
let trImg = renderView()
// Metadata-only degradation (no conversation service armed): two attachment
// cards (images + other), two image items, placeholder tiles, no <img>.
assert.equal(byClass(trImg, 'lc-ts-card').length, 2, 'images card + other-content card rendered')
// Metadata-only degradation (no conversation service armed): three section
// cards (content text + images + other), two image items, placeholder
// tiles, no <img>.
assert.equal(byClass(trImg, 'lc-ts-card').length, 3, 'content + images + other-content cards rendered')
assert.equal(byClass(trImg, 'lc-att-item').length, 2, 'one item per image ref')
assert.equal(byClass(trImg, 'lc-att-thumb').length, 2, 'one tile per image')
assert.equal(byClass(trImg, 'lc-att-ph').length, 2, 'placeholder tiles without the loader')
const imgCardText = textOf(byClass(trImg, 'lc-ts-card')[0])
const imgCardText = textOf(byClass(trImg, 'lc-ts-card')[1])
assert.match(imgCardText, /screenshot\.png/, 'named image shows its display name')
assert.match(imgCardText, /Raw6000×4000/, 'pre-normalization dims shown when present')
assert.match(imgCardText, /Sent2048×1365 · 153\.6 kB/, 'normalized dims + stored byte size shown')
@@ -52,7 +53,7 @@ test('image attachment cards: card layout, metadata degradation, resolveImage th
// Labeled rows: raw + sent + token on the first card, sent + token on the
// second (no originalDimensions recorded → no Raw row).
assert.equal(byClass(trImg, 'lc-att-row').length, 5, 'one labeled row per known fact')
assert.match(textOf(byClass(trImg, 'lc-ts-card')[1]), /file:\/\/x/, 'unrecognized blocks keep the raw JSON card')
assert.match(textOf(byClass(trImg, 'lc-ts-card')[2]), /file:\/\/x/, 'unrecognized blocks keep the raw JSON card')
assert.match(textOf(byClass(trImg, 'lc-br-content')[0]), /WHAT-IS-THIS/, 'prose stays in the text card')
assert.equal(byClass(trImg, 'lc-att-thumb')[0].args.slice(2).filter(c => c !== null && typeof c === 'object' && c.args[0] === 'img').length, 0, 'no thumbnail img without the loader')
// Arm the conversation service: the loader resolves a display URL per ref.