From 63d581914ce43612e1d2b3ce51a8b5fbf67a9c1a Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 14 Oct 2026 14:51:30 +0900 Subject: [PATCH] fix: restrict console image loading to the Normal viewport Hide attachments in Overview and aggregate image tool calls normally. Load previews near the console scroll viewport, guard stale observer callbacks, and offer explicit loading without IntersectionObserver. Cover visibility and request timing in component and Chromium tests. --- .../console_image_visibility_test.ts | 449 ++++++++++++++++++ tools/web-ux/deno.json | 2 +- .../ConsoleAttachmentImage.browser.spec.ts | 83 +++- .../console/ConsoleAttachmentImage.svelte | 20 +- .../workspace/console/ConsoleLineItem.svelte | 7 +- .../src/lib/workspace/console/model.test.ts | 22 +- .../src/lib/workspace/console/model.ts | 7 +- .../[workerRef]/console/PageContent.svelte | 2 +- 8 files changed, 570 insertions(+), 22 deletions(-) create mode 100644 tools/web-ux/browser-tests/console_image_visibility_test.ts 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}