From ad86f14fb4a50c54268c72d2c6a3bcacfb50f575 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Mon, 15 Jun 2026 00:35:23 -0700 Subject: [PATCH] fix(agents): preview view_image tools with inline thumbnails and lightbox - Add thumbnailSrc to compact developer tool summaries for http(s) and data:image sources - Render a tiny inline thumbnail in Viewed image rows instead of a truncated URL - Show a larger preview when the tool accordion expands and open a lightbox on click - Route relay media URLs through rewriteRelayUrl and fall back to basename text for local paths - Add unit tests for thumbnail extraction and local path fallback --- .../agents/ui/AgentSessionToolItem.tsx | 145 +++++++++++++++++- .../ui/agentSessionToolSummary.test.mjs | 24 ++- .../agents/ui/agentSessionToolSummary.ts | 58 +++++-- 3 files changed, 211 insertions(+), 16 deletions(-) diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem.tsx index 3b699797fe..d8fa3b44d1 100644 --- a/desktop/src/features/agents/ui/AgentSessionToolItem.tsx +++ b/desktop/src/features/agents/ui/AgentSessionToolItem.tsx @@ -1,4 +1,5 @@ import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; import { ArrowUpRight, ChevronDown, CircleDot, Wrench } from "lucide-react"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; @@ -7,6 +8,7 @@ import { resolveUserLabel } from "@/features/profile/lib/identity"; import type { Channel, UserProfileSummary } from "@/shared/api/types"; import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext"; import { cn } from "@/shared/lib/cn"; +import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; import { Badge } from "@/shared/ui/badge"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { UserAvatar } from "@/shared/ui/UserAvatar"; @@ -86,6 +88,7 @@ export function ToolItem({ ) : ( @@ -137,6 +140,14 @@ export function ToolItem({ description={buzzTool?.label} hasArgs={hasArgs} hasResult={hasResult} + imagePreview={ + compactSummary?.kind === "view_image" && isExpanded + ? { + src: compactSummary.thumbnailSrc, + title: compactSummary.preview, + } + : null + } isError={item.isError} result={item.result} /> @@ -149,15 +160,33 @@ function CompactToolSummaryRow({ duration, label, preview, + thumbnailSrc, }: { duration: string | null; label: string; preview: string | null; + thumbnailSrc: string | null; }) { + const [thumbnailFailed, setThumbnailFailed] = React.useState(false); + const resolvedThumbnail = React.useMemo(() => { + if (!thumbnailSrc || thumbnailFailed) return null; + return resolveImageSrc(thumbnailSrc); + }, [thumbnailFailed, thumbnailSrc]); + return ( <> {label} - {preview ? ( + {resolvedThumbnail ? ( + setThumbnailFailed(true)} + src={resolvedThumbnail} + title={preview ?? undefined} + /> + ) : preview ? ( resolveImageSrc(src), [src]); + const alt = title ?? "Viewed image"; + + if (imageFailed) { + return null; + } + + return ( + <> + {/* biome-ignore lint/a11y/useKeyWithClickEvents: opens lightbox on click */} + {alt} setLightboxOpen(true)} + onError={() => setImageFailed(true)} + src={resolvedSrc} + title={title ?? undefined} + /> + + + ); +} + +function ImageLightbox({ + alt, + onOpenChange, + open, + src, +}: { + alt: string; + onOpenChange: (open: boolean) => void; + open: boolean; + src: string; +}) { + return ( + + + + event.preventDefault()} + onPointerDownOutside={(event) => event.preventDefault()} + > + + {alt} + + + Full-size image preview. Press Escape or click outside the image to + close. + + + {alt} + + + + + + + ); +} + function getToolDuration(item: Extract) { if (item.startedAt && item.completedAt) { return formatDuration(item.startedAt, item.completedAt); @@ -222,6 +357,7 @@ function ToolDetailBlocks({ description, hasArgs, hasResult, + imagePreview, isError, result, }: { @@ -229,6 +365,7 @@ function ToolDetailBlocks({ description?: string; hasArgs: boolean; hasResult: boolean; + imagePreview: { src: string | null; title: string | null } | null; isError: boolean; result: string; }) { @@ -239,6 +376,12 @@ function ToolDetailBlocks({ {description}

) : null} + {imagePreview?.src ? ( + + ) : null} {hasArgs ? ( { assert.equal(summary.preview, "git status"); }); -test("buildCompactToolSummary formats view_image source preview", () => { +test("buildCompactToolSummary formats view_image thumbnail source", () => { + const source = + "https://sprout-oss.stage.blox.sqprod.co/media/ffd1b2721f2d52e19f0ca2be9aa7842cdec5b4e0215aaab2a67c26a2a76a6a83.png"; const summary = buildCompactToolSummary( makeTool({ toolName: "buzz-dev-mcp__view_image", - args: { - source: - "https://sprout-oss.stage.blox.sqprod.co/media/ffd1b2721f2d52e19f0ca2be9aa7842cdec5b4e0215aaab2a67c26a2a76a6a83.png", - }, + args: { source }, }), ); assert.equal(summary.label, "Viewed image"); - assert.ok(summary.preview?.startsWith("https://sprout-oss")); + assert.equal(summary.thumbnailSrc, source); + assert.equal(summary.preview, source); +}); + +test("buildCompactToolSummary uses basename for local view_image paths", () => { + const summary = buildCompactToolSummary( + makeTool({ + toolName: "view_image", + args: { source: "desktop/assets/screenshot.png" }, + }), + ); + + assert.equal(summary.thumbnailSrc, null); + assert.equal(summary.preview, "screenshot.png"); }); test("buildCompactToolSummary formats read_file path preview", () => { diff --git a/desktop/src/features/agents/ui/agentSessionToolSummary.ts b/desktop/src/features/agents/ui/agentSessionToolSummary.ts index 34ec5957c0..70aaccbbff 100644 --- a/desktop/src/features/agents/ui/agentSessionToolSummary.ts +++ b/desktop/src/features/agents/ui/agentSessionToolSummary.ts @@ -19,6 +19,8 @@ export type CompactToolSummary = { kind: CompactToolKind; label: string; preview: string | null; + /** When set, the compact row renders a tiny image instead of text preview. */ + thumbnailSrc: string | null; }; const DEVELOPER_TOOL_BASES = new Set([ @@ -44,11 +46,12 @@ export function isCompactDeveloperTool(item: ToolItem): boolean { /** Build the compact summary label and preview for developer MCP tool rows. */ export function buildCompactToolSummary(item: ToolItem): CompactToolSummary { const kind = resolveDeveloperToolKind(item) ?? "dev_mcp"; - const preview = extractCompactToolPreview(item, kind); + const { preview, thumbnailSrc } = extractCompactToolPreview(item, kind); return { kind, label: compactToolLabel(kind, item.status, item.isError), preview, + thumbnailSrc, }; } @@ -153,33 +156,70 @@ function compactToolLabel( return set.completed; } +type CompactToolPreview = { + preview: string | null; + thumbnailSrc: string | null; +}; + function extractCompactToolPreview( item: ToolItem, kind: CompactToolKind, -): string | null { +): CompactToolPreview { const args = item.args; switch (kind) { case "shell": - return getToolString(args, ["command"]); + return textPreview(getToolString(args, ["command"])); case "read_file": case "str_replace": - return getToolString(args, ["path"]); + return textPreview(getToolString(args, ["path"])); case "view_image": - return getToolString(args, ["source"]); + return getViewImagePreview(getToolString(args, ["source"])); case "todo": - return getTodoPreview(args); + return textPreview(getTodoPreview(args)); case "stop_hook": case "post_compact_hook": - return null; + return emptyPreview(); case "dev_mcp": - return ( + return textPreview( getToolString(args, ["command", "path", "source", "query", "name"]) ?? - null + null, ); } } +function textPreview(preview: string | null): CompactToolPreview { + return { preview, thumbnailSrc: null }; +} + +function emptyPreview(): CompactToolPreview { + return { preview: null, thumbnailSrc: null }; +} + +function getViewImagePreview(source: string | null): CompactToolPreview { + if (!source) { + return emptyPreview(); + } + + const trimmed = source.trim(); + if ( + trimmed.startsWith("data:image/") || + trimmed.startsWith("http://") || + trimmed.startsWith("https://") + ) { + return { + preview: trimmed, + thumbnailSrc: trimmed, + }; + } + + const basename = trimmed.split(/[/\\]/).pop() ?? trimmed; + return { + preview: basename, + thumbnailSrc: null, + }; +} + function getTodoPreview(args: Record): string | null { const todos = args.todos; if (!Array.isArray(todos)) {