Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 30 additions & 28 deletions packages/app-bundle/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -516,16 +516,16 @@
"packages/session-ui/src/components/build-trace.test.ts": "6259c529fa667a3514bfee1c771e8f623bcb8c69e37ceb17ea541aa42e207451",
"packages/session-ui/src/components/build-trace.ts": "153e3182c3a37e4aca9c88bf0e2052b0ff4ec117a60d74d2c81567ab91180066",
"packages/session-ui/src/components/group-parts.test.ts": "a31ce19009e1d62e75e83b682190a4613da0eed334420409ed73a9e21834ae84",
"packages/session-ui/src/components/image-lightbox.tsx": "e03904142307429b9f48275d0e1d6ec4b9fcbbf24ea47420bc4de89db78e9e89",
"packages/session-ui/src/components/image-lightbox.tsx": "d6bddb0db8736f8284568d74ae2a33b1c3b1acf51e8627a711ae6637386a0b23",
"packages/session-ui/src/components/line-comment-styles.ts": "2077836c13bd3c3f1c39c67722119e448a2dfa123377f94bfd3676634e955d00",
"packages/session-ui/src/components/markdown-file-refs.test.ts": "2172d17a0ee5e49636c8bd323f4fb797a7f41e469d0098864b82d374f0c10144",
"packages/session-ui/src/components/markdown-file-refs.ts": "417417f3660fe18f04a13ca4765b155b5479d7ce41b3638a57ba1abaa5830054",
"packages/session-ui/src/components/markdown-local-images.test.ts": "f344e43f4272841e867909c31955655be9b6dbfd81c986d8c6910b3ae01f6d33",
"packages/session-ui/src/components/markdown-local-images.ts": "566cb252bed28e300a93efaefd0f1bf3d893c77d2bccfa35f1fc6b961341702c",
"packages/session-ui/src/components/markdown-local-images.test.ts": "f614e986656d128f5b72ca772a7f3c8ea607cbed69559227feb3d9286f4110be",
"packages/session-ui/src/components/markdown-local-images.ts": "17fd69cdda8056711227601c695d7419bc8147433f7700eb3ed301b1803ce188",
"packages/session-ui/src/components/markdown-stream.test.ts": "b73b1a15ffa2cfea5358b2d6313ce17d26a5a11302b0e3b7d823cde0c98cbc83",
"packages/session-ui/src/components/markdown-stream.ts": "50e38abd5d4ff919564655799678be10463dec58502fc5487c1c97a9a8db5399",
"packages/session-ui/src/components/markdown.css": "e5654bf01501ab93bcad54b488b460d6ca283da9875978557f853106738ec830",
"packages/session-ui/src/components/markdown.tsx": "9eba82666012819518e4e9145e75373e3a6d1d0d33738da639c8e5be117c43c5",
"packages/session-ui/src/components/markdown.css": "8698bc5865247f6003d0aeb5d264dd84a38cedbdd1079be7c7250b2f1314358a",
"packages/session-ui/src/components/markdown.tsx": "ab9302aa816c272157b60ba62d85733d248e928050ef7597fccb3e8dde5254e1",
"packages/session-ui/src/components/message-part-css.test.ts": "bc164695d0195684d646ebf490ba7bb4ed72adcf2bf3d01b00de47f4f03bcd4f",
"packages/session-ui/src/components/message-part-groups.ts": "37be069bf46e926fcde83d3744ee706911ff002e6b532e279290d841aeee96da",
"packages/session-ui/src/components/message-part-skill.test.ts": "453bdcb25e4df91ad1004635b8766070a60421355adf18ee53f43cd2fa7d0486",
Expand Down Expand Up @@ -553,6 +553,7 @@
"packages/session-ui/src/pierre/worker.ts": "7f210f931777956567eaeea15f2f5478befc7874a64a4b685504c59c66503fee",
"packages/session-ui/src/util/clipboard.test.ts": "16dc2c7bc04f00134841e6e1e6b503f519a14aa41e4cb83cb1d63002a770992c",
"packages/session-ui/src/util/clipboard.ts": "89dc04d0faec989f48c44d82107a5b3d024f078f6146998f2392672b1647131e",
"packages/session-ui/src/util/save-image.ts": "599b4fe69c191c41db1d231303b94a9acdf0fe2e4c2056e914367c321039a24c",
"packages/ui/src/amicode/activity-dot.tsx": "fc3c9e620cdd204e930f0d53a0a44456e0c886679174c91f0efbf9707daa7bc6",
"packages/ui/src/amicode/activity.test.ts": "1674ca5138780242767b0eb42fc444a9b8ce7868ac119117dd1487b7209180b2",
"packages/ui/src/amicode/activity.ts": "a2c9b4fe27175a400ea0bcfeb433dfcb572cd793766bf0fd232c741feef5cfbf",
Expand Down Expand Up @@ -725,24 +726,24 @@
"packages/ui/src/context/marked-math.test.ts": "0a53b2701d79ef16abe3132a514831ada60a535324991e4d72ee4e0ced9a0ae6",
"packages/ui/src/context/marked-parser.tsx": "2d8d0c2e5e28278f54296640d1e851fff4ef3b2902c7f70d02cdbcd82545bf39",
"packages/ui/src/hooks/create-auto-scroll.tsx": "4a8e9aa41f075b5e1815e647ab3a8a5333340d00e20ff7414f5e6c1f9a738337",
"packages/ui/src/i18n/ar.ts": "8113d320fe4a5300f1bd70150ae1695d68cb783e1c6e2be43b8a429b7dbdb711",
"packages/ui/src/i18n/br.ts": "cbde2da43ef820fe2664cb96cb973fd722156a6c8a3c5e16d18638bf014e5196",
"packages/ui/src/i18n/bs.ts": "23ea0ae34a4edaa4599b13f63f481be861e28da3b0d898987e291c7e91a9f11e",
"packages/ui/src/i18n/da.ts": "e8083f8a7ab01f73990a1eabcba817afdc663858d524e56e652ea69238cefc47",
"packages/ui/src/i18n/de.ts": "eb854fdd43f16fc2346ec43d7d1629d2714709991b3e5f7ac5ee99011324aa43",
"packages/ui/src/i18n/en.ts": "1b412a1cd0ea8f7d0ce6069d3bd94dc40778cc38c23c60191cd90071d4187ac8",
"packages/ui/src/i18n/es.ts": "a34d132adc2ad68ae3da98b6afd5070be48a0cd6cb15c4866cc7ea99deefc4ab",
"packages/ui/src/i18n/fr.ts": "4a8296dec41894e6ecb209e44564ac79c04cae73035b5447eb233cd984ef4bae",
"packages/ui/src/i18n/ja.ts": "6c13f118491c8badfac33baf449bee55779f6058792b6a54973bfe975e4c25c3",
"packages/ui/src/i18n/ko.ts": "b97f70d80c9ab7561c05ab91d461e9323c4c846d6a27bed0a41ad5fbcdf0c1db",
"packages/ui/src/i18n/no.ts": "120e5d11f5aebde00822def6fda9ae12db47cd020bdd04114cdb098d5ba67449",
"packages/ui/src/i18n/pl.ts": "8133cedc013364bf84a3902399b431f4029d6c1d57acf23f12a739127d4583e9",
"packages/ui/src/i18n/ru.ts": "11d79d60f2f678597ee2b33b51fa666f38f1e78b7ed051a686229ae0dd8aac72",
"packages/ui/src/i18n/th.ts": "afa5a46ff445461db8f069338aae760cb9cac09b370c47ced5542549c92c9235",
"packages/ui/src/i18n/tr.ts": "fac27e4bec327ff83976d9cdd8f54c2a241cb26b44c644a93c591e99f09290ed",
"packages/ui/src/i18n/uk.ts": "3531fcf92efaec359381fcc2f0cdbb644d52936a20037067451b057ef327ea1c",
"packages/ui/src/i18n/zh.ts": "6aac9e8c8973fb538effee075740035ce58a631615d39c0c8fb49375d45a56e5",
"packages/ui/src/i18n/zht.ts": "d50b7119139f53180eac64a6317341f54efc87ff76d56961a3e86ef74e3ad29b",
"packages/ui/src/i18n/ar.ts": "97076b6ad801490738d826b32a85ae319a71de80bf54913b2df336b9bac20253",
"packages/ui/src/i18n/br.ts": "4980710f624237a2431d9c998fb96f3a639a57ad17294673a0c5686e5bccb786",
"packages/ui/src/i18n/bs.ts": "76d4a6bbf6c1a2a70c5faa7f3f92be184527e2c4f35caf671e78383466acd0d3",
"packages/ui/src/i18n/da.ts": "3a0f67922cca3e51295f94ae6359ddd8f6d0aae3d083b555f7270eec971df61e",
"packages/ui/src/i18n/de.ts": "2aa33b5f4999b2b5e86fa4c1aa87a4e1065d50404f380516d9bf74d42d546f04",
"packages/ui/src/i18n/en.ts": "09cef9a7387c1520608e121a7ba3ea49e634fc1a5fa2699719ce83d7878f329c",
"packages/ui/src/i18n/es.ts": "60edc806b466c94a23442a0aab94ecffb392582a55253e0b24005881937a5900",
"packages/ui/src/i18n/fr.ts": "fd05b582176bd197fa895e8f88a03af52a9b5c375818cd06e935b81c66d729e2",
"packages/ui/src/i18n/ja.ts": "7d0034042e62bce6875da6ea9b5cc3624af9b32120676b6e6ba8285b87b6bb27",
"packages/ui/src/i18n/ko.ts": "a2bb5964089bf5b96470eb2c8f5b4f2a474fd35bcda36697d8a831d966d950b7",
"packages/ui/src/i18n/no.ts": "4950514b740d052ac097644559e8e8e52d304feeee56a24c9bd6fa40ae3aac85",
"packages/ui/src/i18n/pl.ts": "c9a31c1fc2352755136459ece574688465c42788101b3a39490fe92f2cb1f4c5",
"packages/ui/src/i18n/ru.ts": "5ddb560fa1155f1b0242be83c6e2a88533efd3c2b3f37856207da526b2b95944",
"packages/ui/src/i18n/th.ts": "54b511d48e6d606da7a44601400317075852c3b26cf67f8802f13c40561f170c",
"packages/ui/src/i18n/tr.ts": "f862f1c8bf700a9087e507e32a4c4094d834c37e23f3107d6551213b74328ca0",
"packages/ui/src/i18n/uk.ts": "5c5566b45e5ea82b28c417f3c4c83c380e61de7934da7815449f4e3e423fbb1e",
"packages/ui/src/i18n/zh.ts": "fde440e791fc340728374dd04690c9fd286da1293298074439d5a9526f86738b",
"packages/ui/src/i18n/zht.ts": "41944597b6dada282c3ca0483d85bd188c797bd906561cb8025411d0190546ca",
"packages/ui/src/styles/index.css": "2eae4e3d82e992c3d99f4a91ff25b3d6e9e514b38e9bebd232e6c41009cdc1fd",
"packages/ui/src/styles/theme.css": "0dc88bf675df5cdf08cfe0cc53c11eaf030a1bc0cf8b36cbeb3977367c2b362b",
"packages/ui/src/theme/context.tsx": "93b0b3f8a258bc8a33d4b6a4cda7016e91f6d999bd32c60a43aaaf45eadd1227",
Expand Down Expand Up @@ -784,7 +785,7 @@
"packages/app/src/components/session/pdf-restore.ts": "e5c92096b9302a452439babfc2732610a7e0d18e23793ce3bf6352fa32f91a36",
"packages/app/src/components/session/preview-file-helpers.test.ts": "e0c1ccb40b48990c0c5b87ea62a35de60b860cd760067c7f1327cbb1d36584b1",
"packages/app/src/components/session/preview-file-helpers.ts": "2244aaef25f7412df7b53a45a7d2f72380d45eb90caefa5e96c48d01e988eb4e",
"packages/app/src/components/session/preview-file-view.tsx": "caccb771210950896631ac7efd4cb3ca5689e0ba5049d9e2f53154ca86bb07b9",
"packages/app/src/components/session/preview-file-view.tsx": "5cf02d46673f30c2a1be19f4f1c838fc6ffe86205e0239b45586b1baa62befc4",
"packages/app/src/components/session/preview-frontmatter-card.tsx": "00f439e3bf482673dfb06c0ce3464c0f51985093ed57b321cdebd38d452f2e5d",
"packages/app/src/components/session/preview-frontmatter.test.ts": "93ec9f7f4c604d8ed8ee6009779c425bffecfca331a122a7a700b588a8ca3c0c",
"packages/app/src/components/session/preview-frontmatter.ts": "036250edd8c2886ee8f8ac0723f2b9a41d363898006a53f47bc44b8194c5a3d3",
Expand Down Expand Up @@ -971,7 +972,7 @@
"packages/ui/src/v2/components/badge-v2.css": "32135a9df5bad856a10138e00a2cb3ede7a6fc0cc2398797ad6bed9335d1f845",
"packages/ui/src/v2/components/dialog-v2.css": "ae6f7b1de755929ba529b9dbb0e837c80f620f00a77188b9d2c59f10b0f28bbd",
"packages/ui/src/v2/components/icon-button-v2.tsx": "966e535e3efe503f054045be4476fc20d2fad36092410b52f75e4cf8de6fce6f",
"packages/ui/src/v2/components/icon.tsx": "9f702e9c307524f79dfa9cad70f9bea32bda85672f917ffdb0871bb14cf13437",
"packages/ui/src/v2/components/icon.tsx": "9beb0192869e9acf61ab148e806f399c155cd9b6c1b414576fe1652b94dcff45",
"packages/ui/src/v2/components/menu-v2.css": "2f262a6c456a8e3cb9027f0b8b3bb3c4327794bd80cadd3cf8c16d08e6125d4d",
"packages/ui/src/v2/components/menu-v2.stories.tsx": "4ed1498d7e4cd51ce9c7459061f3540efdfae71751ff618901c78ee413951559",
"packages/ui/src/v2/components/radio-v2.css": "bf6eb3457a7fc13e48356f5a7dcf3cbf462382580250a3144a745402ff88e2e6",
Expand Down Expand Up @@ -1070,7 +1071,7 @@
"packages/ui/src/v2/components/text-shimmer-v2.tsx"
],
"exceptions": [],
"extracted_at": "2026-10-29T13:09:53.406Z",
"extracted_at": "2026-10-30T02:45:47.212Z",
"per_package": {
"packages/app": {
"A": 289,
Expand Down Expand Up @@ -1108,7 +1109,7 @@
"D": 0
},
"packages/session-ui": {
"A": 49,
"A": 50,
"M": 32,
"D": 0
},
Expand All @@ -1119,7 +1120,7 @@
}
},
"counts": {
"overlay_total": 1048,
"overlay_total": 1049,
"deletions": 7,
"server_coupled": 0
},
Expand Down Expand Up @@ -1669,6 +1670,7 @@
"packages/session-ui/src/pierre/worker.ts": "M",
"packages/session-ui/src/util/clipboard.test.ts": "A",
"packages/session-ui/src/util/clipboard.ts": "A",
"packages/session-ui/src/util/save-image.ts": "A",
"packages/ui/src/amicode/activity-dot.tsx": "A",
"packages/ui/src/amicode/activity.test.ts": "A",
"packages/ui/src/amicode/activity.ts": "A",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -620,6 +620,27 @@ export function PreviewFileView(props: {
"backdrop-filter": "blur(4px)",
}

// AMICODE: save a previewed PDF/image to the user's machine. Downloads are
// dead inside the webview iframe, so the bytes ride the extension's
// save-file bridge (a native Save dialog on the machine the user is at);
// unframed, a plain anchor download stands.
const downloadPreview = () => {
const data = binaryData()
if (!data) return
const filename = props.filePath.split("/").pop() || "download"
const dataUrl = `data:${data.mime};base64,${data.base64}`
if (window.parent !== window) {
window.parent.postMessage({ source: "amicode", kind: "save-file", filename, dataUrl }, "*")
return
}
const link = document.createElement("a")
link.href = dataUrl
link.download = filename
document.body.appendChild(link)
link.click()
link.remove()
}

return (
<div
class="h-full relative overflow-hidden"
Expand Down Expand Up @@ -750,6 +771,19 @@ export function PreviewFileView(props: {
)
}}
</Show>
<Show when={binaryData()}>
<TooltipV2 openDelay={400} value="Download">
<button
data-preview-download
class="shrink-0 flex items-center justify-center w-7 h-7 rounded-md border border-border-base shadow-sm cursor-pointer text-text-weak hover:text-text-base hover:bg-background-stronger transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-border-focus"
style={previewControlSurfaceStyle}
onClick={downloadPreview}
aria-label="Download"
>
<Icon name="download" size="small" />
</button>
</TooltipV2>
</Show>
{/* Zoom controls: [editable %] [reset] [+ over -] */}
<div
class="shrink-0 flex items-center h-7 rounded-md border border-border-base overflow-hidden shadow-sm"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
// AMICODE: full-size preview for a chat image, with copy. The stock
// ImagePreview (ui) is close-only; agent-produced figures (equations, plots)
// are usually opened to be copied into a deck or doc, so the header carries a
// copy action next to close. Same data-component as ImagePreview so it rides
// the shared image-preview styles.
// AMICODE: full-size preview for a chat image, with download and copy. The
// stock ImagePreview (ui) is close-only; agent-produced figures (equations,
// plots) are usually opened to be copied or saved into a deck or doc, so the
// header carries download + copy next to close. Same data-component as
// ImagePreview so it rides the shared image-preview styles.
import { Dialog as Kobalte } from "@kobalte/core/dialog"
import { createSignal, onCleanup } from "solid-js"
import { useI18n } from "@opencode-ai/ui/context/i18n"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { copyImageToClipboard } from "../util/clipboard"
import { saveImage } from "../util/save-image"

export function ImageLightbox(props: { src: string; alt?: string }) {
export function ImageLightbox(props: { src: string; alt?: string; filename?: string }) {
const i18n = useI18n()
const [copied, setCopied] = createSignal(false)
// The host's native write can fail (copyImageToClipboard awaits its nonce'd
Expand All @@ -33,6 +34,14 @@ export function ImageLightbox(props: { src: string; alt?: string }) {
<div data-slot="image-preview-container">
<Kobalte.Content data-slot="image-preview-content">
<div data-slot="image-preview-header">
<IconButton
data-slot="image-preview-download"
icon="download"
variant="ghost"
aria-label={i18n.t("ui.message.download")}
title={i18n.t("ui.message.download")}
onClick={() => void saveImage(props.src, props.filename ?? "image.png")}
/>
<IconButton
data-slot="image-preview-copy"
icon={copied() ? "check-small" : "copy"}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,18 @@ import {
localImageLoaderVersion,
localImagePath,
frameImages,
imageFileName,
loadLocalImages,
registerLocalImageLoader,
} from "./markdown-local-images"

const button = () => {
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(() => {
Expand Down Expand Up @@ -79,14 +82,15 @@ describe("loader registry", () => {
})

describe("chat image frames", () => {
test("every image is framed with a copy button, once", () => {
test("every image is framed with download + copy buttons, once", () => {
const root = block('<p><img src="https://example.com/a.png" alt="a"></p>')
frameImages(root, button)
frameImages(root, button)
const frames = root.querySelectorAll('[data-component="markdown-image"]')
expect(frames.length).toBe(1)
expect(frames[0]!.querySelector("img")?.getAttribute("src")).toBe("https://example.com/a.png")
expect(frames[0]!.querySelectorAll('[data-slot="markdown-copy-button"]').length).toBe(1)
expect(frames[0]!.querySelectorAll('[data-slot="markdown-copy-button"]').length).toBe(2)
expect(frames[0]!.querySelectorAll('[data-action="download"]').length).toBe(1)
})

test("only local images get the paper backing marker (data-local); remote images render as authored", () => {
Expand Down Expand Up @@ -120,3 +124,27 @@ describe("chat image frames", () => {
expect(root.querySelector("img")?.getAttribute("src")).toBe(PNG)
})
})

describe("imageFileName", () => {
const img = (html: string) => {
const root = block(html)
frameImages(root, button)
return root.querySelector("img")!
}

test("a disk image keeps its basename, saved as png", () => {
expect(imageFileName(img('<img src="/home/me/talk/18-piccolo-mintime.png" alt="eq">'))).toBe(
"18-piccolo-mintime.png",
)
expect(imageFileName(img('<img src="/tmp/plot.svg">'))).toBe("plot.png")
expect(imageFileName(img('<img src="/home/me/my%20figure.v2.jpg">'))).toBe("my-figure.v2.png")
})

test("otherwise the alt text names it, else 'image'", () => {
expect(imageFileName(img('<img src="https://example.com/a" alt="Fig 3: comb vs posterior">'))).toBe(
"Fig-3-comb-vs-posterior.png",
)
expect(imageFileName(img('<img src="https://example.com/a">'))).toBe("image.png")
expect(imageFileName(img('<img src="https://example.com/a" alt="../../">'))).toBe("image.png")
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -87,13 +87,13 @@ export function clearLocalImageCache(): void {
inflight.clear()
}

// Every chat image gets a frame with an action button (the caller supplies the
// copy button). Local-path sources (`![](/abs/x.png)`) can't load directly in
// Every chat image gets a frame with action buttons (the caller supplies them:
// download + copy). Local-path sources (`![](/abs/x.png)`) can't load directly in
// the iframe: park the path in data-local-src and drop the src so no doomed
// request fires; loadLocalImages fills it in on the live container, and once
// cached the next pass sets it synchronously, so a morphdom rebuild of the
// block never flashes the image away. Idempotent.
export function frameImages(root: HTMLElement, button: () => HTMLElement) {
export function frameImages(root: HTMLElement, buttons: () => HTMLElement[]) {
for (const img of Array.from(root.querySelectorAll("img"))) {
const local = localImagePath(img.getAttribute("src") ?? "")
if (local) {
Expand All @@ -111,10 +111,23 @@ export function frameImages(root: HTMLElement, button: () => HTMLElement) {
if (local) frame.setAttribute("data-local", "")
img.parentNode?.replaceChild(frame, img)
frame.appendChild(img)
frame.appendChild(button())
for (const button of buttons()) frame.appendChild(button)
}
}

/** The saved file's name: the authored file's basename when the image came
* from disk, else the alt text, else "image" — always `.png`, because the
* save path rasterizes to PNG (the one type the host's save bridge accepts). */
export function imageFileName(img: HTMLImageElement): string {
const local = img.getAttribute("data-local-src") ?? localImagePath(img.getAttribute("src") ?? "")
const base = local ? (local.split("/").pop() ?? "").replace(/\.[^.]+$/, "") : img.alt
const name = base
.trim()
.replace(/[^\w.-]+/g, "-")
.replace(/^[-.]+|[-.]+$/g, "")
return `${name || "image"}.png`
}

/** Live-container pass: load every parked local image that has no src yet. */
export function loadLocalImages(root: HTMLElement) {
for (const img of Array.from(root.querySelectorAll<HTMLImageElement>("img[data-local-src]:not([src])"))) {
Expand Down
Loading
Loading