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.
This commit is contained in:
2026-10-14 14:51:30 +09:00
parent da378fc902
commit 63d581914c
8 changed files with 570 additions and 22 deletions
@@ -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<typeof vi.fn> }[] = [];
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" });
@@ -11,6 +11,7 @@
};
let { attachment, url }: Props = $props();
const supportsIntersectionObserver = typeof IntersectionObserver !== "undefined";
let root: HTMLElement | null = $state(null);
let objectUrl = $state<string | null>(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();
};
});
</script>
@@ -134,6 +135,9 @@
</dialog>
{:else}
<span>{statusText()}</span>
{#if url && status === "idle" && !supportsIntersectionObserver}
<button class="retry" type="button" onclick={() => void load()}>Load image</button>
{/if}
{#if url && ["missing", "expired", "unauthorized", "network", "failed"].includes(status)}
<button class="retry" type="button" onclick={() => void load()}>Retry</button>
{/if}
@@ -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}
<p class="console-plain-text">{item.body || '—'}</p>
{/if}
{#if item.attachments?.length}
{#if viewMode === 'normal' && item.attachments?.length}
<div class="console-attachments">
{#each item.attachments as attachment (attachment.attachment_id)}
<ConsoleAttachmentImage {attachment} url={attachmentUrl(attachment)} />
@@ -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", () => {
@@ -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();
@@ -1989,7 +1989,7 @@
{#if (consoleDisplayState.kind === "ready" || consoleDisplayState.kind === "stale") && lines.length > 0}
<ol class="console-log">
{#each lines as item (item.id)}
<ConsoleLineItem {item} {attachmentUrl} rewindSelected={rewindEntryId !== null && item.entryId === rewindEntryId} />
<ConsoleLineItem {item} viewMode={consoleViewMode} {attachmentUrl} rewindSelected={rewindEntryId !== null && item.entryId === rewindEntryId} />
{/each}
</ol>
{/if}