Skip to content

Commit 044b630

Browse files
benvinegarclaude
andcommitted
feat(surfaces): add issue-tree part kind
A new native part kind renders a parent issue and its nested sub-issues as a tight rail tree with a progress rollup the viewer computes (done ÷ total over descendants, never stored). The shape is the normalized, recursive Issue model — `root.children` are more issues — so a tree nests across providers (a Linear epic owning a GitHub sub-issue and a Sentry leaf). Threaded through every tier: types + recursive zod validation (strict rejects, loose tolerates/drops), the Solid renderer, MCP/HTTP/CLI (`sideshow issue-tree`, `--issue-tree`), the design guide, AGENTS, and the README gallery. Adds an `--ok` status-green chrome token (themed via viewerVars, with a styles.css fallback). Hardening from review: - bound nesting (MAX_ISSUE_TREE_DEPTH/NODES) with an iterative pre-check so a deep tree is a clean 400 (loose drops it), not a stack-overflow 500; plus an app.onError safety net. - sanitize node.url (safeHref allowlist) so javascript:/data: never render as an executable link in the trusted viewer origin. - drop coerced-empty loose children so phantom rows can't skew the rollup. - memoize the rollup walk; progressbar aria; recursive JSON-schema children. Tests: depth rejection, loose state/child coercion, update→issue-tree history, partsByteLength recursion, store-contract deep round-trip, CLI publish + envelope error, and e2e (render, structural nesting depth, and javascript:-url safety). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 20f0f33 commit 044b630

22 files changed

Lines changed: 1105 additions & 24 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"sideshow": minor
3+
---
4+
5+
Add an `issue-tree` surface part. Agents hand over a `root` issue whose `children` are more issues of the same shape, and the viewer renders it natively as a tight rail/elbow tree with a progress rollup computed (`done ÷ total` over descendants) — never stored, so editing a leaf moves the bar. Each node is `{ ref, title, state, source?, note?, url?, children? }`; `state` is one of `open | in-progress | blocked | done | closed`, and `source` (github / linear / jira / gitlab / sentry / …) drives a source chip — so a tree can nest across providers (a Linear epic owning a GitHub sub-issue and a Sentry leaf). Available on all three tiers: an `issue-tree` part over MCP and `POST /api/surfaces`, plus the CLI (`sideshow issue-tree`, and `--issue-tree` on `sideshow publish`). Rendered from data, not sandboxed markup; the viewer themes it (light and dark) from chrome tokens, including a new `--ok` status green.

AGENTS.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ _use_ a running sideshow lives in `guide/AGENT_SETUP.md`, served at `/setup`.)
77
## What this is and why
88

99
A live visual surface for terminal coding agents: agents publish surfaces
10-
(multi-part cards — html, markdown, diff, terminal, image, mermaid) over
10+
(multi-part cards — html, markdown, diff, terminal, image, mermaid, issue-tree) over
1111
CLI/MCP/HTTP; the user watches them render in a browser and comments back. The
1212
two-way loop — publish → live render → comment → revise/reply — is the product.
1313
When in doubt, optimize for the loop.
@@ -33,7 +33,7 @@ consciously, not as a side effect):
3333
(`/api/assets`, `/a/:id`), and the shared flow functions both REST and MCP call.
3434
- `server/types.ts` — data model + `Store` interface; no runtime imports. A
3535
surface is an ordered list of parts (`html` | `markdown` | `diff` | `terminal`
36-
| `image` | `mermaid`); a snippet is sugar for a single html part.
36+
| `image` | `mermaid` | `issue-tree`); a snippet is sugar for a single html part.
3737
`htmlPart` bridges the legacy snippet shape. Assets (uploaded blobs)
3838
are a separate entity, referenced by `image` parts; `selectEvictions`
3939
is the reference-aware LRU policy.
@@ -44,8 +44,8 @@ consciously, not as a side effect):
4444
and both migrate legacy `snippets`/`snippetId` data to surfaces on load.
4545
- `server/surfacePage.ts` — sandboxed document for one html part: CSP allowlist
4646
and the postMessage bridge (resize, sendPrompt, openLink). Only html parts
47-
reach here — markdown, diff, terminal, image, and mermaid parts are data the
48-
viewer renders natively, never markup in the sandbox.
47+
reach here — markdown, diff, terminal, image, mermaid, and issue-tree parts are
48+
data the viewer renders natively, never markup in the sandbox.
4949
- `server/themes.ts` — theme registry (github/gruvbox/one), runtime-agnostic so
5050
both server and viewer import it. One `Palette` per light/dark per theme; the
5151
viewer-chrome vars and the html-part `--color-*` tokens are both _derived_

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,13 @@ viewer. A surface is an ordered list of **parts**; one card can carry several.
114114
<p><b>Parts compose.</b> One card can carry several — here a <code>markdown</code> rationale stacked above its <code>diff</code>, so a single surface holds the why and the what.</p>
115115
</td>
116116
</tr>
117+
<tr>
118+
<td width="50%" valign="top">
119+
<img src="docs/surfaces/09-issue-tree.png" width="100%" alt="issue-tree part — nested sub-issues with a computed progress rollup">
120+
<p><b><code>issue-tree</code></b> — a parent issue and its nested sub-issues, rendered as a rail tree with a rollup the viewer computes. Nests across providers — a Linear epic can own a GitHub sub-issue and a Sentry error.</p>
121+
</td>
122+
<td width="50%" valign="top"></td>
123+
</tr>
117124
</table>
118125

119126
## Run it anywhere

bin/sideshow.js

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ usage:
2424
--mermaid <file|-> add a mermaid part (diagram source → SVG) — combine with html
2525
--diff <file|-> add a diff part from a unified/git patch (combine with html)
2626
--terminal <file|-> add a terminal part from monospace/ANSI output
27+
--issue-tree <file|-> add an issue-tree part from a JSON root issue
2728
--image <file> upload an image and append it as an image part
2829
--session <id> target session (default: auto per agent session)
2930
--session-title <t> name for a newly created session — name the task,
@@ -55,6 +56,10 @@ usage:
5556
sideshow mermaid <file|-> [options] publish a mermaid surface (diagram → SVG)
5657
--title <t> surface title
5758
(also: --session, --session-title, --agent, --new-session)
59+
sideshow issue-tree <file|-> [options] publish an issue-tree surface from JSON
60+
--title <t> surface title
61+
(file is the root issue { ref, title, state, source?, note?, url?, children? })
62+
(also: --session, --session-title, --agent, --new-session)
5863
sideshow update <id> <file|-> revise a surface (new version, same card)
5964
--title <t> replace title
6065
sideshow wait [options] block until the user comments (long-poll)
@@ -294,6 +299,27 @@ function readContent(arg) {
294299
}
295300
}
296301

302+
// Build an issue-tree part from JSON text. Accepts a full part ({ kind, root }),
303+
// a wrapper ({ root }), or a bare root issue ({ ref, title, state, … }) — the
304+
// server validates the shape, so this only normalizes the envelope.
305+
function issueTreePart(text) {
306+
let data;
307+
try {
308+
data = JSON.parse(text);
309+
} catch {
310+
fail("issue-tree input must be JSON (a root issue, or { root } / { kind, root })");
311+
}
312+
if (data && typeof data === "object" && "root" in data) {
313+
return { kind: "issue-tree", root: data.root };
314+
}
315+
// A part object that has a kind but no root ({kind:'issue-tree'}) is a mistake;
316+
// don't silently wrap it as its own root (which yields a confusing field error).
317+
if (data && typeof data === "object" && "kind" in data) {
318+
fail("issue-tree input has a 'kind' but no 'root' — pass the root issue, or { root: {…} }");
319+
}
320+
return { kind: "issue-tree", root: data };
321+
}
322+
297323
function out(value) {
298324
console.log(JSON.stringify(value, null, 2));
299325
}
@@ -666,6 +692,7 @@ const commands = {
666692
diff: { type: "string" },
667693
image: { type: "string" },
668694
terminal: { type: "string" },
695+
"issue-tree": { type: "string" },
669696
layout: { type: "string" },
670697
session: { type: "string" },
671698
"session-title": { type: "string" },
@@ -690,6 +717,9 @@ const commands = {
690717
if (flags.terminal !== undefined) {
691718
parts.push({ kind: "terminal", text: readContent(flags.terminal || "-") });
692719
}
720+
if (flags["issue-tree"] !== undefined) {
721+
parts.push(issueTreePart(readContent(flags["issue-tree"] || "-")));
722+
}
693723
// Resolve the session first so the image upload and the surface share it.
694724
const session = await resolveSession(flags, { create: true });
695725
if (flags.image !== undefined) {
@@ -848,6 +878,21 @@ const commands = {
848878
outSurface(await publishSurface(parts, flags));
849879
},
850880

881+
async "issue-tree"() {
882+
const { values: flags, positionals } = parse({
883+
allowPositionals: true,
884+
options: {
885+
title: { type: "string" },
886+
session: { type: "string" },
887+
"session-title": { type: "string" },
888+
agent: { type: "string" },
889+
"new-session": { type: "boolean" },
890+
},
891+
});
892+
const parts = [issueTreePart(readContent(positionals[0]))];
893+
outSurface(await publishSurface(parts, flags));
894+
},
895+
851896
async update() {
852897
const { values: flags, positionals } = parse({
853898
allowPositionals: true,

docs/surfaces/09-issue-tree.png

102 KB
Loading

e2e/issueTree.spec.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { expect, publishParts, test } from "./fixtures.ts";
2+
3+
const ROOT = {
4+
ref: "ENG-204",
5+
title: "Normalize issue states across providers",
6+
state: "in-progress",
7+
source: "linear",
8+
children: [
9+
{ ref: "octo/web #1432", title: "Dark mode regression", state: "done", source: "github" },
10+
{ ref: "ENG-219", title: "Map GitHub closed states", state: "done", source: "linear" },
11+
{
12+
ref: "SUP-881",
13+
title: "SSO login fails for SAML customers",
14+
state: "blocked",
15+
source: "jira",
16+
children: [
17+
{ ref: "JS-4F2", title: "TypeError reading 'theme'", state: "open", source: "sentry" },
18+
],
19+
},
20+
{ ref: "infra/ci !57", title: "Bump CI runners", state: "in-progress", source: "gitlab" },
21+
],
22+
};
23+
24+
test("an issue-tree part renders a native rail tree with a computed rollup", async ({
25+
page,
26+
server,
27+
}) => {
28+
await publishParts(server.url, {
29+
title: "Sub-issues",
30+
agent: "e2e",
31+
parts: [{ kind: "issue-tree", root: ROOT }],
32+
});
33+
34+
await page.goto(server.url);
35+
const card = page.locator(".card:not(#sessionThread)");
36+
const tree = card.locator(".itree");
37+
38+
// rendered natively in the viewer document (no sandboxed iframe)
39+
await expect(tree).toBeVisible();
40+
await expect(card.locator("iframe")).toHaveCount(0);
41+
42+
// the epic header and a nested cross-provider leaf both made it in
43+
await expect(tree.locator(".itree-epic-title")).toContainText("Normalize issue states");
44+
await expect(tree).toContainText("ENG-204");
45+
await expect(tree).toContainText("JS-4F2");
46+
47+
// rollup is computed from descendants (2 done of 5: #1432, ENG-219)
48+
await expect(tree.locator(".itree-prog")).toContainText("2 / 5 done");
49+
await expect(tree.locator(".itree-prog")).toContainText("40%");
50+
51+
// structural depth: the grandchild lives two subtrees deep, so a regression
52+
// that flattened the tree into siblings would fail this (a visibility-only
53+
// check would not).
54+
await expect(
55+
tree.locator(".itree-subtree .itree-subtree .itree-node", { hasText: "JS-4F2" }),
56+
).toBeVisible();
57+
});
58+
59+
test("a javascript: url on a node is not rendered as an executable link", async ({
60+
page,
61+
server,
62+
}) => {
63+
await publishParts(server.url, {
64+
title: "Links",
65+
agent: "e2e",
66+
parts: [
67+
{
68+
kind: "issue-tree",
69+
root: {
70+
ref: "SAFE-1",
71+
title: "Epic",
72+
state: "open",
73+
url: "https://example.com/epic",
74+
children: [
75+
{
76+
ref: "EVIL-1",
77+
title: "xss",
78+
state: "open",
79+
url: "javascript:alert(document.domain)",
80+
},
81+
],
82+
},
83+
},
84+
],
85+
});
86+
87+
await page.goto(server.url);
88+
const tree = page.locator(".card:not(#sessionThread) .itree");
89+
// the safe https ref is a real anchor
90+
await expect(tree.locator('a.itree-ref[href="https://example.com/epic"]')).toBeVisible();
91+
// the javascript: ref is dropped to plain text — no anchor carries it
92+
await expect(tree.locator("a.itree-ref", { hasText: "EVIL-1" })).toHaveCount(0);
93+
await expect(tree.locator("span.itree-ref", { hasText: "EVIL-1" })).toBeVisible();
94+
// and no anchor anywhere has a javascript: href
95+
await expect(tree.locator('a[href^="javascript:"]')).toHaveCount(0);
96+
});
97+
98+
test("an issue-tree with no children renders the epic and an empty note", async ({
99+
page,
100+
server,
101+
}) => {
102+
await publishParts(server.url, {
103+
title: "Lonely",
104+
agent: "e2e",
105+
parts: [{ kind: "issue-tree", root: { ref: "ENG-1", title: "Just me", state: "open" } }],
106+
});
107+
108+
await page.goto(server.url);
109+
const tree = page.locator(".card:not(#sessionThread) .itree");
110+
await expect(tree.locator(".itree-epic-title")).toContainText("Just me");
111+
// no descendants → no rollup bar, and the empty-state note shows
112+
await expect(tree.locator(".itree-prog")).toHaveCount(0);
113+
await expect(tree.locator(".itree-empty")).toBeVisible();
114+
});

guide/DESIGN_GUIDE.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,12 +47,21 @@ a `kind`:
4747
bold, italic, underline); the viewer renders those and HTML-escapes the rest.
4848
Reach for it to share shell output, build logs, or example commands. (Colors
4949
yes; cursor-addressing TUIs are not resolved — share a captured frame.)
50+
- **`issue-tree`** — a parent issue and its nested sub-issues, rendered natively
51+
as a rail tree with a computed progress rollup. Hand over a `root` issue whose
52+
`children` are more issues of the same shape (it nests as deep as you like, and
53+
across providers — a Linear epic can own a GitHub sub-issue). Each node is
54+
`{ ref, title, state, source?, note?, url?, children? }`; `state` is one of
55+
`open | in-progress | blocked | done | closed`, `source` (github / linear /
56+
jira / gitlab / sentry / …) picks the chip. The rollup (`done ÷ total` over
57+
descendants) is computed by the viewer — don't send it. Reach for it for
58+
sub-issues, epics → stories, or any parent/child issue hierarchy.
5059

5160
A surface can combine parts, e.g. `[html, diff]` is a diagram with its code
5261
review in one card, and `[markdown, diff]` is a written rationale above its
5362
changeset. Trust differs: html parts are sandboxed because you author the
54-
markup; markdown/mermaid/diff/image/trace/terminal parts are rendered by the
55-
viewer from data — send data, never markup.
63+
markup; markdown/mermaid/diff/image/trace/terminal/issue-tree parts are rendered
64+
by the viewer from data — send data, never markup.
5665

5766
A **`SurfacePart`** is one of:
5867

@@ -66,6 +75,8 @@ A **`SurfacePart`** is one of:
6675
{ "kind": "trace", "steps": [{ "label": "...", "kind": "tool", "detail": "...", "ts": "..." }] }
6776
{ "kind": "trace", "assetId": "<id of an uploaded JSON/JSONL trace>", "title": "..." }
6877
{ "kind": "terminal", "text": "<output, may include ANSI SGR escapes>", "cols": 80, "title": "..." }
78+
{ "kind": "issue-tree", "root": { "ref": "ENG-204", "title": "...", "state": "in-progress", "source": "linear",
79+
"children": [ { "ref": "#1432", "title": "...", "state": "done", "source": "github" } ] } }
6980
```
7081

7182
For a diff, send a `patch` — it carries only the changed lines, so it is the

scripts/shoot-surfaces.mjs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,11 @@ const cards = [
171171
{ kind: "diff", patch: read("dlq.patch"), layout: "unified" },
172172
],
173173
},
174+
{
175+
file: "issue-tree",
176+
title: "issue-tree part — nested sub-issues with a computed rollup",
177+
parts: [{ kind: "issue-tree", root: JSON.parse(read("issue-tree.json")) }],
178+
},
174179
];
175180

176181
let session;
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
{
2+
"ref": "ENG-204",
3+
"title": "Normalize issue states across providers",
4+
"state": "in-progress",
5+
"source": "linear",
6+
"children": [
7+
{
8+
"ref": "octo/web #1432",
9+
"title": "Dark mode regression in the settings panel",
10+
"state": "done",
11+
"source": "github"
12+
},
13+
{
14+
"ref": "ENG-219",
15+
"title": "Map GitHub closed states → Done / Closed",
16+
"state": "done",
17+
"source": "linear"
18+
},
19+
{
20+
"ref": "SUP-881",
21+
"title": "SSO login fails for SAML customers",
22+
"state": "blocked",
23+
"source": "jira",
24+
"children": [
25+
{
26+
"ref": "JS-4F2",
27+
"title": "TypeError: undefined (reading 'theme')",
28+
"state": "open",
29+
"source": "sentry",
30+
"note": "unresolved →"
31+
}
32+
]
33+
},
34+
{
35+
"ref": "infra/ci !57",
36+
"title": "Bump CI runners to reduce queue time",
37+
"state": "in-progress",
38+
"source": "gitlab"
39+
}
40+
]
41+
}

server/app.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,15 @@ export function createApp({
201201
const app = new Hono();
202202
const bus = new EventBus();
203203

204+
// Last-resort safety net: any handler that throws (rather than returning a
205+
// status) becomes a clean JSON 500 instead of leaking a stack or a bare crash.
206+
// Validation rejects bad input with 4xx before this, so reaching here means an
207+
// unexpected bug — log it so it isn't swallowed silently.
208+
app.onError((err, c) => {
209+
console.error("sideshow: unhandled error", err);
210+
return c.json({ error: "internal error" }, 500);
211+
});
212+
204213
// Cached, fail-silent update lookup: being offline or rate-limited must
205214
// cost nothing but the absence of the notice. Failures are cached too, so
206215
// a dead network doesn't retry on every viewer load.

0 commit comments

Comments
 (0)