From 47223b375fedd22672aaf95e2c175b3cf14a012c Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 29 Apr 2026 19:47:01 +0700 Subject: [PATCH] feat(app): show loading and error states for assistant images Render an activity indicator while image dimensions resolve and an "Image unavailable" fallback if loading fails, replacing the previous behavior where the surface stayed blank. Reserves a fixed minimum height in both states so message layout doesn't shift. --- packages/app/src/components/message.tsx | 49 +++++++++++++++---- .../utils/assistant-image-metadata.test.ts | 18 +++++++ .../app/src/utils/assistant-image-metadata.ts | 14 ++++++ 3 files changed, 71 insertions(+), 10 deletions(-) diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index a2fd52421..5f97398ec 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -81,8 +81,10 @@ import { getMarkdownListMarker } from "@/utils/markdown-list"; import { openExternalUrl } from "@/utils/open-external-url"; import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks"; import { + getAssistantImageLoadStateFromMetadata, getAssistantImageMetadata, setAssistantImageMetadata, + type AssistantImageLoadState, } from "@/utils/assistant-image-metadata"; import { setAssistantMarkdownBlockHeight } from "@/utils/assistant-message-height-estimate"; import { resolveAssistantImageSource } from "@/utils/assistant-image-source"; @@ -318,7 +320,7 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({ flexDirection: "row", justifyContent: "flex-end", paddingHorizontal: theme.spacing[2], - userSelect: isWeb ? "text" : "auto", + ...(isWeb ? { userSelect: "text" as const } : {}), }, content: { alignItems: "flex-end", @@ -493,7 +495,7 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({ container: { paddingHorizontal: theme.spacing[2], paddingVertical: theme.spacing[3], - userSelect: isWeb ? "text" : "auto", + ...(isWeb ? { userSelect: "text" as const } : {}), }, containerCompactTop: { paddingTop: 0, @@ -567,17 +569,17 @@ const AssistantMarkdownResolvedImage = memo(function AssistantMarkdownResolvedIm () => getAssistantImageMetadata({ source, workspaceRoot, serverId }), [serverId, source, workspaceRoot], ); - const [aspectRatio, setAspectRatio] = useState( - cachedMetadata?.aspectRatio ?? null, + const [loadState, setLoadState] = useState(() => + getAssistantImageLoadStateFromMetadata(cachedMetadata), ); useEffect(() => { if (cachedMetadata) { - setAspectRatio(cachedMetadata.aspectRatio); + setLoadState(getAssistantImageLoadStateFromMetadata(cachedMetadata)); return; } - setAspectRatio(null); + setLoadState({ status: "loading" }); let cancelled = false; Image.getSize( @@ -591,14 +593,17 @@ const AssistantMarkdownResolvedImage = memo(function AssistantMarkdownResolvedIm { source, workspaceRoot, serverId }, { width, height }, ); - setAspectRatio(metadata?.aspectRatio ?? width / height); + setLoadState({ + status: "ready", + aspectRatio: metadata?.aspectRatio ?? width / height, + }); } }, () => { if (cancelled) { return; } - setAspectRatio(null); + setLoadState({ status: "error" }); }, ); @@ -607,19 +612,41 @@ const AssistantMarkdownResolvedImage = memo(function AssistantMarkdownResolvedIm }; }, [cachedMetadata, serverId, source, uri, workspaceRoot]); + const handleImageError = useCallback(() => { + setLoadState({ status: "error" }); + }, []); const surfaceStyle = useMemo>( () => [ assistantMessageStylesheet.imageSurface, - aspectRatio ? { aspectRatio } : { minHeight: ASSISTANT_IMAGE_MIN_HEIGHT }, + loadState.status === "ready" + ? { aspectRatio: loadState.aspectRatio } + : { height: ASSISTANT_IMAGE_MIN_HEIGHT }, ], - [aspectRatio], + [loadState], ); const frameStyle = useMemo>( () => [assistantMessageStylesheet.imageFrame, containerStyle], [containerStyle], ); + const stateSurfaceStyle = useMemo>( + () => [surfaceStyle, assistantMessageStylesheet.imageState], + [surfaceStyle], + ); const imageSource = useMemo(() => ({ uri }), [uri]); + if (loadState.status !== "ready") { + return ( + + + {loadState.status === "loading" ? : null} + {loadState.status === "error" ? ( + Image unavailable + ) : null} + + + ); + } + return ( @@ -628,6 +655,7 @@ const AssistantMarkdownResolvedImage = memo(function AssistantMarkdownResolvedIm style={assistantMessageStylesheet.image} resizeMode="contain" accessibilityLabel={alt} + onError={handleImageError} /> @@ -728,6 +756,7 @@ function AssistantMarkdownImage({ () => [ assistantMessageStylesheet.imageFrame, containerStyle, + { height: ASSISTANT_IMAGE_MIN_HEIGHT }, assistantMessageStylesheet.imageState, ], [containerStyle], diff --git a/packages/app/src/utils/assistant-image-metadata.test.ts b/packages/app/src/utils/assistant-image-metadata.test.ts index 65e2cde8c..d8e8d1569 100644 --- a/packages/app/src/utils/assistant-image-metadata.test.ts +++ b/packages/app/src/utils/assistant-image-metadata.test.ts @@ -3,6 +3,7 @@ import { clearAssistantImageMetadataCache, estimateAssistantMessageHeightFromCache, extractAssistantImageSources, + getAssistantImageLoadStateFromMetadata, getAssistantImageMetadata, setAssistantImageMetadata, } from "./assistant-image-metadata"; @@ -41,6 +42,23 @@ describe("assistant image metadata", () => { }); }); + it("maps missing metadata to the image loading state", () => { + expect(getAssistantImageLoadStateFromMetadata(null)).toEqual({ status: "loading" }); + }); + + it("maps cached metadata to the image ready state", () => { + expect( + getAssistantImageLoadStateFromMetadata({ + width: 900, + height: 1600, + aspectRatio: 9 / 16, + }), + ).toEqual({ + status: "ready", + aspectRatio: 9 / 16, + }); + }); + it("estimates assistant message height from cached image metadata", () => { setAssistantImageMetadata( { diff --git a/packages/app/src/utils/assistant-image-metadata.ts b/packages/app/src/utils/assistant-image-metadata.ts index 9f6e159b0..b11d26baf 100644 --- a/packages/app/src/utils/assistant-image-metadata.ts +++ b/packages/app/src/utils/assistant-image-metadata.ts @@ -8,6 +8,11 @@ export interface AssistantImageMetadata { aspectRatio: number; } +export type AssistantImageLoadState = + | { status: "loading" } + | { status: "ready"; aspectRatio: number } + | { status: "error" }; + const assistantImageMetadataCache = new Map(); const assistantImageParseCache = new Map(); const ASSISTANT_IMAGE_METADATA_CACHE_LIMIT = 500; @@ -126,6 +131,15 @@ export function getAssistantImageMetadata(input: { return null; } +export function getAssistantImageLoadStateFromMetadata( + metadata: AssistantImageMetadata | null, +): AssistantImageLoadState { + if (!metadata) { + return { status: "loading" }; + } + return { status: "ready", aspectRatio: metadata.aspectRatio }; +} + export function setAssistantImageMetadata( input: { source: string;