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 */}
+
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.
+
+
+
+
+
+
+
+
+
+ );
+}
+
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)) {