Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,18 @@ jobs:
- run: npm run typecheck
- run: npm test

e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium webkit
- run: npm run test:e2e

pack-smoke:
runs-on: ubuntu-latest
steps:
Expand All @@ -45,4 +57,5 @@ jobs:
for i in $(seq 1 20); do curl -sf localhost:4242/api/sessions > /dev/null && break; sleep 0.5; done
echo '<p>ci smoke</p>' | sideshow publish - --title "CI smoke" --agent ci | tee /tmp/out.json
grep -q '"sessionId"' /tmp/out.json
sideshow demo | grep -q "Seeded 2 demo sessions"
curl -sf localhost:4242/guide > /dev/null
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@ data/
*.tgz
dist/
.dev.vars
test-results/
playwright-report/
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ All notable user-visible changes to this project are documented in this file.

### Added

- `sideshow demo` seeds two example sessions (a sequence diagram with a
comment thread, an interactive explainer, a metrics card) so the viewer can
be explored without an agent.

### Changed

### Fixed
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ Above: an agent published a sequence diagram while working on an auth
refactor. The user asked a question under it, and the agent answered in the
thread and revised the snippet.

The loop in motion — publish, live appear, comment, revise:

![Animated demo: an agent publishes a diagram that appears live in the viewer, the user types a question under it, and the agent revises the snippet to a second version and replies in the thread](docs/sideshow-demo.gif)

## Quick start

Requires Node 22.18 or newer.
Expand All @@ -38,6 +42,9 @@ curl -s http://localhost:4242/setup >> AGENTS.md
That block teaches any agent with a shell tool (pi, opencode, amp, codex,
Claude Code) how to publish snippets and poll for your comments using curl.

No agent handy? `npx sideshow demo` seeds two example sessions so you can
look around the viewer.

## Connecting agents

Use whichever the agent supports:
Expand Down
187 changes: 187 additions & 0 deletions bin/demoData.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
// Seed content for `sideshow demo` — two example sessions that show what
// agents draw on the surface. Keep this file dependency-free like the CLI.

const JWT_DIAGRAM = `
<svg width="100%" viewBox="0 0 680 320" font-family="var(--font-sans)" font-size="13">
<defs>
<marker id="arr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6.5" markerHeight="6.5" orient="auto-start-reverse">
<path d="M0 0L10 5L0 10z" fill="var(--color-text-secondary)"/>
</marker>
<marker id="arr-danger" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6.5" markerHeight="6.5" orient="auto-start-reverse">
<path d="M0 0L10 5L0 10z" fill="var(--color-text-danger)"/>
</marker>
<marker id="arr-success" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6.5" markerHeight="6.5" orient="auto-start-reverse">
<path d="M0 0L10 5L0 10z" fill="var(--color-text-success)"/>
</marker>
</defs>

<line x1="110" y1="52" x2="110" y2="300" stroke="var(--color-border-tertiary)" stroke-dasharray="3 4"/>
<line x1="340" y1="52" x2="340" y2="300" stroke="var(--color-border-tertiary)" stroke-dasharray="3 4"/>
<line x1="570" y1="52" x2="570" y2="300" stroke="var(--color-border-tertiary)" stroke-dasharray="3 4"/>

<rect x="35" y="10" width="150" height="40" rx="8" fill="var(--color-background-secondary)" stroke="var(--color-border-tertiary)"/>
<text x="110" y="35" text-anchor="middle" font-weight="500" fill="var(--color-text-primary)">Client</text>
<rect x="265" y="10" width="150" height="40" rx="8" fill="var(--color-background-info)" stroke="var(--color-border-info)"/>
<text x="340" y="35" text-anchor="middle" font-weight="500" fill="var(--color-text-info)">/api (guarded)</text>
<rect x="495" y="10" width="150" height="40" rx="8" fill="var(--color-background-warning)" stroke="var(--color-border-warning)"/>
<text x="570" y="35" text-anchor="middle" font-weight="500" fill="var(--color-text-warning)">/auth/refresh</text>

<text x="225" y="84" text-anchor="middle" fill="var(--color-text-secondary)">request + expired JWT</text>
<line x1="110" y1="92" x2="334" y2="92" stroke="var(--color-text-secondary)" marker-end="url(#arr)"/>

<text x="225" y="120" text-anchor="middle" fill="var(--color-text-danger)">401 token_expired</text>
<line x1="340" y1="128" x2="116" y2="128" stroke="var(--color-text-danger)" marker-end="url(#arr-danger)"/>

<text x="340" y="172" text-anchor="middle" fill="var(--color-text-secondary)">refresh token (httpOnly cookie)</text>
<line x1="110" y1="180" x2="564" y2="180" stroke="var(--color-text-secondary)" marker-end="url(#arr)"/>

<text x="340" y="208" text-anchor="middle" fill="var(--color-text-success)">new JWT + rotated refresh token</text>
<line x1="570" y1="216" x2="116" y2="216" stroke="var(--color-text-success)" marker-end="url(#arr-success)"/>

<text x="225" y="260" text-anchor="middle" fill="var(--color-text-secondary)">retry with new JWT</text>
<line x1="110" y1="268" x2="334" y2="268" stroke="var(--color-text-secondary)" marker-end="url(#arr)"/>
</svg>`;

const JWT_EXPLAINER = `
<p style="font-family: var(--font-sans); color: var(--color-text-primary); line-height: 1.6; margin: 14px 6px 4px;">
The access token lives in memory only (a JS variable) — never localStorage, so XSS
can't exfiltrate a long-lived credential. The client never stores the refresh token
in JS — it lives in an httpOnly cookie and only travels to
<code style="font-family: var(--font-mono); font-size: 0.92em;">/auth/refresh</code>.
Rotation means a stolen refresh token dies on first reuse.
</p>`;

const BACKOFF = `
<div id="bk" style="font-family: var(--font-sans); color: var(--color-text-primary);">
<div style="display: flex; align-items: center; gap: 12px;">
<span style="font-weight: 500;">Base delay</span>
<input type="range" id="base" min="50" max="1000" step="50" value="200" style="flex: 1;">
<span id="baseVal" style="width: 64px; text-align: right; font-weight: 500;">200 ms</span>
</div>
<label style="display: flex; align-items: center; gap: 8px; margin: 10px 0 14px; color: var(--color-text-secondary); cursor: pointer;">
<input type="checkbox" id="jitter">
Full jitter — each client waits a random time within the window
</label>
<div id="rows"></div>
</div>
<script>
var baseEl = document.getElementById("base");
var jitterEl = document.getElementById("jitter");
var ATTEMPTS = 5;

function fmt(ms) {
return ms < 1000 ? Math.round(ms) + " ms" : (Math.round(ms / 100) / 10) + " s";
}

function render() {
var base = Number(baseEl.value);
document.getElementById("baseVal").textContent = fmt(base);
var max = base * Math.pow(2, ATTEMPTS - 1);
var html = "";
for (var i = 0; i < ATTEMPTS; i++) {
var delay = base * Math.pow(2, i);
var actual = jitterEl.checked ? Math.random() * delay : delay;
html +=
'<div style="display: flex; align-items: center; gap: 10px; margin: 7px 0;">' +
'<span style="width: 72px; color: var(--color-text-secondary); font-size: 13px;">attempt ' + (i + 1) + "</span>" +
'<span style="flex: 1; height: 10px; border-radius: 5px; background: var(--color-background-secondary); position: relative; overflow: hidden;">' +
'<span style="position: absolute; inset: 0; width: ' + (delay / max) * 100 + '%; background: var(--color-background-info);"></span>' +
'<span style="position: absolute; inset: 0; width: ' + (actual / max) * 100 + '%; background: var(--color-text-info); border-radius: 5px;"></span>' +
"</span>" +
'<span style="width: 64px; text-align: right; font-size: 13px;">' + fmt(actual) + "</span>" +
"</div>";
}
document.getElementById("rows").innerHTML = html;
}

baseEl.oninput = render;
jitterEl.onchange = render;
render();
</script>`;

const QUEUE_METRICS = `
<div style="font-family: var(--font-sans); color: var(--color-text-primary);">
<div style="display: flex; gap: 10px; margin-bottom: 16px;">
<div style="flex: 1; border: 0.5px solid var(--color-border-tertiary); border-radius: var(--border-radius-md); padding: 12px 14px;">
<div style="font-size: 22px; font-weight: 500;">12 ms</div>
<div style="font-size: 12px; color: var(--color-text-secondary);">p50 wait</div>
</div>
<div style="flex: 1; border: 0.5px solid var(--color-border-tertiary); border-radius: var(--border-radius-md); padding: 12px 14px;">
<div style="font-size: 22px; font-weight: 500;">86 ms</div>
<div style="font-size: 12px; color: var(--color-text-secondary);">p95 wait</div>
</div>
<div style="flex: 1; border: 0.5px solid var(--color-border-tertiary); border-radius: var(--border-radius-md); padding: 12px 14px;">
<div style="font-size: 22px; font-weight: 500; color: var(--color-text-success);">−71%</div>
<div style="font-size: 12px; color: var(--color-text-secondary);">p95 vs yesterday</div>
</div>
<div style="flex: 1; border: 0.5px solid var(--color-border-tertiary); border-radius: var(--border-radius-md); padding: 12px 14px;">
<div style="font-size: 22px; font-weight: 500;">1.4k</div>
<div style="font-size: 12px; color: var(--color-text-secondary);">jobs / min</div>
</div>
</div>
<svg width="100%" viewBox="0 0 680 150" font-family="var(--font-sans)" font-size="11">
<g id="bars"></g>
<line x1="430" y1="8" x2="430" y2="120" stroke="var(--color-border-secondary)" stroke-dasharray="3 4"/>
<text x="436" y="16" fill="var(--color-text-tertiary)">batched dequeue deployed</text>
<text x="20" y="140" fill="var(--color-text-tertiary)">p95 queue wait, last 24h</text>
</svg>
</div>
<script>
var p95 = [
270, 290, 310, 285, 300, 320, 295, 305, 330, 310, 290, 315,
300, 295, 310, 88, 84, 90, 82, 86, 84, 88, 85, 86
];
var W = 660 / p95.length;
var g = document.getElementById("bars");
var ns = "http://www.w3.org/2000/svg";
for (var i = 0; i < p95.length; i++) {
var h = (p95[i] / 340) * 112;
var r = document.createElementNS(ns, "rect");
r.setAttribute("x", 20 + i * W + 2);
r.setAttribute("y", 120 - h);
r.setAttribute("width", W - 4);
r.setAttribute("height", h);
r.setAttribute("rx", 2);
r.setAttribute("fill", p95[i] < 150 ? "var(--color-text-success)" : "var(--color-text-info)");
g.appendChild(r);
}
</script>`;

// Seeded in order; the viewer sorts sessions by last activity, so the last
// session here ends up on top.
export const DEMO_SESSIONS = [
{
agent: "pi",
title: "Queue profiling",
snippets: [
{
title: "Queue latency after batched dequeue",
html: QUEUE_METRICS,
},
],
},
{
agent: "claude-code",
title: "Auth refactor",
snippets: [
{
title: "JWT refresh flow",
html: JWT_DIAGRAM,
followups: [
{ comment: { author: "user", text: "Where does the access token live client-side?" } },
{ update: { html: JWT_DIAGRAM + JWT_EXPLAINER } },
{
comment: {
author: "claude-code",
text: "In memory only — never localStorage. Updated the diagram to show it.",
},
},
],
},
{
title: "Exponential backoff, intuitively",
html: BACKOFF,
},
],
},
];
32 changes: 32 additions & 0 deletions bin/sideshow.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ usage:
--author <name> defaults to agent name
sideshow list [--session <id>|--all] list snippets
sideshow sessions list sessions
sideshow demo seed two example sessions to explore the viewer
sideshow guide print the design contract for snippets
sideshow setup print the AGENTS.md integration block
sideshow mcp run the stdio MCP server (for agent configs)
Expand Down Expand Up @@ -320,6 +321,37 @@ const commands = {
out(await api("/api/sessions"));
},

async demo() {
const { DEMO_SESSIONS } = await import("./demoData.js");
for (const demo of DEMO_SESSIONS) {
const session = await api("/api/sessions", {
method: "POST",
body: JSON.stringify({ agent: demo.agent, title: demo.title }),
});
for (const snip of demo.snippets) {
const snippet = await api("/api/snippets", {
method: "POST",
body: JSON.stringify({ session: session.id, title: snip.title, html: snip.html }),
});
for (const step of snip.followups ?? []) {
if (step.update) {
await api(`/api/snippets/${snippet.id}`, {
method: "PUT",
body: JSON.stringify(step.update),
});
}
if (step.comment) {
await api("/api/comments", {
method: "POST",
body: JSON.stringify({ snippet: snippet.id, ...step.comment }),
});
}
}
}
}
console.log(`Seeded ${DEMO_SESSIONS.length} demo sessions — open ${BASE} to look around.`);
},

async guide() {
console.log(await fetchTextWithFallback("/guide", join(ROOT, "guide", "DESIGN_GUIDE.md")));
},
Expand Down
Binary file added docs/sideshow-demo.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
65 changes: 65 additions & 0 deletions e2e/fixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { test as base } from "@playwright/test";
import { type ChildProcess, spawn } from "node:child_process";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { fileURLToPath } from "node:url";
import { join } from "node:path";

// Each test gets its own sideshow server on an ephemeral port with a fresh
// data file, so tests can mutate state freely and run in parallel.
export const test = base.extend<{ server: { url: string } }>({
// oxlint-disable-next-line no-empty-pattern
server: async ({}, use) => {
const dataDir = mkdtempSync(join(tmpdir(), "sideshow-e2e-"));
const proc: ChildProcess = spawn(process.execPath, ["server/index.ts"], {
cwd: fileURLToPath(new URL("..", import.meta.url)),
env: {
...process.env,
PORT: "0",
SIDESHOW_DATA: join(dataDir, "data.json"),
SIDESHOW_TOKEN: "",
},
stdio: ["ignore", "pipe", "inherit"],
});
const url = await new Promise<string>((resolve, reject) => {
let out = "";
proc.stdout?.on("data", (chunk: Buffer) => {
out += chunk.toString();
const match = out.match(/listening on (http:\/\/localhost:\d+)/);
if (match) resolve(match[1]);
});
proc.on("exit", (code) => reject(new Error(`server exited early with code ${code}`)));
setTimeout(() => reject(new Error(`server did not boot in time; output: ${out}`)), 15_000);
});
await use({ url });
proc.kill();
},
});

export { expect } from "@playwright/test";

export async function publish(
serverUrl: string,
body: { html: string; title?: string; agent?: string; session?: string },
): Promise<{ id: string; sessionId: string; version: number }> {
const res = await fetch(`${serverUrl}/api/snippets`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`publish failed: ${res.status}`);
return res.json() as Promise<{ id: string; sessionId: string; version: number }>;
}

export async function update(
serverUrl: string,
id: string,
body: { html?: string; title?: string },
): Promise<void> {
const res = await fetch(`${serverUrl}/api/snippets/${id}`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`update failed: ${res.status}`);
}
Loading
Loading