diff --git a/src/client/components/browser.tsx b/src/client/components/browser.tsx index 0964dbb..135c9e5 100644 --- a/src/client/components/browser.tsx +++ b/src/client/components/browser.tsx @@ -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 ( +
{props.text}
+ vs markdown). */
+ /** Rich-text toggle kit (description section: raw 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 ? (
-
-
- {props.labels.desc}
-
-
-
-
+
) : null}
{params !== null && rows.length > 0 ? (
-
-
- {props.labels.title}
- {rows.length}
-
+
{rows.map(r => )}
-
+
) : params !== null ? (
{props.labels.empty}
) : 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)}
- {jsonOpen ? {schemaJson} : null}
+ {jsonOpen ? {schemaJson} : null}
) : 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 {blk.text}
- }
- return
- }
- const image = imageRefOf(b)
- if (image !== null) return
- if (blk !== null && blk.type === 'tool-result' && Array.isArray(blk.content)) {
- return
- }
- return {JSON.stringify(b, null, 2)}
- })}
- >
- )
+/** 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 (
-
-
- {props.label}
-
-
-
-
- )
+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(
+
+
+ {group.map((a, i) => )}
+
+ ,
+ )
+ }
+ 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
+ ?
+ : )
+ continue
+ }
+ if (blockKind === 'tool-call') {
+ out.push( )
+ continue
+ }
+ if (blockKind === 'tool-result' && Array.isArray(blk?.content)) {
+ out.push( )
+ continue
+ }
+ out.push( )
+ }
+ 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 (
-
-
- {(props.arrow ?? '→') + ' ' + props.name}
- {args !== null ? {Object.keys(args).length} : null}
-
+
{args !== null
? Object.keys(args).map(k => )
: typeof props.argsRaw === 'string' && props.argsRaw !== ''
- ? {props.argsRaw}
+ ? {props.argsRaw}
: null}
-
+
)
}
@@ -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
- ?
- : {render('raw')}
-
+ 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 {props.hint}
+ return {props.hint}
}
- return wrap(mode => (
+ return (
<>
-
+
{props.hint}
>
- ))
+ )
}
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 (
-
- {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
- }
- if (blk.kind === 'reasoning' && typeof blk.text === 'string') {
- return
- }
- if (blk.kind === 'tool-call') {
- return
- }
- const image = imageRefOf(b)
- if (image !== null) return
- return null
- })}
-
- )
+ return
}
if (conv.kind === 'tool-result') {
return (
-
+ <>
{conv.call != null
?
: null}
- {Array.isArray(conv.content) ? : null}
-
+ {Array.isArray(conv.content)
+ ?
+ : null}
+ >
)
}
if (conv.kind === 'compaction') {
- return wrap(mode => (
- <>
- {typeof conv.summary === 'string' && conv.summary !== ''
- ?
- : null}
- >
- ))
+ return typeof conv.summary === 'string' && conv.summary !== ''
+ ?
+ : <>>
}
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 => )
- }
- 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 (
-
- {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.
- (
- <>
- {texts.map((text, i) => )}
- >
- )} />
- ) : null}
- {images.length > 0 ? (
-
-
- {props.labels.images}
- {images.length}
-
-
- {images.map((a, i) => )}
-
-
- ) : null}
- {others.length > 0 ? (
-
-
- {props.labels.other}
- {others.length}
-
- {others.map((b, i) => {JSON.stringify(b, null, 2)})}
-
- ) : null}
-
- )
+ return
}
- return {props.hint}
+ return {props.hint}
}
/** 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 (
- {open ? body : null}
+ {open ? {body} : null}
)
}
@@ -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,
- } />)
+ )
}
if (c === 'tools') {
if (view.header === null) return {t(headers === null ? 'browser.noHeader' : 'browser.noEpoch')}
@@ -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,
-
-
- )
+ )
})
}
// 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)
+ />)
})
}
diff --git a/src/client/components/richText.tsx b/src/client/components/richText.tsx
index 9af2013..134ec71 100644
--- a/src/client/components/richText.tsx
+++ b/src/client/components/richText.tsx
@@ -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
- * . 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 . 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 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) => (
)
return (
-
+
{seg('raw', t('rich.raw'), t('rich.toRaw'))}
{seg('md', t('rich.md'), t('rich.toMd'))}
)
}
- 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
+ return
}
- return {props.text}
+ return {props.text}
}
- function RichCard(props: {
- className?: string
- render: (mode: RichMode) => ReactNS.ReactNode
- }): ReactNS.ReactElement {
- const [mode, setMode] = useRichMode()
- return (
-
-
- {props.render(mode)}
-
- )
- }
-
- return { RichText, RichCard, RichSwitch, useRichMode }
+ return { RichText, RichSwitch, useRichMode }
}
diff --git a/src/client/i18n.ts b/src/client/i18n.ts
index 6fc0bb5..7e726f0 100644
--- a/src/client/i18n.ts
+++ b/src/client/i18n.ts
@@ -105,6 +105,7 @@ export const DICT_ZH: Record = {
// 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 = {
// 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
diff --git a/src/client/styles.css b/src/client/styles.css
index 6cffb09..415b719 100644
--- a/src/client/styles.css
+++ b/src/client/styles.css
@@ -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 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 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; }
diff --git a/tests/assistant-blocks.spec.mjs b/tests/assistant-blocks.spec.mjs
index a63e724..ba52b83 100644
--- a/tests/assistant-blocks.spec.mjs
+++ b/tests/assistant-blocks.spec.mjs
@@ -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')
diff --git a/tests/browser.spec.mjs b/tests/browser.spec.mjs
index 0174780..95e94d3 100644
--- a/tests/browser.spec.mjs
+++ b/tests/browser.spec.mjs
@@ -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 ')
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
diff --git a/tests/helpers/viewBed.mjs b/tests/helpers/viewBed.mjs
index 02e636f..adcca4c 100644
--- a/tests/helpers/viewBed.mjs
+++ b/tests/helpers/viewBed.mjs
@@ -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
diff --git a/tests/images.spec.mjs b/tests/images.spec.mjs
index 0ccd110..c33301f 100644
--- a/tests/images.spec.mjs
+++ b/tests/images.spec.mjs
@@ -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
.
- 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
.
+ 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.