From 24dff6929dce6ed4a90f53e65dbd80df5f014e83 Mon Sep 17 00:00:00 2001
From: David Hill <1879069+iamdavidhill@users.noreply.github.com>
Date: Tue, 15 Sep 2026 16:19:41 -0600
Subject: [PATCH] feat(app): preview timeline images in attachment overlay
(#49111)
---
.../component-tests/markdown.fixture.tsx | 5 +-
.../component-tests/markdown.spec.ts | 27 +++++----
.../component-tests/read-image.fixture.tsx | 59 ++++++++++---------
.../component-tests/read-image.spec.ts | 6 +-
.../src/components/image-preview.tsx | 31 ++++++++++
.../session-ui/src/components/markdown.css | 1 +
.../session-ui/src/components/markdown.tsx | 3 +
.../src/components/message-part.css | 1 +
.../session-ui/src/tools/tool-renderer.tsx | 3 +
9 files changed, 93 insertions(+), 43 deletions(-)
create mode 100644 packages/session-ui/src/components/image-preview.tsx
diff --git a/packages/session-ui/component-tests/markdown.fixture.tsx b/packages/session-ui/component-tests/markdown.fixture.tsx
index c47474aaa709..bb3f5a2f48fd 100644
--- a/packages/session-ui/component-tests/markdown.fixture.tsx
+++ b/packages/session-ui/component-tests/markdown.fixture.tsx
@@ -1,5 +1,6 @@
import { createSignal, Show } from "solid-js"
import { render } from "solid-js/web"
+import { DialogProvider } from "@opencode/ui/context/dialog"
import { Markdown } from "../src/components/markdown"
import { preloadMarkdown } from "../src/components/markdown-cache"
import { MarkdownProvider } from "../src/context/markdown"
@@ -35,7 +36,7 @@ export async function mountMarkdown(options: {
const [streaming, setStreaming] = createSignal(options.streaming ?? false)
const [visible, setVisible] = createSignal(true)
return (
- <>
+
)
}, host)
}
diff --git a/packages/session-ui/component-tests/markdown.spec.ts b/packages/session-ui/component-tests/markdown.spec.ts
index 3619e4351cd9..dab557031a76 100644
--- a/packages/session-ui/component-tests/markdown.spec.ts
+++ b/packages/session-ui/component-tests/markdown.spec.ts
@@ -545,9 +545,9 @@ for (const streaming of [false, true]) {
{ fixture, streaming },
)
const harness = page.getByTestId("markdown-fixture")
- const image = harness.getByRole("img", { name: "Chart", exact: true })
+ const image = harness.getByRole("button", { name: "Chart", exact: true })
await expect.poll(() => image.evaluate((image: HTMLImageElement) => image.naturalWidth)).toBe(1)
- await expect(harness.getByRole("img", { name: "Again", exact: true })).toHaveAttribute("src", /^blob:/)
+ await expect(harness.getByRole("button", { name: "Again", exact: true })).toHaveAttribute("src", /^blob:/)
expect(requests).toHaveLength(1)
expect(new URL(requests[0]).searchParams.get("location[directory]")).toBe("C:/tmp/")
const url = await image.getAttribute("src")
@@ -557,7 +557,9 @@ for (const streaming of [false, true]) {
await harness.getByLabel("Markdown text").fill("")
await expect
.poll(() =>
- harness.getByRole("img", { name: "Replacement" }).evaluate((image: HTMLImageElement) => image.naturalWidth),
+ harness
+ .getByRole("button", { name: "Replacement" })
+ .evaluate((image: HTMLImageElement) => image.naturalWidth),
)
.toBe(1)
expect(requests).toHaveLength(2)
@@ -571,9 +573,9 @@ for (const streaming of [false, true]) {
url,
),
).toBe(true)
- const next = await harness.getByRole("img", { name: "Replacement" }).getAttribute("src")
+ const next = await harness.getByRole("button", { name: "Replacement" }).getAttribute("src")
await harness.getByRole("button", { name: "Toggle Markdown" }).click()
- await expect(harness.getByRole("img")).toHaveCount(0)
+ await expect(harness.locator("img")).toHaveCount(0)
expect(
await page.evaluate(
(url) =>
@@ -602,9 +604,14 @@ story("keeps remote images browser-owned and rejects unsafe image sources", asyn
}, fixture)
const harness = page.getByTestId("markdown-fixture")
await expect
- .poll(() => harness.getByRole("img", { name: "Remote" }).evaluate((image: HTMLImageElement) => image.naturalWidth))
+ .poll(() =>
+ harness.getByRole("button", { name: "Remote" }).evaluate((image: HTMLImageElement) => image.naturalWidth),
+ )
.toBe(1)
- await expect(harness.getByRole("img", { name: "Remote" })).toHaveAttribute("src", "https://images.example/chart.png")
+ await expect(harness.getByRole("button", { name: "Remote" })).toHaveAttribute(
+ "src",
+ "https://images.example/chart.png",
+ )
await expect(harness.locator("[onerror], [src^='javascript:'], [data-local-image]")).toHaveCount(0)
})
@@ -628,7 +635,7 @@ story("keeps scripts and external resources inactive inside local SVG images", a
const { mountMarkdown } = await import(fixture)
await mountMarkdown({ images: true, text: "" })
}, fixture)
- const image = page.getByTestId("markdown-fixture").getByRole("img", { name: "SVG" })
+ const image = page.getByTestId("markdown-fixture").getByRole("button", { name: "SVG" })
await expect.poll(() => image.evaluate((image: HTMLImageElement) => image.naturalWidth)).toBe(32)
await expect(image).toHaveAttribute("src", /^data:image\/svg\+xml;/)
await expect(page).not.toHaveTitle("image-script-ran")
@@ -662,12 +669,12 @@ story("loads file URLs and leaves unreadable images as alt text", async ({ page
await expect
.poll(() =>
harness
- .getByRole("img", { name: "Available", exact: true })
+ .getByRole("button", { name: "Available", exact: true })
.evaluate((image: HTMLImageElement) => image.naturalWidth),
)
.toBe(1)
await expect.poll(() => [...requested].sort()).toEqual(["/api/fs/read/chart%25.png", "/api/fs/read/missing.png"])
- await expect(harness.getByRole("img", { name: "Unavailable" })).not.toHaveAttribute("src")
+ await expect(harness.getByRole("button", { name: "Unavailable" })).not.toHaveAttribute("src")
await harness.getByLabel("Markdown text").fill("Still usable")
await expect(harness.locator('[data-component="markdown"]')).toHaveText("Still usable")
})
diff --git a/packages/session-ui/component-tests/read-image.fixture.tsx b/packages/session-ui/component-tests/read-image.fixture.tsx
index 4b503a8d4a64..a2d06b33a548 100644
--- a/packages/session-ui/component-tests/read-image.fixture.tsx
+++ b/packages/session-ui/component-tests/read-image.fixture.tsx
@@ -1,6 +1,7 @@
import { createMemo, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { render } from "solid-js/web"
+import { DialogProvider } from "@opencode/ui/context/dialog"
import { OpenCode } from "@opencode/client/promise"
import { readLocalImage } from "../../app/src/runtime/server/image"
import { MarkdownProvider } from "../src/context/markdown"
@@ -25,36 +26,38 @@ export function mountReadImage(options: { path: string; grouped: boolean; runnin
...(state.appended ? [storyTool("read_next", "read", "completed", { path: "src/next.ts" })] : []),
])
return (
-
-
-
-
- readLocalImage(api, "C:/project", path, signal)}>
-
-
-
+
+
+
+
+ readLocalImage(api, "C:/project", path, signal)}>
+
+
+
+ }
+ >
+ setState("open", open)}
/>
- }
- >
- setState("open", open)}
- />
+
-
-
-
-
+
+
+
+
)
}, host)
}
diff --git a/packages/session-ui/component-tests/read-image.spec.ts b/packages/session-ui/component-tests/read-image.spec.ts
index 956896fae830..966d91770b24 100644
--- a/packages/session-ui/component-tests/read-image.spec.ts
+++ b/packages/session-ui/component-tests/read-image.spec.ts
@@ -33,7 +33,7 @@ for (const grouped of [true, false]) {
)
const root = page.getByTestId("read-image-fixture")
const trigger = root.getByRole("button", { name: "Read chart%20 one.PNG", exact: true })
- const image = root.getByRole("img", { name: "chart%20 one.PNG", exact: true })
+ const image = root.getByRole("button", { name: "chart%20 one.PNG", exact: true })
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "false")
@@ -115,7 +115,7 @@ for (const width of [840, 390]) {
)
const root = page.getByTestId("read-image-fixture")
await root.getByRole("button", { name: "Read chart.svg", exact: true }).click()
- const image = root.getByRole("img", { name: "chart.svg", exact: true })
+ const image = root.getByRole("button", { name: "chart.svg", exact: true })
await expect(image).toHaveJSProperty("naturalWidth", 1200)
await expect(image).toHaveAttribute("src", /^data:image\/svg\+xml;/)
expect(
@@ -140,7 +140,7 @@ story("keeps an unavailable image read collapsible", async ({ page }) => {
const response = page.waitForResponse("**/api/fs/read/**")
await trigger.click()
expect((await response).status()).toBe(404)
- await expect(root.getByRole("img", { name: "missing.png", exact: true })).not.toHaveAttribute("src")
+ await expect(root.getByRole("button", { name: "missing.png", exact: true })).not.toHaveAttribute("src")
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "false")
})
diff --git a/packages/session-ui/src/components/image-preview.tsx b/packages/session-ui/src/components/image-preview.tsx
new file mode 100644
index 000000000000..17590048ec33
--- /dev/null
+++ b/packages/session-ui/src/components/image-preview.tsx
@@ -0,0 +1,31 @@
+import { useDialog } from "@opencode/ui/context/dialog"
+import { useI18n } from "@opencode/ui/context/i18n"
+import { ImagePreview } from "@opencode/ui/image-preview"
+
+export function createImagePreview() {
+ const dialog = useDialog()
+ const i18n = useI18n()
+ const open = (event: MouseEvent | KeyboardEvent) => {
+ if (!(event.currentTarget instanceof HTMLImageElement)) return
+ const src = event.currentTarget.currentSrc || event.currentTarget.getAttribute("src")
+ if (!src) return
+ const alt = event.currentTarget.alt || i18n.t("ui.imagePreview.alt")
+ event.preventDefault()
+ event.stopPropagation()
+ dialog.show(() => )
+ }
+
+ return (root: HTMLElement) => {
+ root.querySelectorAll("img").forEach((image) => {
+ image.setAttribute("role", "button")
+ image.setAttribute("aria-haspopup", "dialog")
+ image.setAttribute("aria-label", image.alt || i18n.t("ui.imagePreview.alt"))
+ image.tabIndex = 0
+ image.onclick = open
+ image.onkeydown = (event) => {
+ if (event.key !== "Enter" && event.key !== " ") return
+ open(event)
+ }
+ })
+ }
+}
diff --git a/packages/session-ui/src/components/markdown.css b/packages/session-ui/src/components/markdown.css
index 7b613ec34038..480500c3c6f6 100644
--- a/packages/session-ui/src/components/markdown.css
+++ b/packages/session-ui/src/components/markdown.css
@@ -329,6 +329,7 @@
/* Images */
img {
+ cursor: zoom-in;
max-width: 100%;
height: auto;
border-radius: 4px;
diff --git a/packages/session-ui/src/components/markdown.tsx b/packages/session-ui/src/components/markdown.tsx
index 267a1e120171..75441b4d6eaf 100644
--- a/packages/session-ui/src/components/markdown.tsx
+++ b/packages/session-ui/src/components/markdown.tsx
@@ -39,6 +39,7 @@ import { renderMermaidSvg } from "./markdown-mermaid"
import { createMarkdownRenderer } from "./markdown-solid"
import { useMarkdown, type ReadMarkdownImage } from "../context/markdown"
import { createMarkdownImages } from "./markdown-image"
+import { createImagePreview } from "./image-preview"
type RenderedBlock =
| (MarkdownCacheEntry & { key: string; mode: Exclude })
@@ -394,6 +395,7 @@ export function Markdown(
const [local, others] = splitProps(props, ["text", "cacheKey", "streaming", "deferUntilReady", "class", "classList"])
const i18n = useI18n()
const markdown = useMarkdown()
+ const previewImages = createImagePreview()
const [root, setRoot] = createSignal()
const owner = createUniqueId()
const lifetime = new AbortController()
@@ -557,6 +559,7 @@ export function Markdown(
child.remove()
}
images?.update(container)
+ previewImages(container)
container
.querySelectorAll('[data-slot="markdown-copy-button"]')
.forEach((button) => setCopyState(button, labels, button.dataset.copied === "true"))
diff --git a/packages/session-ui/src/components/message-part.css b/packages/session-ui/src/components/message-part.css
index 6b38f96a1e1b..24baa32d5ea9 100644
--- a/packages/session-ui/src/components/message-part.css
+++ b/packages/session-ui/src/components/message-part.css
@@ -369,6 +369,7 @@
min-width: 0;
img {
+ cursor: zoom-in;
display: block;
max-width: 100%;
height: auto;
diff --git a/packages/session-ui/src/tools/tool-renderer.tsx b/packages/session-ui/src/tools/tool-renderer.tsx
index ab985cfe4834..3eb2bec70203 100644
--- a/packages/session-ui/src/tools/tool-renderer.tsx
+++ b/packages/session-ui/src/tools/tool-renderer.tsx
@@ -28,6 +28,7 @@ import { ToolErrorCard } from "../components/tool-error-card"
import { DiffChanges } from "@opencode/ui/diff-changes"
import { Markdown } from "../components/markdown"
import { createMarkdownImages } from "../components/markdown-image"
+import { createImagePreview } from "../components/image-preview"
import { useMarkdown } from "../context/markdown"
import { getDirectory, getFilename } from "@opencode/util/path"
import { checksum } from "@opencode/util/encode"
@@ -1366,8 +1367,10 @@ ToolRegistry.register({
function ReadImage(props: { path: string; onContentRendered?: () => void }) {
const markdown = useMarkdown()
+ const previewImages = createImagePreview()
let root!: HTMLDivElement
createEffect(() => {
+ previewImages(root)
if (!markdown?.readImage) return
const images = createMarkdownImages(markdown.readImage)
images.update(root)