feat(app): preview timeline images in attachment overlay (#49111)

This commit is contained in:
David Hill
2026-09-15 16:19:41 -06:00
committed by GitHub
parent 7e2ca5fe26
commit 24dff6929d
9 changed files with 93 additions and 43 deletions
@@ -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 (
<>
<DialogProvider>
<textarea aria-label="Markdown text" value={text()} onInput={(event) => setText(event.currentTarget.value)} />
<input
aria-label="Streaming"
@@ -58,7 +59,7 @@ export async function mountMarkdown(options: {
/>
</Show>
</MarkdownProvider>
</>
</DialogProvider>
)
}, host)
}
@@ -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("![Replacement](./images/next.png)")
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: "![SVG](Z:/charts/chart.svg)" })
}, 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")
})
@@ -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 (
<section style={{ "max-width": "720px", padding: "24px" }}>
<button onClick={() => setState("running", false)}>Finish read</button>
<button onClick={() => setState("appended", true)}>Append read</button>
<button onClick={() => setState("visible", false)}>Unmount tools</button>
<MarkdownProvider readImage={(path, signal) => readLocalImage(api, "C:/project", path, signal)}>
<CurrentSessionProviders document={storyDocument(tools())}>
<Show when={state.visible}>
<Show
when={options.grouped}
fallback={
<ToolDisplay
id="read_image"
tool="read"
input={{ path: options.path }}
metadata={{}}
status={status()}
<DialogProvider>
<section style={{ "max-width": "720px", padding: "24px" }}>
<button onClick={() => setState("running", false)}>Finish read</button>
<button onClick={() => setState("appended", true)}>Append read</button>
<button onClick={() => setState("visible", false)}>Unmount tools</button>
<MarkdownProvider readImage={(path, signal) => readLocalImage(api, "C:/project", path, signal)}>
<CurrentSessionProviders document={storyDocument(tools())}>
<Show when={state.visible}>
<Show
when={options.grouped}
fallback={
<ToolDisplay
id="read_image"
tool="read"
input={{ path: options.path }}
metadata={{}}
status={status()}
/>
}
>
<CurrentContextToolGroup
parts={tools()}
busy={state.running}
open={state.open}
onOpenChange={(open) => setState("open", open)}
/>
}
>
<CurrentContextToolGroup
parts={tools()}
busy={state.running}
open={state.open}
onOpenChange={(open) => setState("open", open)}
/>
</Show>
</Show>
</Show>
</CurrentSessionProviders>
</MarkdownProvider>
</section>
</CurrentSessionProviders>
</MarkdownProvider>
</section>
</DialogProvider>
)
}, host)
}
@@ -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")
})
@@ -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(() => <ImagePreview src={src} alt={alt} />)
}
return (root: HTMLElement) => {
root.querySelectorAll<HTMLImageElement>("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)
}
})
}
}
@@ -329,6 +329,7 @@
/* Images */
img {
cursor: zoom-in;
max-width: 100%;
height: auto;
border-radius: 4px;
@@ -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<Block["mode"], "code"> })
@@ -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<HTMLDivElement>()
const owner = createUniqueId()
const lifetime = new AbortController()
@@ -557,6 +559,7 @@ export function Markdown(
child.remove()
}
images?.update(container)
previewImages(container)
container
.querySelectorAll<HTMLElement>('[data-slot="markdown-copy-button"]')
.forEach((button) => setCopyState(button, labels, button.dataset.copied === "true"))
@@ -369,6 +369,7 @@
min-width: 0;
img {
cursor: zoom-in;
display: block;
max-width: 100%;
height: auto;
@@ -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)