mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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.
This commit is contained in:
@@ -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<number | null>(
|
||||
cachedMetadata?.aspectRatio ?? null,
|
||||
const [loadState, setLoadState] = useState<AssistantImageLoadState>(() =>
|
||||
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<StyleProp<ViewStyle>>(
|
||||
() => [
|
||||
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<StyleProp<ViewStyle>>(
|
||||
() => [assistantMessageStylesheet.imageFrame, containerStyle],
|
||||
[containerStyle],
|
||||
);
|
||||
const stateSurfaceStyle = useMemo<StyleProp<ViewStyle>>(
|
||||
() => [surfaceStyle, assistantMessageStylesheet.imageState],
|
||||
[surfaceStyle],
|
||||
);
|
||||
const imageSource = useMemo(() => ({ uri }), [uri]);
|
||||
|
||||
if (loadState.status !== "ready") {
|
||||
return (
|
||||
<View style={frameStyle}>
|
||||
<View style={stateSurfaceStyle}>
|
||||
{loadState.status === "loading" ? <ActivityIndicator size="small" /> : null}
|
||||
{loadState.status === "error" ? (
|
||||
<Text style={assistantMessageStylesheet.imageErrorText}>Image unavailable</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={frameStyle}>
|
||||
<View style={surfaceStyle}>
|
||||
@@ -628,6 +655,7 @@ const AssistantMarkdownResolvedImage = memo(function AssistantMarkdownResolvedIm
|
||||
style={assistantMessageStylesheet.image}
|
||||
resizeMode="contain"
|
||||
accessibilityLabel={alt}
|
||||
onError={handleImageError}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
@@ -728,6 +756,7 @@ function AssistantMarkdownImage({
|
||||
() => [
|
||||
assistantMessageStylesheet.imageFrame,
|
||||
containerStyle,
|
||||
{ height: ASSISTANT_IMAGE_MIN_HEIGHT },
|
||||
assistantMessageStylesheet.imageState,
|
||||
],
|
||||
[containerStyle],
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -8,6 +8,11 @@ export interface AssistantImageMetadata {
|
||||
aspectRatio: number;
|
||||
}
|
||||
|
||||
export type AssistantImageLoadState =
|
||||
| { status: "loading" }
|
||||
| { status: "ready"; aspectRatio: number }
|
||||
| { status: "error" };
|
||||
|
||||
const assistantImageMetadataCache = new Map<string, AssistantImageMetadata>();
|
||||
const assistantImageParseCache = new Map<string, { sources: string[]; hasNonImageText: boolean }>();
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user