diff --git a/tools/web-ux/browser-tests/console_image_visibility_test.ts b/tools/web-ux/browser-tests/console_image_visibility_test.ts new file mode 100644 index 000000000..0eebbbb34 --- /dev/null +++ b/tools/web-ux/browser-tests/console_image_visibility_test.ts @@ -0,0 +1,449 @@ +/// +import { assert, assertEquals } from "@std/assert"; +import { fromFileUrl } from "@std/path"; +import { type Browser, chromium, type Page, type Request } from "playwright"; +import type { + SessionConversationTurn, + SessionHistoryPage, + SessionSnapshotEntry, + SessionToolAttachment, +} from "../../../web/workspace/src/lib/generated/protocol.ts"; +import { startOwnedProcesses, stopOwnedProcesses } from "../src/processes.ts"; + +// 本番static assetsとConsole描画を接続し、履歴APIと画像bytesだけをloopback fixtureで代替する。 +// Backend認証・Worker実行は検証しない。callerが標準rootへ隔離buildを配置する。buildは起動しない。 +const staticRoot = fromFileUrl(new URL("../../../web/workspace/build/", import.meta.url)); +const output = fromFileUrl(new URL("../../../.yoi/dev/console-image-review/", import.meta.url)); +const fixtureServer = fromFileUrl(new URL("console_history_fixture_server.ts", import.meta.url)); +const consolePath = "/w/console-history-review/workers/W-900-console-fixture/console"; +const inventoryPath = "/api/w/console-history-review/workers"; +const imagePath = "/api/w/console-history-review/runtimes/runtime-a/workers/worker-a" + + "/sessions/session-a/attachments/console-image-visibility"; +const imageSvg = + 'Synthetic ViewImage fixture'; +const attachment: SessionToolAttachment = { + attachment_id: "console-image-visibility", + media_type: "image/svg+xml", + byte_len: new TextEncoder().encode(imageSvg).byteLength, +}; +const imageEntries: SessionSnapshotEntry[] = [ + { + kind: "message", + entry_id: "user-7", + timestamp: 7, + provenance: "human_input", + role: "user", + content: [{ kind: "text", text: "question 7" }], + }, + { + kind: "tool_call", + entry_id: "read-before-image", + timestamp: 7, + provenance: "model_output", + call_id: "read-before-image", + name: "Read", + arguments: '{"file_path":"fixture.txt"}', + }, + { + kind: "tool_result", + entry_id: "read-before-image-result", + timestamp: 7, + provenance: "tool_output", + call_id: "read-before-image", + summary: "Read fixture", + content: "Read fixture", + is_error: false, + }, + { + kind: "tool_call", + entry_id: "image-call", + timestamp: 7, + provenance: "model_output", + call_id: "image-call", + name: "ViewImage", + arguments: '{"path":"fixture.svg"}', + }, + { + kind: "tool_result", + entry_id: "image-result", + timestamp: 7, + provenance: "tool_output", + call_id: "image-call", + summary: "Synthetic image attached", + content: "Synthetic image attached", + is_error: false, + attachments: [attachment], + }, + { + kind: "message", + entry_id: "following-image-text", + timestamp: 7, + provenance: "model_output", + role: "assistant", + content: [{ + kind: "text", + // 画像の後ろに実際の本文を置き、scroll rootから十分離す。CSSによるfixture余白は作らない。 + text: Array.from( + { length: 140 }, + (_, index) => `Following image paragraph ${index + 1}: retained conversation text.`, + ).join("\n\n"), + }], + }, +]; +const imageTurn: SessionConversationTurn = { turn_id: "user-7", entries: imageEntries }; + +async function withFixture(run: (browser: Browser, base: string) => Promise) { + await Deno.stat(`${staticRoot}index.html`); + const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 }); + const port = (listener.addr as Deno.NetAddr).port; + listener.close(); + const base = `http://127.0.0.1:${port}`; + const processes = await startOwnedProcesses( + [{ + id: "console-image-history", + command: Deno.execPath(), + args: ["run", "--allow-net", "--allow-read", fixtureServer, String(port), staticRoot], + readyUrl: `${base}/health`, + readyTimeoutMs: 5_000, + }], + fixtureServer, + `${output}after/logs/${port}`, + [], + ); + let browser: Browser | undefined; + try { + browser = await chromium.launch({ headless: true, timeout: 10_000 }); + await run(browser, base); + } finally { + try { + await browser?.close(); + } finally { + assertEquals(await stopOwnedProcesses(processes), [], "owned fixture cleanup"); + } + } +} + +async function installHistory(page: Page) { + const requests: string[] = []; + const errors: string[] = []; + const documentStatus: { value: number | null } = { value: null }; + const inventory = { + subscribedOrder: 0, + requests: [] as { order: number; status?: number; finished?: boolean }[], + cancellations: [] as { url: string; error: string; order: number; reason: string }[], + }; + const inventoryRequests: Request[] = []; + const replacement = Promise.withResolvers(); + let order = 0; + let inventoryVerified = false; + page.on("websocket", (socket) => + socket.on("framereceived", ({ payload }) => { + const frame = JSON.parse(String(payload)); + if ( + frame.frame === "response" && frame.message?.result === "subscribed" && + frame.message.payload?.selector?.topic === "workspace_workers" + ) { + inventory.subscribedOrder = ++order; + } + })); + page.on("request", (request) => { + const url = new URL(request.url()); + if (request.method() === "GET" && url.pathname === inventoryPath && !url.search) { + inventoryRequests.push(request); + inventory.requests.push({ order: ++order }); + } + }); + page.on("requestfinished", (request) => { + const index = inventoryRequests.indexOf(request); + if (index < 0) return; + inventory.requests[index].finished = true; + if (index !== 1) return; + void (async () => { + try { + const response = await request.response(); + inventory.requests[index].status = response?.status(); + assertEquals(response?.status(), 200, "replacement inventory GET must succeed"); + const body = await response!.json(); + assertEquals(body.workspace_id, "console-history-review"); + assert( + body.items.some((worker: { resource_key: string }) => worker.resource_key === "W-900"), + "finished replacement inventory must contain the fixture Worker", + ); + } catch (error) { + errors.push(`replacement inventory: ${error}`); + } finally { + replacement.resolve(); + } + })(); + }); + page.on("response", (response) => { + if (response.request().isNavigationRequest() && response.frame() === page.mainFrame()) { + documentStatus.value = response.status(); + } + if (response.status() >= 400) errors.push(`${response.status()} ${response.url()}`); + }); + page.on("requestfailed", (request) => { + const error = request.failure()?.errorText ?? "unknown request failure"; + const failedOrder = ++order; + // subscribedでrefreshCatalogが初回GETをabortする所有lifecycleだけを候補にする。 + // 後続GETのrequestfinished・200・workspace/Worker一致をverifyInventoryで確認してから許容する。 + if ( + !inventoryVerified && request === inventoryRequests[0] && + inventoryRequests.length === 2 && error === "net::ERR_ABORTED" && + !inventory.requests[0].finished && + inventory.requests[0].order < inventory.subscribedOrder && + inventory.subscribedOrder < inventory.requests[1].order + ) { + inventory.cancellations.push({ + url: request.url(), + error, + order: failedOrder, + reason: "initial inventory superseded by workspace_workers subscription snapshot", + }); + } else { + errors.push(`${request.url()}: ${error}`); + } + if (request === inventoryRequests[1]) replacement.resolve(); + }); + page.on("pageerror", (error) => errors.push(String(error))); + page.on("console", (message) => { + if (message.type() === "error") errors.push(message.text()); + }); + page.on("request", (request) => { + if (new URL(request.url()).pathname === imagePath) requests.push(request.url()); + }); + await page.route(`**${imagePath}`, (route) => + route.fulfill({ + status: 200, + contentType: attachment.media_type, + body: imageSvg, + })); + await page.route("**/session/history*", async (route) => { + const response = await route.fetch({ timeout: 5_000 }); + const body: { availability: string; page: SessionHistoryPage } = await response.json(); + assertEquals(body.availability, "page"); + // snapshotと重なる最古turnをhistory側で置換し、追加pagingによる距離の変動を除く。 + body.page.turns = [imageTurn, ...body.page.turns.filter((turn) => turn.turn_id !== "user-7")]; + body.page.has_more = false; + body.page.next_cursor = null; + await route.fulfill({ response, json: body }); + }); + async function verifyInventory() { + let timer: number | undefined; + try { + await Promise.race([ + replacement.promise, + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error("replacement inventory GET did not finish")), + 5_000, + ); + }), + ]); + } finally { + clearTimeout(timer); + } + assertEquals(inventory.requests.length, 2, "initial and subscription-triggered inventory GETs"); + assertEquals(inventoryRequests[1].url(), inventoryRequests[0].url(), "same inventory endpoint"); + assert(inventory.requests[0].order < inventory.subscribedOrder); + assert(inventory.subscribedOrder < inventory.requests[1].order); + assertEquals( + inventory.requests[1].finished, + true, + "replacement inventory must finish, not abort", + ); + assertEquals(inventory.requests[1].status, 200, "replacement inventory must return 200"); + assert(inventory.cancellations.length <= 1, "only the initial owned cancellation is expected"); + inventoryVerified = true; + assertEquals(errors, [], "inventory replacement and all unrelated requests must succeed"); + } + return { requests, errors, documentStatus, inventory, verifyInventory }; +} + +async function paint(page: Page) { + // Svelte更新・scroll・native IntersectionObserverに描画機会を渡す。時間sleepで完了を推測しない。 + await page.evaluate(async () => { + for (let frame = 0; frame < 3; frame++) { + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + } + }); +} + +async function scroll(page: Page, near: boolean) { + await page.locator(".console-scroll").evaluate((element, near) => { + element.scrollTop = near ? 0 : element.scrollHeight; + }, near); + await page.waitForFunction((near) => { + const root = document.querySelector(".console-scroll")!; + return near ? root.scrollTop <= 1 : root.scrollHeight - root.clientHeight - root.scrollTop <= 1; + }, near); + await paint(page); +} + +async function decodedImage(page: Page) { + await page.waitForFunction((id) => { + const image = document.querySelector( + `[data-attachment-id="${id}"] img.thumbnail`, + ); + return image?.complete && image.naturalWidth === 320 && image.naturalHeight === 180; + }, attachment.attachment_id); +} + +async function capture( + page: Page, + name: string, + state: Awaited>, +) { + await paint(page); + const directory = `${output}after`; + await Deno.mkdir(directory, { recursive: true }); + const screenshot = `${directory}/${name}.png`; + await page.screenshot({ path: screenshot }); + const overview = await page.getByRole("switch", { name: "Overview", exact: true }).getAttribute( + "aria-checked", + ); + const accessibility = await page.locator("body").ariaSnapshot(); + const context = await page.evaluate(() => ({ + path: location.pathname, + title: document.title, + viewport: { width: innerWidth, height: innerHeight }, + theme: matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light", + images: [...document.querySelectorAll(".console-attachment img")].map(( + image, + ) => ({ + alt: image.alt, + complete: image.complete, + width: image.naturalWidth, + height: image.naturalHeight, + })), + alerts: [...document.querySelectorAll('[role="alert"]')].map((node) => node.textContent), + pageOverflow: document.documentElement.scrollWidth > innerWidth, + })); + const digest = await crypto.subtle.digest("SHA-256", await Deno.readFile(screenshot)); + const sha256 = [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join( + "", + ); + await Deno.writeTextFile( + `${directory}/${name}.review-context.json`, + JSON.stringify( + { + ...context, + overview, + documentStatus: state.documentStatus.value, + accessibility: accessibility.slice(0, 20_000), + accessibilityTruncated: accessibility.length > 20_000, + staticRoot, + screenshot, + sha256, + imageRequests: state.requests.length, + errors: state.errors, + inventory: state.inventory, + boundary: "synthetic loopback API; production Console rendering", + }, + null, + 2, + ), + ); +} + +Deno.test("ViewImage loads only in Normal near the Console viewport and stays hidden in Overview", async () => { + await withFixture(async (browser, base) => { + for (const colorScheme of ["light", "dark"] as const) { + for (const width of [1440, 768, 390]) { + const context = await browser.newContext({ + viewport: { width, height: 900 }, + colorScheme, + reducedMotion: "reduce", + }); + try { + const page = await context.newPage(); + page.setDefaultTimeout(5_000); + page.setDefaultNavigationTimeout(10_000); + const state = await installHistory(page); + const name = `${colorScheme}-${width}`; + await page.goto(`${base}${consolePath}`); + await state.verifyInventory(); + await page.getByText("Following image paragraph 1:", { exact: false }).first().waitFor(); + const overview = page.getByRole("switch", { name: "Overview", exact: true }); + assertEquals(await overview.getAttribute("aria-checked"), "true"); + await scroll(page, true); + const first = page.locator('[data-console-line-id*="user-7"]').first(); + const near = await first.evaluate((element) => { + const row = element.getBoundingClientRect(); + const root = element.closest(".console-scroll")!.getBoundingClientRect(); + return row.top >= root.top - 1 && row.bottom <= root.bottom; + }); + assert(near, "earliest image turn must be near the console viewport in Overview"); + assertEquals(state.requests.length, 0, "Overview must not request even a nearby image"); + assertEquals(await page.locator(".console-attachment").count(), 0); + assertEquals(await page.locator(".console-line.tool").count(), 0); + assertEquals( + await page.locator(".activity-summary").filter({ hasText: "ViewImage" }) + .allTextContents(), + ["1 file read・ViewImage"], + "ViewImage must join the adjacent Read in one Overview activity", + ); + await capture(page, `${name}-overview-near`, state); + + await scroll(page, false); + await overview.click(); + assertEquals(await overview.getAttribute("aria-checked"), "false"); + await scroll(page, false); + const image = page.locator(`[data-attachment-id="${attachment.attachment_id}"]`); + await image.waitFor({ state: "attached" }); + const distance = await image.evaluate((element) => { + const root = element.closest(".console-scroll")!.getBoundingClientRect(); + return root.top - element.getBoundingClientRect().bottom; + }); + assert( + distance > 2_000, + `Normal image must be far above the console viewport: ${distance}px`, + ); + assertEquals(state.requests.length, 0, "Normal must not request a far-off image"); + assertEquals(await image.locator("img").count(), 0); + await capture(page, `${name}-normal-far`, state); + + const response = page.waitForResponse((response) => + new URL(response.url()).pathname === imagePath + ); + await scroll(page, true); + assertEquals((await response).status(), 200); + await decodedImage(page); + assertEquals( + state.requests.length, + 1, + "scrolling near must issue one real image request", + ); + assert(await image.locator("img.thumbnail").isVisible()); + await capture(page, `${name}-normal-near`, state); + + await overview.click(); + assertEquals(await overview.getAttribute("aria-checked"), "true"); + await paint(page); + assertEquals( + await page.locator(".console-attachment").count(), + 0, + "Overview must remove a loaded image", + ); + assertEquals(state.requests.length, 1); + await capture(page, `${name}-overview-again`, state); + await overview.click(); + await scroll(page, true); + await decodedImage(page); + assertEquals( + state.requests.length, + 1, + "Normal remount must reuse the loaded attachment cache", + ); + assertEquals(state.errors, []); + assertEquals( + await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), + false, + ); + } finally { + await context.close(); + } + } + } + }); +}); diff --git a/tools/web-ux/deno.json b/tools/web-ux/deno.json index 7a01f2a29..6947c8c57 100644 --- a/tools/web-ux/deno.json +++ b/tools/web-ux/deno.json @@ -11,7 +11,7 @@ "web-ux": "deno run --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys cli.ts", "check": "deno check cli.ts src/*.ts tests/*.ts browser-tests/*.ts", "test": "deno test --allow-env --allow-read --allow-write --allow-run --allow-sys tests", - "test:browser": "deno test --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys browser-tests/deferred_navigation_test.ts browser-tests/ticket_shell_test.ts browser-tests/ticket_principal_test.ts browser-tests/capture_smoke_test.ts browser-tests/memory_document_shell_test.ts browser-tests/console_history_shell_test.ts browser-tests/workspace_home_shell_test.ts browser-tests/workspace_settings_shell_test.ts browser-tests/workdir_removal_shell_test.ts browser-tests/console_pending_shell_test.ts browser-tests/console_live_shell_test.ts browser-tests/console_live_admission_shell_test.ts" + "test:browser": "deno test --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys browser-tests/deferred_navigation_test.ts browser-tests/ticket_shell_test.ts browser-tests/ticket_principal_test.ts browser-tests/capture_smoke_test.ts browser-tests/memory_document_shell_test.ts browser-tests/console_history_shell_test.ts browser-tests/console_image_visibility_test.ts browser-tests/workspace_home_shell_test.ts browser-tests/workspace_settings_shell_test.ts browser-tests/workdir_removal_shell_test.ts browser-tests/console_pending_shell_test.ts browser-tests/console_live_shell_test.ts browser-tests/console_live_admission_shell_test.ts" }, "fmt": { "lineWidth": 100, diff --git a/web/workspace/src/lib/workspace/console/ConsoleAttachmentImage.browser.spec.ts b/web/workspace/src/lib/workspace/console/ConsoleAttachmentImage.browser.spec.ts index d28c5991b..dbe74b841 100644 --- a/web/workspace/src/lib/workspace/console/ConsoleAttachmentImage.browser.spec.ts +++ b/web/workspace/src/lib/workspace/console/ConsoleAttachmentImage.browser.spec.ts @@ -3,6 +3,8 @@ import { cleanup, fireEvent, render, waitFor } from "@testing-library/svelte"; import { afterEach, beforeEach, expect, test, vi } from "vitest"; import ConsoleAttachmentImage from "./ConsoleAttachmentImage.svelte"; +import ConsoleLineItem from "./ConsoleLineItem.svelte"; +import type { ConsoleLine } from "./model.ts"; import { ConsoleAttachmentFetchError, loadConsoleAttachment, @@ -20,9 +22,27 @@ const attachment = { }; const blob = new Blob([new Uint8Array([1, 2, 3])], { type: "image/png" }); +function installObserver(automaticallyIntersect = true) { + const observers: { notify: (near: boolean) => void; options?: IntersectionObserverInit; disconnect: ReturnType }[] = []; + vi.stubGlobal("IntersectionObserver", class { + notify: (near: boolean) => void; + disconnect = vi.fn(); + constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) { + this.notify = (near: boolean) => callback( + [{ isIntersecting: near } as IntersectionObserverEntry], + this as unknown as IntersectionObserver, + ); + observers.push({ notify: this.notify, options, disconnect: this.disconnect }); + } + observe() { + if (automaticallyIntersect) queueMicrotask(() => this.notify(true)); + } + }); + return observers; +} + beforeEach(() => { - // Exercise the loader's no-observer fallback deterministically. - vi.stubGlobal("IntersectionObserver", undefined); + installObserver(); vi.mocked(loadConsoleAttachment).mockReset().mockResolvedValue(blob); let nextUrl = 0; vi.spyOn(URL, "createObjectURL").mockImplementation(() => `blob:image-${++nextUrl}`); @@ -35,6 +55,65 @@ afterEach(() => { vi.unstubAllGlobals(); }); +test("Overview neither mounts nor loads images, and switching back releases the Normal preview", async () => { + const observers = installObserver(false); + const item: ConsoleLine = { + id: "view-image", kind: "tool", title: "ViewImage", body: "Attached image", source: "event", + attachments: [attachment], + }; + const attachmentUrl = vi.fn(() => "/image/a"); + const view = render(ConsoleLineItem, { item, viewMode: "overview", attachmentUrl }); + expect(view.container.querySelector(".console-attachments")).toBeNull(); + expect(attachmentUrl).not.toHaveBeenCalled(); + expect(loadConsoleAttachment).not.toHaveBeenCalled(); + expect(observers).toHaveLength(0); + + await view.rerender({ item, viewMode: "normal", attachmentUrl }); + expect(observers).toHaveLength(1); + expect(loadConsoleAttachment).not.toHaveBeenCalled(); + observers[0].notify(true); + await fireEvent.click(await view.findByRole("button", { name: "Enlarge tool output image" })); + const dialog = view.getByRole("dialog") as HTMLDialogElement; + await view.rerender({ item, viewMode: "overview", attachmentUrl }); + expect(dialog.open).toBe(false); + expect(view.container.querySelector(".console-attachments")).toBeNull(); + expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:image-1"); + expect(loadConsoleAttachment).toHaveBeenCalledOnce(); +}); + +test("Normal images wait for the Console scroll viewport and ignore a disposed observer", async () => { + const observers = installObserver(false); + const scroll = document.createElement("div"); + scroll.className = "console-scroll"; + document.body.append(scroll); + try { + const view = render(ConsoleAttachmentImage, { target: scroll, props: { attachment, url: "/image/a" } }); + expect(observers[0].options).toEqual({ root: scroll, rootMargin: "160px" }); + observers[0].notify(false); + await Promise.resolve(); + expect(loadConsoleAttachment).not.toHaveBeenCalled(); + await view.rerender({ attachment, url: "/image/b" }); + expect(observers[0].disconnect).toHaveBeenCalled(); + observers[0].notify(true); + expect(loadConsoleAttachment).not.toHaveBeenCalled(); + observers[1].notify(true); + await view.findByAltText("Tool output attachment"); + expect(loadConsoleAttachment).toHaveBeenCalledExactlyOnceWith("/image/b"); + } finally { + cleanup(); + scroll.remove(); + } +}); + +test("without an observer images require an explicit load instead of eagerly downloading", async () => { + vi.stubGlobal("IntersectionObserver", undefined); + const view = render(ConsoleAttachmentImage, { attachment, url: "/image/a" }); + expect(loadConsoleAttachment).not.toHaveBeenCalled(); + await fireEvent.click(view.getByRole("button", { name: "Load image" })); + await view.findByAltText("Tool output attachment"); + expect(loadConsoleAttachment).toHaveBeenCalledOnce(); +}); + test("clicking a loaded image opens a modal using the same blob, without another fetch", async () => { const view = render(ConsoleAttachmentImage, { attachment, url: "/image/a" }); const trigger = await view.findByRole("button", { name: "Enlarge tool output image" }); diff --git a/web/workspace/src/lib/workspace/console/ConsoleAttachmentImage.svelte b/web/workspace/src/lib/workspace/console/ConsoleAttachmentImage.svelte index 743aeeefc..58a675753 100644 --- a/web/workspace/src/lib/workspace/console/ConsoleAttachmentImage.svelte +++ b/web/workspace/src/lib/workspace/console/ConsoleAttachmentImage.svelte @@ -11,6 +11,7 @@ }; let { attachment, url }: Props = $props(); + const supportsIntersectionObserver = typeof IntersectionObserver !== "undefined"; let root: HTMLElement | null = $state(null); let objectUrl = $state(null); let previewDialog: HTMLDialogElement | null = $state(null); @@ -81,19 +82,19 @@ }); $effect(() => { - if (!root || !url || status !== "idle") return; - if (typeof IntersectionObserver === "undefined") { - void load(); - return; - } + if (!root || !url || status !== "idle" || !supportsIntersectionObserver) return; + let active = true; const observer = new IntersectionObserver((entries) => { - if (entries.some((entry) => entry.isIntersecting)) { + if (active && entries.some((entry) => entry.isIntersecting)) { observer.disconnect(); void load(); } - }, { rootMargin: "160px" }); + }, { root: root.closest(".console-scroll"), rootMargin: "160px" }); observer.observe(root); - return () => observer.disconnect(); + return () => { + active = false; + observer.disconnect(); + }; }); @@ -134,6 +135,9 @@ {:else} {statusText()} + {#if url && status === "idle" && !supportsIntersectionObserver} + + {/if} {#if url && ["missing", "expired", "unauthorized", "network", "failed"].includes(status)} {/if} diff --git a/web/workspace/src/lib/workspace/console/ConsoleLineItem.svelte b/web/workspace/src/lib/workspace/console/ConsoleLineItem.svelte index 11608ce03..bcc59fd3c 100644 --- a/web/workspace/src/lib/workspace/console/ConsoleLineItem.svelte +++ b/web/workspace/src/lib/workspace/console/ConsoleLineItem.svelte @@ -3,15 +3,16 @@ import ConsoleAttachmentImage from '#lib/workspace/console/ConsoleAttachmentImage.svelte'; import RichMarkdown from '#lib/workspace/console/RichMarkdown.svelte'; import type { SessionToolAttachment } from '#lib/generated/protocol.ts'; - import type { ConsoleLine } from '#lib/workspace/console/model.ts'; + import type { ConsoleLine, ConsoleViewMode } from '#lib/workspace/console/model.ts'; type Props = { item: ConsoleLine; + viewMode?: ConsoleViewMode; rewindSelected?: boolean; attachmentUrl?: (attachment: SessionToolAttachment) => string | null; }; - let { item, rewindSelected = false, attachmentUrl = () => null }: Props = $props(); + let { item, viewMode = 'overview', rewindSelected = false, attachmentUrl = () => null }: Props = $props(); let detailOpen = $state(false); let nowMs = $state(Date.now()); @@ -146,7 +147,7 @@ {:else}

{item.body || '—'}

{/if} - {#if item.attachments?.length} + {#if viewMode === 'normal' && item.attachments?.length}
{#each item.attachments as attachment (attachment.attachment_id)} diff --git a/web/workspace/src/lib/workspace/console/model.test.ts b/web/workspace/src/lib/workspace/console/model.test.ts index 8308b4173..ad4ec795d 100644 --- a/web/workspace/src/lib/workspace/console/model.test.ts +++ b/web/workspace/src/lib/workspace/console/model.test.ts @@ -3567,6 +3567,24 @@ Deno.test("overview hides thinking and aggregates uninterrupted tool activity", assertEquals(overview[4].body, "edited +2/-1"); }); +Deno.test("overview folds image attachments into the surrounding tool run while Normal preserves them", () => { + const tools: ConsoleLine[] = ["Read", "ViewImage", "ViewImage", "Bash"].map((name, index) => ({ + ...consoleLine(`tool-${index}`, "tool"), + toolCall: { id: `call-${index}`, name, argsStream: "", state: "done" }, + ...(name === "ViewImage" ? { + attachments: [{ attachment_id: `image-${index}`, media_type: "image/png", byte_len: 100 }], + } : {}), + })); + const lines = [consoleLine("user", "user"), ...tools, consoleLine("answer", "assistant")]; + const overview = projectConsoleLines(lines, "overview"); + assertEquals(overview.map((line) => line.kind), ["user", "activity", "assistant"]); + assertEquals(overview[1].body, "1 file read・ran 1 command・2 ViewImage"); + assertEquals(overview[1].attachments, undefined); + assertEquals(overview[1].toolCall, undefined); + assertEquals(projectConsoleLines(lines, "normal"), lines); + assertEquals(lines[2].attachments?.[0].attachment_id, "image-1"); +}); + Deno.test("overview keeps tool failure counts without marking the activity as an error", () => { const overview = projectOverviewLines([ consoleLine("thinking-active", "thinking"), @@ -3865,9 +3883,11 @@ Deno.test("committed tool identities reconcile one live call block", () => { byte_len: 5_735_340, }]); assertEquals( - projectConsoleLines(lines, "overview")[0].attachments, + projectConsoleLines(lines, "normal")[0].attachments, lines[0].attachments, ); + assertEquals(projectConsoleLines(lines, "overview")[0].kind, "activity"); + assertEquals(projectConsoleLines(lines, "overview")[0].attachments, undefined); }); Deno.test("image rejection correction updates the existing tool result without duplicate rows", () => { diff --git a/web/workspace/src/lib/workspace/console/model.ts b/web/workspace/src/lib/workspace/console/model.ts index e52166b39..455913e53 100644 --- a/web/workspace/src/lib/workspace/console/model.ts +++ b/web/workspace/src/lib/workspace/console/model.ts @@ -609,12 +609,7 @@ export function projectOverviewLines(lines: ConsoleLine[]): ConsoleLine[] { if (line.systemItemKind === "task_reminder") continue; if (isOverviewThinkingLine(line)) continue; if (line.kind === "tool" && line.toolCall) { - if (line.attachments?.length) { - flushTools(); - overview.push(line); - } else { - toolGroup.push(line); - } + toolGroup.push(line); continue; } flushTools(); diff --git a/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/PageContent.svelte b/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/PageContent.svelte index 293e4554b..9240246d8 100644 --- a/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/PageContent.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/PageContent.svelte @@ -1989,7 +1989,7 @@ {#if (consoleDisplayState.kind === "ready" || consoleDisplayState.kind === "stale") && lines.length > 0}
    {#each lines as item (item.id)} - + {/each}
{/if}