feat(app): store assistant image previews as assets

This commit is contained in:
Mohamed Boudra
2026-04-22 22:28:56 +07:00
parent 434b28fc8e
commit 4140e640fe
15 changed files with 476 additions and 38 deletions
+51 -3
View File
@@ -25,6 +25,10 @@ import { lineNumberGutterWidth } from "@/components/code-insets";
import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
import { isWeb } from "@/constants/platform";
import { createMarkdownStyles } from "@/styles/markdown-styles";
import type { AttachmentMetadata } from "@/attachments/types";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import { persistAttachmentFromBase64 } from "@/attachments/service";
import { createPreviewAttachmentId, getFileNameFromPath } from "@/attachments/utils";
interface CodeLineProps {
tokens: HighlightToken[];
@@ -40,6 +44,7 @@ interface FilePreviewBodyProps {
showDesktopWebScrollbar: boolean;
isMobile: boolean;
filePath: string;
imagePreviewUri: string | null;
}
function trimNonEmpty(value: string | null | undefined): string | null {
@@ -60,6 +65,32 @@ function formatFileSize({ size }: { size: number }): string {
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
}
async function createFilePanePreview(file: ExplorerFile | null): Promise<{
file: ExplorerFile | null;
imageAttachment: AttachmentMetadata | null;
}> {
if (!file || file.kind !== "image" || !file.content) {
return { file, imageAttachment: null };
}
const { content: _content, ...imageFile } = file;
const imageAttachment = await persistAttachmentFromBase64({
id: createPreviewAttachmentId({
base64: file.content,
mimeType: file.mimeType ?? "image/png",
path: file.path,
}),
base64: file.content,
mimeType: file.mimeType,
fileName: getFileNameFromPath(file.path),
});
return {
file: imageFile,
imageAttachment,
};
}
const CodeLine = React.memo(function CodeLine({
tokens,
lineNumber,
@@ -116,6 +147,7 @@ function FilePreviewBody({
showDesktopWebScrollbar,
isMobile,
filePath,
imagePreviewUri,
}: FilePreviewBodyProps) {
const { theme } = useUnistyles();
const isDark = theme.colorScheme === "dark";
@@ -230,7 +262,16 @@ function FilePreviewBody({
);
}
if (preview.kind === "image" && preview.content) {
if (preview.kind === "image") {
if (!imagePreviewUri) {
return (
<View style={styles.centerState}>
<ActivityIndicator size="small" />
<Text style={styles.loadingText}>Loading file…</Text>
</View>
);
}
return (
<View style={styles.previewScrollContainer}>
<RNScrollView
@@ -245,7 +286,7 @@ function FilePreviewBody({
>
<RNImage
source={{
uri: `data:${preview.mimeType ?? "image/png"};base64,${preview.content}`,
uri: imagePreviewUri,
}}
style={styles.previewImage}
resizeMode="contain"
@@ -292,11 +333,17 @@ export function FilePane({
normalizedFilePath,
"file",
);
return { file: payload.file ?? null, error: payload.error ?? null };
const preview = await createFilePanePreview(payload.file ?? null);
return {
file: preview.file,
imageAttachment: preview.imageAttachment,
error: payload.error ?? null,
};
},
staleTime: 5_000,
refetchOnMount: true,
});
const imagePreviewUri = useAttachmentPreviewUrl(query.data?.imageAttachment ?? null);
return (
<View style={styles.container} testID="workspace-file-pane">
@@ -312,6 +359,7 @@ export function FilePane({
showDesktopWebScrollbar={showDesktopWebScrollbar}
isMobile={isMobile}
filePath={filePath}
imagePreviewUri={imagePreviewUri}
/>
</View>
);
+45 -6
View File
@@ -78,11 +78,17 @@ import {
setAssistantImageMetadata,
} from "@/utils/assistant-image-metadata";
import { resolveAssistantImageSource } from "@/utils/assistant-image-source";
import {
createPreviewAttachmentId,
getFileNameFromPath,
parseImageDataUrl,
} from "@/attachments/utils";
export type { InlinePathTarget } from "@/utils/inline-path";
import { PlanCard } from "./plan-card";
import { useToolCallSheet } from "./tool-call-sheet";
import { ToolCallDetailsContent } from "./tool-call-details";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import { persistAttachmentFromBase64, persistAttachmentFromDataUrl } from "@/attachments/service";
import type { DaemonClient } from "@server/client/daemon-client";
import { isWeb, isNative } from "@/constants/platform";
@@ -595,6 +601,7 @@ function AssistantMarkdownImage({
() => resolveAssistantImageSource({ source, workspaceRoot }),
[source, workspaceRoot],
);
const dataImage = useMemo(() => parseImageDataUrl(source), [source]);
const containerStyle = useMemo<StyleProp<ViewStyle>>(
() => ({
marginTop: hasLeadingContent ? theme.spacing[4] : 0,
@@ -621,16 +628,44 @@ function AssistantMarkdownImage({
if (payload.error) {
throw new Error(payload.error);
}
if (!payload.file || payload.file.kind !== "image" || !payload.file.content) {
const file = payload.file;
if (!file || file.kind !== "image" || !file.content) {
throw new Error("Image preview unavailable.");
}
return `data:${payload.file.mimeType ?? "image/png"};base64,${payload.file.content}`;
return await persistAttachmentFromBase64({
id: createPreviewAttachmentId({
base64: file.content,
mimeType: file.mimeType ?? "image/png",
path: file.path || resolution.path,
}),
base64: file.content,
mimeType: file.mimeType,
fileName: getFileNameFromPath(file.path || resolution.path),
});
},
});
const dataImageQuery = useQuery({
queryKey: ["assistantMarkdownDataImage", dataImage?.cacheKey ?? null],
enabled: dataImage !== null,
staleTime: 30_000,
queryFn: async () => {
if (!dataImage) {
return null;
}
return await persistAttachmentFromDataUrl({
id: createPreviewAttachmentId(dataImage),
dataUrl: source,
mimeType: dataImage.mimeType,
});
},
});
const directUri = resolution?.kind === "direct" ? resolution.uri : null;
const resolvedUri = directUri ?? query.data ?? null;
const fileAssetUri = useAttachmentPreviewUrl(query.data);
const dataImageAssetUri = useAttachmentPreviewUrl(dataImageQuery.data);
const directUri = resolution?.kind === "direct" && !dataImage ? resolution.uri : null;
const resolvedUri = directUri ?? dataImageAssetUri ?? fileAssetUri ?? null;
if (resolvedUri) {
return (
@@ -645,7 +680,7 @@ function AssistantMarkdownImage({
);
}
if (query.isLoading) {
if (query.isLoading || dataImageQuery.isLoading) {
return (
<View
style={[
@@ -668,7 +703,11 @@ function AssistantMarkdownImage({
]}
>
<Text style={assistantMessageStylesheet.imageErrorText}>
{query.error instanceof Error ? query.error.message : "Unable to load image preview."}
{query.error instanceof Error
? query.error.message
: dataImageQuery.error instanceof Error
? dataImageQuery.error.message
: "Unable to load image preview."}
</Text>
</View>
);