mirror of
https://github.com/block/buzz.git
synced 2026-10-31 14:31:02 +00:00
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:
committed by
npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
parent
934ba9dd9e
commit
ad86f14fb4
@@ -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"
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user