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.label} + {props.actions ?? null} + {props.count !== undefined ? {props.count} : null} +
+ {props.children} +
+ ) +} + +/** + * 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 ( +
}> + +
+ ) +} + +/** 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 ( +
+
{props.text}
+
+ ) +} + /** * 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
 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.