Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] f38c3d7059 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.
2026-11-08 08:06:57 +00:00
paseo-bot[bot] d0876e032f test(app): scope queued image loads to their file pane 2026-11-08 08:02:13 +00:00
paseo-bot[bot] 858cd5511e fix(app): keep image load dimensions tied to their source
Ignore a queued load from an earlier URI after a preview refresh. Replay that event ordering in the existing file preview journey, and move the native SVG regression into the Agent Device suite.
2026-11-08 07:55:24 +00:00
paseo-bot[bot] 4f1eefdba9 fix(app): render SVG files in native image previews
The shared preview used the core native image decoder, which cannot load SVG files. Use the existing image renderer with SVG decoding and obtain viewport dimensions from its successful load event.
2026-11-08 07:36:45 +00:00
9 changed files with 220 additions and 29 deletions
@@ -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,
+43 -1
View File
@@ -14,6 +14,7 @@ import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-
import { seedWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
import { holdNextPreviewLoad } from "../support/helpers/image-preview";
const APP_SETTINGS_KEY = "@paseo:app-settings";
@@ -37,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<void> {
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;
}
@@ -729,6 +739,10 @@ test.describe("CodeMirror workspace file editing", () => {
const imagePath = path.join(workspace.repoPath, "pixel.png");
await writeFile(markdownPath, "# First heading\n", "utf8");
await writeFile(imagePath, RED_PIXEL);
await writeFile(
path.join(workspace.repoPath, "shape.svg"),
await readFile(path.resolve(__dirname, "../mobile/fixtures/native-preview.svg")),
);
await workspace.navigateTo();
await openWorkspaceFile(page, "notes.md");
@@ -761,7 +775,7 @@ test.describe("CodeMirror workspace file editing", () => {
await openWorkspaceFile(page, "pixel.png");
const image = visibleFilePane.locator("img");
await expect(image).toBeVisible();
const imageCanvas = page.getByTestId("image-file-preview-canvas");
const imageCanvas = visibleFilePane.getByTestId("image-file-preview-canvas");
await expect(imageCanvas).toBeVisible();
await imageCanvas.hover();
const transformedContent = imageCanvas.locator(":scope > div").first();
@@ -774,6 +788,34 @@ test.describe("CodeMirror workspace file editing", () => {
const initialSource = await image.getAttribute("src");
await writeFile(imagePath, BLUE_PIXEL);
await expect.poll(() => image.getAttribute("src")).not.toBe(initialSource);
await openWorkspaceFile(page, "shape.svg");
await expect(imageCanvas).toBeVisible();
await expect(image).toBeVisible();
await expect(image).toHaveJSProperty("complete", true);
await expect(image).toHaveJSProperty("naturalWidth", 240);
const oldSource = await image.evaluate((element: HTMLImageElement) => ({
url: element.currentSrc,
width: element.naturalWidth,
height: element.naturalHeight,
}));
const pendingLoad = await holdNextPreviewLoad(visibleFilePane, oldSource.url);
try {
await writeFile(
path.join(workspace.repoPath, "shape.svg"),
'<svg xmlns="http://www.w3.org/2000/svg" width="160" height="240"><rect width="160" height="240" fill="green"/></svg>',
);
await expect.poll(() => image.getAttribute("src")).not.toBe(oldSource.url);
await pendingLoad.wait();
// Native can deliver the old source's queued event to this new listener.
await pendingLoad.deliver(oldSource);
await expect(imageCanvas).toHaveCount(0);
} finally {
await pendingLoad.release();
}
await expect(imageCanvas).toBeVisible();
await expectTallImageFrame(transformedContent);
});
test("renders local relative images inside Markdown preview", async ({ page, withWorkspace }) => {
@@ -0,0 +1,24 @@
# Native SVG preview regression
Copy `../fixtures/native-preview.svg`, `../fixtures/native-preview-viewbox.svg`, and
`../../../assets/images/icon.png` into a workspace on a test daemon. In the Android
or iOS app, connect to that daemon, open the workspace's Files tab, and tap
`native-preview.svg`. The SVG should show a yellow circle on a green rectangle.
With that preview open, run:
```sh
agent-device test packages/app/e2e/mobile/agent-device/native-svg-preview.android.ad \
--metro-port <port> --artifacts-dir .artifacts/svg-preview
```
Run it again with `native-preview-viewbox.svg` open to cover SVG dimensions
inferred from `viewBox`, then with the PNG open to check raster images. Close and
reopen the SVG and rerun to cover cached file previews. Use English app language
for the zoom button selectors. The scripts are discovered by `npm run test:e2e:mobile`; the iOS counterpart is
`native-svg-preview.ios.ad`. They reopen the installed app with its current
preview and leave the daemon connection unchanged.
Before the fix the SVG remains blank and the canvas wait fails. The PNG passes.
The replay checks successful native dimension decoding and zoom/reset controls;
visually verify the circle and rectangle as well.
@@ -0,0 +1,11 @@
context platform=android timeout=60000 retries=0
env APP_ID=sh.paseo.debug
open "${APP_ID}"
wait "id=\"image-file-preview-canvas\"" 10000
wait "role=button label=\"Zoom in\"" 10000
press "role=button label=\"Zoom in\""
wait "role=button label=\"Reset view\" enabled=true" 10000
press "role=button label=\"Reset view\""
wait "role=button label=\"Reset view\" enabled=false" 10000
close
@@ -0,0 +1,11 @@
context platform=ios timeout=60000 retries=0
env APP_ID=sh.paseo.debug
open "${APP_ID}"
wait "id=\"image-file-preview-canvas\"" 10000
wait "role=button label=\"Zoom in\"" 10000
press "role=button label=\"Zoom in\""
wait "role=button label=\"Reset view\" enabled=true" 10000
press "role=button label=\"Reset view\""
wait "role=button label=\"Reset view\" enabled=false" 10000
close
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 160"><rect width="240" height="160" fill="#08a040"/><circle cx="120" cy="80" r="50" fill="#ffcc00"/></svg>

After

Width:  |  Height:  |  Size: 164 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="240" height="160" viewBox="0 0 240 160"><rect width="240" height="160" fill="#08a040"/><circle cx="120" cy="80" r="50" fill="#ffcc00"/></svg>

After

Width:  |  Height:  |  Size: 188 B

@@ -0,0 +1,68 @@
import { expect, type Locator } from "@playwright/test";
function isReady(value: { ready(): boolean }): boolean {
return value.ready();
}
/** Replay a queued native load payload after a real file refresh, through the image's load event. */
export async function holdNextPreviewLoad(pane: Locator, previousUrl: string) {
const pending = await pane.evaluateHandle((container, oldUrl: string) => {
let image: HTMLImageElement | null = null;
let holding = true;
const hold = (event: Event) => {
if (holding) event.stopImmediatePropagation();
};
const observer = new MutationObserver(() => {
if (image) return;
const candidates = container.querySelectorAll<HTMLImageElement>(
'[data-testid="image-file-preview-image"] img',
);
const next = Array.from(candidates).find((candidate) => candidate.src !== oldUrl);
if (!next) return;
image = next;
image.addEventListener("load", hold, true);
observer.disconnect();
});
observer.observe(container, { subtree: true, childList: true, attributes: true });
return {
ready: () => image !== null && image.complete && image.naturalWidth > 0,
deliver(source: { url: string; width: number; height: number }) {
if (!image) throw new Error("No pending preview load");
// Expo's web adapter reads these fields to produce the same public onLoad
// payload that native queues before React dispatches to the current listener.
Object.defineProperties(image, {
currentSrc: { configurable: true, value: source.url },
naturalWidth: { configurable: true, value: source.width },
naturalHeight: { configurable: true, value: source.height },
});
try {
holding = false;
image.dispatchEvent(new Event("load"));
holding = true;
} finally {
Reflect.deleteProperty(image, "currentSrc");
Reflect.deleteProperty(image, "naturalWidth");
Reflect.deleteProperty(image, "naturalHeight");
}
},
release() {
holding = false;
observer.disconnect();
image?.removeEventListener("load", hold, true);
image?.dispatchEvent(new Event("load"));
},
};
}, previousUrl);
return {
async wait() {
await expect.poll(() => pending.evaluate(isReady)).toBe(true);
},
async deliver(source: { url: string; width: number; height: number }) {
await pending.evaluate((value, payload) => value.deliver(payload), source);
},
async release() {
await pending.evaluate((value) => value.release());
await pending.dispose();
},
};
}
@@ -1,10 +1,13 @@
import { useEffect, useMemo, useState } from "react";
import { Image, View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useCallback, useMemo, useState } from "react";
import { Image, type ImageLoadEventData } from "expo-image";
import { View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { ViewportFitOptions, ViewportSize } from "./geometry";
import { ZoomableViewport } from "./index";
import type { ZoomableViewportAction } from "./types";
const ViewportImage = withUnistyles(Image);
interface ZoomableImageProps {
uri: string;
accessibilityLabel?: string;
@@ -34,36 +37,27 @@ export function ZoomableImage({
testID = "zoomable-image",
wheelActivation = "always",
}: ZoomableImageProps) {
const [loadedSize, setLoadedSize] = useState<ViewportSize | null>(null);
const resolvedSize = contentSize ?? loadedSize;
const source = useMemo(() => ({ uri }), [uri]);
useEffect(() => {
if (contentSize) return;
let active = true;
setLoadedSize(null);
Image.getSize(
uri,
(width, height) => {
if (active && width > 0 && height > 0) setLoadedSize({ width, height });
},
() => {
if (active) onError?.();
},
);
return () => {
active = false;
};
}, [contentSize, onError, uri]);
const source = useMemo(() => ({ uri: normalizeImageUri(uri) }), [uri]);
const [loadedImage, setLoadedImage] = useState<{ uri: string; size: ViewportSize } | null>(null);
const resolvedSize = contentSize ?? (loadedImage?.uri === source.uri ? loadedImage.size : null);
const handleLoad = useCallback(
({ source: { url, width, height } }: ImageLoadEventData) => {
if (url === source.uri && width > 0 && height > 0) {
setLoadedImage({ uri: source.uri, size: { width, height } });
}
},
[source.uri],
);
if (!resolvedSize) {
return (
<View style={[styles.root, style]} testID={testID}>
<Image
<ViewportImage
accessibilityLabel={accessibilityLabel}
accessibilityRole="image"
onError={onError}
resizeMode="contain"
onLoad={handleLoad}
contentFit="contain"
source={source}
style={styles.image}
testID={`${testID}-image`}
@@ -85,9 +79,9 @@ export function ZoomableImage({
testID={testID}
wheelActivation={wheelActivation}
>
<Image
<ViewportImage
onError={onError}
resizeMode="contain"
contentFit="contain"
source={source}
style={styles.image}
testID={`${testID}-image`}
@@ -96,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%" },