diff --git a/.changeset/fresh-menus-share.md b/.changeset/fresh-menus-share.md
new file mode 100644
index 0000000..95b1631
--- /dev/null
+++ b/.changeset/fresh-menus-share.md
@@ -0,0 +1,16 @@
+---
+"sideshow": minor
+---
+
+viewer: fold the card's export actions into one share menu, with copy-as-markdown
+
+A card's footer carried three separate icons that all meant "take this
+elsewhere" — copy link, open in a new tab, open as a PNG — and no room for a
+fourth. They are now rows in a single **Share** menu, joined by **Copy as
+markdown**: the whole post as portable markdown, with prose kept as prose,
+code/diffs/terminal output/JSON/mermaid as fenced blocks, images as image links,
+and an html surface degraded to a link back to it rather than a dump of its
+markup.
+
+The flattening is served, not derived in the browser, so every tier can have it:
+`GET /api/posts/:id/markdown` returns the same text for `curl` and the CLI.
diff --git a/README.md b/README.md
index 29cd4f5..37c5b2c 100644
--- a/README.md
+++ b/README.md
@@ -157,9 +157,15 @@ sideshow runs locally as a small Node server, or on Cloudflare Workers when your
agent and your browser live on different machines (or you want the viewer on your
phone). See **[docs/deploying.md](docs/deploying.md)**.
-Each surface has an **open-as-image** action in its footer that renders the
-surface to a PNG (`/p/:id.png`) — handy for pasting into a doc or a chat. The
-image is captured by a headless browser, so it needs Cloudflare's [Browser
+Each card's footer carries a **share** menu for taking a post elsewhere: copy its
+link, copy the whole post as markdown (`/api/posts/:id/markdown` — prose stays
+prose, code/diffs/terminal output/JSON/mermaid become fenced blocks, and an html
+surface links back rather than pasting its markup), open it in a new tab, or open
+it as an image.
+
+That last one renders the surface to a PNG (`/p/:id.png`) — handy for pasting
+into a doc or a chat. The image is captured by a headless browser, so it needs
+Cloudflare's [Browser
Rendering](https://developers.cloudflare.com/browser-rendering/) binding and
only works on a Workers deployment. On the local Node server there is no headless
browser, so the action is shown but disabled.
diff --git a/e2e/embed-stream.spec.ts b/e2e/embed-stream.spec.ts
index f44a42f..b76eddc 100644
--- a/e2e/embed-stream.spec.ts
+++ b/e2e/embed-stream.spec.ts
@@ -80,5 +80,5 @@ test("embedded engine: host layout:'stream' renders no sidebar, readonly hides w
// readonly:true via the host → write controls gone, read actions kept.
await expect(card.locator(".act.del")).toHaveCount(0);
await expect(card.locator(".act.comment")).toHaveCount(0);
- await expect(card.locator(".act.copy")).toBeVisible();
+ await expect(card.locator(".act.share")).toBeVisible();
});
diff --git a/e2e/public-read.spec.ts b/e2e/public-read.spec.ts
index 5ccfcc1..0653806 100644
--- a/e2e/public-read.spec.ts
+++ b/e2e/public-read.spec.ts
@@ -216,8 +216,7 @@ test("readonly cards hide comment and delete controls but keep read actions", as
const card = page.locator(".card:not(#whatsNew)");
await expect(card.locator(".act.comment")).toHaveCount(0);
await expect(card.locator(".act.del")).toHaveCount(0);
- await expect(card.locator(".act.copy")).toBeVisible();
- await expect(card.locator(".act.open")).toBeVisible();
+ await expect(card.locator(".act.share")).toBeVisible();
await expect(card.locator(".cmt-text")).toContainText("existing feedback");
await expect(card.locator(".composer")).toHaveCount(0);
});
diff --git a/e2e/viewer.spec.ts b/e2e/viewer.spec.ts
index a91a31d..74a551d 100644
--- a/e2e/viewer.spec.ts
+++ b/e2e/viewer.spec.ts
@@ -499,6 +499,64 @@ test("a comment's copy button puts an agent-ready paste block on the clipboard",
}
});
+test("the share menu copies a link and a markdown flattening of the post", async ({
+ page,
+ server,
+ context,
+ browserName,
+}) => {
+ const post = await publishParts(server.url, {
+ title: "Retry backoff",
+ parts: [
+ { kind: "markdown", markdown: "the plan" },
+ { kind: "html", html: "
drawn
" },
+ ],
+ agent: "e2e",
+ });
+ if (browserName === "chromium") {
+ await context.grantPermissions(["clipboard-read", "clipboard-write"]);
+ }
+
+ await page.goto(server.url);
+ const card = page.locator(".card:not(#whatsNew)");
+ const menu = page.locator(".share-menu");
+
+ // The three old export icons are gone — one share control replaces them.
+ await expect(card.locator(".act.copy, .act.open, .act.shot")).toHaveCount(0);
+ await card.locator(".act.share").click();
+ await expect(menu).toBeVisible();
+ await expect(menu.getByRole("menuitem")).toHaveText([
+ "Copy link",
+ "Copy as markdown",
+ "Open in new tab",
+ "Open as image",
+ ]);
+ // No Browser Rendering on a Node server, so the image row is inert but visible.
+ await expect(menu.getByRole("menuitem", { name: "Open as image" })).toBeDisabled();
+
+ await menu.getByRole("menuitem", { name: "Copy as markdown" }).click();
+ await expect(menu).toBeHidden();
+ await expect(page.locator("#toast")).toContainText("Copied as markdown");
+ if (browserName === "chromium") {
+ const copied = await page.evaluate(() => navigator.clipboard.readText());
+ expect(copied).toContain("## Retry backoff");
+ expect(copied).toContain("the plan");
+ // An html surface links back rather than pasting agent markup.
+ expect(copied).toContain(`/p/${post.id}?part=1`);
+ expect(copied).not.toContain("
drawn
");
+ }
+
+ // Escape closes and hands focus back to the button; a click outside closes too.
+ await card.locator(".act.share").click();
+ await expect(menu).toBeVisible();
+ await page.keyboard.press("Escape");
+ await expect(menu).toBeHidden();
+ await expect(card.locator(".act.share")).toBeFocused();
+ await card.locator(".act.share").click();
+ await page.locator(".card-title").first().click();
+ await expect(menu).toBeHidden();
+});
+
test("a failed comment send restores the input instead of losing the message", async ({
page,
server,
@@ -715,7 +773,7 @@ test("at phone width the sidebar collapses into a drawer and actions stay visibl
expect((await card.boundingBox())!.width).toBeGreaterThan(300);
// hover-only card actions are always visible at narrow widths
- await expect(card.locator(".act.open")).toHaveCSS("opacity", "1");
+ await expect(card.locator(".act.share")).toHaveCSS("opacity", "1");
// the menu button opens the drawer; picking a session closes it again
await page.locator("#menuBtn").click();
diff --git a/server/app.ts b/server/app.ts
index 0067a83..238c072 100644
--- a/server/app.ts
+++ b/server/app.ts
@@ -17,6 +17,7 @@ import {
import { EventBus, type FeedEvent } from "./events.ts";
import { kitSummaries } from "./kits.ts";
import { registerMcp } from "./mcpHttp.ts";
+import { postToMarkdown } from "./postMarkdown.ts";
import {
escapeHtml,
renderHtmlPage,
@@ -1149,6 +1150,19 @@ export function createApp({
if (!post) return c.json({ error: "post not found" }, 404);
return c.json(viewerPostView(post));
});
+ // The post flattened to portable markdown — what the viewer's share menu
+ // copies, and the same text on the CLI/HTTP tiers. Another canonical post
+ // subresource, like /viewer above. It has to be served rather than derived in
+ // the viewer: the hydrated post the viewer holds omits sandboxed surface
+ // bodies (see apiViews.ts), so only the server can see the whole post.
+ app.get("/api/posts/:id/markdown", async (c) => {
+ const post = await store.getPost(c.req.param("id"));
+ if (!post) return c.json({ error: "post not found" }, 404);
+ const origin = new URL(c.req.url).origin;
+ const base = `${origin}${requestBasePath(c.req.raw)}`;
+ const markdown = postToMarkdown(post, { postUrl: `${base}/p/${post.id}`, assetBase: base });
+ return c.text(markdown, 200, { "content-type": "text/markdown; charset=utf-8" });
+ });
app.get("/api/surfaces/:id", getPost); // legacy alias
app.get("/api/posts/:id", getPost);
app.get("/api/snippets/:id", getPost); // legacy alias
diff --git a/server/postMarkdown.ts b/server/postMarkdown.ts
new file mode 100644
index 0000000..6b86aff
--- /dev/null
+++ b/server/postMarkdown.ts
@@ -0,0 +1,331 @@
+// Flatten a post to portable markdown — what the viewer's "copy as markdown"
+// share action puts on the clipboard, and what GET /api/posts/:id/markdown
+// serves so the CLI/HTTP tiers can have it too.
+//
+// Runtime-agnostic (no `node:` imports, no DOM): the Worker DO serves this route
+// as well. It reads a stored post, so it sees full surface bodies — the viewer's
+// hydrated posts deliberately omit sandboxed surface content (see apiViews.ts),
+// which is exactly why this lives on the server rather than in the viewer.
+//
+// Each kind flattens the honest way: text kinds become fenced blocks, an image
+// becomes an image link, and `html` — markup with no faithful markdown form —
+// degrades to a link back to the surface rather than dumping its source. Unknown
+// and by-reference kinds take that same link fallback.
+import type {
+ CodeSurface,
+ DiffSurface,
+ ImageSurface,
+ JsonSurface,
+ MarkdownSurface,
+ MermaidSurface,
+ Post,
+ Surface,
+ TerminalSurface,
+} from "./types.ts";
+
+export interface PostMarkdownOptions {
+ // Absolute permalink to the post (`…/p/:id`). Surface links append `?part=N`
+ // (the legacy wire key the route still takes). Omit for a link-free document.
+ postUrl?: string;
+ // Absolute base an asset path hangs off (`…/a/:id`), i.e. origin + base path.
+ // Relative `/a/:id` links are useless once pasted elsewhere, so an image
+ // surface without this degrades to its alt text.
+ assetBase?: string;
+}
+
+// A post the flattener can read. Loosened from `Post` so a single version out
+// of `history` (which carries no id/timestamps) can be flattened too.
+export type MarkdownablePost = Pick &
+ Partial>;
+
+// Fence long enough to survive backticks in the content: markdown needs the
+// opening fence to be longer than any backtick run inside it.
+function fence(body: string, info: string): string {
+ const longest = [...body.matchAll(/`+/g)].reduce((max, m) => Math.max(max, m[0].length), 0);
+ const ticks = "`".repeat(Math.max(3, longest + 1));
+ return `${ticks}${info}\n${body.replace(/\n+$/, "")}\n${ticks}`;
+}
+
+// ANSI escapes carry no meaning in a markdown code block — strip SGR and the
+// rest of the CSI/OSC family so pasted terminal output reads as plain text.
+// oxlint-disable no-control-regex -- matching the escapes is the whole point
+export function stripAnsi(text: string): string {
+ return text
+ .replace(/\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g, "") // OSC (titles, hyperlinks)
+ .replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, "") // CSI (SGR colors, cursor moves)
+ .replace(/\u001b[@-Z\\-_]/g, ""); // two-character escapes
+}
+// oxlint-enable no-control-regex
+
+// UTC to the minute. A copied document outlives "2 minutes ago", so the stamp
+// has to be absolute — but seconds are noise for a human reading a paste.
+function stamp(iso: string): string | null {
+ const at = new Date(iso);
+ if (Number.isNaN(at.getTime())) return null;
+ return `${at.toISOString().slice(0, 16).replace("T", " ")} UTC`;
+}
+
+function surfaceUrl(opts: PostMarkdownOptions, index: number): string | null {
+ if (!opts.postUrl) return null;
+ // `?part=` is the legacy wire query key for a surface index — kept byte-identical.
+ return `${opts.postUrl}?part=${index}`;
+}
+
+const KIND_LABELS: Record = {
+ html: "Html surface",
+ trace: "Trace surface",
+};
+
+// Kinds with no markdown form (html) and by-reference kinds (trace) point back
+// at the surface instead. Also the forward-compat path: a kind this build
+// doesn't know still produces a working link rather than nothing.
+function linkFallback(surface: Surface, index: number, opts: PostMarkdownOptions): string {
+ const label = KIND_LABELS[surface.kind] ?? `${surface.kind} surface`;
+ const url = surfaceUrl(opts, index);
+ return url ? `[${label} — open in sideshow](${url})` : `_${label} ${index + 1}_`;
+}
+
+function codeBlock(surface: CodeSurface): string {
+ const body = surface.code ?? "";
+ const heading = codeHeading(surface, body);
+ return `${heading}${fence(body, surface.language ?? "text")}`;
+}
+
+// A code surface's title (usually a filename) becomes a bold line above the
+// block; an excerpt with `lineStart` says which lines it is, so the pasted block
+// keeps the context the viewer shows in its gutter.
+function codeHeading(surface: CodeSurface, body: string): string {
+ if (!surface.title && surface.lineStart === undefined) return "";
+ const name = surface.title ? `\`${surface.title}\`` : "Excerpt";
+ const start = surface.lineStart;
+ if (start === undefined) return `**${name}**\n\n`;
+ // splitLines, not a raw split: a body ending in a newline is not one line longer.
+ const end = start + Math.max(1, splitLines(body).length) - 1;
+ return `**${name}** (lines ${start}–${end})\n\n`;
+}
+
+function imageBlock(surface: ImageSurface, opts: PostMarkdownOptions): string {
+ const alt = surface.alt ?? surface.caption ?? "image";
+ const caption = surface.caption ? `\n\n_${surface.caption}_` : "";
+ if (!opts.assetBase) return `_${alt}_`;
+ return `${caption}`;
+}
+
+function diffBlock(surface: DiffSurface): string | null {
+ if (surface.patch) return fence(surface.patch, "diff");
+ if (!surface.files?.length) return null;
+ const patch = surface.files.map((f) => unifiedDiff(f.filename, f.before, f.after)).join("");
+ return patch ? fence(patch, "diff") : null;
+}
+
+function terminalBlock(surface: TerminalSurface): string {
+ const heading = surface.title ? `**${surface.title}**\n\n` : "";
+ return `${heading}${fence(stripAnsi(surface.text ?? ""), "console")}`;
+}
+
+function jsonBlock(surface: JsonSurface): string {
+ let body: string;
+ try {
+ body = JSON.stringify(surface.data, null, 2) ?? "null";
+ } catch {
+ // A cycle can't reach a stored surface (it arrived as JSON), but the store
+ // is not the only caller — degrade instead of throwing out the whole post.
+ body = String(surface.data);
+ }
+ return fence(body, "json");
+}
+
+export function surfaceToMarkdown(
+ surface: Surface,
+ index: number,
+ opts: PostMarkdownOptions = {},
+): string {
+ switch (surface.kind) {
+ case "markdown":
+ return (surface as MarkdownSurface).markdown?.trim() ?? "";
+ case "code":
+ return codeBlock(surface as CodeSurface);
+ case "diff":
+ return diffBlock(surface as DiffSurface) ?? linkFallback(surface, index, opts);
+ case "terminal":
+ return terminalBlock(surface as TerminalSurface);
+ case "json":
+ return jsonBlock(surface as JsonSurface);
+ case "mermaid":
+ // A ```mermaid fence renders as a diagram on GitHub and in most markdown
+ // viewers, so the diagram survives the paste rather than becoming source.
+ return fence((surface as MermaidSurface).mermaid ?? "", "mermaid");
+ case "image":
+ return imageBlock(surface as ImageSurface, opts);
+ default:
+ return linkFallback(surface, index, opts);
+ }
+}
+
+export function postToMarkdown(post: MarkdownablePost, opts: PostMarkdownOptions = {}): string {
+ const meta = [
+ opts.postUrl ? `[View in sideshow](${opts.postUrl})` : null,
+ post.version && post.version > 1 ? `v${post.version}` : null,
+ post.updatedAt ? stamp(post.updatedAt) : null,
+ ].filter(Boolean);
+ const blocks = [
+ `## ${post.title}`,
+ meta.length ? meta.join(" · ") : null,
+ ...post.surfaces.map((surface, i) => surfaceToMarkdown(surface, i, opts).trim()),
+ ].filter((block): block is string => !!block);
+ return blocks.join("\n\n") + "\n";
+}
+
+// --- unified diff, for a diff surface sent as before/after file pairs --------
+// The `files` form is the documented fallback for agents without a patch, so
+// this is the fallback's fallback: enough of a unified diff to paste and read —
+// and to apply. `git apply` is unforgiving, so the end-of-file newline is
+// tracked as carefully as the lines themselves. Deliberately small and
+// dependency-free: @pierre/diffs renders the real view in the viewer, and
+// pulling its SSR path in here would drag a highlighter into a text transform.
+
+const DIFF_CONTEXT = 3;
+// Above this many changed lines on either side, the middle is emitted as one
+// wholesale replacement instead of a line-matched diff. Keeps the O(n·m) matrix
+// off the heap for large files; a huge rewrite reads the same either way.
+const DIFF_MAX_MATRIX = 1500;
+const NO_EOF_MARKER = "\\ No newline at end of file";
+
+// One line of a file, plus whether it is a last line with no newline after it.
+// That flag is part of the line's IDENTITY, not decoration: "c" and "c" with no
+// trailing newline are different content, so they must not match each other in
+// the LCS — otherwise adding a final newline reads as an empty diff.
+type Entry = { line: string; noEof: boolean };
+
+function splitLines(text: string): string[] {
+ if (text === "") return [];
+ const lines = text.split("\n");
+ if (lines[lines.length - 1] === "") lines.pop();
+ return lines;
+}
+
+function entries(text: string): Entry[] {
+ const lines = splitLines(text);
+ const noEof = lines.length > 0 && !text.endsWith("\n");
+ return lines.map((line, i) => ({ line, noEof: noEof && i === lines.length - 1 }));
+}
+
+const sameEntry = (a: Entry, b: Entry) => a.line === b.line && a.noEof === b.noEof;
+
+type Op = { tag: " " | "-" | "+"; line: string; noEof: boolean };
+
+const op = (tag: Op["tag"], entry: Entry): Op => ({ tag, line: entry.line, noEof: entry.noEof });
+
+function diffOps(before: Entry[], after: Entry[]): Op[] {
+ let head = 0;
+ while (head < before.length && head < after.length && sameEntry(before[head], after[head]))
+ head++;
+ let tail = 0;
+ while (
+ tail < before.length - head &&
+ tail < after.length - head &&
+ sameEntry(before[before.length - 1 - tail], after[after.length - 1 - tail])
+ )
+ tail++;
+
+ const midBefore = before.slice(head, before.length - tail);
+ const midAfter = after.slice(head, after.length - tail);
+ const ops: Op[] = before.slice(0, head).map((entry) => op(" ", entry));
+
+ if (midBefore.length > DIFF_MAX_MATRIX || midAfter.length > DIFF_MAX_MATRIX) {
+ ops.push(...midBefore.map((entry) => op("-", entry)));
+ ops.push(...midAfter.map((entry) => op("+", entry)));
+ } else {
+ ops.push(...lcsOps(midBefore, midAfter));
+ }
+ ops.push(...before.slice(before.length - tail).map((entry) => op(" ", entry)));
+ return ops;
+}
+
+function lcsOps(before: Entry[], after: Entry[]): Op[] {
+ const n = before.length;
+ const m = after.length;
+ // lcs[i][j] = length of the longest common subsequence of before[i..], after[j..].
+ // Int32Array rows: the matrix is the one allocation here worth being careful
+ // about (DIFF_MAX_MATRIX bounds it, but that is still up to ~2.25M cells).
+ const lcs = Array.from({ length: n + 1 }, () => new Int32Array(m + 1));
+ for (let i = n - 1; i >= 0; i--) {
+ for (let j = m - 1; j >= 0; j--) {
+ lcs[i][j] = sameEntry(before[i], after[j])
+ ? lcs[i + 1][j + 1] + 1
+ : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
+ }
+ }
+ const ops: Op[] = [];
+ let i = 0;
+ let j = 0;
+ while (i < n && j < m) {
+ if (sameEntry(before[i], after[j])) {
+ ops.push(op(" ", before[i]));
+ i++;
+ j++;
+ } else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
+ ops.push(op("-", before[i]));
+ i++;
+ } else {
+ ops.push(op("+", after[j]));
+ j++;
+ }
+ }
+ while (i < n) ops.push(op("-", before[i++]));
+ while (j < m) ops.push(op("+", after[j++]));
+ return ops;
+}
+
+// A side that contributes no lines to a hunk is numbered from the line it comes
+// AFTER, so a pure insertion into an empty file is `-0,0` — not `-1,0`.
+const hunkRange = (start: number, count: number) => `${count === 0 ? start - 1 : start},${count}`;
+
+export function unifiedDiff(filename: string, before: string, after: string): string {
+ if (before === after) return "";
+ const ops = diffOps(entries(before), entries(after));
+ const hunks: string[] = [];
+ let beforeLine = 1;
+ let afterLine = 1;
+ let cursor = 0;
+ while (cursor < ops.length) {
+ if (ops[cursor].tag === " ") {
+ beforeLine++;
+ afterLine++;
+ cursor++;
+ continue;
+ }
+ // Walk to the end of this run of changes, absorbing short stretches of
+ // context so two nearby edits land in one hunk rather than two.
+ let end = cursor;
+ for (let i = cursor; i < ops.length; i++) {
+ if (ops[i].tag !== " ") end = i;
+ else if (i - end > DIFF_CONTEXT * 2) break;
+ }
+ const start = Math.max(0, cursor - DIFF_CONTEXT);
+ const stop = Math.min(ops.length, end + DIFF_CONTEXT + 1);
+ const beforeStart = beforeLine - (cursor - start);
+ const afterStart = afterLine - (cursor - start);
+ const body: string[] = [];
+ let beforeCount = 0;
+ let afterCount = 0;
+ for (let i = start; i < stop; i++) {
+ const o = ops[i];
+ body.push(o.tag + o.line);
+ // The marker annotates the line above it and counts toward neither side.
+ if (o.noEof) body.push(NO_EOF_MARKER);
+ if (o.tag !== "+") beforeCount++;
+ if (o.tag !== "-") afterCount++;
+ }
+ hunks.push(
+ `@@ -${hunkRange(beforeStart, beforeCount)} +${hunkRange(afterStart, afterCount)} @@\n${body.join("\n")}\n`,
+ );
+ for (let i = cursor; i < stop; i++) {
+ if (ops[i].tag !== "+") beforeLine++;
+ if (ops[i].tag !== "-") afterLine++;
+ }
+ cursor = stop;
+ }
+ if (!hunks.length) return "";
+ return `--- a/${filename}\n+++ b/${filename}\n${hunks.join("")}`;
+}
diff --git a/test/api.test.ts b/test/api.test.ts
index 7df49d9..02988aa 100644
--- a/test/api.test.ts
+++ b/test/api.test.ts
@@ -304,6 +304,53 @@ test("the viewer render round-trip (POST /api/frames + GET /f/:id) is gone", asy
assert.equal((await app.request("/f/anything")).status, 404);
});
+test("GET /api/posts/:id/markdown flattens the post for the share menu", async () => {
+ const app = makeApp();
+ const created = (await (
+ await app.request(
+ "/api/posts",
+ json({
+ title: "Retry backoff",
+ surfaces: [
+ { kind: "markdown", markdown: "the plan" },
+ { kind: "html", html: "drawn" },
+ ],
+ }),
+ )
+ ).json()) as any;
+
+ const res = await app.request(`https://board.test/api/posts/${created.id}/markdown`);
+ assert.equal(res.status, 200);
+ assert.match(res.headers.get("content-type") ?? "", /text\/markdown/);
+ const md = await res.text();
+ assert.match(md, /^## Retry backoff\n/);
+ assert.match(md, /the plan/);
+ // Links are absolute — the whole point is that the text survives a paste.
+ assert.match(md, new RegExp(`\\(https://board.test/p/${created.id}\\)`));
+ assert.match(md, new RegExp(`\\(https://board.test/p/${created.id}\\?part=1\\)`));
+ // An html surface links back rather than dumping agent markup.
+ assert.doesNotMatch(md, /drawn<\/b>/);
+
+ assert.equal((await app.request("/api/posts/nope/markdown")).status, 404);
+});
+
+test("post markdown resolves links against a base path and reaches public readers", async () => {
+ const app = makeApp("secret", { publicRead: "session", basePath: "/alice" });
+ const created = (await (
+ await app.request(
+ "/api/posts",
+ authedJson({ title: "T", surfaces: [{ kind: "image", assetId: "sha" }] }),
+ )
+ ).json()) as any;
+
+ // Copying a shared post is a read — a public-read visitor gets it unauthenticated.
+ const res = await app.request(`https://board.test/api/posts/${created.id}/markdown`);
+ assert.equal(res.status, 200);
+ const md = await res.text();
+ assert.match(md, new RegExp(`\\(https://board.test/alice/p/${created.id}\\)`));
+ assert.match(md, /!\[image\]\(https:\/\/board\.test\/alice\/a\/sha\)/);
+});
+
test("GET /s/:id serves the viewer shell with link-preview metadata", async () => {
const app = makeApp();
const res = await app.request(
diff --git a/test/postMarkdown.test.ts b/test/postMarkdown.test.ts
new file mode 100644
index 0000000..420cdb3
--- /dev/null
+++ b/test/postMarkdown.test.ts
@@ -0,0 +1,208 @@
+import assert from "node:assert/strict";
+import { execFileSync } from "node:child_process";
+import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { test } from "node:test";
+import { postToMarkdown, stripAnsi, unifiedDiff } from "../server/postMarkdown.ts";
+import type { MarkdownablePost } from "../server/postMarkdown.ts";
+import type { Surface } from "../server/types.ts";
+
+const OPTS = { postUrl: "https://ex.test/p/abc", assetBase: "https://ex.test" };
+
+function post(surfaces: Surface[], extra: Partial = {}): MarkdownablePost {
+ return { title: "Retry backoff", surfaces, ...extra };
+}
+
+// The only honest oracle for a patch is applying it. Asserting on hunk text
+// misses exactly the class of bug that matters — a patch that reads fine and is
+// rejected by `git apply`, or applies to content that isn't the "after" side.
+function assertApplies(before: string, after: string): void {
+ const patch = unifiedDiff("f.txt", before, after);
+ assert.notEqual(patch, "", "differing content must produce a patch");
+ const dir = mkdtempSync(join(tmpdir(), "sideshow-diff-"));
+ try {
+ execFileSync("git", ["init", "-q", "."], { cwd: dir });
+ writeFileSync(join(dir, "f.txt"), before);
+ writeFileSync(join(dir, "p.diff"), patch);
+ execFileSync("git", ["apply", "p.diff"], { cwd: dir, stdio: "pipe" });
+ assert.equal(readFileSync(join(dir, "f.txt"), "utf8"), after, `patch applied wrong:\n${patch}`);
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+}
+
+test("heads the document with the title, permalink, version and an absolute stamp", () => {
+ const md = postToMarkdown(
+ post([{ kind: "markdown", markdown: "prose" }], {
+ version: 3,
+ updatedAt: "2026-08-17T21:20:06.819Z",
+ }),
+ OPTS,
+ );
+ assert.equal(
+ md,
+ "## Retry backoff\n\n[View in sideshow](https://ex.test/p/abc) · v3 · 2026-08-17 21:20 UTC\n\nprose\n",
+ );
+});
+
+test("omits the version on v1 and the link when there is no url", () => {
+ const md = postToMarkdown(post([{ kind: "markdown", markdown: "prose" }], { version: 1 }));
+ assert.equal(md, "## Retry backoff\n\nprose\n");
+});
+
+test("flattens each surface kind to its honest markdown form", () => {
+ const md = postToMarkdown(
+ post([
+ { kind: "markdown", markdown: " prose " },
+ { kind: "code", code: "const x = 1;", language: "ts", title: "x.ts" },
+ { kind: "diff", patch: "--- a/x.ts\n+++ b/x.ts\n@@ -1 +1 @@\n-a\n+b" },
+ { kind: "terminal", text: "\u001b[32mok\u001b[0m" },
+ { kind: "json", data: { a: [1, null] } },
+ { kind: "mermaid", mermaid: "flowchart TD\n A --> B" },
+ { kind: "image", assetId: "sha", alt: "a shot", caption: "after" },
+ ]),
+ OPTS,
+ );
+ assert.equal(
+ md,
+ [
+ "## Retry backoff",
+ "[View in sideshow](https://ex.test/p/abc)",
+ "prose",
+ "**`x.ts`**\n\n```ts\nconst x = 1;\n```",
+ "```diff\n--- a/x.ts\n+++ b/x.ts\n@@ -1 +1 @@\n-a\n+b\n```",
+ "```console\nok\n```",
+ '```json\n{\n "a": [\n 1,\n null\n ]\n}\n```',
+ "```mermaid\nflowchart TD\n A --> B\n```",
+ "\n\n_after_\n",
+ ].join("\n\n"),
+ );
+});
+
+test("html has no markdown form, so it links back to the surface", () => {
+ const md = postToMarkdown(post([{ kind: "html", html: "hi" }]), OPTS);
+ assert.match(md, /\[Html surface — open in sideshow\]\(https:\/\/ex\.test\/p\/abc\?part=0\)/);
+ // Never dump markup into a document meant for pasting elsewhere.
+ assert.doesNotMatch(md, /onclick/);
+});
+
+test("a kind this build doesn't know still links rather than vanishing", () => {
+ const md = postToMarkdown(post([{ kind: "hologram" } as unknown as Surface]), OPTS);
+ assert.match(md, /\[hologram surface — open in sideshow\]\(https:\/\/ex\.test\/p\/abc\?part=0\)/);
+});
+
+test("an excerpt keeps the line numbers the viewer shows", () => {
+ const md = postToMarkdown(
+ post([{ kind: "code", code: "a\nb\nc", language: "ts", title: "x.ts", lineStart: 80 }]),
+ );
+ assert.match(md, /\*\*`x\.ts`\*\* \(lines 80–82\)/);
+});
+
+test("fences grow past backticks in the content", () => {
+ const md = postToMarkdown(post([{ kind: "code", code: "a\n```\nb", language: "text" }]));
+ assert.match(md, /````text\na\n```\nb\n````/);
+});
+
+test("an image without an absolute asset base degrades to its alt text", () => {
+ // A relative /a/:id link is broken the moment the markdown is pasted elsewhere.
+ const md = postToMarkdown(post([{ kind: "image", assetId: "sha", alt: "a shot" }]));
+ assert.equal(md, "## Retry backoff\n\n_a shot_\n");
+});
+
+test("a diff sent as before/after files becomes a real unified patch", () => {
+ const md = postToMarkdown(
+ post([
+ {
+ kind: "diff",
+ files: [{ filename: "x.ts", before: "one\ntwo\nthree\n", after: "one\n2\nthree\n" }],
+ },
+ ]),
+ );
+ assert.equal(
+ md,
+ [
+ "## Retry backoff",
+ "",
+ "```diff",
+ "--- a/x.ts",
+ "+++ b/x.ts",
+ "@@ -1,3 +1,3 @@",
+ " one",
+ "-two",
+ "+2",
+ " three",
+ "```",
+ "",
+ ].join("\n"),
+ );
+});
+
+test("unifiedDiff: no hunks for identical files, additions at the end", () => {
+ assert.equal(unifiedDiff("x.ts", "a\n", "a\n"), "");
+ assert.equal(
+ unifiedDiff("x.ts", "a\n", "a\nb\n"),
+ "--- a/x.ts\n+++ b/x.ts\n@@ -1,1 +1,2 @@\n a\n+b\n",
+ );
+});
+
+test("unifiedDiff: separate edits get separate hunks with context", () => {
+ const before = Array.from({ length: 30 }, (_, i) => `line ${i}`).join("\n");
+ const after = before.replace("line 2", "LINE 2").replace("line 25", "LINE 25");
+ const patch = unifiedDiff("x.ts", before, after);
+ assert.equal(patch.match(/^@@/gm)?.length, 2);
+ assert.match(patch, /-line 2\n\+LINE 2/);
+ assert.match(patch, /-line 25\n\+LINE 25/);
+ // Context is bounded — an unchanged middle never lands in a hunk.
+ assert.doesNotMatch(patch, /line 15/);
+});
+
+test("unifiedDiff: a wholesale rewrite stays bounded instead of building a matrix", () => {
+ const before = Array.from({ length: 4000 }, (_, i) => `old ${i}`).join("\n");
+ const after = Array.from({ length: 4000 }, (_, i) => `new ${i}`).join("\n");
+ const patch = unifiedDiff("big.ts", before, after);
+ assert.match(patch, /^-old 0$/m);
+ assert.match(patch, /^\+new 3999$/m);
+});
+
+test("stripAnsi drops SGR, cursor moves and OSC sequences", () => {
+ assert.equal(stripAnsi("\u001b[1;32mok\u001b[0m\u001b[2J"), "ok");
+ assert.equal(stripAnsi("\u001b]0;title\u0007done"), "done");
+});
+
+// Every case below is one `git apply` reproduced by hand from a real failure:
+// without the `\ No newline at end of file` marker (and with an empty file
+// modelled as one blank line) each of these produced a patch git rejects, or
+// worse, one that applies and yields content the agent never sent.
+test("patches apply cleanly regardless of the end-of-file newline", () => {
+ assertApplies("a\nb\nc\n", "a\nB\nc\n"); // the easy case
+ assertApplies("a\nb\nc", "a\nB\nc"); // neither side ends with a newline
+ assertApplies("a\nb\nc\n", "a\nB\nc"); // the trailing newline is dropped
+ assertApplies("a\nb\nc", "a\nB\nc\n"); // ...and added
+ assertApplies("a\nb\nc", "a\nb\nC"); // the edit lands on the last line
+ assertApplies("", "foo\n"); // an empty file gains content
+ assertApplies("foo\n", ""); // ...and loses all of it
+ assertApplies("", "foo"); // empty in, no trailing newline out
+ assertApplies("one\n", "one\ntwo\nthree\n"); // pure append
+});
+
+test("a trailing-newline-only change is a real diff, not an empty one", () => {
+ // The lines are identical; only the end-of-file newline moves. Treating the
+ // last line as unchanged made this vanish into the link fallback.
+ assertApplies("a\nb\nc", "a\nb\nc\n");
+ const md = postToMarkdown(
+ post([{ kind: "diff", files: [{ filename: "x.ts", before: "a\nb\nc", after: "a\nb\nc\n" }] }]),
+ OPTS,
+ );
+ assert.match(md, /```diff/);
+ assert.match(md, /\\ No newline at end of file/);
+ assert.doesNotMatch(md, /open in sideshow/);
+});
+
+test("an excerpt ending in a newline is not counted one line too long", () => {
+ const heading = (code: string) =>
+ postToMarkdown(post([{ kind: "code", code, language: "ts", lineStart: 10 }]));
+ assert.match(heading("a\nb\nc\n"), /\(lines 10–12\)/);
+ assert.match(heading("a\nb\nc"), /\(lines 10–12\)/);
+ assert.match(heading(""), /\(lines 10–10\)/);
+});
diff --git a/viewer/src/Card.tsx b/viewer/src/Card.tsx
index 5f5faae..43de4ce 100644
--- a/viewer/src/Card.tsx
+++ b/viewer/src/Card.tsx
@@ -12,7 +12,6 @@ import {
import {
api,
appPath,
- canScreenshot,
isReadonly,
relTime,
sessionLabel,
@@ -22,19 +21,10 @@ import {
type Post,
type TraceSurface as TraceSurfaceData,
type ViewerPost,
- postLink,
- postImageLink,
} from "./api.ts";
import { isSandboxedSurfaceKind, SURFACE_FRAME_CLASSES } from "../../server/types.ts";
-import {
- CommentIcon,
- ImageIcon,
- LinkIcon,
- MaximizeIcon,
- OpenIcon,
- PinIcon,
- TrashIcon,
-} from "./icons.tsx";
+import { CommentIcon, MaximizeIcon, PinIcon, TrashIcon } from "./icons.tsx";
+import { ShareMenu } from "./ShareMenu.tsx";
import { root } from "./host.ts";
import { ImageSurface } from "./ImageSurface.tsx";
import { JsonSurface } from "./JsonSurface.tsx";
@@ -579,59 +569,11 @@ export function Card(props: { post: Post | ViewerPost; standalone?: boolean }) {
-
-
-
-
- {/* Open the first renderable surface as a PNG. The image is
- rendered server-side by the Browser Rendering Worker, so the
- action is only live where that exists; on a plain Node server
- it's disabled with a tooltip that points at the README. */}
-
-
-
- }
- >
-
-
-
-
+ {/* Copy link, open in a new tab and open as a PNG all live in the
+ share menu now — one labelled control instead of three icons
+ that all mean "take this elsewhere", with room for the copy
+ formats (markdown today) that have no icon of their own. */}
+