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:
@@ -0,0 +1,449 @@
|
||||
/// <reference lib="dom" />
|
||||
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 =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="320" height="180" viewBox="0 0 320 180"><rect width="320" height="180" fill="#28789e"/><circle cx="160" cy="80" r="40" fill="#fff"/><text x="160" y="150" text-anchor="middle" fill="#fff" font-size="18">Synthetic ViewImage fixture</text></svg>';
|
||||
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<void>) {
|
||||
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<void>();
|
||||
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<never>((_, 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<void>((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<HTMLImageElement>(
|
||||
`[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<ReturnType<typeof installHistory>>,
|
||||
) {
|
||||
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<HTMLImageElement>(".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();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
flushTools();
|
||||
|
||||
+1
-1
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user