Skip to content

feat(viewer): download a surface as the file it came from - #266

Open
banozz0 wants to merge 1 commit into
modem-dev:mainfrom
banozz0:feat/surface-downloads
Open

banozz0 wants to merge 1 commit into
modem-dev:mainfrom
banozz0:feat/surface-downloads

Conversation

@banozz0

@banozz0 banozz0 commented Sep 12, 2026

Copy link
Copy Markdown

Every surface on a card gets a row in its share menu — "Download diagram.mmd", "Download change.patch", "Download plan.md" — serving the raw source behind it rather than a rendering. A diagram comes back editable and a diff comes back appliable; the rendered form already has a door in "Open as image", and a picture of a diagram is not a diagram.

Motivation: posts age out by LRU eviction, and there was no way to keep the valuable ones except copying the whole post as markdown. A surface you can save is a surface you can keep.

What's in it

  • GET /api/posts/:id/surfaces/:target/raw — the bytes, addressed by surface id or 0-based index, the same addressing the PATCH/DELETE surface routes take. So the CLI and curl tiers get this too, not just the viewer.
  • One share-menu row per surface, labelled with the exact filename the save will use. Rows are links, so cmd/middle-click still behaves.
  • Per kind: mermaid → .mmd, markdown → .md, diff → .patch (built from files via unifiedDiff when the surface carries no patch), code → its own filename or the language's extension, terminal → .txt (ANSI kept — the escapes are the output), json → .json, html → .html. Asset-backed surfaces (image, and trace with an uploaded file) redirect to /a/:id rather than re-encoding the blob.
  • Filenames come from the post title, with every surface after the first numbered so a multi-surface post can't collide.

Isolation

Always an attachment with nosniff; the content types are inert and an html surface degrades to application/octet-stream, so agent-authored content can never become a live document on the workspace origin. Same stance as the asset route's ATTACH_SAFE_TYPES. The redirect path reuses /a/:id, which already serves blobs under that policy and keeps the asset LRU touch-on-serve honest.

server/surfaceDownload.ts is runtime-agnostic (no node: imports, no DOM) so the Worker DO serves the route too, and the viewer imports surfaceDownloadName from it — the menu row's label and the file on disk come from one naming rule rather than two that drift.

Verification

npm test (518 node + 8 viewer), npm run test:worker, npm run typecheck, npm run lint, npm run format:check, npm run coverage, npm run bench:check (no regressions), and npm run test:e2e — 186 passing on chromium and webkit, including a new e2e/download.spec.ts whose oracle is the saved file itself: it triggers the download and asserts the filename and the bytes on disk.

npm run security:audit fails, unchanged from main — no dependencies were touched.

Known follow-up

There is no sideshow download <id> CLI command yet. The HTTP tier has the route, so curl works; the zero-dependency CLI tier deserves a verb of its own in a separate change.

🤖 Generated with Claude Code

https://claude.ai/code/session_015AZe6cmKF4ZCjwRZN1w8Yn

Every surface on a card gets a row in its share menu — "Download
diagram.mmd", "Download change.patch", "Download plan.md" — serving the
RAW SOURCE behind it rather than a rendering. A diagram comes back
editable and a diff comes back appliable; the rendered form already has
a door in "Open as image", and a picture of a diagram is not a diagram.

GET /api/posts/:id/surfaces/:target/raw serves the bytes, addressed by
surface id or 0-based index like the PATCH/DELETE surface routes. It is
always an attachment with nosniff, the content types are inert, and an
html surface degrades to application/octet-stream — agent-authored
content must never become a live document on the workspace origin.
Asset-backed surfaces (image, and trace with an uploaded file) redirect
to /a/:id, which already serves blobs under that policy.

surfaceDownload.ts is runtime-agnostic so the Worker DO serves the route
too, and the viewer imports surfaceDownloadName from it — the menu row's
label and the file on disk come from one naming rule, not two that drift.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015AZe6cmKF4ZCjwRZN1w8Yn
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant