From f38c3d7059412b5bd2286ae0b2fa72ed1b90f70d Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sun, 8 Nov 2026 08:06:57 +0000 Subject: [PATCH] fix(app): preserve normalized image URLs in the lightbox Use the decoder URL spelling when tracking image dimensions so mixed-case hosts still initialize the viewport. Keep the source check against queued loads and wait for refreshed image fitting before asserting geometry. --- .../browser/agent-tab-image-stability.spec.ts | 30 +++++++++++++++++++ packages/app/e2e/browser/file-editing.spec.ts | 12 ++++++-- .../components/zoomable-viewport/image.tsx | 19 +++++++++--- 3 files changed, 55 insertions(+), 6 deletions(-) diff --git a/packages/app/e2e/browser/agent-tab-image-stability.spec.ts b/packages/app/e2e/browser/agent-tab-image-stability.spec.ts index 97b1c8337c..34ce41a4c7 100644 --- a/packages/app/e2e/browser/agent-tab-image-stability.spec.ts +++ b/packages/app/e2e/browser/agent-tab-image-stability.spec.ts @@ -146,6 +146,36 @@ test("opens a timeline image in a zoomable lightbox", async ({ await expectAgentReadyToInterrupt(page); }); +test("opens a remote Markdown image whose URL needs normalization", async ({ + imageWorkspace: workspace, + page, +}) => { + const image = await createSmallAssistantPng(workspace, { + alt: "Remote image with a mixed-case host", + fileName: "remote.png", + width: 240, + height: 160, + }); + const delivery = await serveDelayedImage(path.join(workspace.repoPath, image.relativePath)); + const url = delivery.url.replace("127.0.0.1", "LOCALHOST"); + try { + delivery.release(); + const agent = await createSettledMockAgent(workspace, "Remote image lightbox"); + await emitSettledAssistantImage(workspace.client, agent, image, url); + await openAssistantImageTimeline(page, agent); + const rendered = page.getByRole("button", { name: image.alt }).first(); + await expect(rendered).toHaveJSProperty("naturalWidth", image.width); + await rendered.click(); + await expect(page.getByTestId("attachment-lightbox-canvas")).toBeVisible(); + await page.getByTestId("zoomable-viewport-toolbar").hover(); + await page.getByRole("button", { name: "Zoom in", exact: true }).click(); + await page.getByRole("button", { name: "Close image", exact: true }).click(); + await expect(page.getByTestId("attachment-lightbox-image")).toHaveCount(0); + } finally { + await delivery.close(); + } +}); + test("reloading a timeline reserves image height without a load-time scroll jump", async ({ imageWorkspace: workspace, page, diff --git a/packages/app/e2e/browser/file-editing.spec.ts b/packages/app/e2e/browser/file-editing.spec.ts index 13dc38f2f2..6eb4e2a62b 100644 --- a/packages/app/e2e/browser/file-editing.spec.ts +++ b/packages/app/e2e/browser/file-editing.spec.ts @@ -38,6 +38,15 @@ function editor(page: Page) { return page.getByTestId("file-source-editor").filter({ visible: true }).locator(".cm-content"); } +async function expectTallImageFrame(frame: Locator): Promise { + await expect + .poll(async () => { + const box = await frame.boundingBox(); + return box ? box.height - box.width : 0; + }) + .toBeGreaterThan(0); +} + function hasHorizontalOverflow(element: HTMLElement): boolean { return element.scrollWidth > element.clientWidth; } @@ -806,8 +815,7 @@ test.describe("CodeMirror workspace file editing", () => { await pendingLoad.release(); } await expect(imageCanvas).toBeVisible(); - const tallFrame = await transformedContent.boundingBox(); - expect(tallFrame!.height).toBeGreaterThan(tallFrame!.width); + await expectTallImageFrame(transformedContent); }); test("renders local relative images inside Markdown preview", async ({ page, withWorkspace }) => { diff --git a/packages/app/src/components/zoomable-viewport/image.tsx b/packages/app/src/components/zoomable-viewport/image.tsx index e4535dc7eb..a91a309cad 100644 --- a/packages/app/src/components/zoomable-viewport/image.tsx +++ b/packages/app/src/components/zoomable-viewport/image.tsx @@ -37,14 +37,16 @@ export function ZoomableImage({ testID = "zoomable-image", wheelActivation = "always", }: ZoomableImageProps) { + const source = useMemo(() => ({ uri: normalizeImageUri(uri) }), [uri]); const [loadedImage, setLoadedImage] = useState<{ uri: string; size: ViewportSize } | null>(null); - const resolvedSize = contentSize ?? (loadedImage?.uri === uri ? loadedImage.size : null); - const source = useMemo(() => ({ uri }), [uri]); + const resolvedSize = contentSize ?? (loadedImage?.uri === source.uri ? loadedImage.size : null); const handleLoad = useCallback( ({ source: { url, width, height } }: ImageLoadEventData) => { - if (url === uri && width > 0 && height > 0) setLoadedImage({ uri, size: { width, height } }); + if (url === source.uri && width > 0 && height > 0) { + setLoadedImage({ uri: source.uri, size: { width, height } }); + } }, - [uri], + [source.uri], ); if (!resolvedSize) { @@ -88,6 +90,15 @@ export function ZoomableImage({ ); } +// Use the same URL spelling as the decoder while preserving opaque native URIs. +function normalizeImageUri(uri: string): string { + try { + return new URL(uri).href; + } catch { + return uri; + } +} + const styles = StyleSheet.create(() => ({ root: { flex: 1, minHeight: 0 }, image: { width: "100%", height: "100%" },