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;