diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json
index c4f0c7ffc..280bb24f3 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",
@@ -784,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",
@@ -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:45:47.212Z",
"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/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 -] */}
+
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('
')
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('
'))).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('
'))).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 (``) can't load directly in
+// Every chat image gets a frame with action buttons (the caller supplies them:
+// download + copy). Local-path sources (``) 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: ``,
diff --git a/packages/extension/src/chat_bridge.ts b/packages/extension/src/chat_bridge.ts
index 5d4258882..576d72641 100644
--- a/packages/extension/src/chat_bridge.ts
+++ b/packages/extension/src/chat_bridge.ts
@@ -615,34 +615,25 @@ export function handleAmicodeBridgeMessage(msg: unknown, io: BridgeIo): boolean
return true;
}
- // Save bridge (run-card PNG + session markdown export): downloads are dead
- // inside the framed app — the extension shows a save dialog and writes the
- // file. Bounded size, basename-only: the payload is untrusted.
+ // Save bridge (run-card PNG, session markdown export, chat image + preview
+ // downloads): downloads are dead inside the framed app — the extension shows
+ // a save dialog and writes the file. Bounded size, basename-only, extension
+ // allow-listed: the payload is untrusted.
if (
msg.kind === "save-file" &&
typeof (msg as { filename?: unknown }).filename === "string" &&
typeof (msg as { dataUrl?: unknown }).dataUrl === "string"
) {
const raw = msg as unknown as { filename: string; dataUrl: string };
- const name = path.basename(raw.filename).replace(/[^\w.-]+/g, "-");
- if (!name || !/\.(png|md|markdown|txt)$/i.test(name)) return true;
- let base64: string | undefined;
- if (raw.dataUrl.startsWith("data:image/png;base64,")) base64 = raw.dataUrl.slice("data:image/png;base64,".length);
- else if (raw.dataUrl.startsWith("data:text/markdown;base64,")) base64 = raw.dataUrl.slice("data:text/markdown;base64,".length);
- else if (raw.dataUrl.startsWith("data:text/plain;base64,")) base64 = raw.dataUrl.slice("data:text/plain;base64,".length);
- else {
- const m = raw.dataUrl.match(/^data:[^;]+;base64,(.+)$/s);
- if (m) base64 = m[1];
- }
- if (!base64 || base64.length > 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();