Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions apps/code/src/renderer/components/ui/SafeImagePreview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { Flex, Text } from "@radix-ui/themes";
import {
buildImageDataUrl,
isAllowedImageMimeType,
MAX_IMAGE_BASE64_LENGTH,
} from "@shared/utils/imageDataUrl";
import { useState } from "react";

interface SafeImagePreviewProps {
/** Base64-encoded image data (no data URL prefix). */
base64: string;
mimeType: string;
alt?: string;
className?: string;
/** Rendered when the image fails to decode or has a disallowed mime type. */
fallback?: React.ReactNode;
}

function DefaultFallback() {
return (
<Flex
align="center"
justify="center"
className="size-full min-h-12 p-3 text-(--gray-11)"
>
<Text className="text-[13px]">Unable to render image preview</Text>
</Flex>
);
}

export function SafeImagePreview({
base64,
mimeType,
alt,
className,
fallback,
}: SafeImagePreviewProps) {
const [hasError, setHasError] = useState(false);
Comment thread
pauldambra marked this conversation as resolved.
const [lastSource, setLastSource] = useState({ base64, mimeType });

if (lastSource.base64 !== base64 || lastSource.mimeType !== mimeType) {
setLastSource({ base64, mimeType });
setHasError(false);
}

const isPayloadValid =
base64.length > 0 &&
base64.length <= MAX_IMAGE_BASE64_LENGTH &&
isAllowedImageMimeType(mimeType);

if (!isPayloadValid || hasError) {
return <>{fallback ?? <DefaultFallback />}</>;
}

return (
<img
src={buildImageDataUrl(mimeType, base64)}
alt={alt ?? "image preview"}
className={className ?? "max-h-full max-w-full object-contain"}
onError={() => setHasError(true)}
/>
);
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { PanelMessage } from "@components/ui/PanelMessage";
import { SafeImagePreview } from "@components/ui/SafeImagePreview";
import { Tooltip } from "@components/ui/Tooltip";
import { CodeMirrorEditor } from "@features/code-editor/components/CodeMirrorEditor";
import { EnrichmentPopover } from "@features/code-editor/components/EnrichmentPopover";
Expand All @@ -16,6 +17,7 @@ import { Box, Flex, IconButton, Text } from "@radix-ui/themes";
import { trpcClient, useTRPC } from "@renderer/trpc/client";
import { getImageMimeType, isImageFile } from "@shared/constants/image";
import type { Task } from "@shared/types";
import { parseImageDataUrl } from "@shared/utils/imageDataUrl";

import { useQuery } from "@tanstack/react-query";
import { useCallback, useMemo, useState } from "react";
Expand All @@ -29,6 +31,40 @@ interface CodeEditorPanelProps {
absolutePath: string;
}

function FilePanelImagePreview({
base64,
mimeType,
filePath,
absolutePath,
}: {
base64: string;
mimeType: string;
filePath: string;
absolutePath: string;
}) {
return (
<Flex
align="center"
justify="center"
height="100%"
p="4"
className="overflow-auto"
>
<SafeImagePreview
base64={base64}
mimeType={mimeType}
alt={filePath}
className="max-h-[100%] max-w-[100%] object-contain"
fallback={
<PanelMessage detail={absolutePath}>
Failed to render image
</PanelMessage>
}
/>
</Flex>
);
}

export function CodeEditorPanel({
taskId,
task: _task,
Expand Down Expand Up @@ -128,6 +164,12 @@ export function CodeEditorPanel({
content: isImage ? null : fileContent,
});

const dataUrlImage = useMemo(
() =>
isImage || fileContent == null ? null : parseImageDataUrl(fileContent),
[isImage, fileContent],
);

if (isImage) {
if (isCloudRun) {
return (
Expand All @@ -144,21 +186,13 @@ export function CodeEditorPanel({
<PanelMessage detail={absolutePath}>Failed to load image</PanelMessage>
);
}
const mimeType = getImageMimeType(absolutePath);
return (
<Flex
align="center"
justify="center"
height="100%"
p="4"
className="overflow-auto"
>
<img
src={`data:${mimeType};base64,${imageQuery.data}`}
alt={filePath}
className="max-h-[100%] max-w-[100%] object-contain"
/>
</Flex>
<FilePanelImagePreview
base64={imageQuery.data}
mimeType={getImageMimeType(absolutePath)}
filePath={filePath}
absolutePath={absolutePath}
/>
);
}

Expand Down Expand Up @@ -192,6 +226,17 @@ export function CodeEditorPanel({
return <PanelMessage>File is empty</PanelMessage>;
}

if (dataUrlImage) {
return (
Comment thread
pauldambra marked this conversation as resolved.
<FilePanelImagePreview
base64={dataUrlImage.base64}
mimeType={dataUrlImage.mimeType}
filePath={filePath}
absolutePath={absolutePath}
/>
);
}

if (isMarkdown) {
const handleCopySource = () => {
navigator.clipboard.writeText(fileContent);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { EditorView } from "@codemirror/view";
import { SafeImagePreview } from "@components/ui/SafeImagePreview";
import { MultiFileDiff } from "@pierre/diffs/react";
import { Code } from "@radix-ui/themes";
import { parseImageDataUrl } from "@shared/utils/imageDataUrl";
import { useThemeStore } from "@stores/themeStore";
import { compactHomePath } from "@utils/path";
import { useEffect, useMemo, useRef } from "react";
Expand Down Expand Up @@ -31,6 +33,10 @@ export function CodePreview({
cacheKey,
}: CodePreviewProps) {
const isDiff = oldContent !== undefined && oldContent !== null;
const imageDataUrl = useMemo(
() => (isDiff ? null : parseImageDataUrl(content)),
[isDiff, content],
);

if (isDiff) {
return (
Expand All @@ -45,6 +51,18 @@ export function CodePreview({
);
}

if (imageDataUrl) {
return (
<ImageDataUrlPreview
filePath={filePath}
showPath={showPath}
mimeType={imageDataUrl.mimeType}
base64={imageDataUrl.base64}
maxHeight={maxHeight}
/>
);
}

return (
<PlainCodePreview
content={content}
Expand All @@ -56,6 +74,43 @@ export function CodePreview({
);
}

function ImageDataUrlPreview({
filePath,
showPath,
mimeType,
base64,
maxHeight,
}: {
filePath?: string;
showPath?: boolean;
mimeType: string;
base64: string;
maxHeight?: string;
}) {
return (
<div style={CODE_PREVIEW_CONTAINER_STYLE}>
{showPath && filePath && (
<div style={CODE_PREVIEW_PATH_STYLE} title={filePath}>
<Code variant="ghost" className="truncate text-[13px]">
{compactHomePath(filePath)}
</Code>
</div>
)}
<div
className="flex items-center justify-center bg-(--gray-2) p-2"
style={maxHeight ? { maxHeight, overflow: "auto" } : undefined}
>
<SafeImagePreview
base64={base64}
mimeType={mimeType}
alt={filePath ?? "Image preview"}
className="max-h-96 max-w-full object-contain"
/>
</div>
</div>
);
}

function DiffPreview({
content,
filePath,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { SafeImagePreview } from "@components/ui/SafeImagePreview";
import { FileText } from "@phosphor-icons/react";
import { Box, Flex } from "@radix-ui/themes";
import { useState } from "react";
import { CodePreview } from "./CodePreview";
import { FileMentionChip } from "./FileMentionChip";
import {
ExpandableIcon,
getContentImage,
getReadToolContent,
StatusIndicators,
ToolTitle,
Expand All @@ -27,9 +29,10 @@ export function ReadToolView({

const filePath = locations?.[0]?.path ?? "";
const startLine = locations?.[0]?.line ?? 0;
const fileContent = getReadToolContent(content);
const imageContent = getContentImage(content);
const fileContent = imageContent ? undefined : getReadToolContent(content);
Comment thread
pauldambra marked this conversation as resolved.
const lineCount = fileContent ? fileContent.split("\n").length : null;
const isExpandable = !!fileContent;
const isExpandable = !!fileContent || !!imageContent;
const firstLineNumber = startLine + 1;

const handleClick = () => {
Expand All @@ -53,12 +56,27 @@ export function ReadToolView({
isExpanded={isExpanded}
/>
<ToolTitle className="shrink-0 whitespace-nowrap">
Read{lineCount !== null ? ` ${lineCount} lines in` : ""}
{imageContent
? "Read image in"
: `Read${lineCount !== null ? ` ${lineCount} lines in` : ""}`}
</ToolTitle>
{filePath && <FileMentionChip filePath={filePath} />}
<StatusIndicators isFailed={isFailed} wasCancelled={wasCancelled} />
</Flex>

{isExpanded && imageContent && (
<Box className="mt-2 ml-5">
<Box className="max-w-4xl overflow-hidden rounded-lg border border-gray-6 bg-(--gray-2) p-2">
<SafeImagePreview
base64={imageContent.base64}
mimeType={imageContent.mimeType}
alt={filePath || "Read tool image preview"}
className="max-h-96 max-w-full object-contain"
/>
</Box>
</Box>
)}

{isExpanded && fileContent && (
<Box className="mt-2 ml-5">
<Box className="max-w-4xl overflow-hidden rounded-lg border border-gray-6">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,26 @@ export function getContentText(
return undefined;
}

export interface ImageContentData {
Comment thread
pauldambra marked this conversation as resolved.
base64: string;
mimeType: string;
}

export function getContentImage(
content: ToolCall["content"],
): ImageContentData | undefined {
if (!content?.length) return undefined;
for (const item of content) {
if (item.type === "content" && item.content.type === "image") {
const { data, mimeType } = item.content;
if (typeof data === "string" && typeof mimeType === "string") {
return { base64: data, mimeType };
}
}
}
return undefined;
}

export function getReadToolContent(
content: ToolCall["content"],
): string | undefined {
Expand Down
1 change: 0 additions & 1 deletion apps/code/src/shared/constants/image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ export const IMAGE_MIME_TYPES: Record<string, string> = {
webp: "image/webp",
bmp: "image/bmp",
ico: "image/x-icon",
svg: "image/svg+xml",
tiff: "image/tiff",
tif: "image/tiff",
};
Expand Down
Loading
Loading