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
This commit is contained in:
Taylor Ho
2026-06-15 00:35:23 -07:00
committed by npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
parent 934ba9dd9e
commit ad86f14fb4
3 changed files with 211 additions and 16 deletions

View File

@@ -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({
<CompactToolSummaryRow
duration={duration}
preview={compactSummary.preview}
thumbnailSrc={compactSummary.thumbnailSrc}
label={compactSummary.label}
/>
) : (
@@ -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 (
<>
<span className="shrink-0 text-sm font-semibold">{label}</span>
{preview ? (
{resolvedThumbnail ? (
<img
alt=""
className="h-5 w-auto max-w-12 shrink-0 rounded-sm object-cover"
decoding="async"
loading="lazy"
onError={() => setThumbnailFailed(true)}
src={resolvedThumbnail}
title={preview ?? undefined}
/>
) : preview ? (
<span
className="min-w-0 max-w-48 truncate text-sm text-muted-foreground/70"
title={preview}
@@ -175,6 +204,112 @@ function CompactToolSummaryRow({
);
}
function resolveImageSrc(source: string): string {
if (source.startsWith("data:image/")) {
return source;
}
return rewriteRelayUrl(source);
}
function ViewImageToolPreview({
src,
title,
}: {
src: string;
title: string | null;
}) {
const [lightboxOpen, setLightboxOpen] = React.useState(false);
const [imageFailed, setImageFailed] = React.useState(false);
const resolvedSrc = React.useMemo(() => resolveImageSrc(src), [src]);
const alt = title ?? "Viewed image";
if (imageFailed) {
return null;
}
return (
<>
{/* biome-ignore lint/a11y/useKeyWithClickEvents: opens lightbox on click */}
<img
alt={alt}
className="block max-h-64 max-w-sm cursor-pointer rounded-md object-contain"
decoding="async"
loading="lazy"
onClick={() => setLightboxOpen(true)}
onError={() => setImageFailed(true)}
src={resolvedSrc}
title={title ?? undefined}
/>
<ImageLightbox
alt={alt}
onOpenChange={setLightboxOpen}
open={lightboxOpen}
src={resolvedSrc}
/>
</>
);
}
function ImageLightbox({
alt,
onOpenChange,
open,
src,
}: {
alt: string;
onOpenChange: (open: boolean) => void;
open: boolean;
src: string;
}) {
return (
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
<DialogPrimitive.Content
className="fixed inset-0 z-50 flex items-center justify-center p-8"
onInteractOutside={(event) => event.preventDefault()}
onPointerDownOutside={(event) => event.preventDefault()}
>
<DialogPrimitive.Title className="sr-only">
{alt}
</DialogPrimitive.Title>
<DialogPrimitive.Description className="sr-only">
Full-size image preview. Press Escape or click outside the image to
close.
</DialogPrimitive.Description>
<DialogPrimitive.Close
aria-label="Close lightbox"
className="absolute inset-0 cursor-default"
/>
<img
alt={alt}
className="relative max-h-[90vh] max-w-[90vw] rounded-lg object-contain"
src={src}
/>
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-full bg-black/50 p-2 text-white/80 transition-colors hover:bg-black/70 hover:text-white focus:outline-hidden focus:ring-2 focus:ring-white/30">
<svg
aria-hidden="true"
fill="none"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M18 6L6 18M6 6l12 12"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
/>
</svg>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
);
}
function getToolDuration(item: Extract<TranscriptItem, { type: "tool" }>) {
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}
</p>
) : null}
{imagePreview?.src ? (
<ViewImageToolPreview
src={imagePreview.src}
title={imagePreview.title}
/>
) : null}
{hasArgs ? (
<ToolCodeBlock
label="Parameters"

View File

@@ -63,19 +63,31 @@ test("buildCompactToolSummary formats shell command preview", () => {
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", () => {

View File

@@ -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, unknown>): string | null {
const todos = args.todos;
if (!Array.isArray(todos)) {