/** * Diff-aware state tree for the audit panel. * * Renders a serialized `ChatState` (see `audit/serialize.ts`) as a * collapsible tree, colored by the structural diff against the previous * trail entry: added = green, removed = red + strikethrough, modified = * amber (`old → new` on leaves). Every field is rendered — long strings * are tail-truncated (`tailTrunc`) but no key is ever dropped, and a string * containing newlines collapses into one compact button (first-line preview * + line count) whose hover pops a `fixed` full-text panel at the button's * right edge. * * The tree is driven entirely by a `DiffNode` root: diff mode passes * `diffValue(prev, next)`, plain state mode passes `plainNode(value)`. * Diff nodes that carry an object/array but no children (whole-subtree * added/removed, or reference-equal unchanged subtrees collapsed by the * diff fast path) are expanded into same-status blocks at render time * (`blockNode`) — they show as collapsible `{ …N }` rows instead of an * unreadable one-line JSON dump. */ import { useEffect, useRef, useState } from 'react'; import { elementId, type DiffNode, type DiffStatus } from '../../audit/diff'; import { tailTrunc } from '../../audit/truncate'; /** Build an all-`unchanged` tree over a plain value (plain state mode). */ export function plainNode(value: unknown): DiffNode { return blockNode(value, 'unchanged'); } function blockNode(value: unknown, status: DiffStatus): DiffNode { const current = status === 'removed' ? undefined : value; const prev = status === 'removed' ? value : undefined; if (typeof value === 'object' && value !== null) { const children = new Map(); const entries: Array = Array.isArray(value) ? value.map((item, index) => [elementId(item) ?? `#${index}`, item] as const) : Object.entries(value as Record).filter(([, item]) => item !== undefined); for (const [key, item] of entries) children.set(key, blockNode(item, status)); return { status, value: current, prev, children }; } return { status, value: current, prev }; } function subtreeHasChanges(node: DiffNode): boolean { if (node.status !== 'unchanged') return true; if (node.children === undefined) return false; for (const child of node.children.values()) { if (subtreeHasChanges(child)) return true; } return false; } const ROW_TONE: Record = { unchanged: 'border-transparent', added: 'border-green-800 bg-green-950/40', removed: 'border-red-800 bg-red-950/40', modified: 'border-amber-700 bg-amber-950/30', }; const TEXT_TONE: Record = { unchanged: 'text-neutral-400', added: 'text-green-400', removed: 'text-red-400 line-through', modified: 'text-amber-300', }; function Leaf({ value, tone }: { value: unknown; tone: DiffStatus }) { const className = TEXT_TONE[tone]; if (value === null) return null; if (value === undefined) return undefined; if (typeof value === 'string') { if (value.includes('\n')) return ; return ( "{tailTrunc(value)}" ); } if (typeof value === 'number' || typeof value === 'boolean') { return {String(value)}; } return {JSON.stringify(value) ?? 'unknown'}; } /** * Multiline string leaf: rendered as one compact button (first-line preview * + line count) so a prompt or tool output never stretches the tree. Hovering * the button pops a `fixed` panel at its right edge with the full text — * `fixed` because the tree sits inside an `overflow-y-auto` column that * would clip an absolutely-positioned popup. A 150 ms close delay lets the * pointer travel from the button into the panel (to scroll or select). * `popupStyle` keeps the whole panel inside the viewport: the total height * is capped by the space left below the clamped top edge, and the panel * flips to the button's left side when the remaining right space is cramped. */ function popupStyle(rect: DOMRect): React.CSSProperties { const margin = 8; const vh = window.innerHeight; const vw = window.innerWidth; const maxHeight = Math.max(160, Math.floor(vh * 0.7)); const top = Math.max(margin, Math.min(rect.top, vh - maxHeight - margin)); const rightSpace = vw - rect.right - margin * 2; if (rightSpace >= 320) { return { left: rect.right + margin, top, maxHeight, width: Math.min(560, rightSpace) }; } const leftSpace = rect.left - margin * 2; return { right: vw - rect.left + margin, top, maxHeight, width: Math.min(560, Math.max(280, leftSpace)), }; } function MultilineString({ value, tone }: { value: string; tone: DiffStatus }) { const [rect, setRect] = useState(null); const closeTimer = useRef | undefined>(undefined); useEffect(() => () => clearTimeout(closeTimer.current), []); const lines = value.split('\n'); const firstLine = lines[0] ?? ''; const preview = firstLine.length > 40 ? `${firstLine.slice(0, 40)}…` : firstLine; const open = (e: React.MouseEvent) => { clearTimeout(closeTimer.current); setRect(e.currentTarget.getBoundingClientRect()); }; const scheduleClose = () => { clearTimeout(closeTimer.current); closeTimer.current = setTimeout(() => setRect(null), 150); }; return ( <> {rect !== null ? (
clearTimeout(closeTimer.current)} onMouseLeave={scheduleClose} >
{lines.length} lines · {value.length} chars
            {tailTrunc(value, 5000)}
          
) : null} ); } function Node({ name, node, depth, defaultDepth, }: { name: string; node: DiffNode; depth: number; defaultDepth: number; }) { // Childless object/array nodes (whole-subtree added/removed, or // reference-equal unchanged subtrees) are expanded into same-status // blocks so every field stays visible — never a one-line JSON dump. const raw = node.status === 'removed' ? node.prev : node.value; const effective = node.children === undefined && typeof raw === 'object' && raw !== null ? blockNode(raw, node.status) : node; const [open, setOpen] = useState(() => subtreeHasChanges(effective) || depth < defaultDepth); const label = {name}: ; if (effective.children === undefined) { return (
{label} {effective.status === 'modified' ? ( <> → ) : ( )}
); } const isArray = Array.isArray(raw); const [openBrace, closeBrace] = isArray ? ['[', ']'] : ['{', '}']; return (
setOpen((v) => !v)} > {open ? '▾ ' : '▸ '} {label} {open ? openBrace : `${openBrace} …${effective.children.size} ${closeBrace}`}
{open ? ( <> {[...effective.children.entries()].map(([key, child]) => ( ))}
{closeBrace}
) : null}
); } export function StateTree({ root, defaultDepth = 0, }: { root: DiffNode; /** Unchanged levels opened by default (diff mode 0 = only changes auto-open). */ defaultDepth?: number; }) { if (root.children === undefined) { return (
); } return (
{[...root.children.entries()].map(([key, child]) => ( ))}
); }