Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 34 additions & 3 deletions src/browser/components/tools/GenericToolCall.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
} from "./shared/ToolPrimitives";
import { useToolExpansion, getStatusDisplay, type ToolStatus } from "./shared/toolUtils";
import { JsonHighlight } from "./shared/HighlightedCode";
import { ToolResultImages, extractImagesFromToolResult } from "./shared/ToolResultImages";

interface GenericToolCallProps {
toolName: string;
Expand All @@ -21,6 +22,28 @@ interface GenericToolCallProps {
status?: ToolStatus;
}

/**
* Filter out image data from result for JSON display (to avoid showing huge base64 strings).
* Replaces media content with a placeholder indicator.
*/
function filterResultForDisplay(result: unknown): unknown {
if (typeof result !== "object" || result === null) return result;

const contentResult = result as { type?: string; value?: unknown[] };
if (contentResult.type !== "content" || !Array.isArray(contentResult.value)) return result;

// Replace media entries with placeholder
const filteredValue = contentResult.value.map((item) => {
if (typeof item === "object" && item !== null && (item as { type?: string }).type === "media") {
const mediaItem = item as { mediaType?: string };
return { type: "media", mediaType: mediaItem.mediaType, data: "[image data]" };
}
return item;
});

return { ...contentResult, value: filteredValue };
}

export const GenericToolCall: React.FC<GenericToolCallProps> = ({
toolName,
args,
Expand All @@ -30,15 +53,23 @@ export const GenericToolCall: React.FC<GenericToolCallProps> = ({
const { expanded, toggleExpanded } = useToolExpansion();

const hasDetails = args !== undefined || result !== undefined;
const images = extractImagesFromToolResult(result);
const hasImages = images.length > 0;

// Auto-expand if there are images to show
const shouldShowDetails = expanded || hasImages;

return (
<ToolContainer expanded={expanded}>
<ToolContainer expanded={shouldShowDetails}>
<ToolHeader onClick={() => hasDetails && toggleExpanded()}>
{hasDetails && <ExpandIcon expanded={expanded}>â–¶</ExpandIcon>}
{hasDetails && <ExpandIcon expanded={shouldShowDetails}>â–¶</ExpandIcon>}
<ToolName>{toolName}</ToolName>
<StatusIndicator status={status}>{getStatusDisplay(status)}</StatusIndicator>
</ToolHeader>

{/* Always show images if present */}
{hasImages && <ToolResultImages result={result} />}

{expanded && hasDetails && (
<ToolDetails>
{args !== undefined && (
Expand All @@ -54,7 +85,7 @@ export const GenericToolCall: React.FC<GenericToolCallProps> = ({
<DetailSection>
<DetailLabel>Result</DetailLabel>
<DetailContent>
<JsonHighlight value={result} />
<JsonHighlight value={filterResultForDisplay(result)} />
</DetailContent>
</DetailSection>
)}
Expand Down
82 changes: 82 additions & 0 deletions src/browser/components/tools/shared/ToolResultImages.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { describe, it, expect } from "bun:test";
import { extractImagesFromToolResult } from "./ToolResultImages";

describe("extractImagesFromToolResult", () => {
it("should extract images from MCP content result format", () => {
const result = {
type: "content",
value: [
{ type: "text", text: "Screenshot taken" },
{ type: "media", data: "base64imagedata", mediaType: "image/png" },
],
};

const images = extractImagesFromToolResult(result);

expect(images).toHaveLength(1);
expect(images[0]).toEqual({
type: "media",
data: "base64imagedata",
mediaType: "image/png",
});
});

it("should extract multiple images", () => {
const result = {
type: "content",
value: [
{ type: "media", data: "image1data", mediaType: "image/png" },
{ type: "text", text: "Some text" },
{ type: "media", data: "image2data", mediaType: "image/jpeg" },
],
};

const images = extractImagesFromToolResult(result);

expect(images).toHaveLength(2);
expect(images[0].mediaType).toBe("image/png");
expect(images[1].mediaType).toBe("image/jpeg");
});

it("should return empty array for non-content results", () => {
expect(extractImagesFromToolResult({ success: true })).toEqual([]);
expect(extractImagesFromToolResult(null)).toEqual([]);
expect(extractImagesFromToolResult(undefined)).toEqual([]);
expect(extractImagesFromToolResult("string")).toEqual([]);
expect(extractImagesFromToolResult(123)).toEqual([]);
});

it("should return empty array for content without images", () => {
const result = {
type: "content",
value: [
{ type: "text", text: "Just text" },
{ type: "text", text: "More text" },
],
};

expect(extractImagesFromToolResult(result)).toEqual([]);
});

it("should skip malformed media entries", () => {
const result = {
type: "content",
value: [
{ type: "media", data: "valid", mediaType: "image/png" }, // Valid
{ type: "media", data: 123, mediaType: "image/png" }, // Invalid: data not string
{ type: "media", data: "valid", mediaType: null }, // Invalid: mediaType not string
{ type: "media" }, // Invalid: missing fields
],
};

const images = extractImagesFromToolResult(result);

expect(images).toHaveLength(1);
expect(images[0].data).toBe("valid");
});

it("should return empty for wrong type value", () => {
expect(extractImagesFromToolResult({ type: "error", value: [] })).toEqual([]);
expect(extractImagesFromToolResult({ type: "content", value: "not-array" })).toEqual([]);
});
});
71 changes: 71 additions & 0 deletions src/browser/components/tools/shared/ToolResultImages.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import React from "react";

/**
* Image content from MCP tool results (transformed from MCP's image type to AI SDK's media type)
*/
interface MediaContent {
type: "media";
data: string; // base64
mediaType: string;
}

/**
* Structure of transformed MCP results that contain images
*/
interface ContentResult {
type: "content";
value: Array<{ type: string; text?: string; data?: string; mediaType?: string }>;
}

/**
* Extract images from a tool result.
* Handles the transformed MCP result format: { type: "content", value: [...] }
*/
export function extractImagesFromToolResult(result: unknown): MediaContent[] {
if (typeof result !== "object" || result === null) return [];

const contentResult = result as ContentResult;
if (contentResult.type !== "content" || !Array.isArray(contentResult.value)) return [];

return contentResult.value.filter(
(item): item is MediaContent =>
item.type === "media" && typeof item.data === "string" && typeof item.mediaType === "string"
);
}

interface ToolResultImagesProps {
result: unknown;
}

/**
* Display images extracted from MCP tool results (e.g., Chrome DevTools screenshots)
*/
export const ToolResultImages: React.FC<ToolResultImagesProps> = ({ result }) => {
const images = extractImagesFromToolResult(result);

if (images.length === 0) return null;

return (
<div className="mt-2 flex flex-wrap gap-2">
{images.map((image, index) => {
const dataUrl = `data:${image.mediaType};base64,${image.data}`;
return (
<a
key={index}
href={dataUrl}
target="_blank"
rel="noopener noreferrer"
className="border-border-light bg-dark group block overflow-hidden rounded border transition-opacity hover:opacity-80"
title="Click to open full size"
>
<img
src={dataUrl}
alt={`Tool result image ${index + 1}`}
className="max-h-48 max-w-full object-contain"
/>
</a>
);
})}
</div>
);
};