diff --git a/apps/mobile/src/features/threads/ThreadMarkdownImage.test.tsx b/apps/mobile/src/features/threads/ThreadMarkdownImage.test.tsx
index 14d595950e22..7c0b08c2c5c1 100644
--- a/apps/mobile/src/features/threads/ThreadMarkdownImage.test.tsx
+++ b/apps/mobile/src/features/threads/ThreadMarkdownImage.test.tsx
@@ -5,17 +5,24 @@ import { expect, it, vi } from "vite-plus/test";
const harness = vi.hoisted(() => ({
failImage: () => {},
+ loadImage: (_event: { source: { width: number; height: number } }) => {},
Passthrough: ({ children }: { children?: unknown }) => children,
}));
vi.mock("react-native", () => ({
Pressable: harness.Passthrough,
StyleSheet: { absoluteFill: {} },
- View: harness.Passthrough,
+ View: ({ children, style }: { children?: ReactNode; style?: unknown }) => (
+
{children}
+ ),
}));
vi.mock("expo-image", () => ({
- Image: (props: { onError: () => void }) => {
+ Image: (props: {
+ onError: () => void;
+ onLoad: (event: { source: { width: number; height: number } }) => void;
+ }) => {
harness.failImage = props.onError;
+ harness.loadImage = props.onLoad;
return
;
},
}));
@@ -41,7 +48,43 @@ import { AtomRegistry } from "effect/reactivity";
import { fetch } from "expo/fetch";
import { parse } from "react-native-svg";
-import { ThreadMarkdownImageView } from "./ThreadMarkdownImage";
+import { MarkdownImageAvailableWidthContext, ThreadMarkdownImageView } from "./ThreadMarkdownImage";
+
+it("keeps the frame size when the same image reports a different decoded width", async () => {
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+ const container = document.createElement("div");
+ const root = createRoot(container);
+ const frameSize = () =>
+ [...container.querySelectorAll("div[data-style]")]
+ .map((element) => JSON.parse(element.dataset.style ?? "null") as Record)
+ .find((style) => typeof style?.width === "number");
+ try {
+ await act(async () =>
+ root.render(
+
+ {}}
+ />
+ ,
+ ),
+ );
+ // Android reports the bitmap downsampled for the current frame, so the same file
+ // alternates between widths (969 and 970 of 1000) as the frame changes size.
+ await act(async () => harness.loadImage({ source: { width: 970, height: 567 } }));
+ const first = frameSize();
+ expect(first).toBeDefined();
+
+ await act(async () => harness.loadImage({ source: { width: 969, height: 567 } }));
+ expect(frameSize()).toEqual(first);
+ } finally {
+ await act(async () => root.unmount());
+ }
+});
it("retries a failed image once its URI comes back through null", async () => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
diff --git a/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx b/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx
index 88c64e4deda7..a0ff3301da46 100644
--- a/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx
+++ b/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx
@@ -192,7 +192,16 @@ export function ThreadMarkdownImageView(props: {
key={props.sourceKey}
uri={props.uri}
cacheKey={props.cacheKey}
- onLoad={(size) => setDecoded({ sourceKey: props.sourceKey, size })}
+ onLoad={(size) =>
+ // Only the first decode counts: later loads report the bitmap
+ // downsampled for the current frame (969 vs 970 wide), so keeping
+ // them resizes the frame, which reloads the image, and so on.
+ setDecoded((current) =>
+ current?.sourceKey === props.sourceKey
+ ? current
+ : { sourceKey: props.sourceKey, size },
+ )
+ }
onError={() => setFailure({ uri: props.uri, failed: true })}
/>
)}
@@ -269,6 +278,18 @@ export function ThreadMarkdownImage(props: {
}) {
const withGallery = useFilePreviewGallery();
const assetUrl = useAssetUrlState(props.environmentId, props.resource);
+ const resourceKey =
+ props.resource._tag === "attachment"
+ ? `attachment:${props.resource.attachmentId}`
+ : props.resource._tag === "tool-output-image"
+ ? `tool-output:${props.resource.itemId}:${props.resource.index}`
+ : `workspace:${props.resource.path}`;
+ // The view keeps the first decoded size per key, so a file edited in place needs a
+ // new key; a re-signed URL for the same revision must not get one.
+ const sourceKey =
+ assetUrl._tag === "Success" && assetUrl.fileRevision !== undefined
+ ? `${resourceKey}@${assetUrl.fileRevision}`
+ : resourceKey;
return (