diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json index cf31e78cb..a1386f9b4 100644 --- a/packages/app-bundle/manifest.json +++ b/packages/app-bundle/manifest.json @@ -18,7 +18,7 @@ "packages/opencode/pasqal-live-test.sh": "0bb0089eb413956f0252939f933a139c2f11520466887ad91fcae362f1698e41", "packages/session-ui/bunfig.toml": "468114e9b52c3cf926d2385c12d93385992cd70c97ea9e32ed22154617b7799d", "packages/session-ui/happydom.ts": "d81d6b14e988f058fa502c841df34b55307c57fc94a26fb4f8a5deb6aa0d4834", - "packages/session-ui/package.json": "f354c8675067dc8dd1493924cd6c8f561fb838ff97ae6cbd7e024868cbf0e0e8", + "packages/session-ui/package.json": "9661896519610a5909b1ce9d9b9aa66c113704b1c91f288ecf81207cb1a2fc86", "packages/session-ui/preload-worker-mock.ts": "b53280029a042575734d7bfdf27713f6c5cd6a6665599dc429d839a3e3372bd1", "packages/ui/package.json": "b1d168d0371e9094faae1107fc6c00be197f09bc69daa2247a3890d607f4b629", "packages/app/public/amico.svg": "7c64b145bdd063bacd53318336a423f0654ec702de2b4e0c4a4a384694052804", @@ -108,7 +108,7 @@ "packages/app/src/components/amicode-default-model.test.ts": "941750d074706bd5f4e433c9737b19ebc0ac0f9ba9cd1faaa599a24b22126e05", "packages/app/src/components/amicode-default-model.tsx": "16a2de1feb12e07416fe5fbb25879391f912ee0909b553c868c1b531f2ed1f2d", "packages/app/src/components/amicode-defaults-capsule.tsx": "94dc081bc47abb7e50566fcd8e9e53124f9d7632d61f93eff4f1627e0c2efb2d", - "packages/app/src/components/amicode-file-ref-bridge.tsx": "bd5c295080ccd80fe89208008c02db209ba301e431f61bc4432d3c8f2a72401f", + "packages/app/src/components/amicode-file-ref-bridge.tsx": "b1292ac91ad1d07a374251199fc92392a5cf68ee6933083dbf6d3978fceb5d9d", "packages/app/src/components/connection-banner.tsx": "82194291181165ed7b1b1b3a6801a1bdcd2c2b7cdd733c28486bd69f86477a24", "packages/app/src/components/dialog-command-palette-v2.css": "85984957adaf15efcc0828310f3a0b438cedaaba776a9fec2eddb3ad83487fa2", "packages/app/src/components/dialog-command-palette-v2.tsx": "e12da3e5e9e503089dd9139b20e04f6b566cf74fa445f23a2e193f5883f7c5f3", @@ -493,13 +493,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": "9626b81323ac4741ce4cd9276e510a619c5be64cb2b96e0b0e9c0b7ff4a05e9c", "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": "1482381f4bce78cf224a76ea7f44a7b6104445b9d8b932788ac215903e839d26", + "packages/session-ui/src/components/markdown-local-images.ts": "6115ef3a47a77aa4c528e3aa9a307e433032b905154fde3f4ca0407d6e3f9edb", "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": "24e7c19218620386647656a5d27e116cdeca911f1916c5d5b8609edca11ff165", - "packages/session-ui/src/components/markdown.tsx": "00a2248ca301e39eb5f2a9b78abfe9d11068ea5daa970516e1f8700c44878c48", + "packages/session-ui/src/components/markdown.css": "500c69fcef79c356bba9bbdfa421489442a1b7233b34fdaf8b57eb8b32e5744f", + "packages/session-ui/src/components/markdown.tsx": "b74c66e03e9df8ed7ca95d04d4ab712b71c0e16d6318031ff7ea74373014c263", "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", @@ -525,7 +528,8 @@ "packages/session-ui/src/components/tool-status-title.tsx": "ae4561b58f55985d2294a352802e723b18943ac9a6551d97f1c4a6cc95368c8a", "packages/session-ui/src/context/data.tsx": "7b840b5bf6929445d21da2cbd8e8006316c6950a09d559ea77e8c397e728cd99", "packages/session-ui/src/pierre/worker.ts": "7f210f931777956567eaeea15f2f5478befc7874a64a4b685504c59c66503fee", - "packages/session-ui/src/util/clipboard.ts": "c37ba53914714ed04135e5a254e8ea90186f097d7470fa0873a83de82f3fef2d", + "packages/session-ui/src/util/clipboard.ts": "f0a5e5b2b4f89b7aceea245e9ecda4e6d36edf9e6169451f52993f9bf7dbd8a0", + "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", @@ -698,24 +702,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", @@ -932,7 +936,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", @@ -1031,7 +1035,7 @@ "packages/ui/src/v2/components/text-shimmer-v2.tsx" ], "exceptions": [], - "extracted_at": "2026-10-26T18:34:42.306Z", + "extracted_at": "2026-10-30T02:38:43.036Z", "per_package": { "packages/app": { "A": 257, @@ -1069,7 +1073,7 @@ "D": 0 }, "packages/session-ui": { - "A": 45, + "A": 49, "M": 32, "D": 0 }, @@ -1080,7 +1084,7 @@ } }, "counts": { - "overlay_total": 1009, + "overlay_total": 1013, "deletions": 7, "server_coupled": 0 }, @@ -1570,9 +1574,12 @@ "packages/session-ui/src/components/build-trace.test.ts": "A", "packages/session-ui/src/components/build-trace.ts": "A", "packages/session-ui/src/components/group-parts.test.ts": "A", + "packages/session-ui/src/components/image-lightbox.tsx": "A", "packages/session-ui/src/components/line-comment-styles.ts": "M", "packages/session-ui/src/components/markdown-file-refs.test.ts": "A", "packages/session-ui/src/components/markdown-file-refs.ts": "A", + "packages/session-ui/src/components/markdown-local-images.test.ts": "A", + "packages/session-ui/src/components/markdown-local-images.ts": "A", "packages/session-ui/src/components/markdown-stream.test.ts": "M", "packages/session-ui/src/components/markdown-stream.ts": "M", "packages/session-ui/src/components/markdown.css": "M", @@ -1603,6 +1610,7 @@ "packages/session-ui/src/context/data.tsx": "M", "packages/session-ui/src/pierre/worker.ts": "M", "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/amicode-file-ref-bridge.tsx b/packages/app-bundle/overlay/packages/app/src/components/amicode-file-ref-bridge.tsx index 27e4245ef..fb3a58e88 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/amicode-file-ref-bridge.tsx +++ b/packages/app-bundle/overlay/packages/app/src/components/amicode-file-ref-bridge.tsx @@ -6,6 +6,7 @@ // with the bridge unmounted no resolver is registered and pills stay pills. import { onCleanup, onMount } from "solid-js" import { registerFileRefResolver } from "@opencode-ai/session-ui/markdown-file-refs" +import { registerLocalImageLoader } from "@opencode-ai/session-ui/markdown-local-images" import { ServerConnection, useServer } from "@/context/server" import { useGlobal } from "@/context/global" import { amicodeGet } from "@/utils/amicode-fetch" @@ -15,22 +16,42 @@ export function AmicodeFileRefBridge() { const server = useServer() const global = useGlobal() let unregister: (() => void) | undefined + let unregisterImages: (() => void) | undefined + const pick = () => + pickVaultServer({ + current: server.current, + list: server.list, + healthy: (c) => global.servers.health[ServerConnection.key(c)]?.healthy === true, + }) onMount(() => { // #1845: keep the unregister handle — it clears only THIS registration, // so a remounting shell's late cleanup can't wipe its successor's resolver. unregister = registerFileRefResolver(async (text) => { - const conn = pickVaultServer({ - current: server.current, - list: server.list, - healthy: (c) => global.servers.health[ServerConnection.key(c)]?.healthy === true, - }) + const conn = pick() if (!conn) return null const raw = (await amicodeGet(conn, `/amicode/resolve-file?path=${encodeURIComponent(text)}`)) as | { ok?: boolean; found?: boolean; path?: unknown } | undefined return raw?.ok === true && raw.found === true && typeof raw.path === "string" ? raw.path : null }) + // Chat images authored as `![](/abs/path.png)`: read the bytes through the + // server (the iframe can't reach its disk, and a bare can't carry the + // credential) and hand session-ui a data: URL. + unregisterImages = registerLocalImageLoader(async (path) => { + const raw = (await amicodeGet(pick(), `/file/content?path=${encodeURIComponent(path)}`)) as + | { type?: string; content?: unknown; encoding?: string; mimeType?: unknown } + | undefined + if (typeof raw?.content !== "string" || raw.content === "") return null + if (raw.type === "binary" && raw.encoding === "base64" && typeof raw.mimeType === "string" && raw.mimeType.startsWith("image/")) + return `data:${raw.mimeType};base64,${raw.content}` + if (raw.type === "text" && path.toLowerCase().endsWith(".svg")) + return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(raw.content)}` + return null + }) + }) + onCleanup(() => { + unregister?.() + unregisterImages?.() }) - onCleanup(() => unregister?.()) return null } diff --git a/packages/app-bundle/overlay/packages/session-ui/package.json b/packages/app-bundle/overlay/packages/session-ui/package.json index 4f0901216..c6167263b 100644 --- a/packages/app-bundle/overlay/packages/session-ui/package.json +++ b/packages/app-bundle/overlay/packages/session-ui/package.json @@ -13,6 +13,7 @@ "./markdown-stream": "./src/components/markdown-stream.ts", "./markdown-cache": "./src/components/markdown-cache.tsx", "./markdown-file-refs": "./src/components/markdown-file-refs.ts", + "./markdown-local-images": "./src/components/markdown-local-images.ts", "./line-comment-styles": "./src/components/line-comment-styles.ts", "./pierre": "./src/pierre/index.ts", "./pierre/*": "./src/pierre/*.ts", 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 new file mode 100644 index 000000000..43a775fab --- /dev/null +++ b/packages/app-bundle/overlay/packages/session-ui/src/components/image-lightbox.tsx @@ -0,0 +1,60 @@ +// 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; filename?: string }) { + const i18n = useI18n() + const [copied, setCopied] = createSignal(false) + let timer: ReturnType | undefined + onCleanup(() => clearTimeout(timer)) + const copy = async () => { + if (!(await copyImageToClipboard(props.src))) return + setCopied(true) + clearTimeout(timer) + timer = setTimeout(() => setCopied(false), 2000) + } + return ( +
+
+ +
+ void saveImage(props.src, props.filename ?? "image.png")} + /> + void copy()} + /> + +
+
+ {props.alt +
+
+
+
+ ) +} diff --git a/packages/app-bundle/overlay/packages/session-ui/src/components/markdown-local-images.test.ts b/packages/app-bundle/overlay/packages/session-ui/src/components/markdown-local-images.test.ts new file mode 100644 index 000000000..0a346b2a7 --- /dev/null +++ b/packages/app-bundle/overlay/packages/session-ui/src/components/markdown-local-images.test.ts @@ -0,0 +1,141 @@ +import { beforeEach, describe, expect, test } from "bun:test" +import { + cachedLocalImage, + clearLocalImageCache, + loadLocalImageCached, + localImageLoaderVersion, + localImagePath, + frameImages, + imageFileName, + loadLocalImages, + registerLocalImageLoader, +} from "./markdown-local-images" + +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(() => { + registerLocalImageLoader(undefined) + clearLocalImageCache() +}) + +function block(html: string) { + const root = document.createElement("div") + root.innerHTML = html + document.body.appendChild(root) + return root +} + +const tick = () => new Promise((resolve) => setTimeout(resolve, 0)) + +describe("localImagePath", () => { + test("absolute posix image paths are local; query and hash are dropped", () => { + expect(localImagePath("/home/me/talk/eq 1.png")).toBe("/home/me/talk/eq 1.png") + expect(localImagePath("/home/me/talk/eq%201.png?v=2#x")).toBe("/home/me/talk/eq 1.png") + expect(localImagePath("file:///tmp/plot.svg")).toBe("/tmp/plot.svg") + }) + + test("remote, data, protocol-relative, relative and non-image sources are not", () => { + expect(localImagePath("https://example.com/a.png")).toBeUndefined() + expect(localImagePath(PNG)).toBeUndefined() + expect(localImagePath("//cdn.example.com/a.png")).toBeUndefined() + expect(localImagePath("plots/a.png")).toBeUndefined() + expect(localImagePath("/home/me/notes.md")).toBeUndefined() + }) +}) + +describe("loader registry", () => { + test("no loader → null, nothing cached", async () => { + expect(await loadLocalImageCached("/a.png")).toBeNull() + expect(cachedLocalImage("/a.png")).toBeUndefined() + }) + + test("loads cache hits and dedupe in flight; misses stay uncached for retry", async () => { + let calls = 0 + registerLocalImageLoader(async (path) => { + calls++ + return path === "/real.png" ? PNG : null + }) + const [a, b] = await Promise.all([loadLocalImageCached("/real.png"), loadLocalImageCached("/real.png")]) + expect(a).toBe(PNG) + expect(b).toBe(PNG) + expect(await loadLocalImageCached("/real.png")).toBe(PNG) + expect(calls).toBe(1) + expect(await loadLocalImageCached("/missing.png")).toBeNull() + expect(cachedLocalImage("/missing.png")).toBeUndefined() + }) + + test("a stale unregister keeps the newer loader; registration bumps the version", async () => { + const before = localImageLoaderVersion() + const stale = registerLocalImageLoader(async () => null) + registerLocalImageLoader(async () => PNG) + stale() + expect(localImageLoaderVersion()).toBeGreaterThan(before) + expect(await loadLocalImageCached("/x.png")).toBe(PNG) + }) +}) + +describe("chat image frames", () => { + 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(2) + expect(frames[0]!.querySelectorAll('[data-action="download"]').length).toBe(1) + }) + + test("a local path never reaches the network: src is parked until the loader answers", async () => { + let release: (url: string) => void = () => {} + registerLocalImageLoader(() => new Promise((resolve) => (release = resolve))) + const root = block('

eq

') + frameImages(root, button) + const img = root.querySelector("img")! + expect(img.hasAttribute("src")).toBe(false) + expect(img.getAttribute("data-local-src")).toBe("/home/me/eq.png") + loadLocalImages(root) + release(PNG) + await tick() + expect(img.getAttribute("src")).toBe(PNG) + }) + + test("once cached, re-decorating sets the data URL synchronously (no flash on rebuild)", async () => { + registerLocalImageLoader(async () => PNG) + await loadLocalImageCached("/home/me/eq.png") + const root = block('

') + frameImages(root, button) + 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 new file mode 100644 index 000000000..89b4be3d2 --- /dev/null +++ b/packages/app-bundle/overlay/packages/session-ui/src/components/markdown-local-images.ts @@ -0,0 +1,137 @@ +// AMICODE: inline previews for images the agent wrote to the server's disk. +// `![equation](/home/me/talk/eq.png)` in chat markdown can't load directly: the +// chat iframe's origin is the server, a bare can't carry the Basic +// credential, and file:// is stripped by the sanitizer and blocked by the CSP. +// The app registers a loader (it owns the server connection + auth) that reads +// the file through the server and returns a data: URL; the Markdown component +// swaps it in. With no loader registered (stories, tests, a plain browser +// embed) local images stay unloaded and show their alt text. + +import { createSignal } from "solid-js" + +export type LocalImageLoader = (path: string) => Promise + +let loader: LocalImageLoader | undefined +const [version, setVersion] = createSignal(0) + +/** Same registration contract as registerFileRefResolver (#1845): the returned + * unregister clears ONLY this registration, so a remounting shell's late + * cleanup can't wipe its successor. */ +export function registerLocalImageLoader(next: LocalImageLoader | undefined): () => void { + loader = next + setVersion((v) => v + 1) + return () => { + if (loader !== next) return + loader = undefined + setVersion((v) => v + 1) + } +} + +export function localImageLoaderVersion(): number { + return version() +} + +const IMAGE_EXT = /\.(png|jpe?g|gif|webp|svg|bmp)$/i + +/** An authored src that names a file on the server's disk: an absolute posix + * path (not a protocol-relative `//host`) or a file:// URL. */ +export function localImagePath(src: string): string | undefined { + const value = src.trim() + if (/^file:\/\//i.test(value)) { + const path = decodeURIComponent(value.replace(/^file:\/\//i, "")) + return IMAGE_EXT.test(path) ? path : undefined + } + if (!value.startsWith("/") || value.startsWith("//")) return undefined + const path = decodeURIComponent(value.split(/[?#]/)[0] ?? "") + return IMAGE_EXT.test(path) ? path : undefined +} + +// path → data URL (null = known-unloadable). Bounded FIFO by entry count; the +// images are small (equations, plots) but a session can show many of them. +const CACHE_MAX = 64 +const cache = new Map() +const inflight = new Map>() + +export function cachedLocalImage(path: string): string | null | undefined { + return cache.get(path) +} + +/** Load once, dedupe in-flight. Transient failures stay uncached so the next + * render retries (the image may still be being written). */ +export function loadLocalImageCached(path: string): Promise { + const hit = cache.get(path) + if (hit) return Promise.resolve(hit) + const pending = inflight.get(path) + if (pending) return pending + const load = loader + if (!load) return Promise.resolve(null) + const p = load(path) + .then((url) => { + if (!url) return null + if (cache.size >= CACHE_MAX) { + const oldest = cache.keys().next() + if (!oldest.done) cache.delete(oldest.value) + } + cache.set(path, url) + return url + }) + .catch(() => null) + .finally(() => inflight.delete(path)) + inflight.set(path, p) + return p +} + +/** Test hook. */ +export function clearLocalImageCache(): void { + cache.clear() + inflight.clear() +} + +// 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, buttons: () => HTMLElement[]) { + for (const img of Array.from(root.querySelectorAll("img"))) { + const local = localImagePath(img.getAttribute("src") ?? "") + if (local) { + img.setAttribute("data-local-src", local) + const cached = cache.get(local) + if (cached) img.setAttribute("src", cached) + else img.removeAttribute("src") + } + if (img.parentElement?.getAttribute("data-component") === "markdown-image") continue + const frame = document.createElement("span") + frame.setAttribute("data-component", "markdown-image") + img.parentNode?.replaceChild(frame, img) + frame.appendChild(img) + 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])"))) { + const path = img.getAttribute("data-local-src") + if (!path) continue + void loadLocalImageCached(path).then((url) => { + if (!url || !img.isConnected || img.getAttribute("data-local-src") !== path) return + img.src = url + }) + } +} 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 f1871c1ad..55ad9bafc 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 @@ -309,6 +309,40 @@ margin: 1.5rem 0; display: block; } + + /* AMICODE: chat image frame — click to preview, hover to copy. The paper + backing is deliberate, not a theme color: agent figures (equations, plots) + are usually transparent PNGs inked for a light slide, and vanish on a dark + chat background. The copied image keeps its transparency. */ + [data-component="markdown-image"] { + position: relative; + display: block; + width: fit-content; + max-width: 100%; + margin: 1.5rem 0; + padding: 8px; + border-radius: var(--radius-sm); + background: #fff; + + img { + margin: 0; + cursor: zoom-in; + } + + img:not([src]) { + width: 160px; + height: 48px; + } + } + + /* 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; + } } body[data-new-layout] [data-component="markdown"] { 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 502107dbf..bae97326b 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 @@ -1,6 +1,6 @@ import { useMarked } from "@opencode-ai/ui/context/marked" import { useI18n } from "@opencode-ai/ui/context/i18n" -import { copyTextToClipboard } from "../util/clipboard" +import { copyImageToClipboard, copyTextToClipboard } from "../util/clipboard" import { normalizeDisplayMath } from "./markdown-stream" import morphdom from "morphdom" import { checksum } from "@opencode-ai/core/util/encode" @@ -34,6 +34,10 @@ 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, 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" type RenderedBlock = | (MarkdownCacheEntry & { key: string; mode: Exclude }) @@ -88,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 @@ -111,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(() => { @@ -121,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" ? ( + + ) : ( + <> + + + + ) } /> @@ -343,6 +358,28 @@ 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 + download + copy) — see markdown-local-images. + frameImages(root, () => [createCopyButton(labels, "download"), createCopyButton(labels)]) +} + +// 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 + if (!(target instanceof HTMLImageElement)) return + if (target.closest('[data-component="markdown-image"]') === null) return + if (!target.src) return + event.preventDefault() + open(target.src, target.alt, imageFileName(target)) + } + root.addEventListener("click", handleClick) + return () => root.removeEventListener("click", handleClick) } // Issue #270: Mark regular markdown links as external so they open via the bridge @@ -373,11 +410,22 @@ function setupCodeCopy(root: HTMLDivElement, getLabels: () => CopyLabels) { const button = target.closest('[data-slot="markdown-copy-button"]') if (!(button instanceof HTMLElement)) return - const code = button.closest('[data-component="markdown-code"]')?.querySelector("code") - const content = code?.textContent ?? "" - if (!content) return - // Amicode webview: navigator.clipboard dies in the chat iframe — bridge-aware write. - if (!(await copyTextToClipboard(content))) 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 || !(await copyImageToClipboard(image.src))) return + } + if (!image) { + const code = button.closest('[data-component="markdown-code"]')?.querySelector("code") + const content = code?.textContent ?? "" + if (!content) return + // Amicode webview: navigator.clipboard dies in the chat iframe — bridge-aware write. + if (!(await copyTextToClipboard(content))) return + } const labels = getLabels() setCopyState(button, labels, true) const existing = timeouts.get(button) @@ -468,6 +516,11 @@ export function Markdown( const i18n = useI18n() const [root, setRoot] = createSignal() const owner = createUniqueId() + const dialog = optionalDialog() + const openImage = dialog + ? (src: string, alt: string, filename: string) => + dialog.show(() => ) + : undefined const activeCodeKeys = new Set() const completedCode = new Map>() const projection = createMemo((previous: Projection | undefined) => @@ -557,6 +610,7 @@ export function Markdown( let copyCleanup: (() => void) | undefined let linkCleanup: (() => void) | undefined + let imageCleanup: (() => void) | undefined createEffect(() => { const container = root() @@ -574,6 +628,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) => { @@ -595,14 +650,23 @@ 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 (!linkCleanup) linkCleanup = setupExternalLinks(container) + if (!imageCleanup) imageCleanup = setupImagePreview(container, openImage) // #1839: file-reference linkification is core, not a layout preference — // runs unconditionally (the pills it consumes are tagged unconditionally // in decorate). The live report: with data-new-layout off, every file // link in chat was silently inert. markFileLinks(container) + loadLocalImages(container) + }) + + // A loader registered after this message rendered loads its local images. + createEffect(() => { + localImageLoaderVersion() + const container = root() + if (container) loadLocalImages(container) }) // #1845: a resolver registered after this message rendered re-linkifies it @@ -616,6 +680,7 @@ export function Markdown( onCleanup(() => { if (copyCleanup) copyCleanup() if (linkCleanup) linkCleanup() + if (imageCleanup) imageCleanup() activeCodeKeys.forEach(disposeCode) completedCode.clear() }) @@ -795,3 +860,13 @@ function createTokenSpan(token: MarkdownToken) { span.textContent = token[0] return span } + +function optionalDialog() { + // useDialog throws outside a DialogProvider; Markdown also renders in + // provider-less hosts (stories, the widget frame), where previews are off. + try { + return useDialog() + } catch { + return undefined + } +} diff --git a/packages/app-bundle/overlay/packages/session-ui/src/util/clipboard.ts b/packages/app-bundle/overlay/packages/session-ui/src/util/clipboard.ts index 87f3a8524..930f7a5dd 100644 --- a/packages/app-bundle/overlay/packages/session-ui/src/util/clipboard.ts +++ b/packages/app-bundle/overlay/packages/session-ui/src/util/clipboard.ts @@ -32,3 +32,43 @@ export async function copyTextToClipboard(text: string): Promise { () => false, ) } + +// Image copy. The OS clipboard belongs to the VS Code extension host, which +// runs on the machine the user is sitting at (an interface machine, not +// necessarily the server that rendered the image), so inside the chat iframe +// the PNG rides the bridge as "clipboard-image-write" and the host writes it +// natively (vscode.env.clipboard is text-only). Unframed, the async Clipboard +// API stands. Non-PNG sources are rasterized first: PNG is the one image type +// every clipboard target (Slides, Keynote, Docs) accepts. +export async function copyImageToClipboard(src: string): Promise { + const png = await pngDataUrl(src) + if (!png) return false + if (typeof window !== "undefined" && window.parent !== window) { + window.parent.postMessage({ source: "amicode", kind: "clipboard-image-write", dataUrl: png }, "*") + return true // fire-and-forget, like copyTextToClipboard + } + const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard + if (!clipboard?.write || typeof ClipboardItem === "undefined") return false + const blob = await fetch(png).then((res) => res.blob()) + return clipboard.write([new ClipboardItem({ "image/png": blob })]).then( + () => true, + () => false, + ) +} + +export async function pngDataUrl(src: string): Promise { + if (src.startsWith("data:image/png;base64,")) return src + if (typeof document === "undefined") return undefined + const image = new Image() + image.src = src + const loaded = await image.decode().then( + () => true, + () => false, + ) + if (!loaded || !image.naturalWidth) return undefined + const canvas = document.createElement("canvas") + canvas.width = image.naturalWidth + canvas.height = image.naturalHeight + canvas.getContext("2d")?.drawImage(image, 0, 0) + return canvas.toDataURL("image/png") +} 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 1fd62a3e5..88be5b73b 100644 --- a/packages/extension/src/chat_bridge.ts +++ b/packages/extension/src/chat_bridge.ts @@ -569,6 +569,25 @@ export function handleAmicodeBridgeMessage(msg: unknown, io: BridgeIo): boolean return true; } + // Image copy bridge (chat image frame + preview "Copy"): vscode.env.clipboard + // is text-only, so write the PNG with the platform tool. This handler runs in + // the extension host — on the machine the user is sitting at — which is what + // makes the paste land there even when the image was rendered on a remote + // server. PNG only (the iframe rasterizes anything else), bounded like + // save-file; the result is echoed for the nonce'd caller. + if (msg.kind === "clipboard-image-write" && typeof (msg as { dataUrl?: unknown }).dataUrl === "string") { + if (!io.visible()) return true; + const raw = msg as unknown as { dataUrl: string; nonce?: string; tab?: string }; + const prefix = "data:image/png;base64,"; + if (!raw.dataUrl.startsWith(prefix)) return true; + const base64 = raw.dataUrl.slice(prefix.length); + if (!base64 || base64.length > 24_000_000) return true; + void writeClipboardImageNative(Buffer.from(base64, "base64")).then((ok) => { + io.postToWebview({ source: "amicode", kind: "clipboard-image-written", nonce: raw.nonce, tab: raw.tab, ok }); + }); + return true; + } + // Clipboard image bridge: the outer webview's navigator.clipboard.read() // requires user activation (a keystroke on that document), but the user typed // inside the sandboxed iframe — so the async Clipboard API throws in newer @@ -1640,6 +1659,66 @@ export function handleAmicodeBridgeMessage(msg: unknown, io: BridgeIo): boolean return false; } +// --------------------------------------------------------------------------- +// Native clipboard image writer +// --------------------------------------------------------------------------- +// Puts a PNG on the system clipboard via a temp file and the platform tool. +// The path is passed as an argument (never spliced into a script). Resolves +// false on any failure or an unsupported platform. + +export async function writeClipboardImageNative(png: Buffer): Promise { + const command = clipboardImageWriteCommand(process.platform, process.env); + if (!command) return false; + const file = path.join(os.tmpdir(), `amicode-copy-${process.pid}-${Date.now()}.png`); + fs.writeFileSync(file, png); + const ok = await runToExit(command.cmd, [...command.args, file]); + // xclip/wl-copy fork a selection server that keeps the bytes in memory, but + // give it a moment to finish reading before the temp file goes. + setTimeout(() => fs.rmSync(file, { force: true }), 5000); + return ok; +} + +/** The writer for this host; the PNG path is appended as the last argument. */ +export function clipboardImageWriteCommand( + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv, +): { cmd: string; args: string[] } | null { + if (platform === "darwin") + return { + cmd: "osascript", + args: ["-e", "on run argv", "-e", "set the clipboard to (read (POSIX file (item 1 of argv)) as «class PNGf»)", "-e", "end run"], + }; + if (platform === "linux" && env.WAYLAND_DISPLAY) + return { cmd: "sh", args: ["-c", 'exec wl-copy --type image/png < "$1"', "amicode-copy"] }; + if (platform === "linux") return { cmd: "xclip", args: ["-selection", "clipboard", "-t", "image/png", "-i"] }; + // win32 is served through WSL (the linux branch); a native Windows host has no writer. + return null; +} + +// Resolve on the launched process's own exit, not on stdio close: xclip and +// wl-copy daemonize a child that inherits stdio, so execFile would hang until +// its timeout and report failure for a copy that worked. +function runToExit(cmd: string, args: string[]): Promise { + return import("node:child_process").then( + ({ spawn }) => + new Promise((resolve) => { + const child = spawn(cmd, args, { stdio: "ignore" }); + const timer = setTimeout(() => { + child.kill(); + resolve(false); + }, 5000); + child.on("error", () => { + clearTimeout(timer); + resolve(false); + }); + child.on("exit", (code) => { + clearTimeout(timer); + resolve(code === 0); + }); + }), + ); +} + // --------------------------------------------------------------------------- // Native clipboard image reader // --------------------------------------------------------------------------- diff --git a/packages/extension/src/chat_panel.ts b/packages/extension/src/chat_panel.ts index 89fc92dfb..f306baeb2 100644 --- a/packages/extension/src/chat_panel.ts +++ b/packages/extension/src/chat_panel.ts @@ -689,7 +689,7 @@ export class ChatPanel implements LaunchTarget { vscode.postMessage(d); return; } - if (d && d.source === "amicode" && (d.kind === "command" || d.kind === "clipboard-request" || d.kind === "clipboard-write" || d.kind === "open-external" || d.kind === "open-file" || d.kind === "save-file" || d.kind === "set-default-model" || d.kind === "bug-filed" || d.kind === "bug-report-closed" || d.kind === "bug-report-poke" || d.kind === "dev-tools-update" || d.kind === "dev-tools-rebuild" || d.kind === "dev-tools-build-vsix" || d.kind === "data-storage-query" || d.kind === "data-storage-update" || d.kind === "redo-onboarding" || d.kind === "connect-harmoniqs-provider" || d.kind === "device:refresh" || d.kind === "connections-credential" || d.kind === "connections-disconnect" || d.kind === "connections-revalidate" || d.kind === "connections-auth" || d.kind === "connections-choose-project" || d.kind === "connections-add-custom" || d.kind === "connections-remove" || d.kind === "skill-providers-query" || d.kind === "skill-providers-add" || d.kind === "skill-providers-remove" || d.kind === "skill-providers-rename" || d.kind === "skill-providers-autodiscover" || d.kind === "skill-providers-pick-directory" || d.kind === "add-workspace-project" || d.kind === "project-selected" || d.kind === "app-ready" || d.kind === "watch-files" || d.kind === "preview-visible-children-request" || d.kind === "explorer-icon-theme-request" || d.kind === "run-latex" || d.kind === "fleet-enable-control")) { + if (d && d.source === "amicode" && (d.kind === "command" || d.kind === "clipboard-request" || d.kind === "clipboard-write" || d.kind === "clipboard-image-write" || d.kind === "open-external" || d.kind === "open-file" || d.kind === "save-file" || d.kind === "set-default-model" || d.kind === "bug-filed" || d.kind === "bug-report-closed" || d.kind === "bug-report-poke" || d.kind === "dev-tools-update" || d.kind === "dev-tools-rebuild" || d.kind === "dev-tools-build-vsix" || d.kind === "data-storage-query" || d.kind === "data-storage-update" || d.kind === "redo-onboarding" || d.kind === "connect-harmoniqs-provider" || d.kind === "device:refresh" || d.kind === "connections-credential" || d.kind === "connections-disconnect" || d.kind === "connections-revalidate" || d.kind === "connections-auth" || d.kind === "connections-choose-project" || d.kind === "connections-add-custom" || d.kind === "connections-remove" || d.kind === "skill-providers-query" || d.kind === "skill-providers-add" || d.kind === "skill-providers-remove" || d.kind === "skill-providers-rename" || d.kind === "skill-providers-autodiscover" || d.kind === "skill-providers-pick-directory" || d.kind === "add-workspace-project" || d.kind === "project-selected" || d.kind === "app-ready" || d.kind === "watch-files" || d.kind === "preview-visible-children-request" || d.kind === "explorer-icon-theme-request" || d.kind === "run-latex" || d.kind === "fleet-enable-control")) { vscode.postMessage(d); } return; @@ -860,7 +860,7 @@ export class ChatPanel implements LaunchTarget { vscode.postMessage(d); return; } - if (d && d.source === "amicode" && (d.kind === "command" || d.kind === "clipboard-request" || d.kind === "clipboard-write" || d.kind === "open-external" || d.kind === "open-file" || d.kind === "save-file" || d.kind === "set-default-model" || d.kind === "bug-filed" || d.kind === "bug-report-closed" || d.kind === "bug-report-poke" || d.kind === "dev-tools-update" || d.kind === "dev-tools-rebuild" || d.kind === "dev-tools-build-vsix" || d.kind === "data-storage-query" || d.kind === "data-storage-update" || d.kind === "redo-onboarding" || d.kind === "connect-harmoniqs-provider" || d.kind === "device:refresh" || d.kind === "connections-credential" || d.kind === "connections-disconnect" || d.kind === "connections-revalidate" || d.kind === "connections-auth" || d.kind === "connections-choose-project" || d.kind === "connections-add-custom" || d.kind === "connections-remove" || d.kind === "skill-providers-query" || d.kind === "skill-providers-add" || d.kind === "skill-providers-remove" || d.kind === "skill-providers-rename" || d.kind === "skill-providers-autodiscover" || d.kind === "skill-providers-pick-directory" || d.kind === "add-workspace-project" || d.kind === "project-selected" || d.kind === "app-ready" || d.kind === "watch-files" || d.kind === "preview-visible-children-request" || d.kind === "explorer-icon-theme-request" || d.kind === "run-latex" || d.kind === "fleet-enable-control")) { + if (d && d.source === "amicode" && (d.kind === "command" || d.kind === "clipboard-request" || d.kind === "clipboard-write" || d.kind === "clipboard-image-write" || d.kind === "open-external" || d.kind === "open-file" || d.kind === "save-file" || d.kind === "set-default-model" || d.kind === "bug-filed" || d.kind === "bug-report-closed" || d.kind === "bug-report-poke" || d.kind === "dev-tools-update" || d.kind === "dev-tools-rebuild" || d.kind === "dev-tools-build-vsix" || d.kind === "data-storage-query" || d.kind === "data-storage-update" || d.kind === "redo-onboarding" || d.kind === "connect-harmoniqs-provider" || d.kind === "device:refresh" || d.kind === "connections-credential" || d.kind === "connections-disconnect" || d.kind === "connections-revalidate" || d.kind === "connections-auth" || d.kind === "connections-choose-project" || d.kind === "connections-add-custom" || d.kind === "connections-remove" || d.kind === "skill-providers-query" || d.kind === "skill-providers-add" || d.kind === "skill-providers-remove" || d.kind === "skill-providers-rename" || d.kind === "skill-providers-autodiscover" || d.kind === "skill-providers-pick-directory" || d.kind === "add-workspace-project" || d.kind === "project-selected" || d.kind === "app-ready" || d.kind === "watch-files" || d.kind === "preview-visible-children-request" || d.kind === "explorer-icon-theme-request" || d.kind === "run-latex" || d.kind === "fleet-enable-control")) { vscode.postMessage(d); } return; diff --git a/packages/extension/test/chat_bridge.test.ts b/packages/extension/test/chat_bridge.test.ts index 3057987d0..5939ddd62 100644 --- a/packages/extension/test/chat_bridge.test.ts +++ b/packages/extension/test/chat_bridge.test.ts @@ -4,6 +4,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { + clipboardImageWriteCommand, extractReportBugModel, handleAmicodeBridgeMessage, rebuildAppBundleStep, @@ -385,6 +386,38 @@ describe("amicode bridge — clipboard", () => { }); }); +describe("amicode bridge — clipboard-image-write", () => { + it("writes on the host the user sits at and answers the nonce'd caller", async () => { + const host = io(); + const png = "data:image/png;base64,iVBORw0KGgo="; + expect(handleAmicodeBridgeMessage({ source: "amicode", kind: "clipboard-image-write", dataUrl: png, nonce: "c1", tab: "pane-2" }, host)).toBe(true); + await vi.waitFor(() => expect(host.posted).toHaveLength(1)); + const reply = host.posted[0] as { kind: string; nonce: string; tab: string; ok: unknown }; + expect(reply).toMatchObject({ source: "amicode", kind: "clipboard-image-written", nonce: "c1", tab: "pane-2" }); + expect(typeof reply.ok).toBe("boolean"); + }); + + it("drops non-PNG payloads, oversize payloads, and hidden panels", async () => { + const hidden = io(false); + expect(handleAmicodeBridgeMessage({ source: "amicode", kind: "clipboard-image-write", dataUrl: "data:image/png;base64,AA==" }, hidden)).toBe(true); + const host = io(); + expect(handleAmicodeBridgeMessage({ source: "amicode", kind: "clipboard-image-write", dataUrl: "data:image/svg+xml;base64,AA==" }, host)).toBe(true); + expect(handleAmicodeBridgeMessage({ source: "amicode", kind: "clipboard-image-write", dataUrl: "data:image/png;base64," + "A".repeat(24_000_001) }, host)).toBe(true); + await flush(); + expect(hidden.posted).toHaveLength(0); + expect(host.posted).toHaveLength(0); + }); + + it("picks the platform writer and never splices the path into a script", () => { + const mac = clipboardImageWriteCommand("darwin", {}); + expect(mac?.cmd).toBe("osascript"); + expect(mac?.args.join(" ")).toContain("item 1 of argv"); + expect(clipboardImageWriteCommand("linux", {})).toEqual({ cmd: "xclip", args: ["-selection", "clipboard", "-t", "image/png", "-i"] }); + expect(clipboardImageWriteCommand("linux", { WAYLAND_DISPLAY: "wayland-0" })?.args[1]).toContain("wl-copy --type image/png"); + expect(clipboardImageWriteCommand("win32", {})).toBeNull(); + }); +}); + describe("amicode bridge — connect-harmoniqs-provider", () => { it("hands a keyless connect off to the onboarding panel and acks so the picker closes (amicode#962)", async () => { // The branded picker row (dialog-connect-provider-harmoniqs.ts) posts with