Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/fresh-menus-share.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion e2e/embed-stream.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
3 changes: 1 addition & 2 deletions e2e/public-read.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
60 changes: 59 additions & 1 deletion e2e/viewer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<p>drawn</p>" },
],
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("<p>drawn</p>");
}

// 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,
Expand Down Expand Up @@ -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();
Expand Down
14 changes: 14 additions & 0 deletions server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading