From 8648a6ac2e9197b241384850188327a523f9aad0 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Thu, 29 Oct 2026 22:38:56 -0400 Subject: [PATCH 1/2] feat(chat): download action for chat image previews Chat image frames and the full-size preview gain a Download button next to Copy. Inside the chat iframe the PNG rides the existing save-file bridge kind, so the extension host opens a native Save dialog (default ~/Downloads) on the machine the user is at; unframed, an anchor download stands. The file keeps the authored basename (or the alt text), saved as .png since both save and copy rasterize to PNG. Adds the outline-download v2 icon and the ui.message.download string. --- packages/app-bundle/manifest.json | 56 ++++++++++--------- .../src/components/image-lightbox.tsx | 21 +++++-- .../components/markdown-local-images.test.ts | 34 ++++++++++- .../src/components/markdown-local-images.ts | 21 +++++-- .../session-ui/src/components/markdown.css | 5 ++ .../session-ui/src/components/markdown.tsx | 56 +++++++++++++------ .../session-ui/src/util/save-image.ts | 26 +++++++++ .../overlay/packages/ui/src/i18n/ar.ts | 1 + .../overlay/packages/ui/src/i18n/br.ts | 1 + .../overlay/packages/ui/src/i18n/bs.ts | 1 + .../overlay/packages/ui/src/i18n/da.ts | 1 + .../overlay/packages/ui/src/i18n/de.ts | 1 + .../overlay/packages/ui/src/i18n/en.ts | 1 + .../overlay/packages/ui/src/i18n/es.ts | 1 + .../overlay/packages/ui/src/i18n/fr.ts | 1 + .../overlay/packages/ui/src/i18n/ja.ts | 1 + .../overlay/packages/ui/src/i18n/ko.ts | 1 + .../overlay/packages/ui/src/i18n/no.ts | 1 + .../overlay/packages/ui/src/i18n/pl.ts | 1 + .../overlay/packages/ui/src/i18n/ru.ts | 1 + .../overlay/packages/ui/src/i18n/th.ts | 1 + .../overlay/packages/ui/src/i18n/tr.ts | 1 + .../overlay/packages/ui/src/i18n/uk.ts | 1 + .../overlay/packages/ui/src/i18n/zh.ts | 1 + .../overlay/packages/ui/src/i18n/zht.ts | 1 + .../packages/ui/src/v2/components/icon.tsx | 5 ++ 26 files changed, 186 insertions(+), 56 deletions(-) create mode 100644 packages/app-bundle/overlay/packages/session-ui/src/util/save-image.ts diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json index c4f0c7ffc..1b05698e2 100644 --- a/packages/app-bundle/manifest.json +++ b/packages/app-bundle/manifest.json @@ -516,16 +516,16 @@ "packages/session-ui/src/components/build-trace.test.ts": "6259c529fa667a3514bfee1c771e8f623bcb8c69e37ceb17ea541aa42e207451", "packages/session-ui/src/components/build-trace.ts": "153e3182c3a37e4aca9c88bf0e2052b0ff4ec117a60d74d2c81567ab91180066", "packages/session-ui/src/components/group-parts.test.ts": "a31ce19009e1d62e75e83b682190a4613da0eed334420409ed73a9e21834ae84", - "packages/session-ui/src/components/image-lightbox.tsx": "e03904142307429b9f48275d0e1d6ec4b9fcbbf24ea47420bc4de89db78e9e89", + "packages/session-ui/src/components/image-lightbox.tsx": "d6bddb0db8736f8284568d74ae2a33b1c3b1acf51e8627a711ae6637386a0b23", "packages/session-ui/src/components/line-comment-styles.ts": "2077836c13bd3c3f1c39c67722119e448a2dfa123377f94bfd3676634e955d00", "packages/session-ui/src/components/markdown-file-refs.test.ts": "2172d17a0ee5e49636c8bd323f4fb797a7f41e469d0098864b82d374f0c10144", "packages/session-ui/src/components/markdown-file-refs.ts": "417417f3660fe18f04a13ca4765b155b5479d7ce41b3638a57ba1abaa5830054", - "packages/session-ui/src/components/markdown-local-images.test.ts": "f344e43f4272841e867909c31955655be9b6dbfd81c986d8c6910b3ae01f6d33", - "packages/session-ui/src/components/markdown-local-images.ts": "566cb252bed28e300a93efaefd0f1bf3d893c77d2bccfa35f1fc6b961341702c", + "packages/session-ui/src/components/markdown-local-images.test.ts": "f614e986656d128f5b72ca772a7f3c8ea607cbed69559227feb3d9286f4110be", + "packages/session-ui/src/components/markdown-local-images.ts": "17fd69cdda8056711227601c695d7419bc8147433f7700eb3ed301b1803ce188", "packages/session-ui/src/components/markdown-stream.test.ts": "b73b1a15ffa2cfea5358b2d6313ce17d26a5a11302b0e3b7d823cde0c98cbc83", "packages/session-ui/src/components/markdown-stream.ts": "50e38abd5d4ff919564655799678be10463dec58502fc5487c1c97a9a8db5399", - "packages/session-ui/src/components/markdown.css": "e5654bf01501ab93bcad54b488b460d6ca283da9875978557f853106738ec830", - "packages/session-ui/src/components/markdown.tsx": "9eba82666012819518e4e9145e75373e3a6d1d0d33738da639c8e5be117c43c5", + "packages/session-ui/src/components/markdown.css": "8698bc5865247f6003d0aeb5d264dd84a38cedbdd1079be7c7250b2f1314358a", + "packages/session-ui/src/components/markdown.tsx": "ab9302aa816c272157b60ba62d85733d248e928050ef7597fccb3e8dde5254e1", "packages/session-ui/src/components/message-part-css.test.ts": "bc164695d0195684d646ebf490ba7bb4ed72adcf2bf3d01b00de47f4f03bcd4f", "packages/session-ui/src/components/message-part-groups.ts": "37be069bf46e926fcde83d3744ee706911ff002e6b532e279290d841aeee96da", "packages/session-ui/src/components/message-part-skill.test.ts": "453bdcb25e4df91ad1004635b8766070a60421355adf18ee53f43cd2fa7d0486", @@ -553,6 +553,7 @@ "packages/session-ui/src/pierre/worker.ts": "7f210f931777956567eaeea15f2f5478befc7874a64a4b685504c59c66503fee", "packages/session-ui/src/util/clipboard.test.ts": "16dc2c7bc04f00134841e6e1e6b503f519a14aa41e4cb83cb1d63002a770992c", "packages/session-ui/src/util/clipboard.ts": "89dc04d0faec989f48c44d82107a5b3d024f078f6146998f2392672b1647131e", + "packages/session-ui/src/util/save-image.ts": "599b4fe69c191c41db1d231303b94a9acdf0fe2e4c2056e914367c321039a24c", "packages/ui/src/amicode/activity-dot.tsx": "fc3c9e620cdd204e930f0d53a0a44456e0c886679174c91f0efbf9707daa7bc6", "packages/ui/src/amicode/activity.test.ts": "1674ca5138780242767b0eb42fc444a9b8ce7868ac119117dd1487b7209180b2", "packages/ui/src/amicode/activity.ts": "a2c9b4fe27175a400ea0bcfeb433dfcb572cd793766bf0fd232c741feef5cfbf", @@ -725,24 +726,24 @@ "packages/ui/src/context/marked-math.test.ts": "0a53b2701d79ef16abe3132a514831ada60a535324991e4d72ee4e0ced9a0ae6", "packages/ui/src/context/marked-parser.tsx": "2d8d0c2e5e28278f54296640d1e851fff4ef3b2902c7f70d02cdbcd82545bf39", "packages/ui/src/hooks/create-auto-scroll.tsx": "4a8e9aa41f075b5e1815e647ab3a8a5333340d00e20ff7414f5e6c1f9a738337", - "packages/ui/src/i18n/ar.ts": "8113d320fe4a5300f1bd70150ae1695d68cb783e1c6e2be43b8a429b7dbdb711", - "packages/ui/src/i18n/br.ts": "cbde2da43ef820fe2664cb96cb973fd722156a6c8a3c5e16d18638bf014e5196", - "packages/ui/src/i18n/bs.ts": "23ea0ae34a4edaa4599b13f63f481be861e28da3b0d898987e291c7e91a9f11e", - "packages/ui/src/i18n/da.ts": "e8083f8a7ab01f73990a1eabcba817afdc663858d524e56e652ea69238cefc47", - "packages/ui/src/i18n/de.ts": "eb854fdd43f16fc2346ec43d7d1629d2714709991b3e5f7ac5ee99011324aa43", - "packages/ui/src/i18n/en.ts": "1b412a1cd0ea8f7d0ce6069d3bd94dc40778cc38c23c60191cd90071d4187ac8", - "packages/ui/src/i18n/es.ts": "a34d132adc2ad68ae3da98b6afd5070be48a0cd6cb15c4866cc7ea99deefc4ab", - "packages/ui/src/i18n/fr.ts": "4a8296dec41894e6ecb209e44564ac79c04cae73035b5447eb233cd984ef4bae", - "packages/ui/src/i18n/ja.ts": "6c13f118491c8badfac33baf449bee55779f6058792b6a54973bfe975e4c25c3", - "packages/ui/src/i18n/ko.ts": "b97f70d80c9ab7561c05ab91d461e9323c4c846d6a27bed0a41ad5fbcdf0c1db", - "packages/ui/src/i18n/no.ts": "120e5d11f5aebde00822def6fda9ae12db47cd020bdd04114cdb098d5ba67449", - "packages/ui/src/i18n/pl.ts": "8133cedc013364bf84a3902399b431f4029d6c1d57acf23f12a739127d4583e9", - "packages/ui/src/i18n/ru.ts": "11d79d60f2f678597ee2b33b51fa666f38f1e78b7ed051a686229ae0dd8aac72", - "packages/ui/src/i18n/th.ts": "afa5a46ff445461db8f069338aae760cb9cac09b370c47ced5542549c92c9235", - "packages/ui/src/i18n/tr.ts": "fac27e4bec327ff83976d9cdd8f54c2a241cb26b44c644a93c591e99f09290ed", - "packages/ui/src/i18n/uk.ts": "3531fcf92efaec359381fcc2f0cdbb644d52936a20037067451b057ef327ea1c", - "packages/ui/src/i18n/zh.ts": "6aac9e8c8973fb538effee075740035ce58a631615d39c0c8fb49375d45a56e5", - "packages/ui/src/i18n/zht.ts": "d50b7119139f53180eac64a6317341f54efc87ff76d56961a3e86ef74e3ad29b", + "packages/ui/src/i18n/ar.ts": "97076b6ad801490738d826b32a85ae319a71de80bf54913b2df336b9bac20253", + "packages/ui/src/i18n/br.ts": "4980710f624237a2431d9c998fb96f3a639a57ad17294673a0c5686e5bccb786", + "packages/ui/src/i18n/bs.ts": "76d4a6bbf6c1a2a70c5faa7f3f92be184527e2c4f35caf671e78383466acd0d3", + "packages/ui/src/i18n/da.ts": "3a0f67922cca3e51295f94ae6359ddd8f6d0aae3d083b555f7270eec971df61e", + "packages/ui/src/i18n/de.ts": "2aa33b5f4999b2b5e86fa4c1aa87a4e1065d50404f380516d9bf74d42d546f04", + "packages/ui/src/i18n/en.ts": "09cef9a7387c1520608e121a7ba3ea49e634fc1a5fa2699719ce83d7878f329c", + "packages/ui/src/i18n/es.ts": "60edc806b466c94a23442a0aab94ecffb392582a55253e0b24005881937a5900", + "packages/ui/src/i18n/fr.ts": "fd05b582176bd197fa895e8f88a03af52a9b5c375818cd06e935b81c66d729e2", + "packages/ui/src/i18n/ja.ts": "7d0034042e62bce6875da6ea9b5cc3624af9b32120676b6e6ba8285b87b6bb27", + "packages/ui/src/i18n/ko.ts": "a2bb5964089bf5b96470eb2c8f5b4f2a474fd35bcda36697d8a831d966d950b7", + "packages/ui/src/i18n/no.ts": "4950514b740d052ac097644559e8e8e52d304feeee56a24c9bd6fa40ae3aac85", + "packages/ui/src/i18n/pl.ts": "c9a31c1fc2352755136459ece574688465c42788101b3a39490fe92f2cb1f4c5", + "packages/ui/src/i18n/ru.ts": "5ddb560fa1155f1b0242be83c6e2a88533efd3c2b3f37856207da526b2b95944", + "packages/ui/src/i18n/th.ts": "54b511d48e6d606da7a44601400317075852c3b26cf67f8802f13c40561f170c", + "packages/ui/src/i18n/tr.ts": "f862f1c8bf700a9087e507e32a4c4094d834c37e23f3107d6551213b74328ca0", + "packages/ui/src/i18n/uk.ts": "5c5566b45e5ea82b28c417f3c4c83c380e61de7934da7815449f4e3e423fbb1e", + "packages/ui/src/i18n/zh.ts": "fde440e791fc340728374dd04690c9fd286da1293298074439d5a9526f86738b", + "packages/ui/src/i18n/zht.ts": "41944597b6dada282c3ca0483d85bd188c797bd906561cb8025411d0190546ca", "packages/ui/src/styles/index.css": "2eae4e3d82e992c3d99f4a91ff25b3d6e9e514b38e9bebd232e6c41009cdc1fd", "packages/ui/src/styles/theme.css": "0dc88bf675df5cdf08cfe0cc53c11eaf030a1bc0cf8b36cbeb3977367c2b362b", "packages/ui/src/theme/context.tsx": "93b0b3f8a258bc8a33d4b6a4cda7016e91f6d999bd32c60a43aaaf45eadd1227", @@ -971,7 +972,7 @@ "packages/ui/src/v2/components/badge-v2.css": "32135a9df5bad856a10138e00a2cb3ede7a6fc0cc2398797ad6bed9335d1f845", "packages/ui/src/v2/components/dialog-v2.css": "ae6f7b1de755929ba529b9dbb0e837c80f620f00a77188b9d2c59f10b0f28bbd", "packages/ui/src/v2/components/icon-button-v2.tsx": "966e535e3efe503f054045be4476fc20d2fad36092410b52f75e4cf8de6fce6f", - "packages/ui/src/v2/components/icon.tsx": "9f702e9c307524f79dfa9cad70f9bea32bda85672f917ffdb0871bb14cf13437", + "packages/ui/src/v2/components/icon.tsx": "9beb0192869e9acf61ab148e806f399c155cd9b6c1b414576fe1652b94dcff45", "packages/ui/src/v2/components/menu-v2.css": "2f262a6c456a8e3cb9027f0b8b3bb3c4327794bd80cadd3cf8c16d08e6125d4d", "packages/ui/src/v2/components/menu-v2.stories.tsx": "4ed1498d7e4cd51ce9c7459061f3540efdfae71751ff618901c78ee413951559", "packages/ui/src/v2/components/radio-v2.css": "bf6eb3457a7fc13e48356f5a7dcf3cbf462382580250a3144a745402ff88e2e6", @@ -1070,7 +1071,7 @@ "packages/ui/src/v2/components/text-shimmer-v2.tsx" ], "exceptions": [], - "extracted_at": "2026-10-29T13:09:53.406Z", + "extracted_at": "2026-10-30T02:40:30.114Z", "per_package": { "packages/app": { "A": 289, @@ -1108,7 +1109,7 @@ "D": 0 }, "packages/session-ui": { - "A": 49, + "A": 50, "M": 32, "D": 0 }, @@ -1119,7 +1120,7 @@ } }, "counts": { - "overlay_total": 1048, + "overlay_total": 1049, "deletions": 7, "server_coupled": 0 }, @@ -1669,6 +1670,7 @@ "packages/session-ui/src/pierre/worker.ts": "M", "packages/session-ui/src/util/clipboard.test.ts": "A", "packages/session-ui/src/util/clipboard.ts": "A", + "packages/session-ui/src/util/save-image.ts": "A", "packages/ui/src/amicode/activity-dot.tsx": "A", "packages/ui/src/amicode/activity.test.ts": "A", "packages/ui/src/amicode/activity.ts": "A", diff --git a/packages/app-bundle/overlay/packages/session-ui/src/components/image-lightbox.tsx b/packages/app-bundle/overlay/packages/session-ui/src/components/image-lightbox.tsx index 0747d484e..9c6b5e25f 100644 --- a/packages/app-bundle/overlay/packages/session-ui/src/components/image-lightbox.tsx +++ b/packages/app-bundle/overlay/packages/session-ui/src/components/image-lightbox.tsx @@ -1,15 +1,16 @@ -// AMICODE: full-size preview for a chat image, with copy. The stock -// ImagePreview (ui) is close-only; agent-produced figures (equations, plots) -// are usually opened to be copied into a deck or doc, so the header carries a -// copy action next to close. Same data-component as ImagePreview so it rides -// the shared image-preview styles. +// AMICODE: full-size preview for a chat image, with download and copy. The +// stock ImagePreview (ui) is close-only; agent-produced figures (equations, +// plots) are usually opened to be copied or saved into a deck or doc, so the +// header carries download + copy next to close. Same data-component as +// ImagePreview so it rides the shared image-preview styles. import { Dialog as Kobalte } from "@kobalte/core/dialog" import { createSignal, onCleanup } from "solid-js" import { useI18n } from "@opencode-ai/ui/context/i18n" import { IconButton } from "@opencode-ai/ui/icon-button" import { copyImageToClipboard } from "../util/clipboard" +import { saveImage } from "../util/save-image" -export function ImageLightbox(props: { src: string; alt?: string }) { +export function ImageLightbox(props: { src: string; alt?: string; filename?: string }) { const i18n = useI18n() const [copied, setCopied] = createSignal(false) // The host's native write can fail (copyImageToClipboard awaits its nonce'd @@ -33,6 +34,14 @@ export function ImageLightbox(props: { src: string; alt?: string }) {
+ void saveImage(props.src, props.filename ?? "image.png")} + /> { +const host = (action?: string) => { const el = document.createElement("div") el.setAttribute("data-slot", "markdown-copy-button") + if (action) el.setAttribute("data-action", action) return el } +const button = () => [host("download"), host()] const PNG = "data:image/png;base64,iVBORw0KGgo=" beforeEach(() => { @@ -79,14 +82,15 @@ describe("loader registry", () => { }) describe("chat image frames", () => { - test("every image is framed with a copy button, once", () => { + test("every image is framed with download + copy buttons, once", () => { const root = block('

a

') frameImages(root, button) frameImages(root, button) const frames = root.querySelectorAll('[data-component="markdown-image"]') expect(frames.length).toBe(1) expect(frames[0]!.querySelector("img")?.getAttribute("src")).toBe("https://example.com/a.png") - expect(frames[0]!.querySelectorAll('[data-slot="markdown-copy-button"]').length).toBe(1) + expect(frames[0]!.querySelectorAll('[data-slot="markdown-copy-button"]').length).toBe(2) + expect(frames[0]!.querySelectorAll('[data-action="download"]').length).toBe(1) }) test("only local images get the paper backing marker (data-local); remote images render as authored", () => { @@ -120,3 +124,27 @@ describe("chat image frames", () => { expect(root.querySelector("img")?.getAttribute("src")).toBe(PNG) }) }) + +describe("imageFileName", () => { + const img = (html: string) => { + const root = block(html) + frameImages(root, button) + return root.querySelector("img")! + } + + test("a disk image keeps its basename, saved as png", () => { + expect(imageFileName(img('eq'))).toBe( + "18-piccolo-mintime.png", + ) + expect(imageFileName(img(''))).toBe("plot.png") + expect(imageFileName(img(''))).toBe("my-figure.v2.png") + }) + + test("otherwise the alt text names it, else 'image'", () => { + expect(imageFileName(img('Fig 3: comb vs posterior'))).toBe( + "Fig-3-comb-vs-posterior.png", + ) + expect(imageFileName(img(''))).toBe("image.png") + expect(imageFileName(img('../../'))).toBe("image.png") + }) +}) diff --git a/packages/app-bundle/overlay/packages/session-ui/src/components/markdown-local-images.ts b/packages/app-bundle/overlay/packages/session-ui/src/components/markdown-local-images.ts index 50526e6c3..9e26176c7 100644 --- a/packages/app-bundle/overlay/packages/session-ui/src/components/markdown-local-images.ts +++ b/packages/app-bundle/overlay/packages/session-ui/src/components/markdown-local-images.ts @@ -87,13 +87,13 @@ export function clearLocalImageCache(): void { inflight.clear() } -// Every chat image gets a frame with an action button (the caller supplies the -// copy button). Local-path sources (`![](/abs/x.png)`) can't load directly in +// Every chat image gets a frame with action buttons (the caller supplies them: +// download + copy). Local-path sources (`![](/abs/x.png)`) can't load directly in // the iframe: park the path in data-local-src and drop the src so no doomed // request fires; loadLocalImages fills it in on the live container, and once // cached the next pass sets it synchronously, so a morphdom rebuild of the // block never flashes the image away. Idempotent. -export function frameImages(root: HTMLElement, button: () => HTMLElement) { +export function frameImages(root: HTMLElement, buttons: () => HTMLElement[]) { for (const img of Array.from(root.querySelectorAll("img"))) { const local = localImagePath(img.getAttribute("src") ?? "") if (local) { @@ -111,10 +111,23 @@ export function frameImages(root: HTMLElement, button: () => HTMLElement) { if (local) frame.setAttribute("data-local", "") img.parentNode?.replaceChild(frame, img) frame.appendChild(img) - frame.appendChild(button()) + for (const button of buttons()) frame.appendChild(button) } } +/** The saved file's name: the authored file's basename when the image came + * from disk, else the alt text, else "image" — always `.png`, because the + * save path rasterizes to PNG (the one type the host's save bridge accepts). */ +export function imageFileName(img: HTMLImageElement): string { + const local = img.getAttribute("data-local-src") ?? localImagePath(img.getAttribute("src") ?? "") + const base = local ? (local.split("/").pop() ?? "").replace(/\.[^.]+$/, "") : img.alt + const name = base + .trim() + .replace(/[^\w.-]+/g, "-") + .replace(/^[-.]+|[-.]+$/g, "") + return `${name || "image"}.png` +} + /** Live-container pass: load every parked local image that has no src yet. */ export function loadLocalImages(root: HTMLElement) { for (const img of Array.from(root.querySelectorAll("img[data-local-src]:not([src])"))) { diff --git a/packages/app-bundle/overlay/packages/session-ui/src/components/markdown.css b/packages/app-bundle/overlay/packages/session-ui/src/components/markdown.css index af7db7782..7c8afed1b 100644 --- a/packages/app-bundle/overlay/packages/session-ui/src/components/markdown.css +++ b/packages/app-bundle/overlay/packages/session-ui/src/components/markdown.css @@ -340,6 +340,11 @@ background: #fff; } + /* Download sits left of copy: copy's 4px inset + its 24px width + an 8px gap. */ + [data-component="markdown-image"] [data-slot="markdown-copy-button"][data-action="download"] { + right: 36px; + } + [data-component="markdown-image"]:hover [data-slot="markdown-copy-button"] { opacity: 1; } diff --git a/packages/app-bundle/overlay/packages/session-ui/src/components/markdown.tsx b/packages/app-bundle/overlay/packages/session-ui/src/components/markdown.tsx index 9a23bb59f..d204a7ca4 100644 --- a/packages/app-bundle/overlay/packages/session-ui/src/components/markdown.tsx +++ b/packages/app-bundle/overlay/packages/session-ui/src/components/markdown.tsx @@ -34,7 +34,8 @@ import { shouldResetCodeTokens, type RenderedCodeState } from "./markdown-code-s import { getCachedMarkdown, sanitizeMarkdown, touchCachedMarkdown, type MarkdownCacheEntry } from "./markdown-cache" import { inlineCodeKind } from "./markdown-inline-code-kind" import { cachedFileRef, fileRefResolver, fileRefResolverVersion, fileRefUrl, resolveFileRefCached } from "./markdown-file-refs" -import { frameImages, loadLocalImages, localImageLoaderVersion } from "./markdown-local-images" +import { frameImages, imageFileName, loadLocalImages, localImageLoaderVersion } from "./markdown-local-images" +import { saveImage } from "../util/save-image" import { useDialog } from "@opencode-ai/ui/context/dialog" import { ImageLightbox } from "./image-lightbox" @@ -91,8 +92,13 @@ async function code(text: string, language: string | undefined, key: string, com type CopyLabels = { copy: string copied: string + download: string } +// A copy-button host either copies (code, image) or saves (image). Both ride +// the same host lifecycle — render/dispose, the morphdom skip, label refresh. +type CopyButtonKind = "copy" | "download" + type CopyButtonState = { setLabels: Setter setCopied: Setter @@ -114,9 +120,10 @@ function codeUrl(text: string) { } } -function createCopyButton(labels: CopyLabels) { +function createCopyButton(labels: CopyLabels, kind: CopyButtonKind = "copy") { const host = document.createElement("div") host.setAttribute("data-slot", "markdown-copy-button") + if (kind === "download") host.setAttribute("data-action", "download") const state: Partial = {} const dispose = render(() => { @@ -124,15 +131,16 @@ function createCopyButton(labels: CopyLabels) { const [copied, setCopied] = createSignal(false) state.setLabels = setLabels state.setCopied = setCopied - return + return }, host) state.dispose = dispose copyButtonState.set(host, state as CopyButtonState) return host } -function MarkdownCopyButton(props: { labels: Accessor; copied: Accessor }) { - const label = () => (props.copied() ? props.labels().copied : props.labels().copy) +function MarkdownCopyButton(props: { labels: Accessor; copied: Accessor; kind: CopyButtonKind }) { + const label = () => + props.kind === "download" ? props.labels().download : props.copied() ? props.labels().copied : props.labels().copy return ( ; copied: Acces variant="ghost-muted" aria-label={label()} icon={ - <> - - - + props.kind === "download" ? ( + + ) : ( + <> + + + + ) } /> @@ -359,13 +371,17 @@ function decorate(root: HTMLDivElement, labels: CopyLabels) { // linkification (markFileLinks consumes data-inline-code-kind) — a core // capability, not a layout preference. Runs unconditionally. markInlineCode(root) - // AMICODE: chat image frames (preview + copy) — see markdown-local-images. - frameImages(root, () => createCopyButton(labels)) + // AMICODE: chat image frames (preview + download + copy) — see markdown-local-images. + frameImages(root, () => [createCopyButton(labels, "download"), createCopyButton(labels)]) } -// Clicking a chat image opens it full-size (with copy). Optional dialog: a -// Markdown rendered outside a DialogProvider (stories, embeds) keeps images inert. -function setupImagePreview(root: HTMLDivElement, open: ((src: string, alt: string) => void) | undefined) { +// Clicking a chat image opens it full-size (with download + copy). Optional +// dialog: a Markdown rendered outside a DialogProvider (stories, embeds) keeps +// images inert. +function setupImagePreview( + root: HTMLDivElement, + open: ((src: string, alt: string, filename: string) => void) | undefined, +) { if (!open) return const handleClick = (event: MouseEvent) => { const target = event.target @@ -373,7 +389,7 @@ function setupImagePreview(root: HTMLDivElement, open: ((src: string, alt: strin if (target.closest('[data-component="markdown-image"]') === null) return if (!target.src) return event.preventDefault() - open(target.src, target.alt) + open(target.src, target.alt, imageFileName(target)) } root.addEventListener("click", handleClick) return () => root.removeEventListener("click", handleClick) @@ -408,6 +424,11 @@ function setupCodeCopy(root: HTMLDivElement, getLabels: () => CopyLabels) { const button = target.closest('[data-slot="markdown-copy-button"]') if (!(button instanceof HTMLElement)) return const image = button.closest('[data-component="markdown-image"]')?.querySelector("img") + if (button.getAttribute("data-action") === "download") { + // The host shows its own Save dialog; no "copied" state to flash here. + if (image?.src) await saveImage(image.src, imageFileName(image)) + return + } if (image) { if (!image.src) return // The host's native image write can fail — copyImageToClipboard awaits its @@ -520,7 +541,8 @@ export function Markdown( const owner = createUniqueId() const dialog = optionalDialog() const openImage = dialog - ? (src: string, alt: string) => dialog.show(() => ) + ? (src: string, alt: string, filename: string) => + dialog.show(() => ) : undefined const activeCodeKeys = new Set() const completedCode = new Map>() @@ -629,6 +651,7 @@ export function Markdown( const labels = { copy: i18n.t("ui.message.copy"), copied: i18n.t("ui.message.copied"), + download: i18n.t("ui.message.download"), } const nextCodeKeys = new Set(content.filter((block) => block.mode === "code").map((block) => block.key)) activeCodeKeys.forEach((key) => { @@ -650,6 +673,7 @@ export function Markdown( copyCleanup = setupCodeCopy(container, () => ({ copy: i18n.t("ui.message.copy"), copied: i18n.t("ui.message.copied"), + download: i18n.t("ui.message.download"), })) if (!linkCleanup) linkCleanup = setupExternalLinks(container) if (!imageCleanup) imageCleanup = setupImagePreview(container, openImage) diff --git a/packages/app-bundle/overlay/packages/session-ui/src/util/save-image.ts b/packages/app-bundle/overlay/packages/session-ui/src/util/save-image.ts new file mode 100644 index 000000000..bb3555fb6 --- /dev/null +++ b/packages/app-bundle/overlay/packages/session-ui/src/util/save-image.ts @@ -0,0 +1,26 @@ +// Image download that lands on the machine the user is sitting at. Inside the +// Amicode chat iframe an goes nowhere useful (the webview has no +// download manager), so the PNG rides the existing "save-file" bridge envelope: +// the extension host opens a native Save dialog (defaulting to ~/Downloads) and +// writes the bytes there. Unframed (desktop shell, plain browser), a normal +// anchor download stands. The bridge accepts PNG only, so every source is +// rasterized first, the same as image copy. +import { pngDataUrl } from "./clipboard" + +export async function saveImage(src: string, filename: string): Promise { + const png = await pngDataUrl(src) + if (!png) return false + if (typeof window !== "undefined" && window.parent !== window) { + window.parent.postMessage({ source: "amicode", kind: "save-file", filename, dataUrl: png }, "*") + return true // fire-and-forget: the host owns the dialog and the outcome + } + if (typeof document === "undefined") return false + const link = document.createElement("a") + link.href = png + link.download = filename + link.rel = "noopener" + document.body.appendChild(link) + link.click() + link.remove() + return true +} diff --git a/packages/app-bundle/overlay/packages/ui/src/i18n/ar.ts b/packages/app-bundle/overlay/packages/ui/src/i18n/ar.ts index c00cf7e73..017595a6b 100644 --- a/packages/app-bundle/overlay/packages/ui/src/i18n/ar.ts +++ b/packages/app-bundle/overlay/packages/ui/src/i18n/ar.ts @@ -191,4 +191,5 @@ export const dict = { "ui.message.duration.seconds": "{{count}}ث", "ui.message.duration.minutesSeconds": "{{minutes}}د {{seconds}}ث", "ui.message.copyTrace": "Copy trace", + "ui.message.download": "Download", } diff --git a/packages/app-bundle/overlay/packages/ui/src/i18n/br.ts b/packages/app-bundle/overlay/packages/ui/src/i18n/br.ts index cb3069e0a..cd0de223a 100644 --- a/packages/app-bundle/overlay/packages/ui/src/i18n/br.ts +++ b/packages/app-bundle/overlay/packages/ui/src/i18n/br.ts @@ -191,4 +191,5 @@ export const dict = { "ui.message.duration.seconds": "{{count}}s", "ui.message.duration.minutesSeconds": "{{minutes}}m {{seconds}}s", "ui.message.copyTrace": "Copy trace", + "ui.message.download": "Download", } diff --git a/packages/app-bundle/overlay/packages/ui/src/i18n/bs.ts b/packages/app-bundle/overlay/packages/ui/src/i18n/bs.ts index 888834b1b..544966da1 100644 --- a/packages/app-bundle/overlay/packages/ui/src/i18n/bs.ts +++ b/packages/app-bundle/overlay/packages/ui/src/i18n/bs.ts @@ -195,4 +195,5 @@ export const dict = { "ui.message.duration.seconds": "{{count}}s", "ui.message.duration.minutesSeconds": "{{minutes}}m {{seconds}}s", "ui.message.copyTrace": "Copy trace", + "ui.message.download": "Download", } diff --git a/packages/app-bundle/overlay/packages/ui/src/i18n/da.ts b/packages/app-bundle/overlay/packages/ui/src/i18n/da.ts index 395402435..8799f8a21 100644 --- a/packages/app-bundle/overlay/packages/ui/src/i18n/da.ts +++ b/packages/app-bundle/overlay/packages/ui/src/i18n/da.ts @@ -190,4 +190,5 @@ export const dict = { "ui.message.duration.seconds": "{{count}}s", "ui.message.duration.minutesSeconds": "{{minutes}}m {{seconds}}s", "ui.message.copyTrace": "Copy trace", + "ui.message.download": "Download", } diff --git a/packages/app-bundle/overlay/packages/ui/src/i18n/de.ts b/packages/app-bundle/overlay/packages/ui/src/i18n/de.ts index 68a1e050c..a42fefd2d 100644 --- a/packages/app-bundle/overlay/packages/ui/src/i18n/de.ts +++ b/packages/app-bundle/overlay/packages/ui/src/i18n/de.ts @@ -197,4 +197,5 @@ export const dict = { "ui.message.duration.seconds": "{{count}}s", "ui.message.duration.minutesSeconds": "{{minutes}}m {{seconds}}s", "ui.message.copyTrace": "Copy trace", + "ui.message.download": "Download", } diff --git a/packages/app-bundle/overlay/packages/ui/src/i18n/en.ts b/packages/app-bundle/overlay/packages/ui/src/i18n/en.ts index fbbfa2fc3..ab26d2aa4 100644 --- a/packages/app-bundle/overlay/packages/ui/src/i18n/en.ts +++ b/packages/app-bundle/overlay/packages/ui/src/i18n/en.ts @@ -176,6 +176,7 @@ export const dict: Record = { "ui.message.revertMessage": "Revert message", "ui.message.copyResponse": "Copy response", "ui.message.copyTrace": "Copy trace", + "ui.message.download": "Download", "ui.message.copied": "Copied", "ui.message.duration.seconds": "{{count}}s", "ui.message.duration.minutesSeconds": "{{minutes}}m {{seconds}}s", diff --git a/packages/app-bundle/overlay/packages/ui/src/i18n/es.ts b/packages/app-bundle/overlay/packages/ui/src/i18n/es.ts index 2ba67a7dd..dc4c033b1 100644 --- a/packages/app-bundle/overlay/packages/ui/src/i18n/es.ts +++ b/packages/app-bundle/overlay/packages/ui/src/i18n/es.ts @@ -191,4 +191,5 @@ export const dict = { "ui.message.duration.seconds": "{{count}}s", "ui.message.duration.minutesSeconds": "{{minutes}}m {{seconds}}s", "ui.message.copyTrace": "Copy trace", + "ui.message.download": "Download", } diff --git a/packages/app-bundle/overlay/packages/ui/src/i18n/fr.ts b/packages/app-bundle/overlay/packages/ui/src/i18n/fr.ts index 44a8ad134..032543d2e 100644 --- a/packages/app-bundle/overlay/packages/ui/src/i18n/fr.ts +++ b/packages/app-bundle/overlay/packages/ui/src/i18n/fr.ts @@ -191,4 +191,5 @@ export const dict = { "ui.message.duration.seconds": "{{count}}s", "ui.message.duration.minutesSeconds": "{{minutes}}m {{seconds}}s", "ui.message.copyTrace": "Copy trace", + "ui.message.download": "Download", } diff --git a/packages/app-bundle/overlay/packages/ui/src/i18n/ja.ts b/packages/app-bundle/overlay/packages/ui/src/i18n/ja.ts index f7a895bb3..008383fc1 100644 --- a/packages/app-bundle/overlay/packages/ui/src/i18n/ja.ts +++ b/packages/app-bundle/overlay/packages/ui/src/i18n/ja.ts @@ -190,4 +190,5 @@ export const dict = { "ui.message.duration.seconds": "{{count}}秒", "ui.message.duration.minutesSeconds": "{{minutes}}分 {{seconds}}秒", "ui.message.copyTrace": "Copy trace", + "ui.message.download": "Download", } diff --git a/packages/app-bundle/overlay/packages/ui/src/i18n/ko.ts b/packages/app-bundle/overlay/packages/ui/src/i18n/ko.ts index b10fe5602..3aba72cbb 100644 --- a/packages/app-bundle/overlay/packages/ui/src/i18n/ko.ts +++ b/packages/app-bundle/overlay/packages/ui/src/i18n/ko.ts @@ -192,4 +192,5 @@ export const dict = { "ui.sessionTurn.diffs.showLess": "간략히 표시", "ui.sessionTurn.diffs.more": "+{{count}}개 더 보기", "ui.message.copyTrace": "Copy trace", + "ui.message.download": "Download", } diff --git a/packages/app-bundle/overlay/packages/ui/src/i18n/no.ts b/packages/app-bundle/overlay/packages/ui/src/i18n/no.ts index ae0d85ffa..f7068983d 100644 --- a/packages/app-bundle/overlay/packages/ui/src/i18n/no.ts +++ b/packages/app-bundle/overlay/packages/ui/src/i18n/no.ts @@ -195,4 +195,5 @@ export const dict: Record = { "ui.sessionTurn.diffs.showLess": "Vis færre", "ui.sessionTurn.diffs.more": "+{{count}} til", "ui.message.copyTrace": "Copy trace", + "ui.message.download": "Download", } diff --git a/packages/app-bundle/overlay/packages/ui/src/i18n/pl.ts b/packages/app-bundle/overlay/packages/ui/src/i18n/pl.ts index a4de5714d..7017fa33a 100644 --- a/packages/app-bundle/overlay/packages/ui/src/i18n/pl.ts +++ b/packages/app-bundle/overlay/packages/ui/src/i18n/pl.ts @@ -190,4 +190,5 @@ export const dict = { "ui.message.duration.seconds": "{{count}}s", "ui.message.duration.minutesSeconds": "{{minutes}}m {{seconds}}s", "ui.message.copyTrace": "Copy trace", + "ui.message.download": "Download", } diff --git a/packages/app-bundle/overlay/packages/ui/src/i18n/ru.ts b/packages/app-bundle/overlay/packages/ui/src/i18n/ru.ts index 448c20755..2f9a7b150 100644 --- a/packages/app-bundle/overlay/packages/ui/src/i18n/ru.ts +++ b/packages/app-bundle/overlay/packages/ui/src/i18n/ru.ts @@ -190,4 +190,5 @@ export const dict = { "ui.message.duration.seconds": "{{count}}с", "ui.message.duration.minutesSeconds": "{{minutes}}м {{seconds}}с", "ui.message.copyTrace": "Copy trace", + "ui.message.download": "Download", } diff --git a/packages/app-bundle/overlay/packages/ui/src/i18n/th.ts b/packages/app-bundle/overlay/packages/ui/src/i18n/th.ts index 8bece1b5b..52de9478e 100644 --- a/packages/app-bundle/overlay/packages/ui/src/i18n/th.ts +++ b/packages/app-bundle/overlay/packages/ui/src/i18n/th.ts @@ -192,4 +192,5 @@ export const dict = { "ui.message.duration.seconds": "{{count}}วิ", "ui.message.duration.minutesSeconds": "{{minutes}}นาที {{seconds}}วิ", "ui.message.copyTrace": "Copy trace", + "ui.message.download": "Download", } diff --git a/packages/app-bundle/overlay/packages/ui/src/i18n/tr.ts b/packages/app-bundle/overlay/packages/ui/src/i18n/tr.ts index 574edbb00..77edadc91 100644 --- a/packages/app-bundle/overlay/packages/ui/src/i18n/tr.ts +++ b/packages/app-bundle/overlay/packages/ui/src/i18n/tr.ts @@ -197,4 +197,5 @@ export const dict = { "ui.message.duration.seconds": "{{count}}sn", "ui.message.duration.minutesSeconds": "{{minutes}}dk {{seconds}}sn", "ui.message.copyTrace": "Copy trace", + "ui.message.download": "Download", } diff --git a/packages/app-bundle/overlay/packages/ui/src/i18n/uk.ts b/packages/app-bundle/overlay/packages/ui/src/i18n/uk.ts index 18451f384..df9b4007a 100644 --- a/packages/app-bundle/overlay/packages/ui/src/i18n/uk.ts +++ b/packages/app-bundle/overlay/packages/ui/src/i18n/uk.ts @@ -194,4 +194,5 @@ export const dict: Record = { "ui.question.singleHint": "Виберіть одну відповідь", "ui.question.custom.placeholder": "Введіть свою відповідь...", "ui.message.copyTrace": "Copy trace", + "ui.message.download": "Download", } diff --git a/packages/app-bundle/overlay/packages/ui/src/i18n/zh.ts b/packages/app-bundle/overlay/packages/ui/src/i18n/zh.ts index 7b2b4c9fd..ea3a7900e 100644 --- a/packages/app-bundle/overlay/packages/ui/src/i18n/zh.ts +++ b/packages/app-bundle/overlay/packages/ui/src/i18n/zh.ts @@ -194,4 +194,5 @@ export const dict = { "ui.message.duration.seconds": "{{count}}秒", "ui.message.duration.minutesSeconds": "{{minutes}}分 {{seconds}}秒", "ui.message.copyTrace": "Copy trace", + "ui.message.download": "Download", } diff --git a/packages/app-bundle/overlay/packages/ui/src/i18n/zht.ts b/packages/app-bundle/overlay/packages/ui/src/i18n/zht.ts index 8b674f964..0c186910c 100644 --- a/packages/app-bundle/overlay/packages/ui/src/i18n/zht.ts +++ b/packages/app-bundle/overlay/packages/ui/src/i18n/zht.ts @@ -194,4 +194,5 @@ export const dict = { "ui.message.duration.seconds": "{{count}}秒", "ui.message.duration.minutesSeconds": "{{minutes}}分 {{seconds}}秒", "ui.message.copyTrace": "Copy trace", + "ui.message.download": "Download", } diff --git a/packages/app-bundle/overlay/packages/ui/src/v2/components/icon.tsx b/packages/app-bundle/overlay/packages/ui/src/v2/components/icon.tsx index 6b73fd5d0..f49b3edf1 100644 --- a/packages/app-bundle/overlay/packages/ui/src/v2/components/icon.tsx +++ b/packages/app-bundle/overlay/packages/ui/src/v2/components/icon.tsx @@ -155,6 +155,11 @@ const icons = { viewBox: "0 0 16 16", body: ``, }, + // AMICODE: outline-share's tray with the arrow pointing in (chat image save). + "outline-download": { + viewBox: "0 0 16 16", + body: ``, + }, reset: { viewBox: "0 0 20 20", body: ``, From bb8bd74afdbc0d34074af677212032565007fb2a Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Thu, 29 Oct 2026 22:46:22 -0400 Subject: [PATCH 2/2] feat(preview): download button for previewed PDFs and images The side-panel file preview gains a Download control beside zoom whenever it holds binary content (PDF, image). Inside the webview the bytes ride the save-file bridge to a native Save dialog on the user's machine; unframed, an anchor download stands. save-file's validation moves into saveFileRequest: the allow-list grows from png/md/txt to images, PDF and markdown, each offered under its own dialog filter; payloads stay basename-only, base64, ~18 MB max. --- packages/app-bundle/manifest.json | 4 +- .../components/session/preview-file-view.tsx | 34 ++++++++++++ packages/extension/src/chat_bridge.ts | 53 ++++++++++++------- packages/extension/test/chat_bridge.test.ts | 29 ++++++++++ 4 files changed, 100 insertions(+), 20 deletions(-) diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json index 1b05698e2..280bb24f3 100644 --- a/packages/app-bundle/manifest.json +++ b/packages/app-bundle/manifest.json @@ -785,7 +785,7 @@ "packages/app/src/components/session/pdf-restore.ts": "e5c92096b9302a452439babfc2732610a7e0d18e23793ce3bf6352fa32f91a36", "packages/app/src/components/session/preview-file-helpers.test.ts": "e0c1ccb40b48990c0c5b87ea62a35de60b860cd760067c7f1327cbb1d36584b1", "packages/app/src/components/session/preview-file-helpers.ts": "2244aaef25f7412df7b53a45a7d2f72380d45eb90caefa5e96c48d01e988eb4e", - "packages/app/src/components/session/preview-file-view.tsx": "caccb771210950896631ac7efd4cb3ca5689e0ba5049d9e2f53154ca86bb07b9", + "packages/app/src/components/session/preview-file-view.tsx": "5cf02d46673f30c2a1be19f4f1c838fc6ffe86205e0239b45586b1baa62befc4", "packages/app/src/components/session/preview-frontmatter-card.tsx": "00f439e3bf482673dfb06c0ce3464c0f51985093ed57b321cdebd38d452f2e5d", "packages/app/src/components/session/preview-frontmatter.test.ts": "93ec9f7f4c604d8ed8ee6009779c425bffecfca331a122a7a700b588a8ca3c0c", "packages/app/src/components/session/preview-frontmatter.ts": "036250edd8c2886ee8f8ac0723f2b9a41d363898006a53f47bc44b8194c5a3d3", @@ -1071,7 +1071,7 @@ "packages/ui/src/v2/components/text-shimmer-v2.tsx" ], "exceptions": [], - "extracted_at": "2026-10-30T02:40:30.114Z", + "extracted_at": "2026-10-30T02:45:47.212Z", "per_package": { "packages/app": { "A": 289, diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/preview-file-view.tsx b/packages/app-bundle/overlay/packages/app/src/components/session/preview-file-view.tsx index ec73c779b..8870dcf0c 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/session/preview-file-view.tsx +++ b/packages/app-bundle/overlay/packages/app/src/components/session/preview-file-view.tsx @@ -620,6 +620,27 @@ export function PreviewFileView(props: { "backdrop-filter": "blur(4px)", } + // AMICODE: save a previewed PDF/image to the user's machine. Downloads are + // dead inside the webview iframe, so the bytes ride the extension's + // save-file bridge (a native Save dialog on the machine the user is at); + // unframed, a plain anchor download stands. + const downloadPreview = () => { + const data = binaryData() + if (!data) return + const filename = props.filePath.split("/").pop() || "download" + const dataUrl = `data:${data.mime};base64,${data.base64}` + if (window.parent !== window) { + window.parent.postMessage({ source: "amicode", kind: "save-file", filename, dataUrl }, "*") + return + } + const link = document.createElement("a") + link.href = dataUrl + link.download = filename + document.body.appendChild(link) + link.click() + link.remove() + } + return (
+ + + + + {/* Zoom controls: [editable %] [reset] [+ over -] */}
24_000_000) return true; - const isPng = name.toLowerCase().endsWith(".png"); + const save = saveFileRequest(raw.filename, raw.dataUrl); + if (!save) return true; void (async () => { const target = await vscode.window.showSaveDialog({ - defaultUri: vscode.Uri.file(path.join(os.homedir(), "Downloads", name)), - filters: isPng ? { Images: ["png"] } : { Markdown: ["md", "txt", "markdown"] }, + defaultUri: vscode.Uri.file(path.join(os.homedir(), "Downloads", save.name)), + filters: save.filters, }); if (!target) return; - await vscode.workspace.fs.writeFile(target, Buffer.from(base64!, "base64")); + await vscode.workspace.fs.writeFile(target, save.bytes); const pick = await vscode.window.showInformationMessage(`Amicode: saved ${path.basename(target.fsPath)}`, "Reveal"); if (pick === "Reveal") await vscode.commands.executeCommand("revealFileInOS", target); })(); @@ -1664,6 +1655,32 @@ export function handleAmicodeBridgeMessage(msg: unknown, io: BridgeIo): boolean return false; } +// --------------------------------------------------------------------------- +// save-file payload validation +// --------------------------------------------------------------------------- + +const SAVE_FILE_TYPES: Record = { + Images: ["png", "jpg", "jpeg", "gif", "webp", "svg"], + PDF: ["pdf"], + Markdown: ["md", "markdown", "txt"], +}; + +/** The save the host will offer for an untrusted save-file payload, or null to + * refuse: the basename only (sanitized), an allow-listed extension, a base64 + * data: URL, at most ~18 MB decoded. The dialog filter is the file's own type. */ +export function saveFileRequest( + filename: string, + dataUrl: string, +): { name: string; bytes: Buffer; filters: Record } | null { + const name = path.basename(filename).replace(/[^\w.-]+/g, "-"); + const ext = name.toLowerCase().split(".").pop() ?? ""; + const kind = name.includes(".") ? Object.keys(SAVE_FILE_TYPES).find((k) => SAVE_FILE_TYPES[k]!.includes(ext)) : undefined; + if (!kind) return null; + const m = dataUrl.match(/^data:[^;,]+;base64,(.+)$/s); + if (!m || m[1]!.length > 24_000_000) return null; + return { name, bytes: Buffer.from(m[1]!, "base64"), filters: { [kind]: SAVE_FILE_TYPES[kind]! } }; +} + // --------------------------------------------------------------------------- // Native clipboard image writer // --------------------------------------------------------------------------- diff --git a/packages/extension/test/chat_bridge.test.ts b/packages/extension/test/chat_bridge.test.ts index c2611ca14..10192ea0b 100644 --- a/packages/extension/test/chat_bridge.test.ts +++ b/packages/extension/test/chat_bridge.test.ts @@ -11,6 +11,7 @@ import { rebuildGitCommand, resolveDbBackupDir, resolveRebuildMode, + saveFileRequest, writeClipboardImageNative, type BridgeIo, } from "../src/chat_bridge"; @@ -181,6 +182,34 @@ describe("developer-tools rebuild — only amicodePath is required (#1018/#1115) }); }); +describe("amicode bridge — save-file payloads", () => { + const b64 = (text: string) => Buffer.from(text).toString("base64"); + + it("saves a previewed PDF with a PDF filter and its exact bytes", () => { + const save = saveFileRequest("comb-vs-posterior.pdf", `data:application/pdf;base64,${b64("%PDF-1.5 x")}`); + expect(save?.name).toBe("comb-vs-posterior.pdf"); + expect(save?.filters).toEqual({ PDF: ["pdf"] }); + expect(save?.bytes.toString()).toBe("%PDF-1.5 x"); + }); + + it("keeps the run-card PNG and markdown export paths", () => { + expect(saveFileRequest("card.png", `data:image/png;base64,${b64("png")}`)?.filters).toEqual({ + Images: ["png", "jpg", "jpeg", "gif", "webp", "svg"], + }); + expect(saveFileRequest("session.md", `data:text/markdown;base64,${b64("# hi")}`)?.filters).toEqual({ + Markdown: ["md", "markdown", "txt"], + }); + }); + + it("refuses paths, unknown types, non-base64 URLs and oversized payloads", () => { + expect(saveFileRequest("../../etc/x.pdf", `data:application/pdf;base64,${b64("x")}`)?.name).toBe("x.pdf"); + expect(saveFileRequest("run.sh", `data:text/plain;base64,${b64("x")}`)).toBeNull(); + expect(saveFileRequest("pdf", `data:application/pdf;base64,${b64("x")}`)).toBeNull(); + expect(saveFileRequest("a.pdf", "data:application/pdf,%25PDF")).toBeNull(); + expect(saveFileRequest("a.pdf", `data:application/pdf;base64,${"A".repeat(24_000_004)}`)).toBeNull(); + }); +}); + describe("amicode bridge — open-external", () => { it("opens https URLs and nothing else", () => { const host = io();