diff --git a/.changeset/per-surface-operations.md b/.changeset/per-surface-operations.md new file mode 100644 index 0000000..7b1430d --- /dev/null +++ b/.changeset/per-surface-operations.md @@ -0,0 +1,51 @@ +--- +"sideshow": minor +--- + +Per-surface operations across CLI, HTTP API, and MCP. Surfaces now carry +stable server-assigned ids for targeted operations. + +**CLI** + +- `sideshow publish` now honors flag order: surfaces appear in the order + their `--md` / `--code` / `--diff` / etc. flags appear on the command line, + not a fixed sequence (fixes #158). +- `sideshow update --surface N` targets a specific surface in a + multi-surface post (by id or 0-based index) for content-only edits. +- New `sideshow surface` subcommand: + - `surface add [--md f] [--code f] ...` — append surfaces to an + existing post (flag order honored). + - `surface remove ` — remove a single surface. + - `surface edit ` — replace a surface's content. + - `surface move --to M` — reorder a surface. + +**HTTP API** + +- `POST /api/posts/:id/surfaces` — append a surface (optional `before`/`after` + for insert position). +- `PATCH /api/posts/:id/surfaces/:target` — replace a surface (full or + content-only). `:target` is a surface id or 0-based index. +- `DELETE /api/posts/:id/surfaces/:target` — remove a surface (400 if last). +- `PATCH /api/posts/:id/surfaces` — reorder surfaces. Body: `{order: [id, ...]}` + or `{order: [2, 0, 1]}`. +- `PATCH /api/posts/:id` extended: optional `surface` param targets a specific + surface in multi-surface posts (previously rejected with 400). + +**MCP** + +- New tools: `add_surface`, `edit_surface`, `remove_surface`, + `reorder_surfaces` — all additive; `update_post` full-replace stays for + back-compat. Available on both stdio and HTTP MCP transports. + +**Data model** + +- Every surface now carries an optional `id: string`, assigned server-side on + create/update. Existing data is migrated automatically (one-time migration + on first boot, gated on a settings sentinel for SqlStore; in-memory + normalization on load for JsonFileStore). + +**Viewer** + +- Surfaces are keyed by stable `id` (Solid `` with `reconcile({ key: "id" +})`) instead of array position, so reordering moves DOM nodes instead of + re-creating them. diff --git a/bin/sideshow.js b/bin/sideshow.js index fceb0e4..e41a364 100755 --- a/bin/sideshow.js +++ b/bin/sideshow.js @@ -75,6 +75,22 @@ usage: sideshow update revise a post (new version, same card) --title replace title --kit opt the html surface into a kit (repeatable) + --surface target surface N (id or 0-based index) in a multi-surface post + sideshow surface [options] edit individual surfaces of a post + surface add [flags] append a surface to an existing post + --md markdown surface + --code code surface (language inferred from filename) + --diff diff surface from a patch + --terminal terminal surface + --mermaid mermaid surface + --json json surface + --image image surface (uploads the file first) + --layout split split layout for --diff surfaces + --before insert before surface N (id or index) + --after insert after surface N (id or index) + surface remove remove surface N (id or 0-based index) + surface edit replace surface N's content (kind preserved) + surface move --to move surface N to position M sideshow wait [options] block until the user comments (long-poll) --session session to watch (default: auto) --timeout max seconds to wait (default 120) @@ -799,7 +815,12 @@ const commands = { }, async publish() { - const { values: flags, positionals } = parse({ + const { + values: flags, + positionals, + tokens, + } = parse({ + tokens: true, allowPositionals: true, options: { title: { type: "string" }, @@ -821,44 +842,60 @@ const commands = { const htmlPart = { kind: "html", html: readContent(positionals[0]) }; const kits = normalizeKits(flags.kit); if (kits) htmlPart.kits = kits; - const parts = [htmlPart]; - if (flags.md !== undefined) { - parts.push({ kind: "markdown", markdown: readContent(flags.md || "-") }); - } - if (flags.mermaid !== undefined) { - parts.push({ kind: "mermaid", mermaid: readContent(flags.mermaid || "-") }); - } - if (flags.diff !== undefined) { - parts.push({ - kind: "diff", - patch: readContent(flags.diff || "-"), - ...(flags.layout === "split" && { layout: "split" }), - }); - } - if (flags.terminal !== undefined) { - parts.push({ kind: "terminal", text: readContent(flags.terminal || "-") }); - } - if (flags.json !== undefined) { - const text = readContent(flags.json || "-"); - try { - parts.push({ kind: "json", data: JSON.parse(text) }); - } catch { - fail(`--json: invalid JSON${flags.json ? ` in ${flags.json}` : ""}`); + // Surfaces render top-to-bottom, so order is user-visible. Walk the + // parseArgs tokens (which preserve command-line order) and append each + // surface flag the first time it appears, instead of a fixed if-ladder. + const SURFACE_FLAGS = new Map([ + ["md", "markdown"], + ["mermaid", "mermaid"], + ["diff", "diff"], + ["terminal", "terminal"], + ["json", "json"], + ["code", "code"], + ["image", "image"], + ]); + const orderedKinds = []; + const seen = new Set(); + for (const t of tokens ?? []) { + if (t.kind === "option" && SURFACE_FLAGS.has(t.name) && !seen.has(t.name)) { + seen.add(t.name); + orderedKinds.push(SURFACE_FLAGS.get(t.name)); } } - if (flags.code !== undefined) { - const codeFile = flags.code || "-"; - const part = { kind: "code", code: readContent(codeFile) }; - const codeLang = codeFile !== "-" ? inferLang(codeFile) : undefined; - if (codeLang) part.language = codeLang; - if (codeFile !== "-") part.title = codeFile.split("/").pop() || codeFile; - parts.push(part); - } - // Resolve the session first so the image upload and the post share it. + // Resolve the session first so image uploads and the post share it. const session = await resolveSession(flags, { create: true }); - if (flags.image !== undefined) { - const asset = await uploadFile(flags.image, { session, kind: "image" }); - parts.push({ kind: "image", assetId: asset.id }); + const parts = [htmlPart]; + for (const kind of orderedKinds) { + if (kind === "markdown") { + parts.push({ kind: "markdown", markdown: readContent(flags.md || "-") }); + } else if (kind === "mermaid") { + parts.push({ kind: "mermaid", mermaid: readContent(flags.mermaid || "-") }); + } else if (kind === "diff") { + parts.push({ + kind: "diff", + patch: readContent(flags.diff || "-"), + ...(flags.layout === "split" && { layout: "split" }), + }); + } else if (kind === "terminal") { + parts.push({ kind: "terminal", text: readContent(flags.terminal || "-") }); + } else if (kind === "json") { + const text = readContent(flags.json || "-"); + try { + parts.push({ kind: "json", data: JSON.parse(text) }); + } catch { + fail(`--json: invalid JSON${flags.json ? ` in ${flags.json}` : ""}`); + } + } else if (kind === "code") { + const codeFile = flags.code || "-"; + const part = { kind: "code", code: readContent(codeFile) }; + const codeLang = codeFile !== "-" ? inferLang(codeFile) : undefined; + if (codeLang) part.language = codeLang; + if (codeFile !== "-") part.title = codeFile.split("/").pop() || codeFile; + parts.push(part); + } else if (kind === "image") { + const asset = await uploadFile(flags.image, { session, kind: "image" }); + parts.push({ kind: "image", assetId: asset.id }); + } } outSurface(await publishSurface(parts, { ...flags, session })); }, @@ -1070,16 +1107,22 @@ const commands = { async update() { const { values: flags, positionals } = parse({ allowPositionals: true, - options: { title: { type: "string" }, kit: { type: "string", multiple: true } }, + options: { + title: { type: "string" }, + kit: { type: "string", multiple: true }, + surface: { type: "string" }, + }, }); const id = positionals[0]; - if (!id) fail("usage: sideshow update "); - const body = { title: flags.title }; + if (!id) fail("usage: sideshow update [--surface N]"); + const body = {}; + if (flags.title !== undefined) body.title = flags.title; if (positionals[1] !== undefined) { body.content = readContent(positionals[1]); } const kits = normalizeKits(flags.kit); if (kits) body.kits = kits; + if (flags.surface !== undefined) body.surface = flags.surface; outSurface( await api(`/api/posts/${id}`, { method: "PATCH", @@ -1088,6 +1131,151 @@ const commands = { ); }, + async surface() { + const sub = rest.shift(); + if (!sub || sub === "--help" || sub === "-h") { + console.log(HELP); + process.exit(0); + } + + if (sub === "add") { + const { + values: flags, + positionals, + tokens, + } = parse({ + tokens: true, + allowPositionals: true, + options: { + md: { type: "string" }, + mermaid: { type: "string" }, + diff: { type: "string" }, + terminal: { type: "string" }, + json: { type: "string" }, + code: { type: "string" }, + image: { type: "string" }, + before: { type: "string" }, + after: { type: "string" }, + layout: { type: "string" }, + session: { type: "string" }, + }, + }); + const postId = positionals[0]; + if (!postId) fail("usage: sideshow surface add [--md f] [--code f] ..."); + + const SURFACE_FLAGS = new Map([ + ["md", "markdown"], + ["mermaid", "mermaid"], + ["diff", "diff"], + ["terminal", "terminal"], + ["json", "json"], + ["code", "code"], + ["image", "image"], + ]); + const orderedKinds = []; + const seen = new Set(); + for (const t of tokens ?? []) { + if (t.kind === "option" && SURFACE_FLAGS.has(t.name) && !seen.has(t.name)) { + seen.add(t.name); + orderedKinds.push(SURFACE_FLAGS.get(t.name)); + } + } + if (orderedKinds.length === 0) fail("provide at least one surface flag (--md, --code, ...)"); + const session = await resolveSession(flags, { create: true }); + let lastResult; + for (const kind of orderedKinds) { + let surface; + if (kind === "markdown") { + surface = { kind: "markdown", markdown: readContent(flags.md || "-") }; + } else if (kind === "mermaid") { + surface = { kind: "mermaid", mermaid: readContent(flags.mermaid || "-") }; + } else if (kind === "diff") { + surface = { + kind: "diff", + patch: readContent(flags.diff || "-"), + ...(flags.layout === "split" && { layout: "split" }), + }; + } else if (kind === "terminal") { + surface = { kind: "terminal", text: readContent(flags.terminal || "-") }; + } else if (kind === "json") { + const text = readContent(flags.json || "-"); + try { + surface = { kind: "json", data: JSON.parse(text) }; + } catch { + fail(`--json: invalid JSON${flags.json ? ` in ${flags.json}` : ""}`); + } + } else if (kind === "code") { + const codeFile = flags.code || "-"; + surface = { kind: "code", code: readContent(codeFile) }; + const codeLang = codeFile !== "-" ? inferLang(codeFile) : undefined; + if (codeLang) surface.language = codeLang; + if (codeFile !== "-") surface.title = codeFile.split("/").pop() || codeFile; + } else if (kind === "image") { + const asset = await uploadFile(flags.image, { session, kind: "image" }); + surface = { kind: "image", assetId: asset.id }; + } + const body = { surface }; + if (flags.before !== undefined) body.before = flags.before; + if (flags.after !== undefined) body.after = flags.after; + lastResult = await api(`/api/posts/${postId}/surfaces`, { + method: "POST", + body: JSON.stringify(body), + }); + } + outSurface(lastResult); + } else if (sub === "remove") { + const { positionals } = parse({ allowPositionals: true }); + const [postId, target] = positionals; + if (!postId || !target) fail("usage: sideshow surface remove "); + outSurface(await api(`/api/posts/${postId}/surfaces/${target}`, { method: "DELETE" })); + } else if (sub === "edit") { + const { positionals } = parse({ allowPositionals: true }); + const [postId, target, file] = positionals; + if (!postId || !target || file === undefined) { + fail("usage: sideshow surface edit "); + } + outSurface( + await api(`/api/posts/${postId}/surfaces/${target}`, { + method: "PATCH", + body: JSON.stringify({ content: readContent(file) }), + }), + ); + } else if (sub === "move") { + const { values: flags, positionals } = parse({ + allowPositionals: true, + options: { to: { type: "string" } }, + }); + const [postId, target] = positionals; + if (!postId || !target || flags.to === undefined) { + fail("usage: sideshow surface move --to "); + } + const post = await api(`/api/posts/${postId}`); + const surfaces = post.surfaces ?? []; + let fromIdx = surfaces.findIndex((s) => s.id === target); + if (fromIdx < 0) { + fromIdx = Number(target); + if (!Number.isInteger(fromIdx) || fromIdx < 0 || fromIdx >= surfaces.length) { + fail(`surface "${target}" not found`); + } + } + const toIdx = Number(flags.to); + if (!Number.isInteger(toIdx) || toIdx < 0 || toIdx >= surfaces.length) { + fail(`--to must be a valid index (0-${surfaces.length - 1})`); + } + const ids = surfaces.map((s) => s.id); + const [moved] = ids.splice(fromIdx, 1); + ids.splice(toIdx, 0, moved); + outSurface( + await api(`/api/posts/${postId}/surfaces`, { + method: "PATCH", + body: JSON.stringify({ order: ids }), + }), + ); + } else { + fail(`unknown surface subcommand: ${sub} (use add, remove, edit, or move)`); + } + }, + async wait() { const { values: flags } = parse({ options: { diff --git a/docs/plans/per-surface-operations.md b/docs/plans/per-surface-operations.md new file mode 100644 index 0000000..2e67172 --- /dev/null +++ b/docs/plans/per-surface-operations.md @@ -0,0 +1,227 @@ +# Plan: Per-surface operations across CLI / HTTP API / MCP / Viewer + +Status as of 2026-06-26. Written so it's useful cold (after a context compaction). + +Originates from [modem-dev/sideshow#158](https://github.com/modem-dev/sideshow/issues/158) +(CLI publish: surface order is fixed by flag identity, not command-line flag order), +but #158 is one symptom of a broader gap that spans the whole app. + +## Goal + +Let an agent (or human) **target the individual surfaces that compose a post** — +append, edit, remove, and reorder a single surface without re-sending the whole +`surfaces` array — consistently across all three integration tiers (CLI, HTTP, +MCP), and fix the CLI flag-order bug along the way. + +## Where things stand right now (context for after a compact) + +A post is an ordered list of surfaces (`server/types.ts:180`). Every tier today +treats `surfaces` as an **opaque whole-array blob**: there is no surface +identity (no stable per-surface id), no index targeting, and no per-surface +operation primitive anywhere. The server's own comment at `server/app.ts:924` +flags `"multi-surface needs --surface N"` as never-built future work. + +### Current capability matrix + +| Capability | CLI | HTTP API | MCP | +| --------------------------------------- | --------------- | ------------ | ------ | +| Create post (full surfaces array) | yes | yes | yes | +| Replace all surfaces (full `PUT`) | **not exposed** | yes | yes | +| Content-only edit (single-surface post) | `update` | `PATCH` | **no** | +| Content-only edit (multi-surface, N) | **no (400)** | **no (400)** | **no** | +| Append a surface to existing post | **no** | **no** | **no** | +| Remove a single surface | **no** | **no** | **no** | +| Reorder surfaces | **no** | **no** | **no** | +| Flag order honored on publish | **no (#158)** | — | — | + +To do any per-surface operation today, a client must read-modify-write: `GET` +the post, mutate the `surfaces` array client-side, `PUT` the whole thing back. +There is no atomic server-side primitive for append / edit-one / remove-one / +reorder. + +### Key findings from the investigation + +- **CLI** (`bin/sideshow.js`): `publish` builds surfaces via a hardcoded + if-ladder (lines 824–862) in fixed order `html → markdown → mermaid → diff → +terminal → json → code → image`, regardless of flag order. The flag parser is + `node:util` `parseArgs` (line 8), which returns a name→value object and + discards cross-flag ordering. `update` (lines 1070–1089) is content-only, + single-surface only — it `PATCH`es `{title, content, kits}` and the server + rejects multi-surface posts with a 400. The CLI never calls `PUT` (full + replace) at all — only `demo` does, via the legacy snippet route. +- **HTTP API** (`server/app.ts`): `PUT /api/posts/:id` (line 919, handler + `revise` at 887) is full-array replacement. `PATCH /api/posts/:id` (line 934) + is content-only but explicitly rejects posts with >1 surface (line 949). The + shared flow function `reviseSurface` (line 466) passes `patch.parts` straight + to `store.updatePost` as a whole `surfaces` array. No `appendSurface` / + `removeSurface` / `updateSurfaceAt` / `reorderSurfaces` exists in the `Store` + interface (`server/types.ts:261`) or either implementation. +- **MCP** (`server/mcpSpec.ts`, `mcp/server.ts`, `server/mcpHttp.ts`): + `publish_post` and `update_post` take a full `surfaces` array (canonical) or + `parts` (legacy alias). `update_post` is wholesale replace — "Pass the full + replacement surfaces array" (`mcpSpec.ts:161`). There is **no** MCP equivalent + of the REST `PATCH` content-only update at all. stdio MCP is a thin HTTP + client; HTTP MCP calls the same shared flow functions as REST. +- **Viewer** (`viewer/src/Card.tsx`): purely a read-only renderer for surfaces. + No add/remove/reorder/edit UI. Surfaces render via `` + (line 237) — keyed by array position, not stable identity. On `post-updated` + SSE, `state.ts:upsertPost` (line 339) re-fetches the whole post and + `reconcile`s it; if the version bumped, every sandboxed surface iframe reloads + (its `src` is version-keyed). The server no longer emits + `surface-created/updated/deleted` events — everything flows through + `post-created`/`post-updated` (`server/events.ts`). + +## Plan + +### Phase 1 — Fix #158: honor CLI flag order on publish + +**Scope:** `bin/sideshow.js`, `test/cli.test.ts` + +The `publish` command builds surfaces via a hardcoded if-ladder that ignores the +order flags appear on the command line. `parseArgs` returns a name→value object +and discards cross-flag ordering (only within-`--kit` ordering is preserved). + +**Fix:** walk the raw `process.argv` tokens (or `parseArgs`' `tokens` output, +which preserves first-appearance order) to determine the order of `--md` / +`--code` / `--diff` / `--terminal` / `--mermaid` / `--json` / `--image`, and +append surfaces in that order instead of the hardcoded cascade. The positional +html arg stays first (it's the primary content). Update the pinned test at +`test/cli.test.ts:447` to assert flag order is honored. + +This is the smallest, highest-value change and ships independently. + +### Phase 2 — Foundation: surface identity + server-side per-surface ops + +#### 2a. Surface ids + +**Scope:** `server/types.ts`, `server/storage.ts`, `server/sqlStore.ts`, +`test/storeContract.ts` + +Add an optional `id: string` to every `Surface` in `server/types.ts`. Generated +server-side via `newId()` on create/update if absent. This is the foundation +that makes targeting robust — "replace surface abc" is unambiguous even across +reorders or concurrent edits. + +- Migrate existing surfaces: assign ids lazily in the store's + surface-normalization path. Both `JsonFileStore` and `SqlStore` already + normalize legacy `snippet`/`parts` shapes on load — extend that same path. +- Support **both** id and index targeting in the API — id for robustness, index + for curl ergonomics. +- Viewer benefit: key by surface `id` instead of `` so reordering + doesn't reload every iframe — only moved surfaces get a new `?part=N`. + +#### 2b. HTTP API per-surface endpoints + +**Scope:** `server/app.ts`, `server/postSurfaces.ts` + +**Extend existing PATCH** (content-only, now multi-surface): + +- `PATCH /api/posts/:id` gains optional `surface: ` — when present, + slots `content` into _that_ surface (preserving kind + extra fields like + `language`, `cols`, `layout`). This removes the single-surface 400 at + `app.ts:949`. + +**New sub-resource routes** (canonical + legacy aliases on `/api/surfaces/:id/...`): + +| Method | Route | Purpose | +| -------- | --------------------------------- | ------------------------------------------------------------------ | +| `POST` | `/api/posts/:id/surfaces` | Append a surface. Body: `{surface, before?, after?}` for position. | +| `PATCH` | `/api/posts/:id/surfaces/:target` | Replace one surface (full) or content-only via `{content}`. | +| `DELETE` | `/api/posts/:id/surfaces/:target` | Remove one surface. 400 if it's the last (posts need ≥1). | +| `PATCH` | `/api/posts/:id/surfaces` | Reorder. Body: `{order: [id, ...]}` or `{order: [2, 0, 1]}`. | + +`:target` is a surface id or a 0-based index. + +**Shared flow functions** (REST + MCP both call these, per the existing +`publishSurface`/`reviseSurface` pattern): + +- Add `appendSurface(id, surface, pos?)`, `replaceSurface(id, target, +surface|content)`, `removeSurface(id, target)`, `reorderSurfaces(id, order)` + alongside existing `publishSurface`/`reviseSurface`. +- Each validates the single surface via `validateSurfaces([surface])`, applies + the mutation, bumps version, pushes history, broadcasts `post-updated` SSE + (the viewer already handles this event). + +#### 2c. MCP per-surface tools + +**Scope:** `server/mcpSpec.ts`, `server/mcpHttp.ts`, `mcp/server.ts`, +`test/mcpSpec.test.ts` + +Add four new tools (additive — `update_post` full-replace stays for back-compat): + +| Tool | Args | Maps to | +| ------------------ | -------------------------------------- | ---------------------------------------- | +| `add_surface` | `postId, surface, before?, after?` | `POST /api/posts/:id/surfaces` | +| `edit_surface` | `postId, target, surface? \| content?` | `PATCH /api/posts/:id/surfaces/:target` | +| `remove_surface` | `postId, target` | `DELETE /api/posts/:id/surfaces/:target` | +| `reorder_surfaces` | `postId, order` | `PATCH /api/posts/:id/surfaces` | + +- Schemas: add to `HTTP_MCP_TOOLS` + `STDIO_MCP_INPUT_SCHEMAS` in `mcpSpec.ts`. +- stdio handlers in `mcp/server.ts` call the new HTTP routes (thin client + pattern — zero business logic, same as existing tools). +- HTTP handlers in `mcpHttp.ts` call the new flow functions directly. +- This also closes the asymmetry where MCP has no content-only update at all + (REST/CLI have PATCH, MCP doesn't). + +### Phase 3 — CLI surface commands + viewer key-by-id + +#### 3a. CLI surface commands + +**Scope:** `bin/sideshow.js` + +**Extend `update`** for multi-surface: + +- `sideshow update --surface N` — content-only edit of surface N + (by id or index). Maps to extended PATCH. +- Without `--surface`, keeps current single-surface behavior (back-compat). + +**New `surface` subcommand:** + +- `sideshow surface add [--md f] [--code f] [--diff f] ...` — append + surface(s) to an existing post. Uses the same flag-order fix from Phase 1. +- `sideshow surface remove ` — remove a surface. +- `sideshow surface edit ` — replace a surface's content. +- `sideshow surface move --to M` — reorder. + +**Expose full-replace:** add a path that maps to `PUT /api/posts/:id` with a +full surfaces array (currently the CLI never calls PUT). + +#### 3b. Viewer key-by-id + +**Scope:** `viewer/src/Card.tsx` + +- Change `` to `` keyed by `surface.id` at `Card.tsx:237` — stable + identity across reorders means only moved surfaces get a new `?part=N` render + URL. +- The iframe `src` still uses `?part=N` (positional render index) — the server + renders by position; the viewer maps id→position at render time. +- **No editing UI** — the viewer stays read-only for surfaces. This is correct: + surfaces are agent-authored, and the sandboxing invariant depends on it. + +## Invariants preserved + +- **Legacy routes** (`/api/surfaces`, `/api/snippets`) and legacy keys (`parts`) + stay byte-identical — new routes are additive. +- **Sandboxing rule** unchanged — no new surface kinds in this work, and any + future kind still picks (a) string-served sandbox or (b) native data render. +- **`update_post` full-replace** stays for back-compat; new MCP tools are + additive. +- **Store contract test** (`test/storeContract.ts`) covers the new operations + on both `JsonFileStore` and `SqlStore`. +- **Post version bump + `post-updated` SSE** on every per-surface mutation — + the viewer already re-fetches and reconciles on this event. +- **Runtime-agnostic** — new flow functions and routes in `server/app.ts` stay + free of `node:` imports; stdio MCP stays a thin HTTP client. + +## Suggested implementation sequence + +1. **P1** (standalone, ships first): CLI flag-order fix — `bin/sideshow.js` + + test update + changeset. +2. **P2a**: Surface ids in `types.ts` + store migration + store contract tests. +3. **P2b**: HTTP per-surface endpoints + flow functions + API tests. +4. **P2c**: MCP per-surface tools + `mcpSpec` test. +5. **P3a**: CLI `surface` subcommand + `update --surface N`. +6. **P3b**: Viewer key-by-id. + +Each phase is independently shippable with a changeset. P1 is the #158 fix; +P2 is the API/MCP gap; P3 is CLI ergonomics + viewer polish. diff --git a/mcp/server.ts b/mcp/server.ts index 1782265..f3da176 100644 --- a/mcp/server.ts +++ b/mcp/server.ts @@ -245,4 +245,69 @@ server.registerTool( async () => text(await api("/guide")), ); +server.registerTool( + "add_surface", + { + description: MCP_TOOL_DESCRIPTIONS.addSurface, + inputSchema: STDIO_MCP_INPUT_SCHEMAS.addSurface, + }, + async ({ postId, surface, before, after }) => { + const updated = JSON.parse( + await api(`/api/posts/${postId}/surfaces`, { + method: "POST", + body: JSON.stringify({ surface, before, after }), + }), + ); + return text({ ...updated, url: `${API}/p/${updated.id}` }); + }, +); + +server.registerTool( + "edit_surface", + { + description: MCP_TOOL_DESCRIPTIONS.editSurface, + inputSchema: STDIO_MCP_INPUT_SCHEMAS.editSurface, + }, + async ({ postId, target, surface, content, kits }) => { + const updated = JSON.parse( + await api(`/api/posts/${postId}/surfaces/${target}`, { + method: "PATCH", + body: JSON.stringify({ surface, content, kits }), + }), + ); + return text({ ...updated, url: `${API}/p/${updated.id}` }); + }, +); + +server.registerTool( + "remove_surface", + { + description: MCP_TOOL_DESCRIPTIONS.removeSurface, + inputSchema: STDIO_MCP_INPUT_SCHEMAS.removeSurface, + }, + async ({ postId, target }) => { + const updated = JSON.parse( + await api(`/api/posts/${postId}/surfaces/${target}`, { method: "DELETE" }), + ); + return text({ ...updated, url: `${API}/p/${updated.id}` }); + }, +); + +server.registerTool( + "reorder_surfaces", + { + description: MCP_TOOL_DESCRIPTIONS.reorderSurfaces, + inputSchema: STDIO_MCP_INPUT_SCHEMAS.reorderSurfaces, + }, + async ({ postId, order }) => { + const updated = JSON.parse( + await api(`/api/posts/${postId}/surfaces`, { + method: "PATCH", + body: JSON.stringify({ order }), + }), + ); + return text({ ...updated, url: `${API}/p/${updated.id}` }); + }, +); + await server.connect(new StdioServerTransport()); diff --git a/server/app.ts b/server/app.ts index 5e97895..a97b2af 100644 --- a/server/app.ts +++ b/server/app.ts @@ -386,6 +386,52 @@ export function createApp({ return feedback.length > 0 ? feedback.map(feedbackView) : undefined; } + // Maps a surface kind to its primary content field — used by content-only + // updates (PATCH with `content`) to slot a raw string into the right field + // while preserving extra fields (language, cols, layout, etc.). + const CONTENT_FIELD: Record = { + html: "html", + markdown: "markdown", + mermaid: "mermaid", + diff: "patch", + terminal: "text", + code: "code", + json: "data", + }; + + // Find a surface's index by id (first match) or 0-based numeric index. + function findSurfaceIndex(surfaces: Surface[], target: string): number { + const byId = surfaces.findIndex((s) => s.id === target); + if (byId >= 0) return byId; + const idx = Number(target); + if (Number.isInteger(idx) && idx >= 0 && idx < surfaces.length) return idx; + return -1; + } + + // Slot a content string into a surface's content field, preserving kind and + // extra fields. Returns null if the kind has no content field or JSON parse + // fails. The caller handles error reporting. + function applyContent(surface: Surface, content: string, kits?: unknown): Surface | null { + const field = CONTENT_FIELD[surface.kind]; + if (!field) return null; + let value: unknown = content; + if (surface.kind === "json") { + try { + value = JSON.parse(content); + } catch { + return null; + } + } + if (surface.kind === "html") { + return { + ...surface, + html: value as string, + ...(kits !== undefined && { kits: Array.isArray(kits) ? kits : undefined }), + }; + } + return { ...surface, [field]: value } as Surface; + } + async function publishSurface(input: { parts: Surface[]; title?: string; @@ -489,6 +535,124 @@ export function createApp({ return { surface, userFeedback: await collectFeedback(surface.sessionId) }; } + // --- per-surface flow functions (append / replace / remove / reorder) --- + // Each reads the existing post, mutates the surfaces array, and writes it + // back via reviseSurface so version/history/SSE stay consistent. Untouched + // surfaces keep their ids (normalizeSurfaceIds preserves existing ids). + + async function appendSurface( + id: string, + surface: Surface, + pos?: { before?: string; after?: string }, + ): Promise< + { surface: Post; userFeedback?: Feedback[] } | { error: string; status: 400 | 404 | 413 } + > { + const existing = await store.getPost(id); + if (!existing) return { error: "post not found", status: 404 }; + let insertAt = existing.surfaces.length; + if (pos?.before !== undefined) { + const i = findSurfaceIndex(existing.surfaces, pos.before); + if (i < 0) return { error: `surface "${pos.before}" not found`, status: 404 }; + insertAt = i; + } else if (pos?.after !== undefined) { + const i = findSurfaceIndex(existing.surfaces, pos.after); + if (i < 0) return { error: `surface "${pos.after}" not found`, status: 404 }; + insertAt = i + 1; + } + const parts = [...existing.surfaces]; + parts.splice(insertAt, 0, surface); + return reviseSurface(id, { parts }); + } + + async function replaceSurface( + id: string, + target: string, + replacement: { surface?: Surface; content?: string; kits?: unknown }, + ): Promise< + { surface: Post; userFeedback?: Feedback[] } | { error: string; status: 400 | 404 | 413 } + > { + const existing = await store.getPost(id); + if (!existing) return { error: "post not found", status: 404 }; + const idx = findSurfaceIndex(existing.surfaces, target); + if (idx < 0) return { error: `surface "${target}" not found`, status: 404 }; + let updated: Surface; + if (replacement.surface !== undefined) { + // Full replacement — preserve the old surface's id so the viewer can + // key by stable identity across edits. If kits were supplied and the + // replacement is an html surface, apply them (matches content-only). + updated = { ...replacement.surface, id: existing.surfaces[idx].id }; + if (replacement.kits !== undefined && updated.kind === "html") { + updated = { + ...updated, + kits: Array.isArray(replacement.kits) ? replacement.kits : undefined, + }; + } + } else if (replacement.content !== undefined) { + // Content-only — slot the string into the existing surface's field. + const result = applyContent(existing.surfaces[idx], replacement.content, replacement.kits); + if (!result) { + return { + error: `content update not supported for ${existing.surfaces[idx].kind} surfaces`, + status: 400, + }; + } + updated = result; + } else { + return { error: "provide surface or content", status: 400 }; + } + const parsed = await validateSurfaces([updated]); + if (!parsed.ok) return { error: parsed.error, status: 400 }; + // The validator strips the id field (zod schemas don't declare it), so + // re-apply the target's id after validation to preserve surface identity. + const parts = [...existing.surfaces]; + parts[idx] = { ...parsed.parts[0], id: existing.surfaces[idx].id }; + return reviseSurface(id, { parts }); + } + + async function removeSurface( + id: string, + target: string, + ): Promise< + { surface: Post; userFeedback?: Feedback[] } | { error: string; status: 400 | 404 | 413 } + > { + const existing = await store.getPost(id); + if (!existing) return { error: "post not found", status: 404 }; + const idx = findSurfaceIndex(existing.surfaces, target); + if (idx < 0) return { error: `surface "${target}" not found`, status: 404 }; + if (existing.surfaces.length === 1) { + return { error: "a post needs at least one surface", status: 400 }; + } + const parts = existing.surfaces.filter((_, i) => i !== idx); + return reviseSurface(id, { parts }); + } + + async function reorderSurfaces( + id: string, + order: (string | number)[], + ): Promise< + { surface: Post; userFeedback?: Feedback[] } | { error: string; status: 400 | 404 | 413 } + > { + const existing = await store.getPost(id); + if (!existing) return { error: "post not found", status: 404 }; + if (order.length !== existing.surfaces.length) { + return { error: "order array length must match surface count", status: 400 }; + } + // Build the reordered array. Each entry is a surface id or 0-based index. + const indexed: Surface[] = Array.from({ length: order.length }); + const used = new Set(); + for (const entry of order) { + const idx = findSurfaceIndex(existing.surfaces, String(entry)); + if (idx < 0) return { error: `surface "${entry}" not found`, status: 404 }; + if (used.has(idx)) return { error: `surface "${entry}" appears twice in order`, status: 400 }; + used.add(idx); + } + for (let i = 0; i < order.length; i++) { + const idx = findSurfaceIndex(existing.surfaces, String(order[i])); + indexed[i] = existing.surfaces[idx]; + } + return reviseSurface(id, { parts: indexed }); + } + async function createComment(input: { text: string; surface?: string; @@ -921,66 +1085,54 @@ export function createApp({ // Content-only update: accepts raw content and slots it into the existing // surface's kind, preserving extra fields (language, cols, layout, etc.). - // Only single-surface posts for now; multi-surface needs --surface N. - const CONTENT_FIELD: Record = { - html: "html", - markdown: "markdown", - mermaid: "mermaid", - diff: "patch", - terminal: "text", - code: "code", - json: "data", - }; + // The optional `surface` field (surface id or 0-based index) targets a + // specific surface in a multi-surface post; without it, the post must have + // a single surface (back-compat with the original behavior). app.patch("/api/posts/:id", async (c: any) => { const body = await c.req.json().catch(() => null); if (!body) return c.json({ error: "invalid JSON body" }, 400); - const { content, title, kits } = body; + const { content, title, kits, surface } = body; if (content === undefined && title === undefined) { return c.json({ error: "provide content and/or title" }, 400); } const existing = await store.getPost(c.req.param("id")); if (!existing) return c.json({ error: "post not found" }, 404); - // Build the updated surfaces array. let parts: Surface[] | undefined; if (content !== undefined) { if (typeof content !== "string") { return c.json({ error: '"content" must be a string' }, 400); } - if (existing.surfaces.length > 1) { + const targetIdx = + surface !== undefined + ? findSurfaceIndex(existing.surfaces, String(surface)) + : existing.surfaces.length === 1 + ? 0 + : -1; + if (targetIdx < 0) { + if (surface !== undefined) { + return c.json({ error: `surface "${surface}" not found` }, 404); + } return c.json( { error: - "content update not supported for multi-surface posts; use PUT with a full surfaces array", + 'content update requires a "surface" target (id or index) for multi-surface posts', }, 400, ); } - const surface = existing.surfaces[0]; - const field = CONTENT_FIELD[surface.kind]; - if (!field) { - return c.json({ error: `content update not supported for ${surface.kind} surfaces` }, 400); - } - // For json surfaces, parse the content string into a value. - let value: unknown = content; - if (surface.kind === "json") { - try { - value = JSON.parse(content); - } catch { - return c.json({ error: "content is not valid JSON" }, 400); - } + const updated = applyContent(existing.surfaces[targetIdx], content, kits); + if (!updated) { + return c.json( + { + error: `content update not supported for ${existing.surfaces[targetIdx].kind} surfaces`, + }, + 400, + ); } - // Clone the existing surface, replace the content field, optionally update kits. - const updated: Surface = - surface.kind === "html" - ? { - ...surface, - html: value as string, - ...(kits !== undefined && { kits: Array.isArray(kits) ? kits : undefined }), - } - : ({ ...surface, [field]: value } as Surface); const parsed = await validateSurfaces([updated]); if (!parsed.ok) return c.json({ error: parsed.error }, 400); - parts = parsed.parts; + parts = [...existing.surfaces]; + parts[targetIdx] = { ...parsed.parts[0], id: existing.surfaces[targetIdx].id }; } const result = await reviseSurface(c.req.param("id"), { parts, @@ -993,6 +1145,79 @@ export function createApp({ }); }); + // --- per-surface sub-resource routes --- + + // Append a surface to an existing post. Optional `before`/`after` (surface + // id or index) controls insert position; default is append at the end. + app.post("/api/posts/:id/surfaces", async (c: any) => { + const body = await c.req.json().catch(() => null); + if (!body || !body.surface) { + return c.json({ error: 'body must include a "surface" object' }, 400); + } + const parsed = await validateSurfaces([body.surface]); + if (!parsed.ok) return c.json({ error: parsed.error }, 400); + const result = await appendSurface(c.req.param("id"), parsed.parts[0], { + before: body.before, + after: body.after, + }); + if ("error" in result) return c.json({ error: result.error }, result.status); + return c.json({ + ...writeResult(result.surface), + ...(result.userFeedback && { userFeedback: result.userFeedback }), + }); + }); + + // Replace or content-edit a single surface. `:target` is a surface id or + // 0-based index. Body: `{surface}` for full replacement, or `{content}` for + // content-only (preserves kind + extra fields). + app.patch("/api/posts/:id/surfaces/:target", async (c: any) => { + const body = await c.req.json().catch(() => null); + if (!body || (body.surface === undefined && body.content === undefined)) { + return c.json({ error: 'body must include "surface" or "content"' }, 400); + } + let surface: Surface | undefined; + if (body.surface !== undefined) { + const parsed = await validateSurfaces([body.surface]); + if (!parsed.ok) return c.json({ error: parsed.error }, 400); + surface = parsed.parts[0]; + } + const result = await replaceSurface(c.req.param("id"), c.req.param("target"), { + surface, + content: body.content, + kits: body.kits, + }); + if ("error" in result) return c.json({ error: result.error }, result.status); + return c.json({ + ...writeResult(result.surface), + ...(result.userFeedback && { userFeedback: result.userFeedback }), + }); + }); + + // Remove a single surface. `:target` is a surface id or 0-based index. + // Rejects with 400 if it's the last surface (posts need ≥1). + app.delete("/api/posts/:id/surfaces/:target", async (c: any) => { + const result = await removeSurface(c.req.param("id"), c.req.param("target")); + if ("error" in result) return c.json({ error: result.error }, result.status); + return c.json({ + ...writeResult(result.surface), + ...(result.userFeedback && { userFeedback: result.userFeedback }), + }); + }); + + // Reorder surfaces. Body: `{order: [id, ...]}` or `{order: [0, 2, 1]}`. + app.patch("/api/posts/:id/surfaces", async (c: any) => { + const body = await c.req.json().catch(() => null); + if (!body || !Array.isArray(body.order)) { + return c.json({ error: 'body must include an "order" array' }, 400); + } + const result = await reorderSurfaces(c.req.param("id"), body.order); + if ("error" in result) return c.json({ error: result.error }, result.status); + return c.json({ + ...writeResult(result.surface), + ...(result.userFeedback && { userFeedback: result.userFeedback }), + }); + }); + const remove = async (c: any) => { const surface = await store.getPost(c.req.param("id")); if (!surface) return c.json({ error: "surface not found" }, 404); @@ -1331,6 +1556,10 @@ export function createApp({ basePath: requestBasePath, publishSurface, reviseSurface, + appendSurface, + replaceSurface, + removeSurface, + reorderSurfaces, createComment, waitForComments, uploadAsset, diff --git a/server/mcpHttp.ts b/server/mcpHttp.ts index b208da5..7dca0a4 100644 --- a/server/mcpHttp.ts +++ b/server/mcpHttp.ts @@ -32,6 +32,18 @@ export interface McpDeps { agent?: string; }): FlowResult; reviseSurface(id: string, patch: { parts?: Surface[]; title?: string }): FlowResult; + appendSurface( + id: string, + surface: Surface, + pos?: { before?: string; after?: string }, + ): FlowResult; + replaceSurface( + id: string, + target: string, + replacement: { surface?: Surface; content?: string; kits?: unknown }, + ): FlowResult; + removeSurface(id: string, target: string): FlowResult; + reorderSurfaces(id: string, order: (string | number)[]): FlowResult; createComment(input: { text: string; surface?: string; @@ -213,6 +225,51 @@ export function registerMcp(app: Hono, deps: McpDeps) { } case "get_design_guide": return deps.guide; + case "add_surface": { + const parts = await coerceParts([args.surface]); + if (parts.length === 0) throw new Error("invalid surface"); + const result = await deps.appendSurface(String(args.postId ?? ""), parts[0], { + before: typeof args.before === "string" ? args.before : undefined, + after: typeof args.after === "string" ? args.after : undefined, + }); + if ("error" in result) throw new Error(result.error); + return surfaceResult(result, origin, "p"); + } + case "edit_surface": { + let surface: Surface | undefined; + if (args.surface !== undefined) { + const parts = await coerceParts([args.surface]); + if (parts.length === 0) throw new Error("invalid surface"); + surface = parts[0]; + } + const result = await deps.replaceSurface( + String(args.postId ?? ""), + String(args.target ?? ""), + { + surface, + content: typeof args.content === "string" ? args.content : undefined, + kits: args.kits, + }, + ); + if ("error" in result) throw new Error(result.error); + return surfaceResult(result, origin, "p"); + } + case "remove_surface": { + const result = await deps.removeSurface( + String(args.postId ?? ""), + String(args.target ?? ""), + ); + if ("error" in result) throw new Error(result.error); + return surfaceResult(result, origin, "p"); + } + case "reorder_surfaces": { + const result = await deps.reorderSurfaces( + String(args.postId ?? ""), + Array.isArray(args.order) ? args.order : [], + ); + if ("error" in result) throw new Error(result.error); + return surfaceResult(result, origin, "p"); + } default: throw new Error(`unknown tool: ${name}`); } diff --git a/server/mcpSpec.ts b/server/mcpSpec.ts index e14b652..757603c 100644 --- a/server/mcpSpec.ts +++ b/server/mcpSpec.ts @@ -71,6 +71,7 @@ const d = { "code surface: language id (ts, js, python, go, rust, …); omit or use 'text' for plain monospace", surfaceLineStart: "code surface: 1-based starting line number for an excerpt (the viewer numbers from here instead of 1)", + surfaceTarget: "Surface id or 0-based index within the post", }; const MCP_SURFACES_DESCRIPTION = @@ -184,6 +185,14 @@ export const MCP_TOOL_DESCRIPTIONS = { "Upload a binary asset (image, trace file, any file) and get back its id and URL. base64-encode the bytes in `data`. Then reference it: put {kind:'image', assetId} or {kind:'trace', assetId} in a post's surfaces, or embed the returned url in an html surface (). Attached to this conversation's session.", getDesignGuide: "Fetch the design contract: post surfaces, html fragment rules, theme CSS variables, CDN allowlist, and the interactivity bridge. Call once per session before publishing.", + addSurface: + "Append a surface to an existing post (same card, new version). Optionally pass before/after (surface id or 0-based index) to control insert position; default is append at the end. If the result includes userFeedback, read it.", + editSurface: + "Replace or content-edit a single surface in a post (same card, new version). Pass `surface` for a full replacement, or `content` for a content-only update that preserves the surface kind and extra fields (language, cols, layout, etc.). `target` is a surface id or 0-based index. If the result includes userFeedback, read it.", + removeSurface: + "Remove a single surface from a post (same card, new version). `target` is a surface id or 0-based index. Rejects if it's the last surface (posts need at least one). If the result includes userFeedback, read it.", + reorderSurfaces: + "Reorder the surfaces in a post (same card, new version). Pass an array of surface ids or 0-based indices in the desired order; the length must match the current surface count. If the result includes userFeedback, read it.", } as const; export const HTTP_MCP_TOOLS = [ @@ -344,6 +353,66 @@ export const HTTP_MCP_TOOLS = [ description: MCP_TOOL_DESCRIPTIONS.getDesignGuide, inputSchema: { type: "object", properties: {} }, }, + { + name: "add_surface", + description: MCP_TOOL_DESCRIPTIONS.addSurface, + inputSchema: { + type: "object", + properties: { + postId: { type: "string", description: d.surfaceId }, + surface: MCP_SURFACE_JSON_SCHEMA, + before: { type: "string", description: d.surfaceTarget }, + after: { type: "string", description: d.surfaceTarget }, + }, + required: ["postId", "surface"], + }, + }, + { + name: "edit_surface", + description: MCP_TOOL_DESCRIPTIONS.editSurface, + inputSchema: { + type: "object", + properties: { + postId: { type: "string", description: d.surfaceId }, + target: { type: "string", description: d.surfaceTarget }, + surface: MCP_SURFACE_JSON_SCHEMA, + content: { + type: "string", + description: "Raw content to slot into the existing surface's content field", + }, + kits: { type: "array", items: { type: "string" }, description: d.surfaceKits }, + }, + required: ["postId", "target"], + }, + }, + { + name: "remove_surface", + description: MCP_TOOL_DESCRIPTIONS.removeSurface, + inputSchema: { + type: "object", + properties: { + postId: { type: "string", description: d.surfaceId }, + target: { type: "string", description: d.surfaceTarget }, + }, + required: ["postId", "target"], + }, + }, + { + name: "reorder_surfaces", + description: MCP_TOOL_DESCRIPTIONS.reorderSurfaces, + inputSchema: { + type: "object", + properties: { + postId: { type: "string", description: d.surfaceId }, + order: { + type: "array", + items: { oneOf: [{ type: "string" }, { type: "number" }] }, + description: "Surface ids or 0-based indices in the desired order", + }, + }, + required: ["postId", "order"], + }, + }, ] as const; const diffFileSchema = z.object({ @@ -445,4 +514,30 @@ export const STDIO_MCP_INPUT_SCHEMAS = { .optional() .describe("Inferred from contentType if omitted"), }, + addSurface: { + postId: z.string().describe(d.surfaceId), + surface: mcpPartSchema.describe("Surface to append"), + before: z.string().optional().describe(d.surfaceTarget), + after: z.string().optional().describe(d.surfaceTarget), + }, + editSurface: { + postId: z.string().describe(d.surfaceId), + target: z.string().describe(d.surfaceTarget), + surface: mcpPartSchema.optional().describe("Full replacement surface"), + content: z + .string() + .optional() + .describe("Raw content to slot into the existing surface's content field"), + kits: z.array(z.string()).optional().describe(d.surfaceKits), + }, + removeSurface: { + postId: z.string().describe(d.surfaceId), + target: z.string().describe(d.surfaceTarget), + }, + reorderSurfaces: { + postId: z.string().describe(d.surfaceId), + order: z + .array(z.union([z.string(), z.number()])) + .describe("Surface ids or 0-based indices in the desired order"), + }, } as const; diff --git a/server/sqlStore.ts b/server/sqlStore.ts index 10065ed..dad15e4 100644 --- a/server/sqlStore.ts +++ b/server/sqlStore.ts @@ -13,6 +13,7 @@ import { htmlSurface, MAX_WORKSPACE_ASSET_BYTES, newId, + normalizeSurfaceIds, selectEvictions, type Session, type SqlStorage, @@ -82,6 +83,7 @@ export class SqlStore implements Store { } this.migrateToSurfaces(); this.migrateToPosts(); + this.migrateSurfaceIds(); } // Pre-0.5.0 workspaces stored a `snippets` table and `comments.snippetId`. Lift @@ -174,6 +176,32 @@ export class SqlStore implements Store { this.sql.exec("DROP TABLE surfaces"); } + // One-time migration: assign stable ids to surfaces in existing posts that + // were written before surface ids existed. Gated on a settings sentinel so + // it only runs once per workspace; idempotent and safe to retry. + private migrateSurfaceIds() { + const rows = this.sql + .exec("SELECT value FROM settings WHERE key = 'surfaceIdsMigrated'") + .toArray(); + if (rows.length > 0 && rows[0]?.value === "1") return; + for (const r of this.sql.exec("SELECT id, surfaces, history FROM posts").toArray()) { + const surfaces = normalizeSurfaceIds(JSON.parse(r.surfaces as string) as Surface[]); + const history = (JSON.parse(r.history as string) as PostVersion[]).map((h) => ({ + ...h, + surfaces: normalizeSurfaceIds(h.surfaces), + })); + this.sql.exec( + "UPDATE posts SET surfaces = ?, history = ? WHERE id = ?", + JSON.stringify(surfaces), + JSON.stringify(history), + r.id, + ); + } + this.sql.exec( + "INSERT OR REPLACE INTO settings (key, value) VALUES ('surfaceIdsMigrated', '1')", + ); + } + private rowToSession(r: Record): Session { return { id: r.id as string, @@ -348,7 +376,7 @@ export class SqlStore implements Store { id: newId(), sessionId: input.sessionId, title: stripNul(input.title)?.trim() || "Untitled", - surfaces: input.surfaces, + surfaces: normalizeSurfaceIds(input.surfaces), createdAt: now, updatedAt: now, version: 1, @@ -391,7 +419,8 @@ export class SqlStore implements Store { if (history.length > HISTORY_LIMIT) history.shift(); const title = patch.title !== undefined ? stripNul(patch.title).trim() || surface.title : surface.title; - const surfaces = patch.surfaces !== undefined ? patch.surfaces : surface.surfaces; + const surfaces = + patch.surfaces !== undefined ? normalizeSurfaceIds(patch.surfaces) : surface.surfaces; const version = surface.version + 1; const updatedAt = new Date().toISOString(); this.sql.exec( @@ -660,11 +689,13 @@ export class SqlStore implements Store { s.id, s.sessionId, s.title, - JSON.stringify(s.surfaces), + JSON.stringify(normalizeSurfaceIds(s.surfaces)), s.createdAt, s.updatedAt, s.version, - JSON.stringify(s.history), + JSON.stringify( + s.history.map((h) => ({ ...h, surfaces: normalizeSurfaceIds(h.surfaces) })), + ), ); } for (const c of snapshot.comments) { diff --git a/server/storage.ts b/server/storage.ts index ba39cfb..c2b565a 100644 --- a/server/storage.ts +++ b/server/storage.ts @@ -15,6 +15,7 @@ import { htmlSurface, MAX_WORKSPACE_ASSET_BYTES, newId, + normalizeSurfaceIds, selectEvictions, type Session, stripNul, @@ -185,6 +186,13 @@ export class JsonFileStore implements Store { } else if (data.snippets) { for (const s of data.snippets) this.surfaces.set(s.id, liftSnippet(s)); } + // Ensure every surface has a stable id (one-time migration for data + // written before surface ids existed). Cheap: only mutates surfaces + // that lack an id, and persists on the next write. + for (const p of this.surfaces.values()) { + p.surfaces = normalizeSurfaceIds(p.surfaces); + for (const h of p.history) h.surfaces = normalizeSurfaceIds(h.surfaces); + } this.comments = (data.comments ?? []).map(liftComment); for (const a of data.assets ?? []) { this.assets.set(a.id, { @@ -353,7 +361,7 @@ export class JsonFileStore implements Store { id: newId(), sessionId: input.sessionId, title: stripNul(input.title)?.trim() || "Untitled", - surfaces: clone(input.surfaces), + surfaces: normalizeSurfaceIds(clone(input.surfaces)), createdAt: now, updatedAt: now, version: 1, @@ -378,7 +386,7 @@ export class JsonFileStore implements Store { }); if (surface.history.length > HISTORY_LIMIT) surface.history.shift(); if (patch.title !== undefined) surface.title = stripNul(patch.title).trim() || surface.title; - if (patch.surfaces !== undefined) surface.surfaces = clone(patch.surfaces); + if (patch.surfaces !== undefined) surface.surfaces = normalizeSurfaceIds(clone(patch.surfaces)); surface.version += 1; surface.updatedAt = new Date().toISOString(); this.touch(surface.sessionId); diff --git a/server/types.ts b/server/types.ts index e4ad7a0..3d4c4a4 100644 --- a/server/types.ts +++ b/server/types.ts @@ -159,16 +159,20 @@ export interface CodeSurface { lineStart?: number; } +// Every surface optionally carries a server-assigned id — a short, stable +// identifier for per-surface targeting (append/edit/remove/reorder). The id +// is assigned by normalizeSurfaceIds on create/update and preserved across +// per-surface mutations; full-replace updates assign fresh ids. export type Surface = - | HtmlSurface - | DiffSurface - | ImageSurface - | TraceSurface - | MarkdownSurface - | TerminalSurface - | MermaidSurface - | JsonSurface - | CodeSurface; + | (HtmlSurface & { id?: string }) + | (DiffSurface & { id?: string }) + | (ImageSurface & { id?: string }) + | (TraceSurface & { id?: string }) + | (MarkdownSurface & { id?: string }) + | (TerminalSurface & { id?: string }) + | (MermaidSurface & { id?: string }) + | (JsonSurface & { id?: string }) + | (CodeSurface & { id?: string }); export interface PostVersion { version: number; @@ -385,6 +389,14 @@ export async function hashAssetId(data: Uint8Array): Promise { return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join(""); } +// Assign a stable id to every surface that lacks one, preserving existing ids. +// Called by the stores on create/update so all persisted surfaces are +// addressable. Per-surface flow functions call this after mutating a single +// surface so untouched surfaces keep their ids. +export function normalizeSurfaceIds(surfaces: Surface[]): Surface[] { + return surfaces.map((s) => (s.id ? s : { ...s, id: newId() })); +} + // A snippet is sugar for a single html surface; this bridges the legacy // `{ html }` shape (CLI `publish`, `POST /api/snippets`) to the surfaces model. // An optional `kits` list opts the surface into style/behavior bundles (kits.ts). diff --git a/test/api.test.ts b/test/api.test.ts index 334def2..2b26891 100644 --- a/test/api.test.ts +++ b/test/api.test.ts @@ -355,7 +355,9 @@ test("REST surface routes reject malformed parts before storage", async () => { const unchanged = (await (await app.request(`/api/surfaces/${good.id}`)).json()) as any; assert.equal(unchanged.version, 1); - assert.deepEqual(unchanged.surfaces, [{ kind: "html", html: "

x

" }]); + assert.equal(unchanged.surfaces.length, 1); + assert.equal(unchanged.surfaces[0].kind, "html"); + assert.equal(unchanged.surfaces[0].html, "

x

"); }); test("publish_surface MCP tool round-trips a diff part", async () => { @@ -2526,3 +2528,424 @@ test("PATCH /api/posts/:id bumps version and keeps history", async () => { assert.equal(full.history.length, 1); assert.equal(full.history[0].surfaces[0].markdown, "# v1"); }); + +// --------------------------------------------------------------------------- +// Per-surface sub-resource routes (append / replace / remove / reorder) +// --------------------------------------------------------------------------- + +test("POST /api/posts/:id/surfaces appends a surface", async () => { + const app = makeApp(); + const created = (await ( + await app.request( + "/api/posts", + json({ title: "Multi", surfaces: [{ kind: "html", html: "

first

" }] }), + ) + ).json()) as any; + + const res = await app.request( + `/api/posts/${created.id}/surfaces`, + json({ + surface: { kind: "markdown", markdown: "# appended" }, + }), + ); + assert.equal(res.status, 200); + const updated = (await res.json()) as any; + assert.deepEqual(updated.kinds, ["html", "markdown"]); + + const full = (await (await app.request(`/api/posts/${created.id}`)).json()) as any; + assert.equal(full.surfaces.length, 2); + assert.equal(full.surfaces[1].kind, "markdown"); + assert.equal(full.surfaces[1].markdown, "# appended"); + assert.ok(full.surfaces[1].id, "appended surface gets an id"); +}); + +test("POST /api/posts/:id/surfaces inserts at a position via before/after", async () => { + const app = makeApp(); + const created = (await ( + await app.request( + "/api/posts", + json({ + title: "Pos", + surfaces: [ + { kind: "html", html: "

a

" }, + { kind: "html", html: "

b

" }, + ], + }), + ) + ).json()) as any; + const full0 = (await (await app.request(`/api/posts/${created.id}`)).json()) as any; + const firstId = full0.surfaces[0].id; + + // Insert before the first surface (by id) + await app.request( + `/api/posts/${created.id}/surfaces`, + json({ + surface: { kind: "markdown", markdown: "# inserted" }, + before: firstId, + }), + ); + const full = (await (await app.request(`/api/posts/${created.id}`)).json()) as any; + assert.deepEqual( + full.surfaces.map((s: any) => s.kind), + ["markdown", "html", "html"], + ); +}); + +test("PATCH /api/posts/:id/surfaces/:target replaces a surface by id", async () => { + const app = makeApp(); + const created = (await ( + await app.request( + "/api/posts", + json({ + title: "Rep", + surfaces: [ + { kind: "html", html: "

orig

" }, + { kind: "markdown", markdown: "# keep" }, + ], + }), + ) + ).json()) as any; + const full0 = (await (await app.request(`/api/posts/${created.id}`)).json()) as any; + const targetId = full0.surfaces[0].id; + + const res = await app.request( + `/api/posts/${created.id}/surfaces/${targetId}`, + patch({ + surface: { kind: "code", code: "console.log('new')" }, + }), + ); + assert.equal(res.status, 200); + const full = (await (await app.request(`/api/posts/${created.id}`)).json()) as any; + assert.equal(full.surfaces[0].kind, "code"); + assert.equal(full.surfaces[0].code, "console.log('new')"); + assert.equal(full.surfaces[0].id, targetId, "replaced surface keeps its id"); + assert.equal(full.surfaces[1].kind, "markdown", "other surface untouched"); +}); + +test("PATCH /api/posts/:id/surfaces/:target full replacement applies kits to html surface", async () => { + const app = makeApp(); + const created = (await ( + await app.request( + "/api/posts", + json({ + title: "Kits", + surfaces: [ + { kind: "html", html: "

orig

" }, + { kind: "markdown", markdown: "# keep" }, + ], + }), + ) + ).json()) as any; + const full0 = (await (await app.request(`/api/posts/${created.id}`)).json()) as any; + const targetId = full0.surfaces[0].id; + + const res = await app.request( + `/api/posts/${created.id}/surfaces/${targetId}`, + patch({ + surface: { kind: "html", html: "

new

" }, + kits: ["issues"], + }), + ); + assert.equal(res.status, 200); + const full = (await (await app.request(`/api/posts/${created.id}`)).json()) as any; + assert.equal(full.surfaces[0].kind, "html"); + assert.equal(full.surfaces[0].html, "

new

"); + assert.deepEqual(full.surfaces[0].kits, ["issues"], "kits applied to full html replacement"); + assert.equal(full.surfaces[0].id, targetId, "replaced surface keeps its id"); +}); + +test("PATCH /api/posts/:id/surfaces/:target content-only update by index", async () => { + const app = makeApp(); + const created = (await ( + await app.request( + "/api/posts", + json({ + title: "CI", + surfaces: [ + { kind: "html", html: "

a

" }, + { kind: "markdown", markdown: "# b" }, + ], + }), + ) + ).json()) as any; + + const res = await app.request( + `/api/posts/${created.id}/surfaces/1`, + patch({ + content: "# updated", + }), + ); + assert.equal(res.status, 200); + const full = (await (await app.request(`/api/posts/${created.id}`)).json()) as any; + assert.equal(full.surfaces[1].kind, "markdown", "kind preserved"); + assert.equal(full.surfaces[1].markdown, "# updated"); + assert.equal(full.surfaces[0].html, "

a

", "other surface untouched"); +}); + +test("DELETE /api/posts/:id/surfaces/:target removes a surface", async () => { + const app = makeApp(); + const created = (await ( + await app.request( + "/api/posts", + json({ + title: "Del", + surfaces: [ + { kind: "html", html: "

a

" }, + { kind: "markdown", markdown: "# b" }, + ], + }), + ) + ).json()) as any; + + const res = await app.request(`/api/posts/${created.id}/surfaces/1`, { + method: "DELETE", + }); + assert.equal(res.status, 200); + const full = (await (await app.request(`/api/posts/${created.id}`)).json()) as any; + assert.equal(full.surfaces.length, 1); + assert.equal(full.surfaces[0].kind, "html"); +}); + +test("DELETE /api/posts/:id/surfaces/:target rejects removing the last surface", async () => { + const app = makeApp(); + const created = (await ( + await app.request( + "/api/posts", + json({ title: "Last", surfaces: [{ kind: "html", html: "

only

" }] }), + ) + ).json()) as any; + + const res = await app.request(`/api/posts/${created.id}/surfaces/0`, { + method: "DELETE", + }); + assert.equal(res.status, 400); + assert.match(((await res.json()) as any).error, /at least one surface/); +}); + +test("PATCH /api/posts/:id/surfaces reorders surfaces by id", async () => { + const app = makeApp(); + const created = (await ( + await app.request( + "/api/posts", + json({ + title: "Ord", + surfaces: [ + { kind: "html", html: "

a

" }, + { kind: "markdown", markdown: "# b" }, + { kind: "code", code: "x" }, + ], + }), + ) + ).json()) as any; + const full0 = (await (await app.request(`/api/posts/${created.id}`)).json()) as any; + const ids = full0.surfaces.map((s: any) => s.id); + + const res = await app.request( + `/api/posts/${created.id}/surfaces`, + patch({ + order: [ids[2], ids[0], ids[1]], + }), + ); + assert.equal(res.status, 200); + const full = (await (await app.request(`/api/posts/${created.id}`)).json()) as any; + assert.deepEqual( + full.surfaces.map((s: any) => s.kind), + ["code", "html", "markdown"], + ); + // ids are preserved on the surfaces + assert.equal(full.surfaces[0].id, ids[2]); + assert.equal(full.surfaces[1].id, ids[0]); + assert.equal(full.surfaces[2].id, ids[1]); +}); + +test("PATCH /api/posts/:id with surface param targets multi-surface post", async () => { + const app = makeApp(); + const created = (await ( + await app.request( + "/api/posts", + json({ + title: "T", + surfaces: [ + { kind: "html", html: "

a

" }, + { kind: "markdown", markdown: "# b" }, + ], + }), + ) + ).json()) as any; + const full0 = (await (await app.request(`/api/posts/${created.id}`)).json()) as any; + const targetId = full0.surfaces[1].id; + + // Content-only update targeting surface 1 by id + const res = await app.request( + `/api/posts/${created.id}`, + patch({ + content: "# updated b", + surface: targetId, + }), + ); + assert.equal(res.status, 200); + const full = (await (await app.request(`/api/posts/${created.id}`)).json()) as any; + assert.equal(full.surfaces[1].markdown, "# updated b"); + assert.equal(full.surfaces[0].html, "

a

", "other surface untouched"); +}); + +// --------------------------------------------------------------------------- +// MCP per-surface tools (add_surface / edit_surface / remove_surface / reorder_surfaces) +// --------------------------------------------------------------------------- + +test("mcp add_surface appends a surface via HTTP MCP", async () => { + const app = makeApp(); + const pub = (await ( + await app.request( + "/mcp", + mcpCall(1, "tools/call", { + name: "publish_post", + arguments: { title: "MCP", surfaces: [{ kind: "html", html: "

a

" }] }, + }), + ) + ).json()) as any; + const postId = JSON.parse(pub.result.content[0].text).id; + + const res = (await ( + await app.request( + "/mcp", + mcpCall(2, "tools/call", { + name: "add_surface", + arguments: { postId, surface: { kind: "markdown", markdown: "# appended" } }, + }), + ) + ).json()) as any; + assert.equal(res.result.isError, undefined); + const out = JSON.parse(res.result.content[0].text); + assert.equal(out.version, 2); + assert.ok(out.url.includes(`/p/${postId}`)); + + const full = (await (await app.request(`/api/posts/${postId}`)).json()) as any; + assert.deepEqual( + full.surfaces.map((s: any) => s.kind), + ["html", "markdown"], + ); +}); + +test("mcp edit_surface content-only update via HTTP MCP", async () => { + const app = makeApp(); + const pub = (await ( + await app.request( + "/mcp", + mcpCall(1, "tools/call", { + name: "publish_post", + arguments: { + title: "MCP", + surfaces: [ + { kind: "html", html: "

a

" }, + { kind: "markdown", markdown: "# b" }, + ], + }, + }), + ) + ).json()) as any; + const postId = JSON.parse(pub.result.content[0].text).id; + const full0 = (await (await app.request(`/api/posts/${postId}`)).json()) as any; + const target = full0.surfaces[1].id; + + const res = (await ( + await app.request( + "/mcp", + mcpCall(2, "tools/call", { + name: "edit_surface", + arguments: { postId, target, content: "# updated via mcp" }, + }), + ) + ).json()) as any; + assert.equal(res.result.isError, undefined); + + const full = (await (await app.request(`/api/posts/${postId}`)).json()) as any; + assert.equal(full.surfaces[1].markdown, "# updated via mcp"); + assert.equal(full.surfaces[1].id, target, "id preserved"); + assert.equal(full.surfaces[0].html, "

a

", "other surface untouched"); +}); + +test("mcp remove_surface via HTTP MCP", async () => { + const app = makeApp(); + const pub = (await ( + await app.request( + "/mcp", + mcpCall(1, "tools/call", { + name: "publish_post", + arguments: { + title: "MCP", + surfaces: [ + { kind: "html", html: "

keep

" }, + { kind: "markdown", markdown: "# remove me" }, + ], + }, + }), + ) + ).json()) as any; + const postId = JSON.parse(pub.result.content[0].text).id; + + const res = (await ( + await app.request( + "/mcp", + mcpCall(2, "tools/call", { + name: "remove_surface", + arguments: { postId, target: "1" }, + }), + ) + ).json()) as any; + assert.equal(res.result.isError, undefined); + + const full = (await (await app.request(`/api/posts/${postId}`)).json()) as any; + assert.equal(full.surfaces.length, 1); + assert.equal(full.surfaces[0].kind, "html"); +}); + +test("mcp reorder_surfaces via HTTP MCP", async () => { + const app = makeApp(); + const pub = (await ( + await app.request( + "/mcp", + mcpCall(1, "tools/call", { + name: "publish_post", + arguments: { + title: "MCP", + surfaces: [ + { kind: "html", html: "

a

" }, + { kind: "markdown", markdown: "# b" }, + { kind: "code", code: "x" }, + ], + }, + }), + ) + ).json()) as any; + const postId = JSON.parse(pub.result.content[0].text).id; + const full0 = (await (await app.request(`/api/posts/${postId}`)).json()) as any; + const ids = full0.surfaces.map((s: any) => s.id); + + const res = (await ( + await app.request( + "/mcp", + mcpCall(2, "tools/call", { + name: "reorder_surfaces", + arguments: { postId, order: [ids[2], ids[0], ids[1]] }, + }), + ) + ).json()) as any; + assert.equal(res.result.isError, undefined); + + const full = (await (await app.request(`/api/posts/${postId}`)).json()) as any; + assert.deepEqual( + full.surfaces.map((s: any) => s.kind), + ["code", "html", "markdown"], + ); +}); + +test("mcp tools/list includes the new per-surface tools", 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("add_surface")); + assert.ok(names.includes("edit_surface")); + assert.ok(names.includes("remove_surface")); + assert.ok(names.includes("reorder_surfaces")); +}); diff --git a/test/cli.test.ts b/test/cli.test.ts index b3ce3a6..b3e4441 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -88,6 +88,7 @@ for (const cmd of [ "publish", "diff", "update", + "surface", "wait", "watch", "comment", @@ -419,7 +420,7 @@ test("publish reads html from stdin with '-'", async () => { } }); -test("publish combines html with --md, --code, --terminal, --mermaid surfaces", async () => { +test("publish combines html with --md, --code, --terminal, --mermaid surfaces in flag order", async () => { const server = await serveSession(); try { const html = tmpFile("h.html", "
x
"); @@ -442,9 +443,53 @@ test("publish combines html with --md, --code, --terminal, --mermaid surfaces", ); assert.equal(exit, 0); const out = JSON.parse(stdout); - // The publish command appends in a fixed order: html, md, mermaid, diff, - // terminal, json, code, image — independent of flag order on the command line. - assert.deepEqual(out.kinds, ["html", "markdown", "mermaid", "terminal", "code"]); + // Surfaces appear in the order their flags were passed on the command line. + assert.deepEqual(out.kinds, ["html", "markdown", "code", "terminal", "mermaid"]); + } finally { + await server.close(); + } +}); + +test("publish surface order follows flag order, not a fixed sequence", async () => { + const server = await serveSession(); + try { + const html = tmpFile("h.html", "
x
"); + const md = tmpFile("m.md", "# heading"); + const code = tmpFile("snippet.ts", "const x = 1;"); + const mermaid = tmpFile("d.mmd", "graph TD; A-->B"); + + // Same flags, different order → different surface order. + const a = await cli(server, "publish", html, "--code", code, "--mermaid", mermaid, "--md", md); + const b = await cli(server, "publish", html, "--md", md, "--mermaid", mermaid, "--code", code); + assert.equal(a.code, 0); + assert.equal(b.code, 0); + const outA = JSON.parse(a.stdout); + const outB = JSON.parse(b.stdout); + assert.deepEqual(outA.kinds, ["html", "code", "mermaid", "markdown"]); + assert.deepEqual(outB.kinds, ["html", "markdown", "mermaid", "code"]); + } finally { + await server.close(); + } +}); + +test("publish surfaces with --terminal before --md produces terminal-then-markdown order", async () => { + const server = await serveSession(); + try { + const html = tmpFile("h.html", "
x
"); + const md = tmpFile("m.md", "# heading"); + const term = tmpFile("t.log", "$ echo hi"); + const { code: exit, stdout } = await cli( + server, + "publish", + html, + "--terminal", + term, + "--md", + md, + ); + assert.equal(exit, 0); + const out = JSON.parse(stdout); + assert.deepEqual(out.kinds, ["html", "terminal", "markdown"]); } finally { await server.close(); } @@ -648,6 +693,112 @@ test("update without an id fails with a usage error", async () => { } }); +// --- surface subcommand (add / remove / edit / move) ----------------------- + +test("surface add appends a markdown surface to an existing post", async () => { + const server = await serveSession(); + try { + const html = tmpFile("h.html", "

first

"); + const pub = await cli(server, "publish", html); + const id = JSON.parse(pub.stdout).id; + + const md = tmpFile("m.md", "# appended"); + const { code, stdout } = await cli(server, "surface", "add", id, "--md", md); + assert.equal(code, 0); + const out = JSON.parse(stdout); + assert.deepEqual(out.kinds, ["html", "markdown"]); + + const full = (await fetch(`${server.url}/api/posts/${id}`).then((r) => r.json())) as any; + assert.equal(full.surfaces[1].markdown, "# appended"); + } finally { + await server.close(); + } +}); + +test("surface add appends a diff surface with --layout split", async () => { + const server = await serveSession(); + try { + const html = tmpFile("h.html", "

first

"); + const pub = await cli(server, "publish", html); + const id = JSON.parse(pub.stdout).id; + + const patch = tmpFile("d.patch", "--- a\n+++ b\n@@ -1 +1 @@\n-old\n+new\n"); + const { code } = await cli(server, "surface", "add", id, "--diff", patch, "--layout", "split"); + assert.equal(code, 0); + + const full = (await fetch(`${server.url}/api/posts/${id}`).then((r) => r.json())) as any; + assert.equal(full.surfaces[1].kind, "diff"); + assert.equal(full.surfaces[1].layout, "split", "layout split is propagated"); + } finally { + await server.close(); + } +}); + +test("surface remove deletes a surface by index", async () => { + const server = await serveSession(); + try { + const html = tmpFile("h.html", "

a

"); + const md = tmpFile("m.md", "# b"); + const pub = await cli(server, "publish", html, "--md", md); + const id = JSON.parse(pub.stdout).id; + + const { code, stdout } = await cli(server, "surface", "remove", id, "1"); + assert.equal(code, 0); + const out = JSON.parse(stdout); + assert.deepEqual(out.kinds, ["html"]); + } finally { + await server.close(); + } +}); + +test("surface edit replaces a surface's content by id", async () => { + const server = await serveSession(); + try { + const html = tmpFile("h.html", "

orig

"); + const md = tmpFile("m.md", "# orig md"); + const pub = await cli(server, "publish", html, "--md", md); + const id = JSON.parse(pub.stdout).id; + + const full = (await fetch(`${server.url}/api/posts/${id}`).then((r) => r.json())) as any; + const mdId = full.surfaces[1].id; + + const newMd = tmpFile("m2.md", "# updated md"); + const { code } = await cli(server, "surface", "edit", id, mdId, newMd); + assert.equal(code, 0); + + const updated = (await fetch(`${server.url}/api/posts/${id}`).then((r) => r.json())) as any; + assert.equal(updated.surfaces[1].markdown, "# updated md"); + assert.equal(updated.surfaces[0].html, "

orig

", "other surface untouched"); + } finally { + await server.close(); + } +}); + +test("surface move reorders a surface by id", async () => { + const server = await serveSession(); + try { + const html = tmpFile("h.html", "

a

"); + const md = tmpFile("m.md", "# b"); + const code = tmpFile("c.ts", "const x = 1;"); + const pub = await cli(server, "publish", html, "--md", md, "--code", code); + const id = JSON.parse(pub.stdout).id; + + const full = (await fetch(`${server.url}/api/posts/${id}`).then((r) => r.json())) as any; + const mdId = full.surfaces[1].id; + + const { code: exitCode } = await cli(server, "surface", "move", id, mdId, "--to", "0"); + assert.equal(exitCode, 0); + + const updated = (await fetch(`${server.url}/api/posts/${id}`).then((r) => r.json())) as any; + assert.deepEqual( + updated.surfaces.map((s: any) => s.kind), + ["markdown", "html", "code"], + ); + } finally { + await server.close(); + } +}); + // --- wait (blocking feedback long-poll) ----------------------------------- test("wait returns a pending user comment immediately", async () => { diff --git a/test/storeContract.ts b/test/storeContract.ts index 51af7ae..fd881d9 100644 --- a/test/storeContract.ts +++ b/test/storeContract.ts @@ -1,12 +1,15 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { HISTORY_LIMIT, htmlSurface, type Store } from "../server/types.ts"; +import { HISTORY_LIMIT, htmlSurface, type Store, type Surface } from "../server/types.ts"; const bytes = (...values: number[]) => new Uint8Array(values); const NUL = String.fromCharCode(0); - const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +// Strip the server-assigned id from each surface for deepEqual comparisons +// against test-constructed surfaces that don't carry ids. +const stripIds = (surfaces: Surface[]) => surfaces.map(({ id: _, ...rest }) => rest); + // Reusable contract suite: every Store implementation must pass it. // makeStore must return a fresh, empty store on each call. export function runStoreContract(name: string, makeStore: () => Store | Promise) { @@ -135,14 +138,18 @@ export function runStoreContract(name: string, makeStore: () => Store | Promise< surface.title = "mutated return"; surface.surfaces[0] = htmlSurface("

mutated return

"); assert.equal((await store.getPost(surface.id))?.title, "Card"); - assert.deepEqual((await store.getPost(surface.id))?.surfaces, [htmlSurface("

v1

")]); + assert.deepEqual(stripIds((await store.getPost(surface.id))?.surfaces ?? []), [ + htmlSurface("

v1

"), + ]); const patchParts = [htmlSurface("

v2

")]; const updated = await store.updatePost(surface.id, { surfaces: patchParts }); assert.ok(updated); patchParts[0].html = "

mutated patch

"; updated.surfaces[0] = htmlSurface("

mutated update return

"); - assert.deepEqual((await store.getPost(surface.id))?.surfaces, [htmlSurface("

v2

")]); + assert.deepEqual(stripIds((await store.getPost(surface.id))?.surfaces ?? []), [ + htmlSurface("

v2

"), + ]); const comment = await store.createComment({ sessionId: session.id, @@ -206,7 +213,7 @@ export function runStoreContract(name: string, makeStore: () => Store | Promise< assert.ok(surface); assert.equal(surface.title, "Untitled"); assert.equal(surface.version, 1); - assert.deepEqual(surface.surfaces, [htmlSurface("

x

")]); + assert.deepEqual(stripIds(surface.surfaces), [htmlSurface("

x

")]); assert.deepEqual(surface.history, []); assert.equal(surface.updatedAt, surface.createdAt); @@ -246,6 +253,34 @@ export function runStoreContract(name: string, makeStore: () => Store | Promise< assert.deepEqual(await store.getPost(surface.id), surface); }); + contract("assigns stable ids to surfaces on create and update", async (store) => { + const session = await store.createSession({ agent: "pi" }); + const surface = await store.createPost({ + sessionId: session.id, + surfaces: [htmlSurface("

a

"), { kind: "markdown", markdown: "# b" }], + }); + assert.ok(surface); + assert.equal(surface.surfaces.length, 2); + assert.ok(surface.surfaces[0].id, "create assigns ids to every surface"); + assert.ok(surface.surfaces[1].id); + assert.notEqual(surface.surfaces[0].id, surface.surfaces[1].id, "ids are unique"); + + // full-replace update assigns fresh ids (the validator strips client-sent ids) + const updated = await store.updatePost(surface.id, { + surfaces: [htmlSurface("

c

"), { kind: "markdown", markdown: "# d" }], + }); + assert.ok(updated); + assert.ok(updated.surfaces[0].id); + assert.ok(updated.surfaces[1].id); + // the old ids and new ids differ (full replace = new surfaces) + assert.notEqual(surface.surfaces[0].id, updated.surfaces[0].id); + + // title-only update preserves surface ids + const retitled = await store.updatePost(surface.id, { title: "T2" }); + assert.ok(retitled); + assert.equal(retitled.surfaces[0].id, updated.surfaces[0].id, "title-only keeps ids"); + }); + 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" }); @@ -280,20 +315,26 @@ export function runStoreContract(name: string, makeStore: () => Store | Promise< const updated = await store.updatePost(surface.id, { surfaces: [htmlSurface("

v2

")] }); assert.equal(updated?.version, 2); - assert.deepEqual(updated?.surfaces, [htmlSurface("

v2

")]); + assert.deepEqual(stripIds(updated?.surfaces ?? []), [htmlSurface("

v2

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

v1

")], - at: v1UpdatedAt, - }); + assert.deepEqual( + { + ...updated?.history[0], + surfaces: stripIds(updated?.history[0].surfaces ?? []), + }, + { + version: 1, + title: "T", + surfaces: [htmlSurface("

v1

")], + at: v1UpdatedAt, + }, + ); // title-only patch keeps parts; blank title keeps the old title const retitled = await store.updatePost(surface.id, { title: "T2" }); assert.equal(retitled?.title, "T2"); - assert.deepEqual(retitled?.surfaces, [htmlSurface("

v2

")]); + assert.deepEqual(stripIds(retitled?.surfaces ?? []), [htmlSurface("

v2

")]); const blank = await store.updatePost(surface.id, { title: " ", surfaces: [htmlSurface("

v4

")], @@ -324,7 +365,7 @@ export function runStoreContract(name: string, makeStore: () => Store | Promise< // 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.deepEqual(final?.history[HISTORY_LIMIT - 1].surfaces, [ + assert.deepEqual(stripIds(final?.history[HISTORY_LIMIT - 1].surfaces ?? []), [ htmlSurface(`

v${updates}

`), ]); }); diff --git a/viewer/src/Card.tsx b/viewer/src/Card.tsx index 37d70c4..dfa5676 100644 --- a/viewer/src/Card.tsx +++ b/viewer/src/Card.tsx @@ -2,7 +2,6 @@ import { createEffect, createSignal, For, - Index, type JSX, Match, onCleanup, @@ -234,7 +233,7 @@ export function Card(props: { post: Post; standalone?: boolean }) { broken diff), so it shows a neutral refresh hint instead. An html iframe src changes only when the version, the active theme, or the resolved light/dark mode does, so unrelated refetches never reload it. */} - + {(surface, i) => ( } > - {/* Every kind that becomes HTML renders the same way: a sandboxed - iframe pointed at /s/:id?part=N, which the server renders (author - html, server-rendered markdown/code/diff/terminal, or the - self-rendering mermaid doc). The src changes only when the - version, active theme, or resolved light/dark mode does, so - unrelated refetches never reload it. (`?part=` is the legacy wire - query key for a surface index.) */} - + - - + + - - + + - - + + )} - +
x.id === s.id); if (idx >= 0) { - setPostsInternal(idx, reconcile(s)); + setPostsInternal(idx, reconcile(s, { key: "id" })); } else { // Follow new posts only when the user is already at the bottom; // never yank them away from whatever they're reading mid-scroll.