From 4547d597efbe7f6e6b7369dbcffbb4ff4a455966 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 12 Apr 2026 22:41:54 +0700 Subject: [PATCH] Add inline image previews in assistant messages --- CHANGELOG.md | 3 + .../app/src/components/agent-stream-view.tsx | 2 + .../agent-stream-web-virtualization.test.ts | 23 ++ .../agent-stream-web-virtualization.ts | 3 +- packages/app/src/components/message.tsx | 257 +++++++++++++++++- .../utils/assistant-image-metadata.test.ts | 58 ++++ .../app/src/utils/assistant-image-metadata.ts | 213 +++++++++++++++ .../src/utils/assistant-image-source.test.ts | 81 ++++++ .../app/src/utils/assistant-image-source.ts | 109 ++++++++ 9 files changed, 738 insertions(+), 11 deletions(-) create mode 100644 packages/app/src/utils/assistant-image-metadata.test.ts create mode 100644 packages/app/src/utils/assistant-image-metadata.ts create mode 100644 packages/app/src/utils/assistant-image-source.test.ts create mode 100644 packages/app/src/utils/assistant-image-source.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3581e931c..117fa1869 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.1.54 - 2026-04-12 +### Added +- Inline image previews in agent messages — screenshots and images generated by agents render directly in the conversation instead of showing as raw markdown links. + ### Improved - Paseo tools are no longer injected into agents by default — opt in from Settings when you need agent-to-agent orchestration. - Agent provider and mode are now resolved server-side, so CLI commands like `paseo run` use consistent defaults without client-side lookups. diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index 78010e1ec..0ca90c0a3 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -331,6 +331,8 @@ const AgentStreamViewComponent = forwardRef ); case "thought": { diff --git a/packages/app/src/components/agent-stream-web-virtualization.test.ts b/packages/app/src/components/agent-stream-web-virtualization.test.ts index a88e05187..ced53df4c 100644 --- a/packages/app/src/components/agent-stream-web-virtualization.test.ts +++ b/packages/app/src/components/agent-stream-web-virtualization.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; import type { StreamItem } from "@/types/stream"; +import { + clearAssistantImageMetadataCache, + setAssistantImageMetadata, +} from "@/utils/assistant-image-metadata"; import { DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS, DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD, @@ -128,6 +132,25 @@ describe("estimateStreamItemHeight", () => { expect(estimateStreamItemHeight(item)).toBe(220); }); + + it("uses cached assistant image metadata when available", () => { + clearAssistantImageMetadataCache(); + setAssistantImageMetadata( + { + source: "https://example.com/tall.png", + }, + { width: 800, height: 1600 }, + ); + + const item: StreamItem = { + kind: "assistant_message", + id: "a-image", + text: "Look at this\n\n![Screenshot](https://example.com/tall.png)", + timestamp: createTimestamp(2), + }; + + expect(estimateStreamItemHeight(item)).toBeGreaterThan(220); + }); }); describe("web virtualization test overrides", () => { diff --git a/packages/app/src/components/agent-stream-web-virtualization.ts b/packages/app/src/components/agent-stream-web-virtualization.ts index 2a5bdb2ce..794413182 100644 --- a/packages/app/src/components/agent-stream-web-virtualization.ts +++ b/packages/app/src/components/agent-stream-web-virtualization.ts @@ -1,4 +1,5 @@ import type { StreamItem } from "@/types/stream"; +import { estimateAssistantMessageHeightFromCache } from "@/utils/assistant-image-metadata"; export const DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = 100; export const DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS = 50; @@ -45,7 +46,7 @@ export function estimateStreamItemHeight(item: StreamItem): number { case "user_message": return item.images && item.images.length > 0 ? 220 : 96; case "assistant_message": - return 220; + return estimateAssistantMessageHeightFromCache(item.text) ?? 220; case "tool_call": return 136; case "thought": diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index eed1bc283..60a0a02dc 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -25,6 +25,7 @@ import { } from "react"; import type { ReactNode, ComponentType } from "react"; import Markdown, { MarkdownIt, type RenderRules } from "react-native-markdown-display"; +import { useQuery } from "@tanstack/react-query"; import MaskedView from "@react-native-masked-view/masked-view"; import { Circle, @@ -74,11 +75,17 @@ import { getMarkdownListMarker } from "@/utils/markdown-list"; import { openExternalUrl } from "@/utils/open-external-url"; import { markScrollInvestigationEvent } from "@/utils/scroll-jank-investigation"; import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks"; +import { + getAssistantImageMetadata, + setAssistantImageMetadata, +} from "@/utils/assistant-image-metadata"; +import { resolveAssistantImageSource } from "@/utils/assistant-image-source"; 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 type { DaemonClient } from "@server/client/daemon-client"; interface UserMessageProps { message: string; @@ -401,6 +408,8 @@ interface AssistantMessageProps { timestamp: number; onInlinePathPress?: (target: InlinePathTarget) => void; workspaceRoot?: string; + serverId?: string; + client?: DaemonClient | null; disableOuterSpacing?: boolean; } @@ -427,8 +436,215 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({ fontSize: 13, userSelect: Platform.OS === "web" ? "text" : "auto", }, + imageFrame: { + width: "100%", + minHeight: 160, + marginHorizontal: -theme.spacing[1], + }, + imageSurface: { + width: "100%", + overflow: "hidden", + }, + image: { + width: "100%", + height: "100%", + }, + imageState: { + alignItems: "center", + justifyContent: "center", + paddingHorizontal: theme.spacing[4], + paddingVertical: theme.spacing[6], + gap: theme.spacing[2], + }, + imageErrorText: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + textAlign: "center", + }, })); +const ASSISTANT_IMAGE_MIN_HEIGHT = 160; + +const AssistantMarkdownResolvedImage = memo(function AssistantMarkdownResolvedImage({ + uri, + alt, + containerStyle, + source, + workspaceRoot, + serverId, +}: { + uri: string; + alt?: string; + containerStyle?: StyleProp; + source: string; + workspaceRoot?: string; + serverId?: string; +}) { + const cachedMetadata = useMemo( + () => getAssistantImageMetadata({ source, workspaceRoot, serverId }), + [serverId, source, workspaceRoot], + ); + const [aspectRatio, setAspectRatio] = useState( + cachedMetadata?.aspectRatio ?? null, + ); + + useEffect(() => { + if (cachedMetadata) { + setAspectRatio(cachedMetadata.aspectRatio); + return; + } + + setAspectRatio(null); + let cancelled = false; + + Image.getSize( + uri, + (width, height) => { + if (cancelled) { + return; + } + if (width > 0 && height > 0) { + const metadata = setAssistantImageMetadata( + { source, workspaceRoot, serverId }, + { width, height }, + ); + setAspectRatio(metadata?.aspectRatio ?? width / height); + } + }, + () => { + if (cancelled) { + return; + } + setAspectRatio(null); + }, + ); + + return () => { + cancelled = true; + }; + }, [cachedMetadata, serverId, source, uri, workspaceRoot]); + + const surfaceStyle = useMemo>( + () => [ + assistantMessageStylesheet.imageSurface, + aspectRatio ? { aspectRatio } : { minHeight: ASSISTANT_IMAGE_MIN_HEIGHT }, + ], + [aspectRatio], + ); + + return ( + + + + + + ); +}); + +function AssistantMarkdownImage({ + source, + alt, + hasLeadingContent, + client, + workspaceRoot, + serverId, +}: { + source: string; + alt?: string; + hasLeadingContent: boolean; + client?: DaemonClient | null; + workspaceRoot?: string; + serverId?: string; +}) { + const { theme } = useUnistyles(); + const resolution = useMemo( + () => resolveAssistantImageSource({ source, workspaceRoot }), + [source, workspaceRoot], + ); + const containerStyle = useMemo>( + () => ({ + marginTop: hasLeadingContent ? theme.spacing[4] : 0, + marginBottom: 0, + }), + [hasLeadingContent, theme], + ); + + const query = useQuery({ + queryKey: [ + "assistantMarkdownImage", + serverId ?? "unknown-server", + resolution?.kind === "file_rpc" ? resolution.cwd : null, + resolution?.kind === "file_rpc" ? resolution.path : null, + ], + enabled: Boolean(client && resolution?.kind === "file_rpc"), + staleTime: 30_000, + queryFn: async () => { + if (!client || !resolution || resolution.kind !== "file_rpc") { + return null; + } + + const payload = await client.exploreFileSystem(resolution.cwd, resolution.path, "file"); + if (payload.error) { + throw new Error(payload.error); + } + if (!payload.file || payload.file.kind !== "image" || !payload.file.content) { + throw new Error("Image preview unavailable."); + } + + return `data:${payload.file.mimeType ?? "image/png"};base64,${payload.file.content}`; + }, + }); + + const directUri = resolution?.kind === "direct" ? resolution.uri : null; + const resolvedUri = directUri ?? query.data ?? null; + + if (resolvedUri) { + return ( + + ); + } + + if (query.isLoading) { + return ( + + + + ); + } + + return ( + + + {query.error instanceof Error ? query.error.message : "Unable to load image preview."} + + + ); +} + function MarkdownLink({ href, style, @@ -741,6 +957,8 @@ export const AssistantMessage = memo(function AssistantMessage({ timestamp, onInlinePathPress, workspaceRoot, + serverId, + client, disableOuterSpacing, }: AssistantMessageProps) { const { theme, rt } = useUnistyles(); @@ -777,7 +995,7 @@ export const AssistantMessage = memo(function AssistantMessage({ [onInlinePathPress, workspaceRoot], ); - const markdownRules = useMemo(() => { + const markdownRules = useMemo(() => { return { text: ( node: any, @@ -892,14 +1110,11 @@ export const AssistantMessage = memo(function AssistantMessage({ ); }, - paragraph: (node: any, children: ReactNode[], parent: any, styles: any) => { - const isLastChild = parent[0]?.children?.at(-1)?.key === node.key; - return ( - - {children} - - ); - }, + paragraph: (node: any, children: ReactNode[], _parent: any, styles: any) => ( + + {children} + + ), link: (node: any, children: ReactNode[], _parent: any, styles: any) => ( ), + image: (node: any, _children: ReactNode[], parent: any, styles: any) => { + const paragraphNode = Array.isArray(parent) + ? parent.find((ancestor: any) => ancestor?.type === "paragraph") + : null; + const paragraphChildren = Array.isArray(paragraphNode?.children) + ? paragraphNode.children + : []; + const imageIndex = paragraphChildren.findIndex((child: any) => child?.key === node.key); + const hasLeadingContent = imageIndex > 0; + + return ( + + ); + }, }; - }, [handleLinkPress, markdownParser, onInlinePathPress]); + }, [client, handleLinkPress, markdownParser, onInlinePathPress, serverId, workspaceRoot]); const blocks = useMemo(() => splitMarkdownBlocks(message), [message]); diff --git a/packages/app/src/utils/assistant-image-metadata.test.ts b/packages/app/src/utils/assistant-image-metadata.test.ts new file mode 100644 index 000000000..4c7054983 --- /dev/null +++ b/packages/app/src/utils/assistant-image-metadata.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + clearAssistantImageMetadataCache, + estimateAssistantMessageHeightFromCache, + extractAssistantImageSources, + getAssistantImageMetadata, + setAssistantImageMetadata, +} from "./assistant-image-metadata"; + +describe("assistant image metadata", () => { + beforeEach(() => { + clearAssistantImageMetadataCache(); + }); + + it("extracts markdown image sources", () => { + expect( + extractAssistantImageSources( + 'Before\n\n![local](/tmp/paseo.png)\n\n![remote](https://example.com/test.png "Remote")', + ), + ).toEqual(["/tmp/paseo.png", "https://example.com/test.png"]); + }); + + it("reuses cached metadata across canonical and raw source keys", () => { + setAssistantImageMetadata( + { + source: "/tmp/paseo-codex-screenshot.png", + workspaceRoot: "/Users/moboudra/dev/paseo", + serverId: "server-1", + }, + { width: 1200, height: 800 }, + ); + + expect( + getAssistantImageMetadata({ + source: "/tmp/paseo-codex-screenshot.png", + }), + ).toEqual({ + width: 1200, + height: 800, + aspectRatio: 1.5, + }); + }); + + it("estimates assistant message height from cached image metadata", () => { + setAssistantImageMetadata( + { + source: "https://example.com/landscape.png", + }, + { width: 1200, height: 800 }, + ); + + expect( + estimateAssistantMessageHeightFromCache( + "Here is the screenshot\n\n![Screenshot](https://example.com/landscape.png)", + ), + ).toBeGreaterThan(220); + }); +}); diff --git a/packages/app/src/utils/assistant-image-metadata.ts b/packages/app/src/utils/assistant-image-metadata.ts new file mode 100644 index 000000000..64e8180c6 --- /dev/null +++ b/packages/app/src/utils/assistant-image-metadata.ts @@ -0,0 +1,213 @@ +import { MAX_CONTENT_WIDTH } from "@/constants/layout"; +import { resolveAssistantImageSource } from "@/utils/assistant-image-source"; + +export interface AssistantImageMetadata { + width: number; + height: number; + aspectRatio: number; +} + +const assistantImageMetadataCache = new Map(); +const assistantImageParseCache = new Map(); +const ASSISTANT_IMAGE_METADATA_CACHE_LIMIT = 500; +const ASSISTANT_IMAGE_PARSE_CACHE_LIMIT = 500; + +const MARKDOWN_IMAGE_PATTERN = /!\[[^\]]*]\((<[^>]+>|[^)\n]+)\)/g; +const ASSISTANT_IMAGE_ESTIMATE_WIDTH = MAX_CONTENT_WIDTH - 8; +const ASSISTANT_IMAGE_MIN_HEIGHT = 160; +const ASSISTANT_IMAGE_BLOCK_GAP = 24; +const ASSISTANT_MESSAGE_BASE_HEIGHT = 96; +const ASSISTANT_MESSAGE_MIN_HEIGHT = 220; +const ASSISTANT_MESSAGE_IMAGE_ONLY_BASE_HEIGHT = 40; + +function touchCacheEntry(cache: Map, key: K, value: V, limit: number): void { + cache.delete(key); + cache.set(key, value); + if (cache.size <= limit) { + return; + } + const oldestKey = cache.keys().next().value; + if (oldestKey !== undefined) { + cache.delete(oldestKey); + } +} + +function normalizeAssistantImageSourceToken(value: string): string | null { + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + + if (trimmed.startsWith("<") && trimmed.endsWith(">")) { + const inner = trimmed.slice(1, -1).trim(); + return inner || null; + } + + const titleMatch = /^(.*?)(?:\s+(['"]).*?\2)?$/.exec(trimmed); + const source = titleMatch?.[1]?.trim() ?? trimmed; + return source || null; +} + +function createSourceAliasKey(source: string): string { + return `source:${source}`; +} + +function createResolutionKey(input: { + source: string; + workspaceRoot?: string; + serverId?: string; +}): string | null { + const resolution = resolveAssistantImageSource({ + source: input.source, + workspaceRoot: input.workspaceRoot, + }); + if (!resolution) { + return null; + } + if (resolution.kind === "direct") { + return `direct:${resolution.uri}`; + } + return `file:${input.serverId ?? "unknown-server"}:${resolution.cwd}:${resolution.path}`; +} + +function getAssistantImageMetadataKeys(input: { + source: string; + workspaceRoot?: string; + serverId?: string; +}): string[] { + const source = input.source.trim(); + if (!source) { + return []; + } + + const keys = [createSourceAliasKey(source)]; + const resolutionKey = createResolutionKey(input); + if (resolutionKey) { + keys.unshift(resolutionKey); + } + return [...new Set(keys)]; +} + +export function getAssistantImageMetadata(input: { + source: string; + workspaceRoot?: string; + serverId?: string; +}): AssistantImageMetadata | null { + for (const key of getAssistantImageMetadataKeys(input)) { + const metadata = assistantImageMetadataCache.get(key); + if (metadata) { + touchCacheEntry( + assistantImageMetadataCache, + key, + metadata, + ASSISTANT_IMAGE_METADATA_CACHE_LIMIT, + ); + return metadata; + } + } + return null; +} + +export function setAssistantImageMetadata( + input: { + source: string; + workspaceRoot?: string; + serverId?: string; + }, + dimensions: { width: number; height: number }, +): AssistantImageMetadata | null { + const { width, height } = dimensions; + if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { + return null; + } + + const metadata: AssistantImageMetadata = { + width, + height, + aspectRatio: width / height, + }; + + for (const key of getAssistantImageMetadataKeys(input)) { + touchCacheEntry( + assistantImageMetadataCache, + key, + metadata, + ASSISTANT_IMAGE_METADATA_CACHE_LIMIT, + ); + } + + return metadata; +} + +export function extractAssistantImageSources(markdown: string): string[] { + const cachedParse = assistantImageParseCache.get(markdown); + if (cachedParse) { + touchCacheEntry( + assistantImageParseCache, + markdown, + cachedParse, + ASSISTANT_IMAGE_PARSE_CACHE_LIMIT, + ); + return cachedParse.sources; + } + + const sources: string[] = []; + for (const match of markdown.matchAll(MARKDOWN_IMAGE_PATTERN)) { + const normalized = normalizeAssistantImageSourceToken(match[1] ?? ""); + if (normalized) { + sources.push(normalized); + } + } + const hasNonImageText = markdown.replace(MARKDOWN_IMAGE_PATTERN, "").trim().length > 0; + touchCacheEntry( + assistantImageParseCache, + markdown, + { sources, hasNonImageText }, + ASSISTANT_IMAGE_PARSE_CACHE_LIMIT, + ); + return sources; +} + +export function estimateAssistantMessageHeightFromCache(markdown: string): number | null { + const cachedParse = assistantImageParseCache.get(markdown); + const parsed = + cachedParse ?? + (() => { + const sources = extractAssistantImageSources(markdown); + const nextParsed = assistantImageParseCache.get(markdown); + return nextParsed ?? { sources, hasNonImageText: true }; + })(); + if (parsed.sources.length === 0) { + return null; + } + + const knownHeights = parsed.sources + .map((source) => getAssistantImageMetadata({ source })) + .filter((metadata): metadata is AssistantImageMetadata => metadata !== null) + .map((metadata) => + Math.max( + ASSISTANT_IMAGE_MIN_HEIGHT, + Math.round(ASSISTANT_IMAGE_ESTIMATE_WIDTH / metadata.aspectRatio), + ), + ); + + if (knownHeights.length === 0) { + return null; + } + + const baseHeight = parsed.hasNonImageText + ? ASSISTANT_MESSAGE_BASE_HEIGHT + : ASSISTANT_MESSAGE_IMAGE_ONLY_BASE_HEIGHT; + + const estimatedHeight = + baseHeight + + knownHeights.reduce((sum, height) => sum + height, 0) + + ASSISTANT_IMAGE_BLOCK_GAP * knownHeights.length; + + return Math.max(ASSISTANT_MESSAGE_MIN_HEIGHT, estimatedHeight); +} + +export function clearAssistantImageMetadataCache(): void { + assistantImageMetadataCache.clear(); + assistantImageParseCache.clear(); +} diff --git a/packages/app/src/utils/assistant-image-source.test.ts b/packages/app/src/utils/assistant-image-source.test.ts new file mode 100644 index 000000000..755d27cfc --- /dev/null +++ b/packages/app/src/utils/assistant-image-source.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; + +import { resolveAssistantImageSource } from "./assistant-image-source"; + +describe("resolveAssistantImageSource", () => { + it("passes through direct image URIs", () => { + expect(resolveAssistantImageSource({ source: "https://example.com/image.png" })).toEqual({ + kind: "direct", + uri: "https://example.com/image.png", + }); + expect(resolveAssistantImageSource({ source: "data:image/png;base64,abc" })).toEqual({ + kind: "direct", + uri: "data:image/png;base64,abc", + }); + }); + + it("uses the workspace root for relative paths", () => { + expect( + resolveAssistantImageSource({ + source: "screenshots/output.png", + workspaceRoot: "/Users/test/project", + }), + ).toEqual({ + kind: "file_rpc", + cwd: "/Users/test/project", + path: "screenshots/output.png", + }); + }); + + it("uses the workspace root for absolute paths inside the workspace", () => { + expect( + resolveAssistantImageSource({ + source: "/Users/test/project/screenshots/output.png", + workspaceRoot: "/Users/test/project", + }), + ).toEqual({ + kind: "file_rpc", + cwd: "/Users/test/project", + path: "/Users/test/project/screenshots/output.png", + }); + }); + + it("falls back to filesystem root for absolute paths outside the workspace", () => { + expect( + resolveAssistantImageSource({ + source: "/tmp/paseo-codex-screenshot.png", + workspaceRoot: "/Users/test/project", + }), + ).toEqual({ + kind: "file_rpc", + cwd: "/", + path: "/tmp/paseo-codex-screenshot.png", + }); + }); + + it("normalizes file URIs into file RPC requests", () => { + expect( + resolveAssistantImageSource({ + source: "file:///tmp/paseo-codex-screenshot.png", + workspaceRoot: "/Users/test/project", + }), + ).toEqual({ + kind: "file_rpc", + cwd: "/", + path: "/tmp/paseo-codex-screenshot.png", + }); + }); + + it("falls back to the drive root for Windows absolute paths", () => { + expect( + resolveAssistantImageSource({ + source: "C:/Users/test/Desktop/screenshot.png", + workspaceRoot: "D:/repo", + }), + ).toEqual({ + kind: "file_rpc", + cwd: "C:/", + path: "C:/Users/test/Desktop/screenshot.png", + }); + }); +}); diff --git a/packages/app/src/utils/assistant-image-source.ts b/packages/app/src/utils/assistant-image-source.ts new file mode 100644 index 000000000..fda471483 --- /dev/null +++ b/packages/app/src/utils/assistant-image-source.ts @@ -0,0 +1,109 @@ +import { fileUriToPath } from "@/attachments/utils"; +import { isAbsolutePath } from "@/utils/path"; + +export type AssistantImageSourceResolution = + | { kind: "direct"; uri: string } + | { kind: "file_rpc"; cwd: string; path: string }; + +function trimTrailingSeparators(value: string): string { + if (value === "/" || /^[A-Za-z]:[\\/]?$/.test(value)) { + return value.replace(/\\/g, "/"); + } + return value.replace(/[\\/]+$/, ""); +} + +function normalizeForPathComparison(value: string): string { + const normalized = trimTrailingSeparators(value.replace(/\\/g, "/")); + if (/^[A-Za-z]:\//.test(normalized)) { + return `${normalized.slice(0, 1).toUpperCase()}${normalized.slice(1)}`; + } + return normalized; +} + +function isPathWithinRoot(candidatePath: string, rootPath: string): boolean { + const candidate = normalizeForPathComparison(candidatePath); + const root = normalizeForPathComparison(rootPath); + if (!candidate || !root) { + return false; + } + if (root === "/") { + return candidate.startsWith("/"); + } + if (candidate === root) { + return true; + } + return candidate.startsWith(`${root}/`); +} + +function deriveFallbackRootFromAbsolutePath(value: string): string | null { + if (value.startsWith("/")) { + return "/"; + } + + const driveMatch = /^([A-Za-z]:)[\\/]/.exec(value); + if (driveMatch?.[1]) { + return `${driveMatch[1]}/`; + } + + const uncMatch = /^(\\\\[^\\]+\\[^\\]+)/.exec(value); + if (uncMatch?.[1]) { + return uncMatch[1]; + } + + return null; +} + +export function resolveAssistantImageSource(input: { + source: string; + workspaceRoot?: string; +}): AssistantImageSourceResolution | null { + const source = input.source.trim(); + if (!source) { + return null; + } + + if (/^(https?:|data:|blob:)/i.test(source)) { + return { kind: "direct", uri: source }; + } + + const sourcePath = source.startsWith("file://") ? fileUriToPath(source) : source; + if (!sourcePath) { + return null; + } + + if (!isAbsolutePath(sourcePath)) { + const workspaceRoot = input.workspaceRoot?.trim(); + if (!workspaceRoot || !isAbsolutePath(workspaceRoot)) { + return null; + } + return { + kind: "file_rpc", + cwd: workspaceRoot, + path: sourcePath, + }; + } + + const workspaceRoot = input.workspaceRoot?.trim(); + if ( + workspaceRoot && + isAbsolutePath(workspaceRoot) && + isPathWithinRoot(sourcePath, workspaceRoot) + ) { + return { + kind: "file_rpc", + cwd: workspaceRoot, + path: sourcePath, + }; + } + + const fallbackRoot = deriveFallbackRootFromAbsolutePath(sourcePath); + if (!fallbackRoot) { + return null; + } + + return { + kind: "file_rpc", + cwd: fallbackRoot, + path: sourcePath, + }; +}