Render JSON log lines as collapsible key/value trees (ticket 014)

LogPreview.tsx now detects when a log line's raw text parses as a JSON
object and renders it as a collapsible tree instead of raw text; any
other line (non-JSON or invalid JSON) keeps today's plain-text
rendering unchanged. Client-side only, no backend changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
This commit is contained in:
Hugh Kaznowski
2026-09-05 19:31:19 +01:00
co-authored by Claude Sonnet 5
parent e6e5bc8f50
commit cb101aa5da
2 changed files with 90 additions and 11 deletions
+90 -1
View File
@@ -82,6 +82,93 @@ function ColumnPicker({
)
}
/// Attempts to parse a log line's raw text as a JSON object (not array/primitive), for structured
/// tree rendering. Returns null for anything that isn't valid JSON or doesn't parse to a plain object,
/// so those lines keep rendering as plain text.
function tryParseJsonObject(text: string): Record<string, unknown> | null {
const trimmed = text.trim()
if (!trimmed.startsWith('{')) return null
try {
const parsed = JSON.parse(trimmed)
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>
}
return null
} catch {
return null
}
}
/// Renders a single JSON value: primitives inline, objects/arrays as collapsible subtrees.
function JsonValueView({ value }: { value: unknown }) {
if (value === null) return <span className="text-text-subtle">null</span>
if (Array.isArray(value)) {
return <JsonNode entries={value.map((v, i) => [String(i), v] as const)} bracket={['[', ']']} />
}
if (typeof value === 'object') {
return <JsonNode entries={Object.entries(value as Record<string, unknown>)} bracket={['{', '}']} />
}
if (typeof value === 'string') return <span className="text-text">"{value}"</span>
return <span className="text-text-muted">{String(value)}</span>
}
function JsonNode({
entries,
bracket,
}: {
entries: ReadonlyArray<readonly [string, unknown]>
bracket: [string, string]
}) {
const [expanded, setExpanded] = useState(true)
if (entries.length === 0) {
return (
<span className="text-text-subtle">
{bracket[0]}
{bracket[1]}
</span>
)
}
return (
<span>
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="mr-1 inline-block w-3 select-none text-text-subtle hover:text-text-muted"
aria-label={expanded ? 'Collapse' : 'Expand'}
>
{expanded ? '▾' : '▸'}
</button>
{expanded ? (
<span>
{bracket[0]}
<span className="ml-3 block border-l border-border-faint pl-2">
{entries.map(([key, value]) => (
<span key={key} className="block">
<span className="text-text-secondary">{key}</span>
<span className="text-text-subtle">: </span>
<JsonValueView value={value} />
</span>
))}
</span>
{bracket[1]}
</span>
) : (
<span className="text-text-subtle">{bracket[0]}…{bracket[1]}</span>
)}
</span>
)
}
/// Renders a log line's message: a collapsible key/value tree when the raw text parses as a JSON
/// object, otherwise the raw text unchanged.
function LogMessage({ text }: { text: string }) {
const parsed = tryParseJsonObject(text)
if (!parsed) return <>{text}</>
return <JsonValueView value={parsed} />
}
/// Keeps the scroll position anchored to the same visible row across a `logs` update (e.g. a sliding time window
/// pruning old rows from the front), instead of the browser's default of holding `scrollTop` constant in pixels —
/// which would otherwise scroll the view to different content whenever rows are removed above it.
@@ -181,7 +268,9 @@ export function LogPreview({ logs, params }: { logs: LogEntry[]; params: ParamDe
</td>
)
})}
<td className="px-3 py-1 text-text">{log.text}</td>
<td className="px-3 py-1 text-text">
<LogMessage text={log.text} />
</td>
</tr>
))}
{logs.length === 0 && (