feat(session-ui): preview images in read tool results (#46513)

This commit is contained in:
Luke Parker
2026-09-01 11:44:39 +10:00
committed by GitHub
Unverified
parent 8fda87614f
commit a20cbc394e
4 changed files with 262 additions and 2 deletions
@@ -0,0 +1,60 @@
import { createMemo, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { render } from "solid-js/web"
import { OpenCode } from "@opencode-ai/client/promise"
import { readLocalImage } from "../../app/src/runtime/server/image"
import { MarkdownProvider } from "../src/context/markdown"
import { CurrentSessionProviders } from "../src/storybook/current-session-story"
import { storyDocument, storyTool } from "../src/storybook/current-session-scenarios"
import { CurrentContextToolGroup, ToolDisplay } from "../src/tools/tool-renderer"
export function mountReadImage(options: { path: string; grouped: boolean; running?: boolean }) {
const host = document.createElement("div")
host.dataset.testid = "read-image-fixture"
document.body.appendChild(host)
render(() => {
const api = OpenCode.make({
baseUrl: location.origin,
headers: { Authorization: `Basic ${btoa("opencode:fixture")}` },
})
const [state, setState] = createStore({ open: true, visible: true, running: !!options.running, appended: false })
const status = () => (state.running ? "running" : "completed")
const tools = createMemo(() => [
storyTool("read_image", "read", status(), { path: options.path }),
storyTool("read_text", "read", "completed", { path: "src/example.ts", limit: 20 }),
...(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()}
/>
}
>
<CurrentContextToolGroup
parts={tools()}
busy={state.running}
open={state.open}
onOpenChange={(open) => setState("open", open)}
/>
</Show>
</Show>
</CurrentSessionProviders>
</MarkdownProvider>
</section>
)
}, host)
}
@@ -0,0 +1,146 @@
import { fileURLToPath } from "node:url"
import { expect, story } from "../../storybook/playwright/story"
const fixture = `/@fs/${fileURLToPath(new URL("./read-image.fixture.tsx", import.meta.url)).replaceAll("\\", "/")}`
const png = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a4ioAAAAASUVORK5CYII=",
"base64",
)
story.beforeEach(async ({ mount }) => {
const root = await mount("current-tool-group--mixed-tools")
await expect(root.getByRole("button", { name: "Used 4 Shell, Read, Agent", exact: true })).toBeVisible()
})
for (const grouped of [true, false]) {
story(
`lazily previews ${grouped ? "grouped" : "standalone"} image reads and releases them on collapse`,
async ({ page }) => {
const requests: string[] = []
await page.route("**/api/fs/read/**", async (route) => {
expect(route.request().headers().authorization).toBe(
`Basic ${Buffer.from("opencode:fixture").toString("base64")}`,
)
requests.push(route.request().url())
await route.fulfill({ contentType: "image/png", body: png })
})
await page.evaluate(
async ({ fixture, grouped }) => {
const { mountReadImage } = await import(fixture)
mountReadImage({ path: "C:\\tmp\\chart%20 one.PNG", grouped, running: true })
},
{ fixture, grouped },
)
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 })
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await root.getByRole("button", { name: "Finish read", exact: true }).click()
await expect(trigger.locator('[data-slot="collapsible-arrow"]')).toBeVisible()
expect(requests).toEqual([])
await expect(image).toHaveCount(0)
if (grouped) {
const text = root.locator('[data-slot="context-tool-group-item"]').filter({ hasText: "example.ts" })
await expect(text).toContainText("limit=20")
await expect(text.getByRole("button")).toHaveCount(0)
}
for (const action of ["click", "Enter", "Space"]) {
if (action === "click") await trigger.click()
if (action !== "click") await trigger.press(action)
await expect(trigger).toHaveAttribute("aria-expanded", "true")
await expect(image).toHaveJSProperty("naturalWidth", 1)
await expect(image).toHaveAttribute("src", /^blob:/)
const url = await image.getAttribute("src")
if (action === "click") {
expect(requests).toHaveLength(1)
expect(new URL(requests[0]).pathname).toBe("/api/fs/read/chart%2520%20one.PNG")
expect(new URL(requests[0]).searchParams.get("location[directory]")).toBe("C:/tmp/")
await root.getByRole("button", { name: "Append read", exact: true }).click()
await expect(image).toHaveAttribute("src", url!)
expect(requests).toHaveLength(1)
}
if (action === "click") await trigger.click()
if (action !== "click") await trigger.press(action)
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await expect(image).toHaveCount(0)
expect(
await page.evaluate(
(url) =>
fetch(url!).then(
() => false,
() => true,
),
url,
),
).toBe(true)
}
await trigger.click()
await expect(image).toHaveJSProperty("naturalWidth", 1)
const url = await image.getAttribute("src")
await root.getByRole("button", { name: "Unmount tools", exact: true }).click()
await expect(image).toHaveCount(0)
expect(
await page.evaluate(
(url) =>
fetch(url!).then(
() => false,
() => true,
),
url,
),
).toBe(true)
},
)
}
for (const width of [840, 390]) {
for (const dir of ["ltr", "rtl"]) {
story(`fits the server image inside the read row at ${width}px in ${dir}`, async ({ page }) => {
await page.setViewportSize({ width, height: 800 })
await page.route("**/api/fs/read/**", (route) =>
route.fulfill({
contentType: "image/svg+xml",
body: '<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="640"><rect width="1200" height="640" fill="green" /></svg>',
}),
)
await page.evaluate(
async ({ fixture, dir }) => {
document.documentElement.dir = dir
const { mountReadImage } = await import(fixture)
mountReadImage({ path: "./images/chart.svg", grouped: true })
},
{ fixture, dir },
)
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 })
await expect(image).toHaveJSProperty("naturalWidth", 1200)
await expect(image).toHaveAttribute("src", /^data:image\/svg\+xml;/)
expect(
await image.evaluate((image) => {
const bounds = image.getBoundingClientRect()
const row = image.closest('[data-slot="context-tool-group-item"]')!.getBoundingClientRect()
return bounds.width > 0 && bounds.left >= row.left && bounds.right <= row.right && bounds.bottom <= row.bottom
}),
).toBe(true)
})
}
}
story("keeps an unavailable image read collapsible", async ({ page }) => {
await page.route("**/api/fs/read/**", (route) => route.fulfill({ status: 404, body: "Not found" }))
await page.evaluate(async (fixture) => {
const { mountReadImage } = await import(fixture)
mountReadImage({ path: "/tmp/missing.png", grouped: true })
}, fixture)
const root = page.getByTestId("read-image-fixture")
const trigger = root.getByRole("button", { name: "Read missing.png", exact: true })
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 trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "false")
})
@@ -363,6 +363,17 @@
}
}
[data-component="read-image"] {
min-width: 0;
img {
display: block;
max-width: 100%;
height: auto;
border-radius: 4px;
}
}
[data-component="bash-output"] {
direction: ltr;
unicode-bidi: isolate;
@@ -27,6 +27,8 @@ import { Icon, type IconProps } from "@opencode-ai/ui/icon"
import { ToolErrorCard } from "../components/tool-error-card"
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { Markdown } from "../components/markdown"
import { createMarkdownImages } from "../components/markdown-image"
import { useMarkdown } from "../context/markdown"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { checksum } from "@opencode-ai/util/encode"
import { Tooltip } from "@opencode-ai/ui/tooltip"
@@ -272,6 +274,12 @@ function readToolPath(input: Record<string, unknown>) {
return undefined
}
function readImagePath(input: Record<string, unknown>) {
const path = readToolPath(input)
if (!path || !/\.(png|jpe?g|gif|webp|svg|avif|bmp|ico)$/i.test(path)) return
return path.replaceAll("\\", "/")
}
function skillToolName(input: Record<string, unknown>, metadata?: Record<string, unknown>) {
if (typeof metadata?.name === "string") return metadata.name
if (typeof input.id === "string") return input.id
@@ -621,7 +629,9 @@ export function CurrentContextToolGroup(props: {
<div data-slot="context-tool-group-item">
<Show
when={
tool().state.status !== "error" && ["read", "glob", "grep", "list"].includes(tool().name)
tool().state.status !== "error" &&
["read", "glob", "grep", "list"].includes(tool().name) &&
!(tool().name === "read" && readImagePath(currentToolInput(tool())))
}
fallback={
<Show
@@ -1049,6 +1059,7 @@ ToolRegistry.register({
render(props) {
const data = useData()
const i18n = useI18n()
const image = createMemo(() => (props.status === "completed" ? readImagePath(props.input) : undefined))
const args: string[] = []
if (typeof props.input.offset === "number") args.push("offset=" + props.input.offset)
if (typeof props.input.limit === "number") args.push("limit=" + props.input.limit)
@@ -1071,12 +1082,22 @@ ToolRegistry.register({
<BasicTool
{...props}
icon="glasses"
hasContent={!!image()}
defer
onOpenChange={(open) => {
props.onOpenChange?.(open)
props.onContentRendered?.()
}}
trigger={{
title: i18n.t("ui.tool.read"),
subtitle: getFilename(readToolPath(props.input) ?? ""),
args,
}}
/>
>
<Show when={image()} keyed>
{(path) => <ReadImage path={path} onContentRendered={props.onContentRendered} />}
</Show>
</BasicTool>
<Show when={paths().length > 0}>
<div
data-component="tool-loaded-item"
@@ -1102,6 +1123,28 @@ ToolRegistry.register({
},
})
function ReadImage(props: { path: string; onContentRendered?: () => void }) {
const markdown = useMarkdown()
let root!: HTMLDivElement
createEffect(() => {
if (!markdown?.readImage) return
const images = createMarkdownImages(markdown.readImage)
images.update(root)
onCleanup(() => images.dispose())
})
onMount(() => props.onContentRendered?.())
return (
<div ref={root} data-component="read-image">
<img
data-local-image={props.path}
alt={getFilename(props.path)}
onLoad={() => props.onContentRendered?.()}
onError={() => props.onContentRendered?.()}
/>
</div>
)
}
ToolRegistry.register({
name: "list",
render(props) {