diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json index c6dfc3db8..ef36b9ded 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", @@ -511,13 +511,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": "7bf691fe5cfa9360d4203de529c547e01d07ea1c284e32fd8b23cd6af08c5cec", "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": "a7fa83953e2393b570b5d3f0533b482b0ca03d8535e35212e402a7dcfdcb8c69", + "packages/session-ui/src/components/markdown-local-images.ts": "221facf13ba914b206f029cce51a68a5198957c25c0d66e8d9ac992af1464c39", "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": "45c7b31097358841a07b5191110d9651605bd9ac1eec577f60ac73400f6d4f1e", + "packages/session-ui/src/components/markdown.tsx": "3448b3db56047656a677c08b8cd68cd9cf490ced3babcda6c2da11aa4d9a2d73", "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", @@ -543,7 +546,7 @@ "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": "5048e1b93374c50ee8c15e86470c10a4efbbcf6be2d29b9a7983e43300a490bf", "packages/ui/src/amicode/activity-dot.tsx": "fc3c9e620cdd204e930f0d53a0a44456e0c886679174c91f0efbf9707daa7bc6", "packages/ui/src/amicode/activity.test.ts": "1674ca5138780242767b0eb42fc444a9b8ce7868ac119117dd1487b7209180b2", "packages/ui/src/amicode/activity.ts": "a2c9b4fe27175a400ea0bcfeb433dfcb572cd793766bf0fd232c741feef5cfbf", @@ -1049,7 +1052,7 @@ "packages/ui/src/v2/components/text-shimmer-v2.tsx" ], "exceptions": [], - "extracted_at": "2026-10-28T20:53:06.380Z", + "extracted_at": "2026-10-29T12:31:54.003Z", "per_package": { "packages/app": { "A": 272, @@ -1087,7 +1090,7 @@ "D": 0 }, "packages/session-ui": { - "A": 45, + "A": 48, "M": 32, "D": 0 }, @@ -1098,7 +1101,7 @@ } }, "counts": { - "overlay_total": 1027, + "overlay_total": 1030, "deletions": 7, "server_coupled": 0 }, @@ -1606,9 +1609,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", 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..2091e0edc --- /dev/null +++ b/packages/app-bundle/overlay/packages/session-ui/src/components/image-lightbox.tsx @@ -0,0 +1,51 @@ +// AMICODE: full-size preview for a chat image, with copy. The stock +// ImagePreview (ui) is close-only; agent-produced figures (equations, plots) +// are usually opened to be copied into a deck or doc, so the header carries a +// copy action next to close. Same data-component as ImagePreview so it rides +// the shared image-preview styles. +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" + +export function ImageLightbox(props: { src: string; alt?: 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 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..26459fd46 --- /dev/null +++ b/packages/app-bundle/overlay/packages/session-ui/src/components/markdown-local-images.test.ts @@ -0,0 +1,113 @@ +import { beforeEach, describe, expect, test } from "bun:test" +import { + cachedLocalImage, + clearLocalImageCache, + loadLocalImageCached, + localImageLoaderVersion, + localImagePath, + frameImages, + loadLocalImages, + registerLocalImageLoader, +} from "./markdown-local-images" + +const button = () => { + const el = document.createElement("div") + el.setAttribute("data-slot", "markdown-copy-button") + return el +} +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 a copy button, once", () => { + const root = block('

a

') + frameImages(root, button) + frameImages(root, button) + const frames = root.querySelectorAll('[data-component="markdown-image"]') + expect(frames.length).toBe(1) + expect(frames[0]!.querySelector("img")?.getAttribute("src")).toBe("https://example.com/a.png") + expect(frames[0]!.querySelectorAll('[data-slot="markdown-copy-button"]').length).toBe(1) + }) + + 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) + }) +}) 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..8931c27a8 --- /dev/null +++ b/packages/app-bundle/overlay/packages/session-ui/src/components/markdown-local-images.ts @@ -0,0 +1,124 @@ +// 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 an action button (the caller supplies the +// copy button). Local-path sources (`![](/abs/x.png)`) can't load directly in +// the iframe: park the path in data-local-src and drop the src so no doomed +// request fires; loadLocalImages fills it in on the live container, and once +// cached the next pass sets it synchronously, so a morphdom rebuild of the +// block never flashes the image away. Idempotent. +export function frameImages(root: HTMLElement, button: () => HTMLElement) { + 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) + frame.appendChild(button()) + } +} + +/** 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..dea45f669 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,35 @@ 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; + } + } + + [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..53a1a7a9b 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,9 @@ 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 { useDialog } from "@opencode-ai/ui/context/dialog" +import { ImageLightbox } from "./image-lightbox" type RenderedBlock = | (MarkdownCacheEntry & { key: string; mode: Exclude }) @@ -343,6 +346,24 @@ 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)) +} + +// 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) { + 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) + } + 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 +394,17 @@ 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 (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 +495,10 @@ export function Markdown( const i18n = useI18n() const [root, setRoot] = createSignal() const owner = createUniqueId() + const dialog = optionalDialog() + const openImage = dialog + ? (src: string, alt: string) => dialog.show(() => ) + : undefined const activeCodeKeys = new Set() const completedCode = new Map>() const projection = createMemo((previous: Projection | undefined) => @@ -557,6 +588,7 @@ export function Markdown( let copyCleanup: (() => void) | undefined let linkCleanup: (() => void) | undefined + let imageCleanup: (() => void) | undefined createEffect(() => { const container = root() @@ -597,12 +629,20 @@ export function Markdown( copied: i18n.t("ui.message.copied"), })) 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 +656,7 @@ export function Markdown( onCleanup(() => { if (copyCleanup) copyCleanup() if (linkCleanup) linkCleanup() + if (imageCleanup) imageCleanup() activeCodeKeys.forEach(disposeCode) completedCode.clear() }) @@ -795,3 +836,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..b3948c4a9 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, + ) +} + +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/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 f95edda00..55c7d56ec 100644 --- a/packages/extension/src/chat_panel.ts +++ b/packages/extension/src/chat_panel.ts @@ -700,7 +700,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; @@ -871,7 +871,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