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
14 changes: 9 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,15 @@ consciously, not as a side effect):
- `server/app.ts` — runtime-agnostic Hono app: all routes, SSE `/api/events`,
long-poll `/api/comments`, renderer `/s/:id`, and the shared flow functions
both REST and MCP call.
- `server/types.ts` — data model + `Store` interface; no runtime imports.
- `server/types.ts` — data model + `Store` interface; no runtime imports. A
surface is an ordered list of parts (`html` | `diff`); a snippet is sugar for
a single html part. `firstHtml`/`htmlPart` bridge the legacy snippet shape.
- `server/storage.ts` — `JsonFileStore` (local Node). `workers/sqlStore.ts` —
`SqlStore` (Durable Object SQLite). Both must pass `test/storeContract.ts`.
- `server/snippetPage.ts` — sandboxed snippet document: CSP allowlist and the
postMessage bridge (resize, sendPrompt, openLink).
`SqlStore` (Durable Object SQLite). Both must pass `test/storeContract.ts`,
and both migrate legacy `snippets`/`snippetId` data to surfaces on load.
- `server/surfacePage.ts` — sandboxed document for one html part: CSP allowlist
and the postMessage bridge (resize, sendPrompt, openLink). Diff parts never
reach here — the viewer renders them natively (they are data, not markup).
- `server/mcpHttp.ts` — stateless MCP at `/mcp`. `mcp/server.ts` — stdio MCP,
a thin client over the HTTP API (passes response fields through untouched).
- `viewer/` — the viewer: Solid + TypeScript in `viewer/src/`, built by Vite
Expand All @@ -49,7 +53,7 @@ consciously, not as a side effect):

## Architecture invariants

- `server/{app,events,mcpHttp,snippetPage,types}.ts` stay runtime-agnostic
- `server/{app,events,mcpHttp,surfacePage,types}.ts` stay runtime-agnostic
(no `node:` imports); `tsconfig.workers.json` typechecks them against
workers types. Node wiring belongs in `server/index.ts` / `server/storage.ts`.
- Server/CLI TypeScript runs directly on Node ≥22.18 via type stripping:
Expand Down
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,27 @@

All notable user-visible changes to this project are documented in this file.

## [Unreleased]

### Added

- Surfaces: a published card is now an ordered list of parts, not a single
HTML blob. A `diff` part renders a unified/git patch as a syntax-highlighted
split/unified code review (via @pierre/diffs) directly in the viewer; an
`html` part is the sandboxed markup snippets always were. Combine them — e.g.
a diagram html part above its diff — in one versioned, commentable card.
- Generic publishing across all tiers: `publish_surface`/`update_surface` (MCP),
`POST /api/surfaces`, and `sideshow diff <patch>` / `sideshow publish --diff`.
Diff parts are rendered from patch data by the viewer, so agents send a patch,
never markup, and the sandbox is untouched.

### Changed

- Snippets are now "surfaces" throughout the API: `/api/surfaces`, `surface-*`
SSE events, and comments keyed by `surfaceId`. The old snippet endpoints and
the `publish_snippet`/`update_snippet` tools remain as back-compat aliases, so
existing agent configs keep working. Stored boards migrate in place on load.

## [0.4.0] - 2026-06-15

### Added
Expand Down
101 changes: 72 additions & 29 deletions bin/sideshow.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,27 +15,32 @@ const HELP = `sideshow — a live visual surface for terminal coding agents

usage:
sideshow serve [--port N] [--open] start the surface (API + viewer)
sideshow publish <file|-> [options] publish an HTML fragment as a snippet
--title <t> snippet title
sideshow publish <file|-> [options] publish an HTML surface (one html part)
--title <t> surface title
--diff <file|-> add a diff part from a unified/git patch (combine with html)
--session <id> target session (default: auto per agent session)
--session-title <t> name for a newly created session — name the task,
e.g. "Auth refactor" (ignored if the session exists)
--agent <name> agent name for new sessions (default: $SIDESHOW_AGENT or "agent")
--new-session force a fresh session
sideshow update <id> <file|-> revise a snippet (new version, same card)
sideshow diff <file|-> [options] publish a diff surface from a patch
--title <t> surface title
--layout <mode> "unified" (default) or "split"
(also: --session, --session-title, --agent, --new-session)
sideshow update <id> <file|-> revise a surface (new version, same card)
--title <t> replace title
sideshow wait [options] block until the user comments (long-poll)
--session <id> session to watch (default: auto)
--timeout <sec> max seconds to wait (default 120)
--after <seq> re-read comments after this cursor (default: where the
agent left off, tracked server-side across CLI/MCP)
sideshow comment <text> [options] post a reply comment
--snippet <id> | --session <id> attach point (default: auto session)
--surface <id> | --session <id> attach point (default: auto session)
--author <name> defaults to agent name
sideshow list [--session <id>|--all] list snippets
sideshow list [--session <id>|--all] list surfaces
sideshow sessions list sessions
sideshow demo seed two example sessions to explore the viewer
sideshow guide print the design contract for snippets
sideshow guide print the design contract for surfaces
sideshow setup print the AGENTS.md integration block
sideshow mcp run the stdio MCP server (for agent configs)

Expand Down Expand Up @@ -161,7 +166,7 @@ async function resolveSession(flags, { create = false } = {}) {
if (process.env.SIDESHOW_SESSION) return process.env.SIDESHOW_SESSION;
const state = readState();
if (state.session && !flags["new-session"]) {
const ok = await fetch(`${BASE}/api/sessions/${state.session}/snippets`, {
const ok = await fetch(`${BASE}/api/sessions/${state.session}/surfaces`, {
headers: TOKEN ? { authorization: `Bearer ${TOKEN}` } : {},
}).then(
(r) => r.ok,
Expand Down Expand Up @@ -201,6 +206,19 @@ function out(value) {
console.log(JSON.stringify(value, null, 2));
}

async function publishSurface(parts, flags) {
const session = await resolveSession(flags, { create: true });
return api("/api/surfaces", {
method: "POST",
body: JSON.stringify({
parts,
title: flags.title,
session,
sessionTitle: flags["session-title"],
}),
});
}

const [cmd, ...rest] = process.argv.slice(2);

// Subcommand flag parsing. parseArgs is strict, so without this --help (or
Expand Down Expand Up @@ -270,24 +288,47 @@ const commands = {
allowPositionals: true,
options: {
title: { type: "string" },
diff: { type: "string" },
layout: { type: "string" },
session: { type: "string" },
"session-title": { type: "string" },
agent: { type: "string" },
"new-session": { type: "boolean" },
},
});
const html = readContent(positionals[0]);
const session = await resolveSession(flags, { create: true });
const snippet = await api("/api/snippets", {
method: "POST",
body: JSON.stringify({
html,
title: flags.title,
session,
sessionTitle: flags["session-title"],
}),
const parts = [{ kind: "html", html: readContent(positionals[0]) }];
if (flags.diff !== undefined) {
parts.push({
kind: "diff",
patch: readContent(flags.diff || "-"),
...(flags.layout === "split" && { layout: "split" }),
});
}
const surface = await publishSurface(parts, flags);
out({ ...surface, url: `${BASE}/s/${surface.id}` });
},

async diff() {
const { values: flags, positionals } = parse({
allowPositionals: true,
options: {
title: { type: "string" },
layout: { type: "string" },
session: { type: "string" },
"session-title": { type: "string" },
agent: { type: "string" },
"new-session": { type: "boolean" },
},
});
out({ ...snippet, url: `${BASE}/s/${snippet.id}` });
const parts = [
{
kind: "diff",
patch: readContent(positionals[0]),
...(flags.layout === "split" && { layout: "split" }),
},
];
const surface = await publishSurface(parts, flags);
out({ ...surface, url: `${BASE}/s/${surface.id}` });
},

async update() {
Expand All @@ -296,13 +337,13 @@ const commands = {
options: { title: { type: "string" } },
});
const id = positionals[0];
if (!id) fail("usage: sideshow update <snippetId> <file|->");
if (!id) fail("usage: sideshow update <id> <file|->");
const html = readContent(positionals[1]);
const snippet = await api(`/api/snippets/${id}`, {
const surface = await api(`/api/surfaces/${id}`, {
method: "PUT",
body: JSON.stringify({ html, title: flags.title }),
body: JSON.stringify({ parts: [{ kind: "html", html }], title: flags.title }),
});
out({ ...snippet, url: `${BASE}/s/${snippet.id}` });
out({ ...surface, url: `${BASE}/s/${surface.id}` });
},

async wait() {
Expand Down Expand Up @@ -342,22 +383,24 @@ const commands = {
const { values: flags, positionals } = parse({
allowPositionals: true,
options: {
snippet: { type: "string" },
surface: { type: "string" },
snippet: { type: "string" }, // legacy alias
session: { type: "string" },
author: { type: "string" },
agent: { type: "string" },
},
});
const text = positionals.join(" ").trim();
if (!text) fail("usage: sideshow comment <text> [--snippet id]");
const session = flags.snippet ? undefined : await resolveSession(flags);
if (!flags.snippet && !session) fail("no active session — pass --snippet or --session");
if (!text) fail("usage: sideshow comment <text> [--surface id]");
const surface = flags.surface ?? flags.snippet;
const session = surface ? undefined : await resolveSession(flags);
if (!surface && !session) fail("no active session — pass --surface or --session");
out(
await api("/api/comments", {
method: "POST",
body: JSON.stringify({
text,
snippet: flags.snippet,
surface,
session,
author: flags.author ?? agentName(flags),
}),
Expand All @@ -373,13 +416,13 @@ const commands = {
const sessions = await api("/api/sessions");
const result = [];
for (const s of sessions) {
result.push({ ...s, snippets: await api(`/api/sessions/${s.id}/snippets`) });
result.push({ ...s, surfaces: await api(`/api/sessions/${s.id}/surfaces`) });
}
return out(result);
}
const session = flags.session ?? (await resolveSession(flags));
if (!session) fail("no active session — pass --session or --all");
out(await api(`/api/sessions/${session}/snippets`));
out(await api(`/api/sessions/${session}/surfaces`));
},

async sessions() {
Expand Down
4 changes: 2 additions & 2 deletions e2e/viewer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,8 @@ test("session thread shows snippet-less comments and messages the agent", async
const res = await fetch(
`${server.url}/api/comments?session=${snippet.sessionId}&author=user`,
);
const data = (await res.json()) as { comments: { snippetId: string | null; text: string }[] };
return data.comments.filter((c) => !c.snippetId).map((c) => c.text);
const data = (await res.json()) as { comments: { surfaceId: string | null; text: string }[] };
return data.comments.filter((c) => !c.surfaceId).map((c) => c.text);
})
.toContain("user note");

Expand Down
36 changes: 24 additions & 12 deletions guide/AGENT_SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,38 @@
## Visual previews (sideshow)

A live preview surface is running at http://localhost:4242 — the user watches it
in a browser. Use it to illustrate concepts, sketch UI ideas, or visualize data
with small HTML snippets. Fetch the full design contract once per session:
in a browser. Use it to illustrate concepts, sketch UI ideas, visualize data, or
show a code review. Fetch the full design contract once per session:

curl -s http://localhost:4242/guide

Publish a snippet (HTML body fragment only — no doctype/html/head/body):
A surface is a card built from ordered **parts**, each with a `kind`: an `html`
part is markup you write, rendered in a sandboxed iframe (body fragment only —
no doctype/html/head/body); a `diff` part is a patch you send as _data_, rendered
natively by the trusted viewer as a code review. Reach for `html` to draw, for
`diff` to show a changeset. Send a patch, not markup, for diffs. Publish a
surface:

curl -s -X POST http://localhost:4242/api/snippets \
curl -s -X POST http://localhost:4242/api/surfaces \
-H 'content-type: application/json' \
-d '{"agent": "YOUR_NAME", "sessionTitle": "Task name", "title": "Short title", "html": "<p>...</p>"}'
-d '{"agent": "YOUR_NAME", "sessionTitle": "Task name", "title": "Short title", "parts": [{"kind": "html", "html": "<p>...</p>"}]}'

A standalone diff surface — `"parts": [{"kind": "diff", "patch": "--- a/x\n+++ b/x\n@@ ..."}]`
(optional `"layout": "split"`). Combine kinds for a diagram with its code review
in one card — `"parts": [{"kind": "html", "html": "..."}, {"kind": "diff", "patch": "..."}]`.

The response includes `id` and `sessionId`. Pass `"session": "<sessionId>"`
on later publishes so your snippets group into one session. `sessionTitle`
on later publishes so your surfaces group into one session. `sessionTitle`
labels that session in the sidebar — name the task at hand ("Auth refactor"),
not your tool; it is honored only on the publish that creates the session.
To revise a snippet instead of posting a new one:
To revise a surface instead of posting a new one:

curl -s -X PUT http://localhost:4242/api/surfaces/<id> \
-H 'content-type: application/json' -d '{"parts": [...]}'

curl -s -X PUT http://localhost:4242/api/snippets/<id> \
-H 'content-type: application/json' -d '{"html": "..."}'
(The legacy `/api/snippets` endpoints still work as html-only aliases.)

The user can comment on your snippets in their browser. Feedback reaches you
The user can comment on your surfaces in their browser. Feedback reaches you
two ways:

1. Publish/update responses may include a `userFeedback` array — comments the
Expand All @@ -40,8 +51,9 @@ two ways:
handle the output and re-arm it.

If the `sideshow` CLI is installed, these are equivalent and easier:
`sideshow publish file.html --title "..."`, `sideshow wait`, `sideshow guide`
(session handling is automatic).
`sideshow publish file.html --title "..."` (html), `sideshow diff change.patch
--title "..."` (standalone diff), `sideshow publish file.html --diff change.patch`
(combined), `sideshow wait`, `sideshow guide` (session handling is automatic).

If this surface is a deployed instance that requires a token, add
`-H "Authorization: Bearer $SIDESHOW_TOKEN"` to every curl call — or set
Expand Down
Loading
Loading