From 1958d7c473f4e5fa985920bf035ee4fb1e1e1f9e Mon Sep 17 00:00:00 2001 From: Ben Vinegar <2153+benvinegar@users.noreply.github.com> Date: Fri, 26 Jun 2026 11:20:00 -0400 Subject: [PATCH] feat(json): support optional openDepth to expand nested containers --- bin/sideshow.js | 17 ++++++++++++++--- guide/DESIGN_GUIDE.md | 4 ++++ server/postSurfaces.ts | 10 ++++++++-- server/types.ts | 6 +++++- test/api.test.ts | 12 ++++++++++++ test/assets.test.ts | 2 +- viewer/src/JsonSurface.tsx | 13 +++++++------ 7 files changed, 51 insertions(+), 13 deletions(-) diff --git a/bin/sideshow.js b/bin/sideshow.js index fceb0e4..8c4ca7f 100755 --- a/bin/sideshow.js +++ b/bin/sideshow.js @@ -26,6 +26,7 @@ usage: --diff add a diff surface from a unified/git patch (combine with html) --terminal add a terminal surface from monospace/ANSI output --json add a json surface from a JSON file (collapsible tree) + --open-depth expand json containers up to this depth (with --json) --code add a code surface from a file (shiki-highlighted) --kit opt the html surface into a kit (repeatable; see "sideshow kits") --image upload an image and append it as an image surface @@ -61,6 +62,8 @@ usage: (also: --session, --session-title, --agent, --new-session) sideshow json [options] publish a JSON post (collapsible tree) --title post title + --open-depth expand all containers up to this depth on render + (0 = only root open, default; 2 = first 2 levels) (also: --session, --session-title, --agent, --new-session) sideshow code [options] publish a code post (shiki-highlighted) --title post (card) title @@ -809,6 +812,7 @@ const commands = { image: { type: "string" }, terminal: { type: "string" }, json: { type: "string" }, + "open-depth": { type: "string" }, code: { type: "string" }, kit: { type: "string", multiple: true }, layout: { type: "string" }, @@ -841,7 +845,10 @@ const commands = { if (flags.json !== undefined) { const text = readContent(flags.json || "-"); try { - parts.push({ kind: "json", data: JSON.parse(text) }); + const jsonPart = { kind: "json", data: JSON.parse(text) }; + const od = Number(flags["open-depth"]); + if (Number.isFinite(od) && od >= 0) jsonPart.openDepth = Math.floor(od); + parts.push(jsonPart); } catch { fail(`--json: invalid JSON${flags.json ? ` in ${flags.json}` : ""}`); } @@ -1017,13 +1024,14 @@ const commands = { allowPositionals: true, options: { title: { type: "string" }, + "open-depth": { type: "string" }, session: { type: "string" }, "session-title": { type: "string" }, agent: { type: "string" }, "new-session": { type: "boolean" }, }, }); - if (!positionals[0]) fail("usage: sideshow json [--title t]"); + if (!positionals[0]) fail("usage: sideshow json [--title t] [--open-depth n]"); const text = readContent(positionals[0]); let data; try { @@ -1031,7 +1039,10 @@ const commands = { } catch { fail(`invalid JSON${positionals[0] !== "-" ? ` in ${positionals[0]}` : ""}`); } - const parts = [{ kind: "json", data }]; + const part = { kind: "json", data }; + const od = Number(flags["open-depth"]); + if (Number.isFinite(od) && od >= 0) part.openDepth = Math.floor(od); + const parts = [part]; outSurface(await publishSurface(parts, flags)); }, async code() { diff --git a/guide/DESIGN_GUIDE.md b/guide/DESIGN_GUIDE.md index f57980c..aab6b1e 100644 --- a/guide/DESIGN_GUIDE.md +++ b/guide/DESIGN_GUIDE.md @@ -50,6 +50,8 @@ a `kind`: for it for API responses, config files, test results — any structured data where a tree beats a fenced code block. Like image/trace it is data, not markup: the viewer renders it with escaped text nodes, so no sandbox is needed. + `openDepth` (number, default 0) expands all containers up to that depth on + initial render — e.g. `2` reveals the first two levels of nested structure. - **`code`** — source code you hand over as _text_; the trusted viewer highlights it with shiki (same highlighter as markdown fenced code blocks) and renders it in a sandboxed iframe. `language` is a shiki lang id (`ts`, `js`, `python`, @@ -83,6 +85,7 @@ A **`Surface`** is one of: { "kind": "trace", "assetId": "", "title": "..." } { "kind": "terminal", "text": "", "cols": 80, "title": "..." } { "kind": "json", "data": { "a": 1, "b": [true, null, "hi"] } } +{ "kind": "json", "data": {...}, "openDepth": 2 } { "kind": "code", "code": "const x = 42;", "language": "ts", "title": "example.ts" } { "kind": "code", "code": "...", "language": "ts", "title": "x.ts", "lineStart": 80 } { "kind": "html", "html": "
    ...
", "kits": ["issues"] } # opt into a kit (see Kits) @@ -160,6 +163,7 @@ sideshow markdown plan.md --title "Migration plan" # markdown sideshow mermaid flow.mmd --title "Request flow" # mermaid sideshow diff change.patch --layout split --title "..." # diff sideshow json data.json --title "API response" # json (collapsible tree) +sideshow json data.json --open-depth 2 --title "Nested" # json (first 2 levels open) sideshow code app.ts --title "Entry point" # code (lang inferred from filename) sideshow code - --language python --title "Script" # code from stdin sideshow code app.ts --line-start 80 --title "app.ts" # excerpt with original line numbers diff --git a/server/postSurfaces.ts b/server/postSurfaces.ts index 02cf5af..af852f8 100644 --- a/server/postSurfaces.ts +++ b/server/postSurfaces.ts @@ -195,24 +195,30 @@ const looseTerminalPart = z.object({ // drops the surface if `data` is absent. The transform fixes zod's inference: // z.unknown() marks the key optional, but data is always present after the // refine, so the output type must be { kind: "json"; data: unknown }. +// `openDepth` optionally expands containers up to that depth on render. +function maybeOpenDepth(n: number | undefined): { openDepth?: number } { + return typeof n === "number" && Number.isFinite(n) && n >= 0 ? { openDepth: Math.floor(n) } : {}; +} const strictJsonPart = z .object({ kind: z.literal("json"), data: z.unknown(), + openDepth: z.number().int().min(0).optional(), }) .refine((p) => p.data !== undefined, { message: 'json surface requires "data"', }) - .transform((p) => ({ kind: "json" as const, data: p.data })); + .transform((p) => ({ kind: "json" as const, data: p.data, ...maybeOpenDepth(p.openDepth) })); const looseJsonPart = z .object({ kind: z.literal("json"), data: z.unknown(), + openDepth: optionalLooseNumber, }) .refine((p) => p.data !== undefined, { message: 'json surface requires "data"', }) - .transform((p) => ({ kind: "json" as const, data: p.data })); + .transform((p) => ({ kind: "json" as const, data: p.data, ...maybeOpenDepth(p.openDepth) })); const strictCodePart = z.object({ kind: z.literal("code"), diff --git a/server/types.ts b/server/types.ts index e4ad7a0..605b509 100644 --- a/server/types.ts +++ b/server/types.ts @@ -139,6 +139,10 @@ export interface TerminalSurface { export interface JsonSurface { kind: "json"; data: unknown; + // How many levels of containers to expand on initial render (root is depth + // 0). Default 0 = only the root object/array is open. Set to e.g. 2 to + // reveal the first two levels of nested structure at a glance. + openDepth?: number; } // A code surface is source code the trusted viewer highlights with shiki (the @@ -415,7 +419,7 @@ export function surfacesByteLength(surfaces: Surface[]): number { } else if (p.kind === "mermaid") { n += p.mermaid.length; } else if (p.kind === "json") { - n += JSON.stringify(p.data).length; + n += JSON.stringify(p.data).length + (p.openDepth ? 4 : 0); } else if (p.kind === "code") { n += p.code.length + (p.language?.length ?? 0) + (p.title?.length ?? 0) + (p.lineStart ? 4 : 0); diff --git a/test/api.test.ts b/test/api.test.ts index 334def2..b898fcd 100644 --- a/test/api.test.ts +++ b/test/api.test.ts @@ -525,6 +525,18 @@ test("json part without data key is rejected", async () => { assert.equal(res.status, 400); }); +test("json part openDepth round-trips", async () => { + const app = makeApp(); + const res = await app.request( + "/api/surfaces", + json({ title: "Tree", parts: [{ kind: "json", data: { a: { b: 1 } }, openDepth: 2 }] }), + ); + assert.equal(res.status, 201); + const surface = (await res.json()) as any; + const full = (await (await app.request(`/api/surfaces/${surface.id}`)).json()) as any; + assert.equal(full.surfaces[0].openDepth, 2); +}); + test("publishes a code part; round-trips code/lang/title and 404s on /s", async () => { const app = makeApp(); const res = await app.request( diff --git a/test/assets.test.ts b/test/assets.test.ts index 7beafd8..28415d6 100644 --- a/test/assets.test.ts +++ b/test/assets.test.ts @@ -89,7 +89,7 @@ test("validateSurfaces accepts all supported part kinds", async () => { { kind: "trace", steps: [{ label: "read", kind: "tool" }], title: "Trace" }, { kind: "trace", assetId: "trace-file" }, { kind: "json", data: { a: 1, b: [true, null, "hi"] } }, - { kind: "json", data: null }, + { kind: "json", data: null, openDepth: 2 }, { kind: "json", data: 42 }, { kind: "code", code: "const x = 1;", language: "ts", title: "a.ts" }, { kind: "code", code: "print('hi')" }, diff --git a/viewer/src/JsonSurface.tsx b/viewer/src/JsonSurface.tsx index 947f79d..52b28b6 100644 --- a/viewer/src/JsonSurface.tsx +++ b/viewer/src/JsonSurface.tsx @@ -2,27 +2,28 @@ import { createSignal, For, Show } from "solid-js"; import type { JsonSurface as JsonSurfaceData } from "./api.ts"; export function JsonSurface(props: { surface: JsonSurfaceData }) { + const openDepth = () => props.surface.openDepth ?? 0; return (
- +
); } -function JsonNode(props: { value: unknown; depth: number }) { +function JsonNode(props: { value: unknown; depth: number; openDepth: number }) { const isContainer = () => typeof props.value === "object" && props.value !== null; return ( }> - + ); } type Entry = readonly [string, unknown]; -function Container(props: { value: object; depth: number }) { - const [open, setOpen] = createSignal(props.depth === 0); +function Container(props: { value: object; depth: number; openDepth: number }) { + const [open, setOpen] = createSignal(props.depth <= props.openDepth); const isArray = () => Array.isArray(props.value); const entries = (): Entry[] => isArray() @@ -66,7 +67,7 @@ function Container(props: { value: object; depth: number }) { "{key}" : - + ,