mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-14 03:56:55 +03:00
feat(session-ui): preview images in read tool results (#46513)
This commit is contained in:
committed by
GitHub
Unverified
parent
8fda87614f
commit
a20cbc394e
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user