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 `![${alt}](${opts.assetBase}/a/${surface.assetId})${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```", + "![a shot](https://ex.test/a/sha)\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. */} + + +
(menu = el)} + class="share-menu" + role="menu" + aria-label={`Share "${props.post.title}"`} + style={{ left: `${at().left}px`, top: `${at().top}px` }} + onKeyDown={onMenuKeyDown} + > + + {(action) => ( + <> + + + + + + )} + +
+
+ + ); +} diff --git a/viewer/src/api.ts b/viewer/src/api.ts index 58de672..7760007 100644 --- a/viewer/src/api.ts +++ b/viewer/src/api.ts @@ -109,6 +109,13 @@ export function postImageLink(id: string): string { return `${location.origin}${appPath(`/p/${encodeURIComponent(id)}.png`)}`; } +// The post flattened to markdown (GET /api/posts/:id/markdown). Served rather +// than derived here: a hydrated post omits sandboxed surface bodies, so only the +// server can see the whole post (see apiViews.ts). +export function postMarkdownPath(id: string): string { + return `/api/posts/${encodeURIComponent(id)}/markdown`; +} + // Whether the deployment can render post screenshots (the /p/:id.png route). // Host-first (cloud embed), falling back to the self-hosted global, mirroring // isReadonly(). False on a plain Node server, which has no Browser Rendering. @@ -128,6 +135,13 @@ export async function api(path: string, init?: RequestInit): Promis return res.json() as Promise; } +// Same fetch as api(), for the routes that answer with text rather than JSON. +export async function apiText(path: string): Promise { + const res = await fetch(appPath(path)); + if (!res.ok) throw new Error(String(res.status)); + return res.text(); +} + export const sessionLabel = (s: Session) => s.title || s.agent + " session"; export function relTime(iso: string): string { diff --git a/viewer/src/clipboard.ts b/viewer/src/clipboard.ts new file mode 100644 index 0000000..28259a4 --- /dev/null +++ b/viewer/src/clipboard.ts @@ -0,0 +1,34 @@ +// Clipboard writes, including the awkward async case. +// +// A copy whose text isn't ready yet (it's being fetched) can't just await and +// then call writeText: Safari ties clipboard permission to the user gesture, and +// the gesture is spent by the time the fetch resolves. The spec's answer is a +// ClipboardItem holding a PROMISE of the text — claimed synchronously inside the +// gesture, resolved after. Where that isn't supported we fall back to awaiting +// and writing, which is fine in Chromium. +export async function writeClipboard(text: string | Promise): Promise { + const clipboard = navigator.clipboard; + if (!clipboard) return false; + try { + if (typeof text === "string") { + await clipboard.writeText(text); + return true; + } + const ClipboardItemCtor = globalThis.ClipboardItem; + if (ClipboardItemCtor && clipboard.write) { + try { + const blob = text.then((t) => new Blob([t], { type: "text/plain" })); + await clipboard.write([new ClipboardItemCtor({ "text/plain": blob })]); + return true; + } catch { + // Older Chromium rejects a promise-valued ClipboardItem outright; the + // await path below still works there. A promise is replayable, so + // resolving it a second time costs nothing. + } + } + await clipboard.writeText(await text); + return true; + } catch { + return false; + } +} diff --git a/viewer/src/icons.tsx b/viewer/src/icons.tsx index aff99c2..1e51e1f 100644 --- a/viewer/src/icons.tsx +++ b/viewer/src/icons.tsx @@ -59,6 +59,29 @@ export function LinkIcon() { ); } +// lucide: share — the card's one "take this elsewhere" affordance. +export function ShareIcon() { + return ( + + + + + + ); +} + +// A file carrying the markdown "M" — lucide has no markdown glyph, so this is +// file-text's outline with the mark drawn in the same stroke weight. +export function MarkdownIcon() { + return ( + + + + + + ); +} + // lucide: image export function ImageIcon() { return ( diff --git a/viewer/src/styles.css b/viewer/src/styles.css index e74fdce..1c3ba12 100644 --- a/viewer/src/styles.css +++ b/viewer/src/styles.css @@ -1475,6 +1475,78 @@ iframe { .card-actions .act.del:hover { color: var(--danger); } +/* An open menu keeps its button lit — pressed, not "mode engaged" (that accent + treatment belongs to the pin toggle, which changes what a click on a surface + does). */ +.card-actions .act.share.open { + color: var(--text); + background: var(--hover); +} +/* Share menu — the card's copy/open actions. `position: fixed` (coordinates set + from the button's rect in ShareMenu.tsx) because `.card` is `overflow: hidden` + and would clip a menu positioned inside the footer. Under the fullscreen + surface dialog (z-index 70) so a diagram opened from a card still covers it. */ +.share-menu { + position: fixed; + z-index: 60; + width: 216px; + padding: 4px; + background: var(--surface); + border: 0.5px solid var(--border-2); + border-radius: 10px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); +} +@media (prefers-color-scheme: dark) { + .share-menu { + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); + } +} +.share-menu .share-item { + display: flex; + align-items: center; + gap: 9px; + width: 100%; + height: 32px; + padding: 0 8px; + font: 13px inherit; + text-align: left; + color: var(--text); + background: none; + border: none; + border-radius: 6px; + cursor: pointer; + text-decoration: none; +} +.share-menu .share-item svg { + width: 14px; + height: 14px; + flex: none; + color: var(--faint); +} +.share-menu .share-item:hover, +.share-menu .share-item:focus-visible { + background: var(--hover); + outline: none; +} +.share-menu .share-item:hover svg, +.share-menu .share-item:focus-visible svg { + color: var(--text); +} +.share-menu .share-item:disabled { + opacity: 0.45; + cursor: not-allowed; +} +.share-menu .share-item:disabled:hover { + background: none; +} +.share-menu .share-item:disabled:hover svg { + color: var(--faint); +} +.share-sep { + height: 1px; + background: var(--border); + margin: 4px 6px; +} /* A disabled action (e.g. screenshots on a server without Browser Rendering) stays visible so the affordance is discoverable, but reads as inert and keeps its tooltip. */ diff --git a/viewer/test/clipboard.test.ts b/viewer/test/clipboard.test.ts new file mode 100644 index 0000000..5da5150 --- /dev/null +++ b/viewer/test/clipboard.test.ts @@ -0,0 +1,70 @@ +import { afterEach, expect, test, vi } from "vitest"; +import { writeClipboard } from "../src/clipboard.ts"; + +// A copy whose text is still being fetched has to be claimed inside the user +// gesture or Safari drops the permission — writeClipboard hands the PROMISE to a +// ClipboardItem where it can, and only falls back to awaiting. +function stubClipboard(impl: Partial) { + Object.defineProperty(navigator, "clipboard", { value: impl, configurable: true }); + return impl; +} + +afterEach(() => { + Reflect.deleteProperty(navigator, "clipboard"); + Reflect.deleteProperty(globalThis, "ClipboardItem"); +}); + +test("a ready string goes straight to writeText", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + stubClipboard({ writeText }); + expect(await writeClipboard("hello")).toBe(true); + expect(writeText).toHaveBeenCalledWith("hello"); +}); + +test("pending text is claimed as a promise-valued ClipboardItem, not awaited first", async () => { + const write = vi.fn().mockResolvedValue(undefined); + const writeText = vi.fn().mockResolvedValue(undefined); + stubClipboard({ write, writeText }); + const items: unknown[] = []; + class FakeClipboardItem { + constructor(data: Record) { + items.push(data); + } + } + Object.defineProperty(globalThis, "ClipboardItem", { + value: FakeClipboardItem, + configurable: true, + }); + + let resolve!: (text: string) => void; + const pending = new Promise((r) => (resolve = r)); + const done = writeClipboard(pending); + // The item is constructed before the text exists — that is the whole point. + expect(items).toHaveLength(1); + resolve("# post"); + expect(await done).toBe(true); + expect(write).toHaveBeenCalled(); + expect(writeText).not.toHaveBeenCalled(); +}); + +test("falls back to awaiting the text where promise-valued items are rejected", async () => { + const write = vi.fn().mockRejectedValue(new Error("no promises here")); + const writeText = vi.fn().mockResolvedValue(undefined); + stubClipboard({ write, writeText }); + Object.defineProperty(globalThis, "ClipboardItem", { + value: class {}, + configurable: true, + }); + + expect(await writeClipboard(Promise.resolve("# post"))).toBe(true); + expect(writeText).toHaveBeenCalledWith("# post"); +}); + +test("reports failure instead of throwing, so the caller can toast", async () => { + stubClipboard({ writeText: vi.fn().mockRejectedValue(new Error("denied")) }); + expect(await writeClipboard("hello")).toBe(false); + + // A failed fetch behind the text must not escape either. + stubClipboard({ writeText: vi.fn().mockResolvedValue(undefined) }); + expect(await writeClipboard(Promise.reject(new Error("offline")))).toBe(false); +});