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",
+ 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\n\n',
+ ),
+ ).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",
+ ),
+ ).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,
+ };
+}