diff --git a/AGENTS.md b/AGENTS.md index ac7f6c2..6f96473 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 @@ -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: diff --git a/CHANGELOG.md b/CHANGELOG.md index cb82db2..ec62d49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` / `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 diff --git a/bin/sideshow.js b/bin/sideshow.js index d0a82d1..f5b1dca 100644 --- a/bin/sideshow.js +++ b/bin/sideshow.js @@ -15,14 +15,19 @@ const HELP = `sideshow — a live visual surface for terminal coding agents usage: sideshow serve [--port N] [--open] start the surface (API + viewer) - sideshow publish [options] publish an HTML fragment as a snippet - --title snippet title + sideshow publish [options] publish an HTML surface (one html part) + --title surface title + --diff add a diff part from a unified/git patch (combine with html) --session target session (default: auto per agent session) --session-title name for a newly created session — name the task, e.g. "Auth refactor" (ignored if the session exists) --agent agent name for new sessions (default: $SIDESHOW_AGENT or "agent") --new-session force a fresh session - sideshow update revise a snippet (new version, same card) + sideshow diff [options] publish a diff surface from a patch + --title surface title + --layout "unified" (default) or "split" + (also: --session, --session-title, --agent, --new-session) + sideshow update revise a surface (new version, same card) --title replace title sideshow wait [options] block until the user comments (long-poll) --session session to watch (default: auto) @@ -30,12 +35,12 @@ usage: --after re-read comments after this cursor (default: where the agent left off, tracked server-side across CLI/MCP) sideshow comment [options] post a reply comment - --snippet | --session attach point (default: auto session) + --surface | --session attach point (default: auto session) --author defaults to agent name - sideshow list [--session |--all] list snippets + sideshow list [--session |--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) @@ -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, @@ -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 @@ -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() { @@ -296,13 +337,13 @@ const commands = { options: { title: { type: "string" } }, }); const id = positionals[0]; - if (!id) fail("usage: sideshow update "); + if (!id) fail("usage: sideshow update "); 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() { @@ -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 [--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 [--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), }), @@ -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() { diff --git a/e2e/viewer.spec.ts b/e2e/viewer.spec.ts index 15266ad..fa5e6cc 100644 --- a/e2e/viewer.spec.ts +++ b/e2e/viewer.spec.ts @@ -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"); diff --git a/guide/AGENT_SETUP.md b/guide/AGENT_SETUP.md index f73fdc9..2fdec9e 100644 --- a/guide/AGENT_SETUP.md +++ b/guide/AGENT_SETUP.md @@ -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": "

...

"}' + -d '{"agent": "YOUR_NAME", "sessionTitle": "Task name", "title": "Short title", "parts": [{"kind": "html", "html": "

...

"}]}' + +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": ""` -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/ \ + -H 'content-type: application/json' -d '{"parts": [...]}' - curl -s -X PUT http://localhost:4242/api/snippets/ \ - -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 @@ -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 diff --git a/guide/DESIGN_GUIDE.md b/guide/DESIGN_GUIDE.md index 078ea4a..5d4a05d 100644 --- a/guide/DESIGN_GUIDE.md +++ b/guide/DESIGN_GUIDE.md @@ -1,33 +1,100 @@ # sideshow — design guide for agents You are drawing to a persistent visual surface the user keeps open in a browser. -Your snippets appear instantly as cards, grouped into a session for this +Your surfaces appear instantly as cards, grouped into a session for this conversation. Read this once before your first publish. +## Surfaces and parts + +A **surface** is a card built from an ordered list of **parts**. Each part has +a `kind`: + +- **`html`** — arbitrary markup you write, rendered in a sandboxed iframe (the + rest of this guide is the contract for it). Reach for it for diagrams, UI + sketches, data viz — anything you draw. +- **`diff`** — a patch you hand over as _data_; the trusted viewer renders it + natively as a syntax-highlighted code review (split or unified). Reach for it + to show a changeset or review code, not to draw. + +A surface can combine parts, e.g. `[html, diff]` is a diagram with its code +review in one card. Trust differs: html parts are sandboxed because you author +the markup; diff parts are rendered by the viewer from patch data — send a +patch, never markup. + +A **`SurfacePart`** is one of: + +``` +{ "kind": "html", "html": "

...

" } +{ "kind": "diff", "patch": "" } # preferred — compact +{ "kind": "diff", "files": [{ "filename": "a.ts", "before": "...", "after": "...", "language": "ts" }] } # fallback +``` + +For a diff, send a `patch` — it carries only the changed lines, so it is the +compact, preferred form. Use `files` (full before/after contents) only when you +don't have a patch. A diff part takes an optional `"layout": "unified" | "split"`. + ## Publishing -Via MCP tools (preferred): `publish_snippet`, `update_snippet`, -`wait_for_feedback`, `reply_to_user`, `list_snippets`. Via CLI: -`sideshow publish file.html --title "..."`, `sideshow wait`. Via raw HTTP: +Via MCP tools (preferred): `publish_surface`, `update_surface`, +`wait_for_feedback`, `reply_to_user`, `list_surfaces`. (`publish_snippet` / +`update_snippet` remain as html-only sugar aliases.) Via CLI: +`sideshow publish file.html --title "..."`, `sideshow diff change.patch +--title "..."`, `sideshow wait`. Via raw HTTP: ``` -POST /api/snippets { "title": "...", "html": "...", "session": "", "agent": "your-name" } -PUT /api/snippets/:id { "html": "..." } # revise — same card, new version +POST /api/surfaces { "title": "...", "parts": [...], "session": "", "agent": "your-name" } +PUT /api/surfaces/:id { "parts": [...] } # revise — same card, new version +GET /api/sessions/:id/surfaces # list a session's surfaces GET /api/comments?session=&author=user&wait=60 # user feedback (long-poll, resumes where you left off) ``` +The legacy `POST /api/snippets { "html": "..." }` endpoints still work as +html-only back-compat aliases. + +### Examples + +An html surface: + +``` +POST /api/surfaces { "title": "Cache layout", "parts": [{ "kind": "html", "html": "" }] } +``` + +A standalone diff surface (a unified patch): + +``` +POST /api/surfaces { "title": "Add retry", "parts": [{ "kind": "diff", "patch": "--- a/x.ts\n+++ b/x.ts\n@@ ..." }] } +``` + +A combined `[html, diff]` surface — a diagram above its code review: + +``` +POST /api/surfaces { "title": "Retry flow", "parts": [ + { "kind": "html", "html": "" }, + { "kind": "diff", "patch": "--- a/x.ts\n+++ b/x.ts\n@@ ..." } +]} +``` + +CLI equivalents: + +``` +sideshow publish sketch.html --title "Cache layout" # html surface +sideshow diff change.patch --title "Add retry" --layout split # standalone diff surface +sideshow publish sketch.html --diff change.patch --title "Retry flow" # combined [html, diff] +``` + Omit `session` on your first publish and the response's `sessionId` is yours — -reuse it so your snippets stay grouped. On that first publish, also set a +reuse it so your surfaces stay grouped. On that first publish, also set a session title naming the task ("Auth refactor"), not your tool — `sessionTitle` (MCP and HTTP) or `--session-title` (CLI). It applies only when the session is -created; never retitle it later. When refining an illustration you +created; never retitle it later. When refining a surface you already published, UPDATE it rather than publishing a near-duplicate; versions are kept and the user can flip between them. ## The feedback loop -The user can type comments under any snippet, or in the session thread at the -bottom of the stream. Feedback reaches you three ways: +The user can type comments under any surface, or in the session thread at the +bottom of the stream. Comments attach to a surface (`surfaceId`); behavior is +otherwise unchanged. Feedback reaches you three ways: - **Piggyback (automatic).** Every publish/update/reply response may include a `userFeedback` array — comments the user left since your last call. Treat @@ -42,10 +109,13 @@ bottom of the stream. Feedback reaches you three ways: it on the session you actually published to. You can answer in the thread with `reply_to_user` / `sideshow comment` — keep -replies short; do substantial revisions as snippet updates instead. +replies short; do substantial revisions as surface updates instead. ## HTML contract +This contract governs `html` parts (diff parts are rendered from patch data, +not markup). + - Send a **body fragment only** — no ``, ``, ``, or ``. The server wraps your fragment in a themed, sandboxed document. - The rendered column is roughly **720–800px wide**. Content sizes its own @@ -61,7 +131,7 @@ Bare `button`, `input`, `select`, and `textarea` are pre-styled to match the viewer, hover/focus included — write the plain element, don't restyle it. Checkboxes, radios, ranges, and progress bars are themed via `accent-color`. -SVG utility classes, available in every snippet: +SVG utility classes, available in every html part: | class | effect | | ---------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | @@ -72,7 +142,7 @@ SVG utility classes, available in every snippet: | `node` | pointer cursor + hover dim, for clickable shapes | | `c-blue` `c-teal` `c-amber` `c-coral` `c-green` `c-red` `c-gray` | color ramp: fill+stroke on shapes (or a whole ``); child `` auto-switches to readable ink in light and dark | -A `` is injected into every snippet doc — end any line with +A `` is injected into every html part — end any line with `marker-end="url(#arrow)"` and the arrowhead inherits the line's stroke color. ```html @@ -113,9 +183,9 @@ A CSP allows loading ONLY from these origins (anything else silently fails): ## Interactivity -Two globals are injected into every snippet: +Two globals are injected into every html part: -- `sendPrompt(text)` — posts the text as a user comment on this snippet, which +- `sendPrompt(text)` — posts the text as a user comment on this surface, which reaches you through the feedback loop. Use for "explore X" affordances. - `openLink(url)` — asks the user to confirm opening an external link. Plain `` clicks are routed through this automatically. @@ -127,5 +197,5 @@ Two globals are injected into every snippet: - Two font weights only: 400 and 500. - SVG works great — for diagrams use `` with the kit classes above. -- Keep it focused: one concept per snippet. Publish a series of small snippets +- Keep it focused: one concept per surface. Publish a series of small surfaces with distinct titles rather than one giant page. diff --git a/mcp/server.ts b/mcp/server.ts index 392eb85..4bc855d 100644 --- a/mcp/server.ts +++ b/mcp/server.ts @@ -51,49 +51,114 @@ async function ensureSession(title?: string): Promise { return sessionId; } +const diffFileSchema = z.object({ + filename: z.string(), + before: z.string(), + after: z.string(), + language: z.string().optional(), +}); + +const partSchema = z + .object({ + kind: z.enum(["html", "diff"]), + html: z.string().optional().describe("html part: body fragment (no doctype/html/head/body)"), + patch: z + .string() + .optional() + .describe("diff part: a unified/git diff string (preferred, compact)"), + files: z + .array(diffFileSchema) + .optional() + .describe("diff part: before/after pairs — heavier (full contents); prefer patch"), + layout: z.enum(["unified", "split"]).optional(), + }) + .describe( + "A surface part: {kind:'html', html} or {kind:'diff', patch} (preferred) / {kind:'diff', files}", + ); + const server = new McpServer( { name: "sideshow", version: "0.1.0" }, { instructions: - "sideshow is a live visual surface the user watches in a browser. Publish HTML snippets to illustrate " + - "concepts, sketch UI ideas, or visualize data while you work. Call get_design_guide once before your first " + - "publish — it defines the HTML contract. Your snippets are grouped into one session for this conversation. " + - 'On your first publish, pass sessionTitle to name the session after the task at hand (e.g. "Auth refactor") ' + - "so the user can tell sessions apart in the sidebar. " + - "The user can comment on snippets in their browser; check with wait_for_feedback after publishing something " + - "you want a reaction to. Any publish/update/reply result may also carry a userFeedback array — comments " + - "the user left since your last call. Treat them as messages from the user; they are delivered once.", + "sideshow is a live visual surface the user watches in a browser. Publish surfaces to illustrate " + + "concepts, sketch UI ideas, visualize data, or show a code review while you work. A surface is an " + + "ordered list of parts: an `html` part is markup you write, a `diff` part is a patch the viewer renders " + + "as a syntax-highlighted split/unified diff. Combine them in one card. publish_surface is the general " + + "tool; publish_snippet is sugar for a single html part. Call get_design_guide once before your first " + + "publish. Your surfaces are grouped into one session for this conversation; on your first publish pass " + + 'sessionTitle to name the session after the task (e.g. "Auth refactor"). The user can comment in their ' + + "browser; check with wait_for_feedback after publishing something you want a reaction to. Any " + + "publish/update/reply result may carry a userFeedback array — comments the user left since your last " + + "call, delivered once.", }, ); server.registerTool( - "publish_snippet", + "publish_surface", { description: - "Publish an HTML snippet to the user's sideshow surface. Send a body fragment only (no " + - "doctype/html/head/body). Returns the snippet id and view URL. On your first publish, pass " + - "sessionTitle naming the task to label this conversation's session in the viewer sidebar (honored " + - "only when the session is created). If the result includes userFeedback, those are new comments " + - "from the user — read them. Call get_design_guide first if you have not this session.", + "Publish a surface (an ordered list of html and/or diff parts) to the user's sideshow board. Returns " + + "the id and view URL. On your first publish, pass sessionTitle naming the task. If the result includes " + + "userFeedback, read it. Call get_design_guide first if you have not this session.", inputSchema: { - title: z.string().describe("Short human-readable title shown above the snippet"), - html: z.string().describe("HTML body fragment to render"), + title: z.string().describe("Short human-readable title shown above the card"), + parts: z.array(partSchema).describe("Ordered parts; combine html and diff freely"), sessionTitle: z .string() .optional() - .describe( - 'Session name shown in the viewer sidebar — name the task, e.g. "Auth refactor", not your ' + - "tool. Used only on the first publish, when the session is created; it never retitles an " + - "existing session.", - ), + .describe('Session name (first publish only), e.g. "Auth refactor"'), + }, + }, + async ({ title, parts, sessionTitle }) => { + const session = await ensureSession(sessionTitle); + const created = JSON.parse( + await api("/api/surfaces", { + method: "POST", + body: JSON.stringify({ title, parts, session }), + }), + ); + return text({ ...created, url: `${API}/s/${created.id}` }); + }, +); + +server.registerTool( + "update_surface", + { + description: + "Revise a surface in place (same card, new version). Prefer this over a near-duplicate. Pass the full " + + "replacement parts array. If the result includes userFeedback, read it.", + inputSchema: { + id: z.string().describe("Surface id returned by publish_surface"), + parts: z.array(partSchema).optional().describe("Replacement parts array"), + title: z.string().optional().describe("Replacement title"), + }, + }, + async ({ id, parts, title }) => { + const updated = JSON.parse( + await api(`/api/surfaces/${id}`, { method: "PUT", body: JSON.stringify({ parts, title }) }), + ); + return text({ ...updated, url: `${API}/s/${updated.id}` }); + }, +); + +server.registerTool( + "publish_snippet", + { + description: + "Publish an HTML snippet — sugar for a surface with one html part. Send a body fragment only. Returns " + + "the id and view URL. Prefer publish_surface when you want a diff or multiple parts.", + inputSchema: { + title: z.string().describe("Short human-readable title shown above the snippet"), + html: z.string().describe("HTML body fragment to render"), + sessionTitle: z.string().optional().describe("Session name (first publish only)"), }, }, async ({ title, html, sessionTitle }) => { const session = await ensureSession(sessionTitle); const created = JSON.parse( - await api("/api/snippets", { + await api("/api/surfaces", { method: "POST", - body: JSON.stringify({ title, html, session }), + body: JSON.stringify({ title, parts: [{ kind: "html", html }], session }), }), ); return text({ ...created, url: `${API}/s/${created.id}` }); @@ -103,18 +168,17 @@ server.registerTool( server.registerTool( "update_snippet", { - description: - "Revise an existing snippet in place (same card, new version). Prefer this over publishing a " + - "near-duplicate. If the result includes userFeedback, those are new comments from the user — read them.", + description: "Revise an html snippet in place — sugar for update_surface with one html part.", inputSchema: { - id: z.string().describe("Snippet id returned by publish_snippet"), + id: z.string().describe("Surface id"), html: z.string().optional().describe("Replacement HTML body fragment"), title: z.string().optional().describe("Replacement title"), }, }, async ({ id, html, title }) => { + const parts = html === undefined ? undefined : [{ kind: "html", html }]; const updated = JSON.parse( - await api(`/api/snippets/${id}`, { method: "PUT", body: JSON.stringify({ html, title }) }), + await api(`/api/surfaces/${id}`, { method: "PUT", body: JSON.stringify({ parts, title }) }), ); return text({ ...updated, url: `${API}/s/${updated.id}` }); }, @@ -124,9 +188,8 @@ server.registerTool( "wait_for_feedback", { description: - "Block until the user comments on this session's snippets in their browser (or the timeout passes). " + - "Returns new comments since the last call. Use after publishing something that needs the user's reaction; " + - "use timeoutSeconds=0 for a non-blocking check.", + "Block until the user comments on this session in their browser (or the timeout passes). Returns new " + + "comments since the last call. Use timeoutSeconds=0 for a non-blocking check.", inputSchema: { timeoutSeconds: z .number() @@ -149,8 +212,8 @@ server.registerTool( } return text({ comments: result.comments.map((c: any) => ({ - snippetId: c.snippetId, - snippetTitle: c.snippetTitle, + surfaceId: c.surfaceId, + surfaceTitle: c.surfaceTitle, text: c.text, at: c.createdAt, })), @@ -162,18 +225,18 @@ server.registerTool( "reply_to_user", { description: - "Post a short reply under a snippet's comment thread in the user's browser. Use to acknowledge feedback " + + "Post a short reply under a surface's comment thread in the user's browser. Use to acknowledge feedback " + "or explain a revision without making the user switch to the terminal.", inputSchema: { - snippetId: z.string().describe("Snippet whose thread to reply in"), + surfaceId: z.string().describe("Surface whose thread to reply in"), message: z.string().describe("Plain-text reply"), }, }, - async ({ snippetId, message }) => { + async ({ surfaceId, message }) => { const created = JSON.parse( await api("/api/comments", { method: "POST", - body: JSON.stringify({ snippet: snippetId, text: message, author: AGENT }), + body: JSON.stringify({ surface: surfaceId, text: message, author: AGENT }), }), ); return text(created); @@ -181,11 +244,11 @@ server.registerTool( ); server.registerTool( - "list_snippets", - { description: "List snippets in this conversation's session.", inputSchema: {} }, + "list_surfaces", + { description: "List surfaces in this conversation's session.", inputSchema: {} }, async () => { if (!sessionId) return text([]); - return text(JSON.parse(await api(`/api/sessions/${sessionId}/snippets`))); + return text(JSON.parse(await api(`/api/sessions/${sessionId}/surfaces`))); }, ); @@ -193,8 +256,8 @@ server.registerTool( "get_design_guide", { description: - "Fetch the design contract for snippets: HTML fragment rules, theme CSS variables, CDN allowlist, and " + - "interactivity bridge. Call once per session before publishing.", + "Fetch the design contract: surface parts, html fragment rules, theme CSS variables, CDN allowlist, and " + + "the interactivity bridge. Call once per session before publishing.", inputSchema: {}, }, async () => text(await api("/guide")), diff --git a/package-lock.json b/package-lock.json index dd67c22..cebfc36 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@hono/node-server": "^1.14.0", "@modelcontextprotocol/sdk": "^1.12.0", + "@pierre/diffs": "^1.2.11", "hono": "^4.7.0", "zod": "^3.24.0" }, @@ -2229,6 +2230,64 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@pierre/diffs": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@pierre/diffs/-/diffs-1.2.11.tgz", + "integrity": "sha512-lSkl5C7eb8Zq7Ote0+J5ZdVOlI72r2EU3vW4+06wULSQqkIMP8mkxG70lVj593b1XYlsM2hCvuyt0cKTA96plQ==", + "license": "apache-2.0", + "dependencies": { + "@pierre/theme": "1.0.3", + "@pierre/theming": "0.0.1", + "@shikijs/transformers": "^3.0.0 || ^4.0.0", + "diff": "8.0.3", + "hast-util-to-html": "9.0.5", + "lru_map": "0.4.1", + "shiki": "^3.0.0 || ^4.0.0" + }, + "peerDependencies": { + "react": "^18.3.1 || ^19.0.0", + "react-dom": "^18.3.1 || ^19.0.0" + } + }, + "node_modules/@pierre/theme": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@pierre/theme/-/theme-1.0.3.tgz", + "integrity": "sha512-sWHv11TMoqKxKDgTIk5VbhQjdPhs8DCcBxbjh3mRlS3YOM/OcrWoGX6MM8eBGn9cUu3M46Py0JnxsG2nJaFTuA==", + "license": "MIT", + "engines": { + "vscode": "^1.0.0" + } + }, + "node_modules/@pierre/theming": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@pierre/theming/-/theming-0.0.1.tgz", + "integrity": "sha512-1thlEtJbqdyLzc1ZS2KQa1q7FzDGHT4dTEdKHoyQjOMeWWOmbVG5/ndEfOKfAb5Fzkz8cNJrOjFLiZoDH/A03A==", + "license": "apache-2.0", + "peerDependencies": { + "@pierre/theme": "^1.0.0", + "@shikijs/themes": "^3.0.0 || ^4.0.0", + "react": "^18.3.1 || ^19.0.0", + "react-dom": "^18.3.1 || ^19.0.0", + "shiki": "^3.0.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "@pierre/theme": { + "optional": true + }, + "@shikijs/themes": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "shiki": { + "optional": true + } + } + }, "node_modules/@playwright/test": { "version": "1.60.0", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", @@ -2549,6 +2608,119 @@ "dev": true, "license": "MIT" }, + "node_modules/@shikijs/core": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.2.0.tgz", + "integrity": "sha512-Hc87Ab1Ld/vEbZRCbwx344I5v+4RU8CVToUTRkqXL1+TjbuOp9U5Xa0M23V4GEWHxVn+yO5otb+HkQVm3ptWQQ==", + "license": "MIT", + "dependencies": { + "@shikijs/primitive": "4.2.0", + "@shikijs/types": "4.2.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.2.0.tgz", + "integrity": "sha512-fjETeq1k5ffyXqRgS6+3hpvqseLalp1kjNfRbXpUgWR8FpZ1CmQfiNHovc5lncYjt/Vg5JK/WJEmLahjwMa0og==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.2.0", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^4.3.6" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.2.0.tgz", + "integrity": "sha512-hTorK1dffPkpbMUk6Z+828PgRo7d07HbnizoP0hNPFjhxMHctj0Px/qoHeGMYafc6ju+u9iMldN4JbVzNQM++g==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.2.0", + "@shikijs/vscode-textmate": "^10.0.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/langs": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.2.0.tgz", + "integrity": "sha512-bwrVRlJ0wUhZxAbVdvBbv2TTC9yLsh4C/IO5Ofz0T8MQntgDvyVnkbjw9vi50r1kx7RCIJdnJnjZAwmAsXFLZQ==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/primitive": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.2.0.tgz", + "integrity": "sha512-NOq+DtUkVBJtZMVXL5A0vI0Xk8nvDYaXetFHSJFlOqjDZIVhIPRYFdGkSoElDqNuegikcc3A76SNUa8dTqtAYA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.2.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/themes": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.2.0.tgz", + "integrity": "sha512-RX8IHYeLv8Cu2W6ruc3RxUqWn0IYCqSrMBzi/uRGAmfyDNOnNO5BF/Px7o97n4XTpmFTo5GbRaazuOWj+2ak2w==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/transformers": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@shikijs/transformers/-/transformers-4.2.0.tgz", + "integrity": "sha512-pKrYVNUr1oPjJvs76gkPPirDySx3GKG9O88P2Y3AQ+7AjSFws9Y+Ry/Q/6Yg6QpyigzjdQ6H5JAMNAvLXZ63dw==", + "license": "MIT", + "dependencies": { + "@shikijs/core": "4.2.0", + "@shikijs/types": "4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/types": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.2.0.tgz", + "integrity": "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw==", + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "license": "MIT" + }, "node_modules/@sindresorhus/is": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", @@ -2625,6 +2797,24 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/node": { "version": "25.9.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz", @@ -2635,6 +2825,18 @@ "undici-types": ">=7.24.0 <7.24.7" } }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", + "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", + "license": "ISC" + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -2922,6 +3124,36 @@ ], "license": "CC-BY-4.0" }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/cli-cursor": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", @@ -2955,6 +3187,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/content-disposition": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", @@ -3066,6 +3308,15 @@ "node": ">= 0.8" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -3076,6 +3327,28 @@ "node": ">=8" } }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/diff": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz", + "integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -3553,6 +3826,42 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hono": { "version": "4.12.25", "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz", @@ -3569,6 +3878,16 @@ "dev": true, "license": "MIT" }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -4120,6 +4439,12 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/lru_map": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/lru_map/-/lru_map-0.4.1.tgz", + "integrity": "sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg==", + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -4139,6 +4464,27 @@ "node": ">= 0.4" } }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/media-typer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", @@ -4176,6 +4522,95 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -4364,6 +4799,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/oniguruma-parser": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz", + "integrity": "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==", + "license": "MIT" + }, + "node_modules/oniguruma-to-es": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.6.tgz", + "integrity": "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==", + "license": "MIT", + "dependencies": { + "oniguruma-parser": "^0.12.2", + "regex": "^6.1.0", + "regex-recursion": "^6.0.2" + } + }, "node_modules/oxfmt": { "version": "0.54.0", "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.54.0.tgz", @@ -4618,6 +5070,16 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -4670,6 +5132,53 @@ "node": ">= 0.10" } }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "license": "MIT" + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -4759,6 +5268,13 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT", + "peer": true + }, "node_modules/semver": { "version": "7.8.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", @@ -4912,6 +5428,25 @@ "node": ">=8" } }, + "node_modules/shiki": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.2.0.tgz", + "integrity": "sha512-hjNax6o/ylDy9lefQEaSDtzaT3iVNtZ3WmpQnbuQNoG4xvnSKf2kSKbihZVO4JRG1TTMejs7CmNRYlWgAL66pQ==", + "license": "MIT", + "dependencies": { + "@shikijs/core": "4.2.0", + "@shikijs/engine-javascript": "4.2.0", + "@shikijs/engine-oniguruma": "4.2.0", + "@shikijs/langs": "4.2.0", + "@shikijs/themes": "4.2.0", + "@shikijs/types": "4.2.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/side-channel": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", @@ -5062,6 +5597,16 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -5098,6 +5643,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/strip-ansi": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", @@ -5186,6 +5745,16 @@ "node": ">=0.6" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -5266,6 +5835,74 @@ "pathe": "^2.0.3" } }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -5315,6 +5952,34 @@ "node": ">= 0.8" } }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vite": { "version": "8.0.16", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", @@ -5664,6 +6329,16 @@ "peerDependencies": { "zod": "^3.25.28 || ^4" } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/package.json b/package.json index b848a25..7a5f007 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "dependencies": { "@hono/node-server": "^1.14.0", "@modelcontextprotocol/sdk": "^1.12.0", + "@pierre/diffs": "^1.2.11", "hono": "^4.7.0", "zod": "^3.24.0" }, diff --git a/server/app.ts b/server/app.ts index 6a5f2bb..5b533c1 100644 --- a/server/app.ts +++ b/server/app.ts @@ -3,10 +3,17 @@ import { getCookie, setCookie } from "hono/cookie"; import { streamSSE } from "hono/streaming"; import { EventBus } from "./events.ts"; import { registerMcp } from "./mcpHttp.ts"; -import { renderSnippetPage } from "./snippetPage.ts"; -import type { Comment, Snippet, Store } from "./types.ts"; - -const MAX_HTML_BYTES = 2 * 1024 * 1024; +import { renderHtmlPage } from "./surfacePage.ts"; +import { + type Comment, + htmlPart, + partsByteLength, + type Store, + type Surface, + type SurfacePart, +} from "./types.ts"; + +const MAX_SURFACE_BYTES = 2 * 1024 * 1024; const MAX_WAIT_SECONDS = 300; // Docs and onboarding snippets are written against the local default; serve // them with the real origin so a deployed instance shows copy-pasteable URLs. @@ -72,18 +79,38 @@ async function fetchLatestFromRegistry(): Promise { const UPDATE_CHECK_TTL_MS = 6 * 60 * 60 * 1000; -const snippetMeta = (s: Snippet) => ({ +// html parts carry arbitrary markup the viewer renders via a sandboxed iframe, +// so the card list never needs their bodies — strip them to a kind marker. +// diff parts are structured data the viewer renders inline, so keep them whole. +const stripParts = (parts: SurfacePart[]): SurfacePart[] => + parts.map((p) => (p.kind === "html" ? { kind: "html", html: "" } : p)); + +const surfaceMeta = (s: Surface) => ({ + id: s.id, + sessionId: s.sessionId, + title: s.title, + createdAt: s.createdAt, + updatedAt: s.updatedAt, + version: s.version, + parts: stripParts(s.parts), +}); + +// Response to an agent's own write: it already holds the parts it just sent, +// so echo only the identifiers (a diff patch can be large — never send it +// back). Reads (`surfaceMeta`, GET /api/surfaces/:id) still carry parts. +const writeResult = (s: Surface) => ({ id: s.id, sessionId: s.sessionId, title: s.title, createdAt: s.createdAt, updatedAt: s.updatedAt, version: s.version, + kinds: s.parts.map((p) => p.kind), }); export interface CommentWait { sessionId?: string; - snippetId?: string; + surfaceId?: string; author?: string; afterSeq?: number; waitSeconds: number; @@ -91,8 +118,8 @@ export interface CommentWait { // Lean comment shape attached to agent-facing responses. export const feedbackView = (c: Comment) => ({ - snippetId: c.snippetId, - snippetTitle: c.snippetTitle, + surfaceId: c.surfaceId, + surfaceTitle: c.surfaceTitle, text: c.text, at: c.createdAt, }); @@ -138,18 +165,21 @@ export function createApp({ return feedback.length > 0 ? feedback.map(feedbackView) : undefined; } - async function publishSnippet(input: { - html: string; + async function publishSurface(input: { + parts: SurfacePart[]; title?: string; session?: string; sessionTitle?: string; agent?: string; cwd?: string; }): Promise< - { snippet: Snippet; userFeedback?: Feedback[] } | { error: string; status: 404 | 413 } + { surface: Surface; userFeedback?: Feedback[] } | { error: string; status: 400 | 404 | 413 } > { - if (input.html.length > MAX_HTML_BYTES) { - return { error: `html exceeds ${MAX_HTML_BYTES} bytes`, status: 413 }; + if (input.parts.length === 0) { + return { error: "a surface needs at least one part", status: 400 }; + } + if (partsByteLength(input.parts) > MAX_SURFACE_BYTES) { + return { error: `surface exceeds ${MAX_SURFACE_BYTES} bytes`, status: 413 }; } let sessionId = input.session; if (sessionId && !(await store.getSession(sessionId))) { @@ -166,54 +196,59 @@ export function createApp({ bus.broadcast({ type: "session-created", id: session.id }); sessionId = session.id; } - const snippet = await store.createSnippet({ + const surface = await store.createSurface({ sessionId, - html: input.html, + parts: input.parts, title: input.title, }); - if (!snippet) return { error: "session not found", status: 404 }; - bus.broadcast({ type: "snippet-created", id: snippet.id, sessionId, version: 1 }); - return { snippet, userFeedback: await collectFeedback(sessionId) }; + if (!surface) return { error: "session not found", status: 404 }; + bus.broadcast({ type: "surface-created", id: surface.id, sessionId, version: 1 }); + return { surface, userFeedback: await collectFeedback(sessionId) }; } - async function reviseSnippet( + async function reviseSurface( id: string, - patch: { html?: string; title?: string }, + patch: { parts?: SurfacePart[]; title?: string }, ): Promise< - { snippet: Snippet; userFeedback?: Feedback[] } | { error: string; status: 404 | 413 } + { surface: Surface; userFeedback?: Feedback[] } | { error: string; status: 400 | 404 | 413 } > { - if (typeof patch.html === "string" && patch.html.length > MAX_HTML_BYTES) { - return { error: `html exceeds ${MAX_HTML_BYTES} bytes`, status: 413 }; + if (patch.parts) { + if (patch.parts.length === 0) { + return { error: "a surface needs at least one part", status: 400 }; + } + if (partsByteLength(patch.parts) > MAX_SURFACE_BYTES) { + return { error: `surface exceeds ${MAX_SURFACE_BYTES} bytes`, status: 413 }; + } } - const snippet = await store.updateSnippet(id, patch); - if (!snippet) return { error: "snippet not found", status: 404 }; + const surface = await store.updateSurface(id, patch); + if (!surface) return { error: "surface not found", status: 404 }; bus.broadcast({ - type: "snippet-updated", - id: snippet.id, - sessionId: snippet.sessionId, - version: snippet.version, + type: "surface-updated", + id: surface.id, + sessionId: surface.sessionId, + version: surface.version, }); - return { snippet, userFeedback: await collectFeedback(snippet.sessionId) }; + return { surface, userFeedback: await collectFeedback(surface.sessionId) }; } async function createComment(input: { text: string; - snippet?: string; + surface?: string; session?: string; author: string; }): Promise< { comment: Comment; userFeedback?: Feedback[] } | { error: string; status: 400 | 404 } > { let sessionId = input.session; - if (input.snippet) { - const snippet = await store.getSnippet(input.snippet); - if (!snippet) return { error: "snippet not found", status: 404 }; - sessionId = snippet.sessionId; + if (input.surface) { + const surface = await store.getSurface(input.surface); + if (!surface) return { error: "surface not found", status: 404 }; + sessionId = surface.sessionId; } - if (!sessionId) return { error: 'provide "snippet" or "session" id', status: 400 }; + if (!sessionId) return { error: 'provide "surface" or "session" id', status: 400 }; const comment = await store.createComment({ sessionId, - snippetId: input.snippet, + surfaceId: input.surface, author: input.author, text: input.text.trim(), }); @@ -222,7 +257,7 @@ export function createApp({ type: "comment-created", id: comment.id, sessionId: comment.sessionId, - snippetId: comment.snippetId, + surfaceId: comment.surfaceId, seq: comment.seq, }); // agent replies are writes too — piggyback pending feedback on them, but @@ -243,7 +278,7 @@ export function createApp({ if (afterSeq === undefined && q.author === "user" && q.sessionId) { afterSeq = (await store.getSession(q.sessionId))?.agentSeq; } - const query = { sessionId: q.sessionId, snippetId: q.snippetId, afterSeq }; + const query = { sessionId: q.sessionId, surfaceId: q.surfaceId, afterSeq }; const matches = (list: Comment[]) => q.author ? list.filter((cm) => cm.author === q.author) : list; const wait = Math.min(Math.max(q.waitSeconds, 0), MAX_WAIT_SECONDS); @@ -255,7 +290,7 @@ export function createApp({ const unsubscribe = bus.subscribe((event) => { if (event.type !== "comment-created") return; if (q.sessionId && event.sessionId !== q.sessionId) return; - if (q.snippetId && event.snippetId !== q.snippetId) return; + if (q.surfaceId && event.surfaceId !== q.surfaceId) return; done(); }); function done() { @@ -314,10 +349,10 @@ export function createApp({ // --- sessions --- app.get("/api/sessions", async (c) => { - const [sessions, snippets] = await Promise.all([store.listSessions(), store.listSnippets()]); + const [sessions, surfaces] = await Promise.all([store.listSessions(), store.listSurfaces()]); const counts = new Map(); - for (const s of snippets) counts.set(s.sessionId, (counts.get(s.sessionId) ?? 0) + 1); - return c.json(sessions.map((s) => ({ ...s, snippetCount: counts.get(s.id) ?? 0 }))); + for (const s of surfaces) counts.set(s.sessionId, (counts.get(s.sessionId) ?? 0) + 1); + return c.json(sessions.map((s) => ({ ...s, surfaceCount: counts.get(s.id) ?? 0 }))); }); app.post("/api/sessions", async (c) => { @@ -349,30 +384,47 @@ export function createApp({ return c.json({ ok: true }); }); - app.get("/api/sessions/:id/snippets", async (c) => { + const listSessionSurfaces = async (c: any) => { const session = await store.getSession(c.req.param("id")); if (!session) return c.json({ error: "session not found" }, 404); - const snippets = await store.listSnippets(session.id); - return c.json(snippets.map(snippetMeta)); - }); - - // --- snippets --- - - app.get("/api/snippets/:id", async (c) => { - const snippet = await store.getSnippet(c.req.param("id")); - if (!snippet) return c.json({ error: "snippet not found" }, 404); - return c.json(snippet); - }); + const surfaces = await store.listSurfaces(session.id); + return c.json(surfaces.map(surfaceMeta)); + }; + app.get("/api/sessions/:id/surfaces", listSessionSurfaces); + app.get("/api/sessions/:id/snippets", listSessionSurfaces); // legacy alias + + // --- surfaces --- + + const getSurface = async (c: any) => { + const surface = await store.getSurface(c.req.param("id")); + if (!surface) return c.json({ error: "surface not found" }, 404); + return c.json(surface); + }; + app.get("/api/surfaces/:id", getSurface); + app.get("/api/snippets/:id", getSurface); // legacy alias // Accepts either an existing session id, or agent/cwd fields to // auto-create a session — so a bare `curl` one-liner works with no ceremony. + app.post("/api/surfaces", async (c) => { + const body = await c.req.json().catch(() => null); + if (!body || !Array.isArray(body.parts)) { + return c.json({ error: 'body must include a "parts" array' }, 400); + } + return publish(c, body, body.parts as SurfacePart[]); + }); + + // Legacy html-only entry — sugar for a single html part. app.post("/api/snippets", async (c) => { const body = await c.req.json().catch(() => null); if (!body || typeof body.html !== "string" || !body.html.trim()) { return c.json({ error: 'body must include non-empty "html" string' }, 400); } - const result = await publishSnippet({ - html: body.html, + return publish(c, body, [htmlPart(body.html)]); + }); + + async function publish(c: any, body: any, parts: SurfacePart[]) { + const result = await publishSurface({ + parts, title: typeof body.title === "string" ? body.title : undefined, session: typeof body.session === "string" ? body.session : undefined, sessionTitle: typeof body.sessionTitle === "string" ? body.sessionTitle : undefined, @@ -382,34 +434,42 @@ export function createApp({ if ("error" in result) return c.json({ error: result.error }, result.status); return c.json( { - ...snippetMeta(result.snippet), + ...writeResult(result.surface), ...(result.userFeedback && { userFeedback: result.userFeedback }), }, 201, ); - }); + } - app.put("/api/snippets/:id", async (c) => { + const revise = async (c: any) => { const body = await c.req.json().catch(() => null); if (!body) return c.json({ error: "invalid JSON body" }, 400); - const result = await reviseSnippet(c.req.param("id"), { - html: typeof body.html === "string" ? body.html : undefined, + // surfaces: a `parts` array; snippets: an `html` string (single html part). + let parts: SurfacePart[] | undefined; + if (Array.isArray(body.parts)) parts = body.parts as SurfacePart[]; + else if (typeof body.html === "string") parts = [htmlPart(body.html)]; + const result = await reviseSurface(c.req.param("id"), { + parts, title: typeof body.title === "string" ? body.title : undefined, }); if ("error" in result) return c.json({ error: result.error }, result.status); return c.json({ - ...snippetMeta(result.snippet), + ...writeResult(result.surface), ...(result.userFeedback && { userFeedback: result.userFeedback }), }); - }); - - app.delete("/api/snippets/:id", async (c) => { - const snippet = await store.getSnippet(c.req.param("id")); - if (!snippet) return c.json({ error: "snippet not found" }, 404); - await store.removeSnippet(snippet.id); - bus.broadcast({ type: "snippet-deleted", id: snippet.id, sessionId: snippet.sessionId }); + }; + app.put("/api/surfaces/:id", revise); + app.put("/api/snippets/:id", revise); // legacy alias + + const remove = async (c: any) => { + const surface = await store.getSurface(c.req.param("id")); + if (!surface) return c.json({ error: "surface not found" }, 404); + await store.removeSurface(surface.id); + bus.broadcast({ type: "surface-deleted", id: surface.id, sessionId: surface.sessionId }); return c.json({ ok: true }); - }); + }; + app.delete("/api/surfaces/:id", remove); + app.delete("/api/snippets/:id", remove); // legacy alias // --- comments --- @@ -418,9 +478,10 @@ export function createApp({ if (!body || typeof body.text !== "string" || !body.text.trim()) { return c.json({ error: 'body must include non-empty "text" string' }, 400); } + const surface = typeof body.surface === "string" ? body.surface : body.snippet; const result = await createComment({ text: body.text, - snippet: typeof body.snippet === "string" ? body.snippet : undefined, + surface: typeof surface === "string" ? surface : undefined, session: typeof body.session === "string" ? body.session : undefined, author: typeof body.author === "string" ? body.author : "user", }); @@ -450,7 +511,7 @@ export function createApp({ app.get("/api/comments", async (c) => { const result = await waitForComments({ sessionId: c.req.query("session"), - snippetId: c.req.query("snippet"), + surfaceId: c.req.query("surface") ?? c.req.query("snippet"), author: c.req.query("author"), afterSeq: c.req.query("after") ? Number(c.req.query("after")) : undefined, waitSeconds: Number(c.req.query("wait") ?? 0) || 0, @@ -460,18 +521,26 @@ export function createApp({ // --- rendering --- + // Serves one html part of a surface as a themed, sandboxed document. The + // viewer points an iframe here per html part; diff parts render natively in + // the viewer (they are data, not arbitrary markup) and never reach here. app.get("/s/:id", async (c) => { - const snippet = await store.getSnippet(c.req.param("id")); - if (!snippet) return c.text("Snippet not found", 404); + const surface = await store.getSurface(c.req.param("id")); + if (!surface) return c.text("Surface not found", 404); const ver = c.req.query("ver"); - let doc = snippet; - if (ver && Number(ver) !== snippet.version) { - const old = snippet.history.find((h) => h.version === Number(ver)); + let title = surface.title; + let parts = surface.parts; + if (ver && Number(ver) !== surface.version) { + const old = surface.history.find((h) => h.version === Number(ver)); if (!old) return c.text(`Version ${ver} not available`, 404); - doc = { ...snippet, title: old.title, html: old.html }; + title = old.title; + parts = old.parts; } + const idx = Number(c.req.query("part") ?? 0); + const part = parts[idx]; + if (!part || part.kind !== "html") return c.text("No html part at that index", 404); c.header("X-Content-Type-Options", "nosniff"); - return c.html(renderSnippetPage(doc)); + return c.html(renderHtmlPage({ title, html: part.html })); }); // --- live feed --- @@ -513,8 +582,8 @@ export function createApp({ registerMcp(app, { store, - publishSnippet, - reviseSnippet, + publishSurface, + reviseSurface, createComment, waitForComments, guide: guideMarkdown, diff --git a/server/events.ts b/server/events.ts index dd624d4..0c8017b 100644 --- a/server/events.ts +++ b/server/events.ts @@ -1,12 +1,12 @@ export type FeedEvent = | { type: "session-created" | "session-updated" | "session-deleted"; id: string } - | { type: "snippet-created" | "snippet-updated"; id: string; sessionId: string; version: number } - | { type: "snippet-deleted"; id: string; sessionId: string } + | { type: "surface-created" | "surface-updated"; id: string; sessionId: string; version: number } + | { type: "surface-deleted"; id: string; sessionId: string } | { type: "comment-created"; id: string; sessionId: string; - snippetId: string | null; + surfaceId: string | null; seq: number; }; diff --git a/server/mcpHttp.ts b/server/mcpHttp.ts index ee976d4..587f388 100644 --- a/server/mcpHttp.ts +++ b/server/mcpHttp.ts @@ -1,93 +1,195 @@ import type { Hono } from "hono"; import type { CommentWait, Feedback } from "./app.ts"; -import type { Comment, Snippet, Store } from "./types.ts"; +import { type Comment, htmlPart, type Store, type Surface, type SurfacePart } from "./types.ts"; // Stateless MCP over streamable HTTP: every request is self-contained, which // is what a serverless deployment needs. Session continuity is explicit — -// publish_snippet returns a sessionId the agent passes back on later calls. +// publish_surface returns a sessionId the agent passes back on later calls. + +type FlowResult = Promise< + { surface: T; userFeedback?: Feedback[] } | { error: string; status: number } +>; export interface McpDeps { store: Store; - publishSnippet(input: { - html: string; + publishSurface(input: { + parts: SurfacePart[]; title?: string; session?: string; sessionTitle?: string; agent?: string; - }): Promise<{ snippet: Snippet; userFeedback?: Feedback[] } | { error: string; status: number }>; - reviseSnippet( - id: string, - patch: { html?: string; title?: string }, - ): Promise<{ snippet: Snippet; userFeedback?: Feedback[] } | { error: string; status: number }>; + }): FlowResult; + reviseSurface(id: string, patch: { parts?: SurfacePart[]; title?: string }): FlowResult; createComment(input: { text: string; - snippet?: string; + surface?: string; author: string; }): Promise<{ comment: Comment; userFeedback?: Feedback[] } | { error: string; status: number }>; waitForComments(q: CommentWait): Promise<{ comments: Comment[]; lastSeq: number }>; guide: string; } +// Coerce loosely-typed tool args into validated SurfacePart[]. Unknown kinds +// and empty parts are dropped rather than rejected, so a slightly-off call +// still publishes what it can. +export function coerceParts(raw: unknown): SurfacePart[] { + if (!Array.isArray(raw)) return []; + const parts: SurfacePart[] = []; + for (const p of raw) { + if (!p || typeof p !== "object") continue; + const kind = (p as any).kind; + if (kind === "html" && typeof (p as any).html === "string") { + parts.push(htmlPart((p as any).html)); + } else if (kind === "diff") { + const patch = typeof (p as any).patch === "string" ? (p as any).patch : undefined; + const files = Array.isArray((p as any).files) + ? (p as any).files + .filter((f: any) => f && typeof f.filename === "string") + .map((f: any) => ({ + filename: String(f.filename), + before: String(f.before ?? ""), + after: String(f.after ?? ""), + ...(typeof f.language === "string" && { language: f.language }), + })) + : undefined; + if (!patch && (!files || files.length === 0)) continue; + const layout = (p as any).layout === "split" ? "split" : undefined; + parts.push({ + kind: "diff", + ...(patch && { patch }), + ...(files && { files }), + ...(layout && { layout }), + }); + } + } + return parts; +} + const INSTRUCTIONS = - "sideshow is a live visual surface the user watches in a browser. Publish HTML snippets to illustrate " + - "concepts, sketch UI ideas, or visualize data while you work. Call get_design_guide once before your first " + - "publish — it defines the HTML contract. Your first publish_snippet creates a session and returns its " + - "sessionId: pass it as `session` on every later call so your snippets stay grouped. On that first publish, " + - 'also pass sessionTitle to name the session after the task at hand (e.g. "Auth refactor") so the user can ' + - "tell sessions apart in the sidebar. The user can comment on " + - "snippets in their browser; call wait_for_feedback after publishing something you want a reaction to — it " + - "resumes where you left off, so comments already delivered are not repeated. Any publish/update/reply " + - "result may also carry a " + - "userFeedback array — comments the user left since your last call. Treat them as messages from the user; " + - "they are delivered once."; + "sideshow is a live visual surface the user watches in a browser. Publish surfaces to illustrate concepts, " + + "sketch UI ideas, visualize data, or show a code review while you work. A surface is an ordered list of " + + "parts: an `html` part is markup you write (a body fragment), a `diff` part is a patch the viewer renders " + + "as a syntax-highlighted, split/unified diff. Combine them — e.g. a diagram html part above a diff part — " + + "in one card. publish_surface is the general tool; publish_snippet is sugar for a single html part. Call " + + "get_design_guide once before your first publish — it defines the contract. Your first publish creates a " + + "session and returns its sessionId: pass it as `session` on later calls so your surfaces stay grouped. On " + + 'that first publish, also pass sessionTitle to name the session after the task (e.g. "Auth refactor"). The ' + + "user can comment in their browser; call wait_for_feedback after publishing something you want a reaction " + + "to — it resumes where you left off. Any publish/update/reply result may also carry a userFeedback array — " + + "comments the user left since your last call, delivered once."; + +const PARTS_SCHEMA = { + type: "array", + description: + "Ordered parts. An html part is {kind:'html', html:''}. A diff part is normally " + + "{kind:'diff', patch:''} (may span multiple files) — send the patch, it is the " + + "compact, preferred form. {kind:'diff', files:[{filename, before, after}]} also works but sends whole " + + "file contents, so reach for it only when you lack a patch. Optional layout 'unified'|'split'. " + + "Combine, e.g. [{kind:'html',html:''},{kind:'diff',patch:'...'}].", + items: { + type: "object", + properties: { + kind: { type: "string", enum: ["html", "diff"] }, + html: { type: "string", description: "html part: body fragment (no doctype/html/head/body)" }, + patch: { + type: "string", + description: "diff part: a unified/git diff string — the preferred, compact form", + }, + files: { + type: "array", + description: + "diff part: explicit before/after pairs — heavier (full file contents); prefer patch", + items: { + type: "object", + properties: { + filename: { type: "string" }, + before: { type: "string" }, + after: { type: "string" }, + language: { type: "string" }, + }, + required: ["filename", "before", "after"], + }, + }, + layout: { type: "string", enum: ["unified", "split"] }, + }, + required: ["kind"], + }, +}; const TOOLS = [ { - name: "publish_snippet", + name: "publish_surface", description: - "Publish an HTML snippet to the user's sideshow surface. Send a body fragment only (no " + - "doctype/html/head/body). Returns the snippet id, view URL, and sessionId — pass sessionId as `session` " + - "on later calls. On your first publish, pass sessionTitle naming the task to label the session in the " + - "viewer sidebar (honored only when the publish creates the session). If the result includes " + - "userFeedback, those are new comments from the user — read them. Call get_design_guide first if you " + - "have not this session.", + "Publish a surface to the user's sideshow board. A surface is an ordered list of parts (html and/or " + + "diff). Returns the surface id, view URL, and sessionId — pass sessionId as `session` on later calls. " + + "On your first publish, pass sessionTitle naming the task. If the result includes userFeedback, those " + + "are new comments from the user. Call get_design_guide first if you have not this session.", inputSchema: { type: "object", properties: { - title: { - type: "string", - description: "Short human-readable title shown above the snippet", - }, - html: { type: "string", description: "HTML body fragment to render" }, + title: { type: "string", description: "Short human-readable title shown above the card" }, + parts: PARTS_SCHEMA, session: { type: "string", - description: "Session id from a previous publish (omit on first publish)", + description: "Session id from a previous publish (omit on first)", }, sessionTitle: { type: "string", description: - 'Session name shown in the viewer sidebar — name the task, e.g. "Auth refactor", not your ' + - "tool. Honored only when this publish creates the session (first publish, no `session`); it " + - "never retitles an existing session.", + 'Session name shown in the sidebar — name the task, e.g. "Auth refactor". Honored only when ' + + "this publish creates the session.", }, agent: { type: "string", - description: - 'Your agent name for the session label, e.g. "claude-code" (first publish only)', + description: "Your agent name for the session label (first publish only)", }, }, + required: ["title", "parts"], + }, + }, + { + name: "update_surface", + description: + "Revise a surface in place (same card, new version). Prefer this over publishing a near-duplicate. " + + "Pass the full replacement parts array. If the result includes userFeedback, read it.", + inputSchema: { + type: "object", + properties: { + id: { type: "string", description: "Surface id returned by publish_surface" }, + parts: PARTS_SCHEMA, + title: { type: "string", description: "Replacement title" }, + }, + required: ["id"], + }, + }, + { + name: "publish_snippet", + description: + "Publish an HTML snippet — sugar for a surface with one html part. Send a body fragment only. Returns " + + "the id, view URL, and sessionId. Pass sessionTitle on first publish. Prefer publish_surface when you " + + "want a diff or multiple parts.", + inputSchema: { + type: "object", + properties: { + title: { type: "string", description: "Short human-readable title" }, + html: { type: "string", description: "HTML body fragment to render" }, + session: { + type: "string", + description: "Session id from a previous publish (omit on first)", + }, + sessionTitle: { type: "string", description: "Session name (first publish only)" }, + agent: { type: "string", description: "Your agent name (first publish only)" }, + }, required: ["title", "html"], }, }, { name: "update_snippet", - description: - "Revise an existing snippet in place (same card, new version). Prefer this over publishing a " + - "near-duplicate. If the result includes userFeedback, those are new comments from the user — read them.", + description: "Revise an html snippet in place — sugar for update_surface with one html part.", inputSchema: { type: "object", properties: { - id: { type: "string", description: "Snippet id returned by publish_snippet" }, + id: { type: "string", description: "Surface id" }, html: { type: "string", description: "Replacement HTML body fragment" }, title: { type: "string", description: "Replacement title" }, }, @@ -97,22 +199,18 @@ const TOOLS = [ { name: "wait_for_feedback", description: - "Block until the user comments on this session's snippets in their browser (or the timeout passes). " + - "Returns new comments since the agent last received feedback on any channel (including piggyback). " + - "Use timeoutSeconds 0 for a non-blocking check.", + "Block until the user comments on this session in their browser (or the timeout passes). Returns new " + + "comments since the agent last received feedback on any channel. Use timeoutSeconds 0 for a " + + "non-blocking check.", inputSchema: { type: "object", properties: { session: { type: "string", description: "Session id to watch" }, afterSeq: { type: "number", - description: - "explicit cursor override — re-reads comments after this seq (default: where the agent left off)", - }, - timeoutSeconds: { - type: "number", - description: "How long to wait, 0-300 (default 60; 0 = check only)", + description: "explicit cursor override (default: where the agent left off)", }, + timeoutSeconds: { type: "number", description: "How long to wait, 0-300 (default 60)" }, }, required: ["session"], }, @@ -120,21 +218,20 @@ const TOOLS = [ { name: "reply_to_user", description: - "Post a short reply under a snippet's comment thread in the user's browser. Use to acknowledge feedback " + - "or explain a revision.", + "Post a short reply under a surface's comment thread. Use to acknowledge feedback or explain a revision.", inputSchema: { type: "object", properties: { - snippetId: { type: "string", description: "Snippet whose thread to reply in" }, + surfaceId: { type: "string", description: "Surface whose thread to reply in" }, message: { type: "string", description: "Plain-text reply" }, author: { type: "string", description: 'Your agent name (default "agent")' }, }, - required: ["snippetId", "message"], + required: ["surfaceId", "message"], }, }, { - name: "list_snippets", - description: "List snippets — pass a session id to scope, or omit for all sessions.", + name: "list_surfaces", + description: "List surfaces — pass a session id to scope, or omit for all sessions.", inputSchema: { type: "object", properties: { @@ -145,55 +242,58 @@ const TOOLS = [ { name: "get_design_guide", description: - "Fetch the design contract for snippets: HTML fragment rules, theme CSS variables, CDN allowlist, and " + - "interactivity bridge. Call once per session before publishing.", + "Fetch the design contract: surface parts, html fragment rules, theme CSS variables, CDN allowlist, " + + "and the interactivity bridge. Call once per session before publishing.", inputSchema: { type: "object", properties: {} }, }, ]; export function registerMcp(app: Hono, deps: McpDeps) { + const surfaceResult = (result: { surface: Surface; userFeedback?: Feedback[] }, origin: string) => + JSON.stringify( + { + id: result.surface.id, + sessionId: result.surface.sessionId, + version: result.surface.version, + url: `${origin}/s/${result.surface.id}`, + ...(result.userFeedback && { userFeedback: result.userFeedback }), + }, + null, + 2, + ); + async function callTool(name: string, args: any, origin: string): Promise { switch (name) { + case "publish_surface": case "publish_snippet": { - const result = await deps.publishSnippet({ - html: String(args.html ?? ""), + const parts = + name === "publish_snippet" + ? [htmlPart(String(args.html ?? ""))] + : coerceParts(args.parts); + if (parts.length === 0) throw new Error("a surface needs at least one part"); + const result = await deps.publishSurface({ + parts, title: typeof args.title === "string" ? args.title : undefined, session: typeof args.session === "string" ? args.session : undefined, sessionTitle: typeof args.sessionTitle === "string" ? args.sessionTitle : undefined, agent: typeof args.agent === "string" ? args.agent : undefined, }); if ("error" in result) throw new Error(result.error); - const s = result.snippet; - return JSON.stringify( - { - id: s.id, - sessionId: s.sessionId, - version: s.version, - url: `${origin}/s/${s.id}`, - ...(result.userFeedback && { userFeedback: result.userFeedback }), - }, - null, - 2, - ); + return surfaceResult(result, origin); } + case "update_surface": case "update_snippet": { - const result = await deps.reviseSnippet(String(args.id ?? ""), { - html: typeof args.html === "string" ? args.html : undefined, + const patch: { parts?: SurfacePart[]; title?: string } = { title: typeof args.title === "string" ? args.title : undefined, - }); + }; + if (name === "update_snippet") { + if (typeof args.html === "string") patch.parts = [htmlPart(args.html)]; + } else if (args.parts !== undefined) { + patch.parts = coerceParts(args.parts); + } + const result = await deps.reviseSurface(String(args.id ?? ""), patch); if ("error" in result) throw new Error(result.error); - const s = result.snippet; - return JSON.stringify( - { - id: s.id, - sessionId: s.sessionId, - version: s.version, - url: `${origin}/s/${s.id}`, - ...(result.userFeedback && { userFeedback: result.userFeedback }), - }, - null, - 2, - ); + return surfaceResult(result, origin); } case "wait_for_feedback": { const result = await deps.waitForComments({ @@ -212,8 +312,8 @@ export function registerMcp(app: Hono, deps: McpDeps) { return JSON.stringify( { comments: result.comments.map((c) => ({ - snippetId: c.snippetId, - snippetTitle: c.snippetTitle, + surfaceId: c.surfaceId, + surfaceTitle: c.surfaceTitle, text: c.text, at: c.createdAt, })), @@ -226,7 +326,7 @@ export function registerMcp(app: Hono, deps: McpDeps) { case "reply_to_user": { const result = await deps.createComment({ text: String(args.message ?? ""), - snippet: String(args.snippetId ?? ""), + surface: String(args.surfaceId ?? ""), author: typeof args.author === "string" ? args.author : "agent", }); if ("error" in result) throw new Error(result.error); @@ -236,15 +336,17 @@ export function registerMcp(app: Hono, deps: McpDeps) { 2, ); } + case "list_surfaces": case "list_snippets": { - const snippets = await deps.store.listSnippets( + const surfaces = await deps.store.listSurfaces( typeof args.session === "string" ? args.session : undefined, ); return JSON.stringify( - snippets.map((s) => ({ + surfaces.map((s) => ({ id: s.id, sessionId: s.sessionId, title: s.title, + kinds: s.parts.map((p) => p.kind), version: s.version, updatedAt: s.updatedAt, })), diff --git a/server/storage.ts b/server/storage.ts index 08b6700..b48d4a8 100644 --- a/server/storage.ts +++ b/server/storage.ts @@ -5,27 +5,83 @@ import { type CommentQuery, type CreateCommentInput, type CreateSessionInput, - type CreateSnippetInput, + type CreateSurfaceInput, HISTORY_LIMIT, + htmlPart, newId, type Session, - type Snippet, type Store, - type UpdateSnippetInput, + type Surface, + type UpdateSurfaceInput, } from "./types.ts"; export type * from "./types.ts"; interface FileShape { sessions: Session[]; - snippets: Snippet[]; + surfaces: Surface[]; comments: Comment[]; lastSeq: number; } +// Pre-0.5.0 boards stored `snippets` (a single `html` field) and comments +// keyed by `snippetId`. Read those shapes and lift them into the parts model. +interface LegacySnippetVersion { + version: number; + title: string; + html: string; + at: string; +} +interface LegacySnippet { + id: string; + sessionId: string; + title: string; + html: string; + createdAt: string; + updatedAt: string; + version: number; + history: LegacySnippetVersion[]; +} +interface LegacyShape extends Partial { + snippets?: LegacySnippet[]; +} + +function liftSnippet(s: LegacySnippet): Surface { + return { + id: s.id, + sessionId: s.sessionId, + title: s.title, + parts: [htmlPart(s.html)], + createdAt: s.createdAt, + updatedAt: s.updatedAt, + version: s.version, + history: (s.history ?? []).map((h) => ({ + version: h.version, + title: h.title, + parts: [htmlPart(h.html)], + at: h.at, + })), + }; +} + +type LegacyComment = Comment & { snippetId?: string | null; snippetTitle?: string | null }; + +function liftComment(c: LegacyComment): Comment { + return { + id: c.id, + seq: c.seq, + sessionId: c.sessionId, + surfaceId: c.surfaceId ?? c.snippetId ?? null, + surfaceTitle: c.surfaceTitle ?? c.snippetTitle ?? null, + author: c.author, + text: c.text, + createdAt: c.createdAt, + }; +} + export class JsonFileStore implements Store { private sessions = new Map(); - private snippets = new Map(); + private surfaces = new Map(); private comments: Comment[] = []; private lastSeq = 0; private loaded = false; @@ -41,13 +97,18 @@ export class JsonFileStore implements Store { this.loaded = true; try { const raw = await readFile(this.filePath, "utf8"); - const data = JSON.parse(raw) as FileShape; + const data = JSON.parse(raw) as LegacyShape; // agentSeq arrived after 0.2.0 — default it for data files written before for (const s of data.sessions ?? []) { this.sessions.set(s.id, { ...s, agentSeq: s.agentSeq ?? 0 }); } - for (const s of data.snippets ?? []) this.snippets.set(s.id, s); - this.comments = data.comments ?? []; + // Prefer the surfaces array; fall back to lifting legacy snippets. + if (data.surfaces) { + for (const s of data.surfaces) this.surfaces.set(s.id, s); + } else if (data.snippets) { + for (const s of data.snippets) this.surfaces.set(s.id, liftSnippet(s)); + } + this.comments = (data.comments ?? []).map(liftComment); this.lastSeq = data.lastSeq ?? 0; } catch (err: any) { if (err?.code !== "ENOENT") throw err; @@ -58,7 +119,7 @@ export class JsonFileStore implements Store { const data = JSON.stringify( { sessions: [...this.sessions.values()], - snippets: [...this.snippets.values()], + surfaces: [...this.surfaces.values()], comments: this.comments, lastSeq: this.lastSeq, } satisfies FileShape, @@ -115,8 +176,8 @@ export class JsonFileStore implements Store { async removeSession(id: string) { await this.load(); if (!this.sessions.delete(id)) return false; - for (const [sid, snippet] of this.snippets) { - if (snippet.sessionId === id) this.snippets.delete(sid); + for (const [sid, surface] of this.surfaces) { + if (surface.sessionId === id) this.surfaces.delete(sid); } this.comments = this.comments.filter((c) => c.sessionId !== id); await this.persist(); @@ -136,67 +197,67 @@ export class JsonFileStore implements Store { await this.persist(); } - // --- snippets --- + // --- surfaces --- - async listSnippets(sessionId?: string) { + async listSurfaces(sessionId?: string) { await this.load(); - const all = [...this.snippets.values()].filter( + const all = [...this.surfaces.values()].filter( (s) => sessionId === undefined || s.sessionId === sessionId, ); return all.sort((a, b) => a.createdAt.localeCompare(b.createdAt)); } - async getSnippet(id: string) { + async getSurface(id: string) { await this.load(); - return this.snippets.get(id) ?? null; + return this.surfaces.get(id) ?? null; } - async createSnippet(input: CreateSnippetInput) { + async createSurface(input: CreateSurfaceInput) { await this.load(); if (!this.sessions.has(input.sessionId)) return null; const now = new Date().toISOString(); - const snippet: Snippet = { + const surface: Surface = { id: newId(), sessionId: input.sessionId, title: input.title?.trim() || "Untitled", - html: input.html, + parts: input.parts, createdAt: now, updatedAt: now, version: 1, history: [], }; - this.snippets.set(snippet.id, snippet); + this.surfaces.set(surface.id, surface); this.touch(input.sessionId); await this.persist(); - return snippet; + return surface; } - async updateSnippet(id: string, patch: UpdateSnippetInput) { + async updateSurface(id: string, patch: UpdateSurfaceInput) { await this.load(); - const snippet = this.snippets.get(id); - if (!snippet) return null; - snippet.history.push({ - version: snippet.version, - title: snippet.title, - html: snippet.html, - at: snippet.updatedAt, + const surface = this.surfaces.get(id); + if (!surface) return null; + surface.history.push({ + version: surface.version, + title: surface.title, + parts: surface.parts, + at: surface.updatedAt, }); - if (snippet.history.length > HISTORY_LIMIT) snippet.history.shift(); - if (patch.title !== undefined) snippet.title = patch.title.trim() || snippet.title; - if (patch.html !== undefined) snippet.html = patch.html; - snippet.version += 1; - snippet.updatedAt = new Date().toISOString(); - this.touch(snippet.sessionId); + if (surface.history.length > HISTORY_LIMIT) surface.history.shift(); + if (patch.title !== undefined) surface.title = patch.title.trim() || surface.title; + if (patch.parts !== undefined) surface.parts = patch.parts; + surface.version += 1; + surface.updatedAt = new Date().toISOString(); + this.touch(surface.sessionId); await this.persist(); - return snippet; + return surface; } - async removeSnippet(id: string) { + async removeSurface(id: string) { await this.load(); - const snippet = this.snippets.get(id); - if (!snippet) return false; - this.snippets.delete(id); - this.comments = this.comments.filter((c) => c.snippetId !== id); + const surface = this.surfaces.get(id); + if (!surface) return false; + this.surfaces.delete(id); + this.comments = this.comments.filter((c) => c.surfaceId !== id); await this.persist(); return true; } @@ -208,7 +269,7 @@ export class JsonFileStore implements Store { return this.comments.filter( (c) => (query.sessionId === undefined || c.sessionId === query.sessionId) && - (query.snippetId === undefined || c.snippetId === query.snippetId) && + (query.surfaceId === undefined || c.surfaceId === query.surfaceId) && (query.afterSeq === undefined || c.seq > query.afterSeq), ); } @@ -216,13 +277,13 @@ export class JsonFileStore implements Store { async createComment(input: CreateCommentInput) { await this.load(); if (!this.sessions.has(input.sessionId)) return null; - const snippet = input.snippetId ? this.snippets.get(input.snippetId) : null; + const surface = input.surfaceId ? this.surfaces.get(input.surfaceId) : null; const comment: Comment = { id: newId(), seq: ++this.lastSeq, sessionId: input.sessionId, - snippetId: snippet?.id ?? null, - snippetTitle: snippet?.title ?? null, + surfaceId: surface?.id ?? null, + surfaceTitle: surface?.title ?? null, author: input.author.trim() || "user", text: input.text, createdAt: new Date().toISOString(), diff --git a/server/snippetPage.ts b/server/surfacePage.ts similarity index 97% rename from server/snippetPage.ts rename to server/surfacePage.ts index 58447fd..268e81e 100644 --- a/server/snippetPage.ts +++ b/server/surfacePage.ts @@ -1,6 +1,4 @@ -import type { Snippet } from "./types.ts"; - -// Origins snippets may load external resources from. Mirrors the allowlist +// Origins html parts may load external resources from. Mirrors the allowlist // agents already know from Claude's inline widget surface. export const CDN_ALLOWLIST = [ "https://cdnjs.cloudflare.com", @@ -210,19 +208,20 @@ if (window.ResizeObserver) { const escapeHtml = (s: string) => s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); -export function renderSnippetPage(snippet: Snippet): string { +// Wrap one html part in the themed, sandboxed document the iframe loads. +export function renderHtmlPage(doc: { title: string; html: string }): string { return ` -${escapeHtml(snippet.title)} +${escapeHtml(doc.title)} ${SVG_DEFS} -${snippet.html} +${doc.html} `; diff --git a/server/types.ts b/server/types.ts index 28f5836..3b0211c 100644 --- a/server/types.ts +++ b/server/types.ts @@ -12,30 +12,61 @@ export interface Session { agentSeq: number; } -export interface SnippetVersion { +// A surface is an ordered list of parts. Each part declares its own kind; +// the surface itself is kind-agnostic. An `html` part is arbitrary agent +// markup (rendered sandboxed in an iframe); a `diff` part is structured data +// (a patch) rendered by the trusted viewer. A snippet is just a surface with +// one html part; a diagram-with-its-diff is `[html, diff]` in one card. +export type SurfacePartKind = "html" | "diff"; + +export interface HtmlPart { + kind: "html"; + html: string; +} + +export interface DiffFile { + filename: string; + before: string; + after: string; + // Shiki language id; inferred from the filename when omitted. + language?: string; +} + +export interface DiffPart { + kind: "diff"; + // A unified/git patch (may span multiple files) and/or explicit before/after + // file pairs. At least one must be present; the viewer prefers `patch`. + patch?: string; + files?: DiffFile[]; + layout?: "unified" | "split"; +} + +export type SurfacePart = HtmlPart | DiffPart; + +export interface SurfaceVersion { version: number; title: string; - html: string; + parts: SurfacePart[]; at: string; } -export interface Snippet { +export interface Surface { id: string; sessionId: string; title: string; - html: string; + parts: SurfacePart[]; createdAt: string; updatedAt: string; version: number; - history: SnippetVersion[]; + history: SurfaceVersion[]; } export interface Comment { id: string; seq: number; sessionId: string; - snippetId: string | null; - snippetTitle: string | null; + surfaceId: string | null; + surfaceTitle: string | null; author: string; text: string; createdAt: string; @@ -47,27 +78,27 @@ export interface CreateSessionInput { cwd?: string; } -export interface CreateSnippetInput { +export interface CreateSurfaceInput { sessionId: string; title?: string; - html: string; + parts: SurfacePart[]; } -export interface UpdateSnippetInput { +export interface UpdateSurfaceInput { title?: string; - html?: string; + parts?: SurfacePart[]; } export interface CreateCommentInput { sessionId: string; - snippetId?: string; + surfaceId?: string; author: string; text: string; } export interface CommentQuery { sessionId?: string; - snippetId?: string; + surfaceId?: string; afterSeq?: number; } @@ -82,11 +113,11 @@ export interface Store { // Advance the delivered-to-agent comment cursor (never moves backwards). markAgentSeen(sessionId: string, seq: number): Promise; - listSnippets(sessionId?: string): Promise; - getSnippet(id: string): Promise; - createSnippet(input: CreateSnippetInput): Promise; - updateSnippet(id: string, patch: UpdateSnippetInput): Promise; - removeSnippet(id: string): Promise; + listSurfaces(sessionId?: string): Promise; + getSurface(id: string): Promise; + createSurface(input: CreateSurfaceInput): Promise; + updateSurface(id: string, patch: UpdateSurfaceInput): Promise; + removeSurface(id: string): Promise; listComments(query: CommentQuery): Promise; createComment(input: CreateCommentInput): Promise; @@ -95,3 +126,26 @@ export interface Store { export const HISTORY_LIMIT = 20; export const newId = () => crypto.randomUUID().split("-")[0]; + +// A snippet is sugar for a single html part; this bridges the legacy +// `{ html }` shape (CLI `publish`, `POST /api/snippets`) to the parts model. +export const htmlPart = (html: string): HtmlPart => ({ kind: "html", html }); + +// The combined byte weight of a surface's parts, for size limits. +export function partsByteLength(parts: SurfacePart[]): number { + let n = 0; + for (const p of parts) { + if (p.kind === "html") n += p.html.length; + else { + n += p.patch?.length ?? 0; + for (const f of p.files ?? []) n += f.before.length + f.after.length; + } + } + return n; +} + +// First html part — the back-compat view used by the legacy snippet routes. +export const firstHtml = (parts: SurfacePart[]): string => { + const p = parts.find((p): p is HtmlPart => p.kind === "html"); + return p ? p.html : ""; +}; diff --git a/skills/sideshow/SKILL.md b/skills/sideshow/SKILL.md index cd2ebcb..3050599 100644 --- a/skills/sideshow/SKILL.md +++ b/skills/sideshow/SKILL.md @@ -1,15 +1,28 @@ --- name: sideshow -description: Draw live HTML previews to the user's sideshow surface — diagrams, UI sketches, data visualizations, interactive explainers — and receive their comments back. Use when the user asks you to illustrate, visualize, sketch, or draw something, mentions sideshow, or when a visual would explain your work better than text. +description: Draw live previews to the user's sideshow surface — diagrams, UI sketches, data visualizations, interactive explainers, code reviews — and receive their comments back. Use when the user asks you to illustrate, visualize, sketch, draw, or review a diff, mentions sideshow, or when a visual would explain your work better than text. --- # sideshow -The user keeps a sideshow surface open in their browser. You publish HTML -snippets to it; they appear instantly. The user can comment on any snippet +The user keeps a sideshow surface open in their browser. You publish surfaces +to it; they appear instantly as cards. The user can comment on any surface and you can pick up those comments from the terminal — it is a two-way surface, not a fire-and-forget renderer. +## Surfaces and parts + +A surface is a card built from ordered **parts**, each with a `kind`: + +- **`html`** — markup you write, rendered in a sandboxed iframe. Reach for it to + draw: diagrams, UI sketches, data viz, explainers. +- **`diff`** — a patch you send as _data_, rendered natively by the trusted + viewer as a syntax-highlighted code review. Reach for it to show a changeset. + +A surface can combine parts — `[html, diff]` is a diagram with its code review +in one card. html parts are sandboxed (you author the markup); diff parts are +rendered from patch data — send a patch, not markup. + ## Before your first publish Fetch the design contract once per session (fragment rules, theme CSS @@ -26,16 +39,20 @@ is not running, start it: `sideshow serve` (or `npx sideshow serve`). If the ## Publishing -Prefer MCP tools if the sideshow MCP server is connected -(`publish_snippet`, `update_snippet`, `wait_for_feedback`, `reply_to_user`). +Prefer MCP tools if the sideshow MCP server is connected: `publish_surface` +`{title, parts, sessionTitle?}`, `update_surface` `{id, title?, parts?}`, +`wait_for_feedback`, `reply_to_user` `{surfaceId, message}`, `list_surfaces`. +(`publish_snippet` / `update_snippet` remain as html-only sugar aliases.) Otherwise use the CLI — session grouping is automatic: ```sh sideshow publish sketch.html --title "Cache layout" --agent your-name --session-title "Cache redesign" echo '

...

' | sideshow publish - --title "Quick note" +sideshow diff change.patch --title "Add retry" --layout split # standalone diff surface +sideshow publish sketch.html --diff change.patch --title "Retry flow" # combined [html, diff] ``` -Save the returned `sessionId` and snippet `id`; all feedback handling depends +Save the returned `sessionId` and surface `id`; all feedback handling depends on watching the exact session you published to. Rules of thumb: @@ -44,13 +61,13 @@ Rules of thumb: refactor"), not the tool — `--session-title` on the CLI, `sessionTitle` on the MCP tool. It applies only when the session is created; never try to retitle later (the user may have renamed it in the viewer). -- One concept per snippet, with a clear title. A series of small snippets +- One concept per surface, with a clear title. A series of small surfaces beats one giant page. - **Iterate with `sideshow update `** (same card, new version) instead of publishing near-duplicates. Versions are kept; the user can flip between them. -- Use the built-in kit from the guide (pre-styled form elements, SVG utility - classes) before writing CSS; for anything else use the theme CSS variables - so snippets work in dark mode. +- For html parts, use the built-in kit from the guide (pre-styled form elements, + SVG utility classes) before writing CSS; for anything else use the theme CSS + variables so surfaces work in dark mode. ## The feedback loop @@ -92,9 +109,10 @@ Feedback reaches you four ways — prefer them in this order: continuing: `sideshow wait --session --timeout 120` in the foreground. +Comments attach to a surface (`surfaceId`); behavior is otherwise unchanged. When comments arrive, acknowledge briefly with -`sideshow comment "..." --snippet ` when useful; do substantial changes as -snippet updates, then re-arm the watcher or continue checkpoint-draining. +`sideshow comment "..." --surface ` when useful; do substantial changes as +surface updates, then re-arm the watcher or continue checkpoint-draining. ## Remote surfaces diff --git a/test/api.test.ts b/test/api.test.ts index 4f4a83a..abd205f 100644 --- a/test/api.test.ts +++ b/test/api.test.ts @@ -40,7 +40,7 @@ test("publish without session auto-creates one", async () => { const sessions = (await (await app.request("/api/sessions")).json()) as any; assert.equal(sessions.length, 1); assert.equal(sessions[0].agent, "pi"); - assert.equal(sessions[0].snippetCount, 1); + assert.equal(sessions[0].surfaceCount, 1); }); test("publish into an existing session groups snippets", async () => { @@ -96,6 +96,59 @@ test("publish into unknown session 404s instead of silently creating", async () assert.equal(res.status, 404); }); +test("publishes a combined html+diff surface; /s renders the html part only", async () => { + const app = makeApp(); + const res = await app.request( + "/api/surfaces", + json({ + title: "Review", + parts: [ + { kind: "html", html: "

diagram

" }, + { kind: "diff", patch: "@@ -1 +1 @@\n-a\n+b", layout: "split" }, + ], + }), + ); + assert.equal(res.status, 201); + const surface = (await res.json()) as any; + // the write response is lean — kinds, no part bodies echoed back + assert.deepEqual(surface.kinds, ["html", "diff"]); + assert.equal(surface.parts, undefined); + + // the full record keeps the html and the diff patch + const full = (await (await app.request(`/api/surfaces/${surface.id}`)).json()) as any; + assert.equal(full.parts.length, 2); + assert.equal(full.parts[0].html, "

diagram

"); + assert.equal(full.parts[1].patch, "@@ -1 +1 @@\n-a\n+b"); + + // /s renders the requested html part; a diff part has no html doc + const part0 = await app.request(`/s/${surface.id}?part=0`); + assert.ok((await part0.text()).includes("

diagram

")); + assert.equal((await app.request(`/s/${surface.id}?part=1`)).status, 404); +}); + +test("publish_surface MCP tool round-trips a diff part", async () => { + const app = makeApp(); + const list = (await (await app.request("/mcp", mcpCall(1, "tools/list"))).json()) as any; + const names = list.result.tools.map((t: any) => t.name); + assert.ok(names.includes("publish_surface")); + assert.ok(names.includes("publish_snippet")); // alias still advertised + + const published = (await ( + await app.request( + "/mcp", + mcpCall(2, "tools/call", { + name: "publish_surface", + arguments: { title: "Diff", parts: [{ kind: "diff", patch: "@@ -1 +1 @@\n-x\n+y" }] }, + }), + ) + ).json()) as any; + const payload = JSON.parse(published.result.content[0].text); + assert.ok(payload.id && payload.sessionId); + const full = (await (await app.request(`/api/surfaces/${payload.id}`)).json()) as any; + assert.equal(full.parts[0].kind, "diff"); + assert.equal(full.parts[0].patch, "@@ -1 +1 @@\n-x\n+y"); +}); + test("update bumps version and keeps history; old version renderable", async () => { const app = makeApp(); const s = (await ( @@ -110,7 +163,7 @@ test("update bumps version and keeps history; old version renderable", async () const full = (await (await app.request(`/api/snippets/${s.id}`)).json()) as any; assert.equal(full.history.length, 1); - assert.equal(full.history[0].html, "

v1

"); + assert.equal(full.history[0].parts[0].html, "

v1

"); const current = await (await app.request(`/s/${s.id}`)).text(); assert.ok(current.includes("

v2

")); @@ -142,7 +195,7 @@ test("comments attach to snippets and filter by author/after", async () => { const all = (await (await app.request(`/api/comments?session=${s.sessionId}`)).json()) as any; assert.equal(all.comments.length, 2); - assert.equal(all.comments[0].snippetTitle, "Sketch"); + assert.equal(all.comments[0].surfaceTitle, "Sketch"); // explicit after=0: re-read from the start regardless of the agent cursor const users = (await ( @@ -450,7 +503,7 @@ test("agent writes piggyback unseen user comments, delivered once", async () => updated.userFeedback.map((f: any) => f.text), ["wrong color", "also add a key"], ); - assert.equal(updated.userFeedback[0].snippetTitle, "Doc"); + assert.equal(updated.userFeedback[0].surfaceTitle, "Doc"); // delivered once — the next write is clean const again = (await ( diff --git a/test/cli.test.ts b/test/cli.test.ts index 20fb2b6..8a6c0d6 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -17,7 +17,7 @@ function run(...args: string[]) { // None of these reach the network: --help and option errors resolve in // parsing, before any request (no server needs to be running). -for (const cmd of ["serve", "publish", "update", "wait", "comment", "list"]) { +for (const cmd of ["serve", "publish", "diff", "update", "wait", "comment", "list"]) { test(`${cmd} --help prints usage and exits 0`, async () => { const { code, stdout, stderr } = await run(cmd, "--help"); assert.equal(code, 0); diff --git a/test/jsonFileStore.test.ts b/test/jsonFileStore.test.ts index 5c24e62..6e9c459 100644 --- a/test/jsonFileStore.test.ts +++ b/test/jsonFileStore.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; import { JsonFileStore } from "../server/storage.ts"; +import { htmlPart } from "../server/types.ts"; import { runStoreContract } from "./storeContract.ts"; const freshPath = () => join(mkdtempSync(join(tmpdir(), "sideshow-store-")), "data.json"); @@ -14,11 +15,14 @@ test("JsonFileStore: data survives a reload from disk", async () => { const path = freshPath(); const store = new JsonFileStore(path); const session = await store.createSession({ agent: "pi", title: "Persisted" }); - const snippet = await store.createSnippet({ sessionId: session.id, html: "

x

" }); - await store.updateSnippet(snippet?.id ?? "", { html: "

v2

" }); + const surface = await store.createSurface({ + sessionId: session.id, + parts: [htmlPart("

x

")], + }); + await store.updateSurface(surface?.id ?? "", { parts: [htmlPart("

v2

")] }); await store.createComment({ sessionId: session.id, - snippetId: snippet?.id, + surfaceId: surface?.id, author: "user", text: "hi", }); @@ -28,7 +32,7 @@ test("JsonFileStore: data survives a reload from disk", async () => { const reloaded = new JsonFileStore(path); assert.equal((await reloaded.getSession(session.id))?.title, "Persisted"); assert.equal((await reloaded.getSession(session.id))?.agentSeq, 1); - const got = await reloaded.getSnippet(snippet?.id ?? ""); + const got = await reloaded.getSurface(surface?.id ?? ""); assert.equal(got?.version, 2); assert.equal(got?.history.length, 1); const comments = await reloaded.listComments({}); diff --git a/test/storeContract.ts b/test/storeContract.ts index a22cd4c..077a0b4 100644 --- a/test/storeContract.ts +++ b/test/storeContract.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { HISTORY_LIMIT, type Store } from "../server/types.ts"; +import { HISTORY_LIMIT, htmlPart, type Store } from "../server/types.ts"; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -52,7 +52,7 @@ export function runStoreContract(name: string, makeStore: () => Store | Promise< // publishing into the older session bumps it to the front await sleep(10); - await store.createSnippet({ sessionId: a.id, html: "

x

" }); + await store.createSurface({ sessionId: a.id, parts: [htmlPart("

x

")] }); assert.deepEqual( (await store.listSessions()).map((s) => s.id), [a.id, b.id], @@ -91,115 +91,145 @@ export function runStoreContract(name: string, makeStore: () => Store | Promise< assert.equal(await store.removeSession(session.id), false); }); - // --- snippets --- + // --- surfaces --- - contract("creates snippets with defaults; unknown session is null", async (store) => { - assert.equal(await store.createSnippet({ sessionId: "missing", html: "

x

" }), null); + contract("creates surfaces with defaults; unknown session is null", async (store) => { + assert.equal( + await store.createSurface({ sessionId: "missing", parts: [htmlPart("

x

")] }), + null, + ); const session = await store.createSession({ agent: "pi" }); - const snippet = await store.createSnippet({ sessionId: session.id, html: "

x

" }); - assert.ok(snippet); - assert.equal(snippet.title, "Untitled"); - assert.equal(snippet.version, 1); - assert.deepEqual(snippet.history, []); - assert.equal(snippet.updatedAt, snippet.createdAt); - - const titled = await store.createSnippet({ + const surface = await store.createSurface({ + sessionId: session.id, + parts: [htmlPart("

x

")], + }); + assert.ok(surface); + assert.equal(surface.title, "Untitled"); + assert.equal(surface.version, 1); + assert.deepEqual(surface.parts, [htmlPart("

x

")]); + assert.deepEqual(surface.history, []); + assert.equal(surface.updatedAt, surface.createdAt); + + const titled = await store.createSurface({ sessionId: session.id, title: " Sketch ", - html: "

y

", + parts: [htmlPart("

y

")], }); assert.equal(titled?.title, "Sketch"); - assert.deepEqual(await store.getSnippet(snippet.id), snippet); - assert.equal(await store.getSnippet("missing"), null); + assert.deepEqual(await store.getSurface(surface.id), surface); + assert.equal(await store.getSurface("missing"), null); + }); + + contract("supports multi-part surfaces (html + diff)", async (store) => { + const session = await store.createSession({ agent: "pi" }); + const surface = await store.createSurface({ + sessionId: session.id, + parts: [htmlPart(""), { kind: "diff", patch: "@@ -1 +1 @@", layout: "split" }], + }); + assert.ok(surface); + assert.equal(surface.parts.length, 2); + assert.deepEqual(await store.getSurface(surface.id), surface); }); - contract("lists snippets oldest first, optionally filtered by session", async (store) => { + contract("lists surfaces oldest first, optionally filtered by session", async (store) => { const one = await store.createSession({ agent: "a" }); const two = await store.createSession({ agent: "b" }); - const s1 = await store.createSnippet({ sessionId: one.id, html: "

1

" }); + const s1 = await store.createSurface({ sessionId: one.id, parts: [htmlPart("

1

")] }); await sleep(10); - const s2 = await store.createSnippet({ sessionId: two.id, html: "

2

" }); + const s2 = await store.createSurface({ sessionId: two.id, parts: [htmlPart("

2

")] }); await sleep(10); - const s3 = await store.createSnippet({ sessionId: one.id, html: "

3

" }); + const s3 = await store.createSurface({ sessionId: one.id, parts: [htmlPart("

3

")] }); assert.deepEqual( - (await store.listSnippets()).map((s) => s.id), + (await store.listSurfaces()).map((s) => s.id), [s1?.id, s2?.id, s3?.id], ); assert.deepEqual( - (await store.listSnippets(one.id)).map((s) => s.id), + (await store.listSurfaces(one.id)).map((s) => s.id), [s1?.id, s3?.id], ); - assert.deepEqual(await store.listSnippets("missing"), []); + assert.deepEqual(await store.listSurfaces("missing"), []); }); contract("updates bump the version and archive the previous one", async (store) => { const session = await store.createSession({ agent: "pi" }); - const snippet = await store.createSnippet({ + const surface = await store.createSurface({ sessionId: session.id, title: "T", - html: "

v1

", + parts: [htmlPart("

v1

")], }); - assert.ok(snippet); - // JsonFileStore mutates the object it returned from createSnippet, so + assert.ok(surface); + // JsonFileStore mutates the object it returned from createSurface, so // capture the pre-update timestamp now - const v1UpdatedAt = snippet.updatedAt; + const v1UpdatedAt = surface.updatedAt; - const updated = await store.updateSnippet(snippet.id, { html: "

v2

" }); + const updated = await store.updateSurface(surface.id, { parts: [htmlPart("

v2

")] }); assert.equal(updated?.version, 2); - assert.equal(updated?.html, "

v2

"); + assert.deepEqual(updated?.parts, [htmlPart("

v2

")]); assert.equal(updated?.title, "T"); assert.equal(updated?.history.length, 1); assert.deepEqual(updated?.history[0], { version: 1, title: "T", - html: "

v1

", + parts: [htmlPart("

v1

")], at: v1UpdatedAt, }); - // title-only patch keeps html; blank title keeps the old title - const retitled = await store.updateSnippet(snippet.id, { title: "T2" }); + // title-only patch keeps parts; blank title keeps the old title + const retitled = await store.updateSurface(surface.id, { title: "T2" }); assert.equal(retitled?.title, "T2"); - assert.equal(retitled?.html, "

v2

"); - const blank = await store.updateSnippet(snippet.id, { title: " ", html: "

v4

" }); + assert.deepEqual(retitled?.parts, [htmlPart("

v2

")]); + const blank = await store.updateSurface(surface.id, { + title: " ", + parts: [htmlPart("

v4

")], + }); assert.equal(blank?.title, "T2"); assert.equal(blank?.version, 4); // the same state is visible on a fresh read - assert.deepEqual(await store.getSnippet(snippet.id), blank); + assert.deepEqual(await store.getSurface(surface.id), blank); - assert.equal(await store.updateSnippet("missing", { html: "

x

" }), null); + assert.equal(await store.updateSurface("missing", { parts: [htmlPart("

x

")] }), null); }); contract(`caps history at ${HISTORY_LIMIT} versions`, async (store) => { const session = await store.createSession({ agent: "pi" }); - const snippet = await store.createSnippet({ sessionId: session.id, html: "

v1

" }); - assert.ok(snippet); + const surface = await store.createSurface({ + sessionId: session.id, + parts: [htmlPart("

v1

")], + }); + assert.ok(surface); const updates = HISTORY_LIMIT + 5; for (let i = 2; i <= updates + 1; i++) { - await store.updateSnippet(snippet.id, { html: `

v${i}

` }); + await store.updateSurface(surface.id, { parts: [htmlPart(`

v${i}

`)] }); } - const final = await store.getSnippet(snippet.id); + const final = await store.getSurface(surface.id); assert.equal(final?.version, updates + 1); assert.equal(final?.history.length, HISTORY_LIMIT); // oldest entries fell off the front; the newest archived version remains assert.equal(final?.history[0].version, updates + 1 - HISTORY_LIMIT); assert.equal(final?.history[HISTORY_LIMIT - 1].version, updates); - assert.equal(final?.history[HISTORY_LIMIT - 1].html, `

v${updates}

`); + assert.deepEqual(final?.history[HISTORY_LIMIT - 1].parts, [htmlPart(`

v${updates}

`)]); }); // --- cascade deletes --- - contract("removing a session cascades to its snippets and comments", async (store) => { + contract("removing a session cascades to its surfaces and comments", async (store) => { const doomed = await store.createSession({ agent: "a" }); const kept = await store.createSession({ agent: "b" }); - const doomedSnippet = await store.createSnippet({ sessionId: doomed.id, html: "

x

" }); - const keptSnippet = await store.createSnippet({ sessionId: kept.id, html: "

y

" }); + const doomedSurface = await store.createSurface({ + sessionId: doomed.id, + parts: [htmlPart("

x

")], + }); + const keptSurface = await store.createSurface({ + sessionId: kept.id, + parts: [htmlPart("

y

")], + }); await store.createComment({ sessionId: doomed.id, - snippetId: doomedSnippet?.id, + surfaceId: doomedSurface?.id, author: "user", text: "bye", }); @@ -207,36 +237,42 @@ export function runStoreContract(name: string, makeStore: () => Store | Promise< assert.equal(await store.removeSession(doomed.id), true); assert.equal(await store.getSession(doomed.id), null); - assert.equal(await store.getSnippet(doomedSnippet?.id ?? ""), null); + assert.equal(await store.getSurface(doomedSurface?.id ?? ""), null); assert.deepEqual( - (await store.listSnippets()).map((s) => s.id), - [keptSnippet?.id], + (await store.listSurfaces()).map((s) => s.id), + [keptSurface?.id], ); const comments = await store.listComments({}); assert.equal(comments.length, 1); assert.equal(comments[0].text, "stay"); }); - contract("removing a snippet cascades to its comments only", async (store) => { + contract("removing a surface cascades to its comments only", async (store) => { const session = await store.createSession({ agent: "pi" }); - const doomed = await store.createSnippet({ sessionId: session.id, html: "

x

" }); - const kept = await store.createSnippet({ sessionId: session.id, html: "

y

" }); + const doomed = await store.createSurface({ + sessionId: session.id, + parts: [htmlPart("

x

")], + }); + const kept = await store.createSurface({ + sessionId: session.id, + parts: [htmlPart("

y

")], + }); await store.createComment({ sessionId: session.id, - snippetId: doomed?.id, + surfaceId: doomed?.id, author: "user", text: "on doomed", }); await store.createComment({ sessionId: session.id, - snippetId: kept?.id, + surfaceId: kept?.id, author: "user", text: "on kept", }); await store.createComment({ sessionId: session.id, author: "user", text: "on session" }); - assert.equal(await store.removeSnippet(doomed?.id ?? ""), true); - assert.equal(await store.removeSnippet(doomed?.id ?? ""), false); + assert.equal(await store.removeSurface(doomed?.id ?? ""), true); + assert.equal(await store.removeSurface(doomed?.id ?? ""), false); assert.ok(await store.getSession(session.id)); const texts = (await store.listComments({})).map((c) => c.text); assert.deepEqual(texts.sort(), ["on kept", "on session"]); @@ -251,37 +287,37 @@ export function runStoreContract(name: string, makeStore: () => Store | Promise< ); const session = await store.createSession({ agent: "pi" }); - const snippet = await store.createSnippet({ + const surface = await store.createSurface({ sessionId: session.id, title: "Sketch", - html: "

x

", + parts: [htmlPart("

x

")], }); - const onSnippet = await store.createComment({ + const onSurface = await store.createComment({ sessionId: session.id, - snippetId: snippet?.id, + surfaceId: surface?.id, author: " user ", text: "love it", }); - assert.equal(onSnippet?.author, "user"); - assert.equal(onSnippet?.snippetId, snippet?.id); - assert.equal(onSnippet?.snippetTitle, "Sketch"); + assert.equal(onSurface?.author, "user"); + assert.equal(onSurface?.surfaceId, surface?.id); + assert.equal(onSurface?.surfaceTitle, "Sketch"); - // a session-level comment, and one pointing at a snippet that doesn't exist + // a session-level comment, and one pointing at a surface that doesn't exist const onSession = await store.createComment({ sessionId: session.id, author: "", text: "general", }); - assert.equal(onSession?.snippetId, null); - assert.equal(onSession?.snippetTitle, null); + assert.equal(onSession?.surfaceId, null); + assert.equal(onSession?.surfaceTitle, null); assert.equal(onSession?.author, "user"); const ghost = await store.createComment({ sessionId: session.id, - snippetId: "missing", + surfaceId: "missing", author: "user", text: "ghost", }); - assert.equal(ghost?.snippetId, null); + assert.equal(ghost?.surfaceId, null); }); contract("comment seq is strictly monotonic, even across deletes", async (store) => { @@ -299,13 +335,13 @@ export function runStoreContract(name: string, makeStore: () => Store | Promise< assert.ok(c3.seq > c2.seq); }); - contract("filters comments by session, snippet, and afterSeq", async (store) => { + contract("filters comments by session, surface, and afterSeq", async (store) => { const one = await store.createSession({ agent: "a" }); const two = await store.createSession({ agent: "b" }); - const snippet = await store.createSnippet({ sessionId: one.id, html: "

x

" }); + const surface = await store.createSurface({ sessionId: one.id, parts: [htmlPart("

x

")] }); const a = await store.createComment({ sessionId: one.id, - snippetId: snippet?.id, + surfaceId: surface?.id, author: "user", text: "a", }); @@ -330,7 +366,7 @@ export function runStoreContract(name: string, makeStore: () => Store | Promise< ["a", "b"], ); assert.deepEqual( - (await store.listComments({ snippetId: snippet?.id ?? "" })).map((x) => x.text), + (await store.listComments({ surfaceId: surface?.id ?? "" })).map((x) => x.text), ["a"], ); assert.deepEqual( diff --git a/viewer/src/App.tsx b/viewer/src/App.tsx index fbfffe3..612b1fa 100644 --- a/viewer/src/App.tsx +++ b/viewer/src/App.tsx @@ -1,6 +1,6 @@ import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js"; import { api, relTime, sessionLabel, type SessionRow } from "./api.ts"; -import { Card, cardEls, SessionThread } from "./Card.tsx"; +import { Card, cardEls, frameForSource, SessionThread } from "./Card.tsx"; import { renderNotes } from "./notes.ts"; import { checkVersion, @@ -19,8 +19,8 @@ import { setNavOpen, setPillTarget, setUnread, - snippets, streamLoading, + surfaces, toast, toastShow, toastText, @@ -132,7 +132,7 @@ export default function App() { setPillTarget(null); }} > - new snippet ↓ + new surface ↓ ); @@ -196,7 +196,7 @@ function WhatsNewCard() { ); } -// Messages from sandboxed snippet iframes (see server/snippetPage.ts bridge). +// Messages from sandboxed surface iframes (see server/surfacePage.ts bridge). async function onBridgeMessage(ev: MessageEvent) { const d = ev.data as { __sideshow?: boolean; @@ -207,27 +207,21 @@ async function onBridgeMessage(ev: MessageEvent) { key?: string; } | null; if (!d || !d.__sideshow) return; - // A snippet iframe forwarded the session-switch shortcut because focus was - // inside it (see server/snippetPage.ts). Mirror the parent keydown handler. + // A surface iframe forwarded the session-switch shortcut because focus was + // inside it (see server/surfacePage.ts). Mirror the parent keydown handler. if (d.type === "switch-session") { void selectAdjacent(d.key === "ArrowUp" ? -1 : 1); return; } - let sourceId: string | null = null; - let sourceFrame: HTMLIFrameElement | null = null; - for (const [id, { iframe }] of cardEls) { - if (iframe.contentWindow === ev.source) { - sourceId = id; - sourceFrame = iframe; - break; - } - } - if (d.type === "resize" && sourceFrame) { - sourceFrame.style.height = Math.min(Math.max(Number(d.height), 48), 2200) + "px"; - } else if (d.type === "send-prompt" && sourceId) { + // Resolve the source surface + iframe by contentWindow — a surface may own + // several html-part iframes, so resize must target the exact one. + const src = frameForSource(ev.source); + if (d.type === "resize" && src) { + src.iframe.style.height = Math.min(Math.max(Number(d.height), 48), 2200) + "px"; + } else if (d.type === "send-prompt" && src) { await api("/api/comments", { method: "POST", - body: JSON.stringify({ snippet: sourceId, text: String(d.text), author: "user" }), + body: JSON.stringify({ surface: src.id, text: String(d.text), author: "user" }), }); toast("Sent to agent: " + d.text); } else if (d.type === "open-link") { @@ -258,8 +252,8 @@ function SessionItem(props: { session: SessionRow }) { >
{label()}
- {props.session.agent} · {props.session.snippetCount} snippet - {props.session.snippetCount === 1 ? "" : "s"} · {relTime(props.session.lastActiveAt)} + {props.session.agent} · {props.session.surfaceCount} surface + {props.session.surfaceCount === 1 ? "" : "s"} · {relTime(props.session.lastActiveAt)}