diff --git a/packages/session-ui/component-tests/read-image.fixture.tsx b/packages/session-ui/component-tests/read-image.fixture.tsx new file mode 100644 index 00000000000..293ee5f90ea --- /dev/null +++ b/packages/session-ui/component-tests/read-image.fixture.tsx @@ -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 ( +
+ + + + readLocalImage(api, "C:/project", path, signal)}> + + + + } + > + 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 new file mode 100644 index 00000000000..956896fae83 --- /dev/null +++ b/packages/session-ui/component-tests/read-image.spec.ts @@ -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: '', + }), + ) + 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") +}) diff --git a/packages/session-ui/src/components/message-part.css b/packages/session-ui/src/components/message-part.css index e24521c1ec7..a81f9b0a40e 100644 --- a/packages/session-ui/src/components/message-part.css +++ b/packages/session-ui/src/components/message-part.css @@ -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; diff --git a/packages/session-ui/src/tools/tool-renderer.tsx b/packages/session-ui/src/tools/tool-renderer.tsx index dbf6e99d9f8..fff21e9078e 100644 --- a/packages/session-ui/src/tools/tool-renderer.tsx +++ b/packages/session-ui/src/tools/tool-renderer.tsx @@ -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) { return undefined } +function readImagePath(input: Record) { + 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, metadata?: Record) { if (typeof metadata?.name === "string") return metadata.name if (typeof input.id === "string") return input.id @@ -621,7 +629,9 @@ export function CurrentContextToolGroup(props: {
(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({ { + props.onOpenChange?.(open) + props.onContentRendered?.() + }} trigger={{ title: i18n.t("ui.tool.read"), subtitle: getFilename(readToolPath(props.input) ?? ""), args, }} - /> + > + + {(path) => } + + 0}>
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 ( +
+ {getFilename(props.path)} props.onContentRendered?.()} + onError={() => props.onContentRendered?.()} + /> +
+ ) +} + ToolRegistry.register({ name: "list", render(props) {