feat(site): render computer tool screenshots as images in chat UI (#23074)

Instead of showing raw base64 JSON for Anthropic's computer use tool,
render the screenshot as an inline image. The image is clickable to open
at full resolution in a new tab.

## Changes

- **ComputerTool.tsx** — New component that renders base64 image data as
an `<img>` tag
- **Tool.tsx** — Added `ComputerRenderer` handling both single-object
and array-of-blocks result shapes
- **ToolIcon.tsx** — Added `MonitorIcon` for the `computer` tool
- **ToolLabel.tsx** — Added \Screenshot\ label for the `computer` tool
This commit is contained in:
Thomas Kosiewski
2026-03-16 12:36:18 +01:00
committed by GitHub
parent cb05419872
commit d6306461bb
6 changed files with 256 additions and 0 deletions
@@ -643,3 +643,112 @@ export const EditFilesError: Story = {
result: { error: "File not found" },
},
};
// ---------------------------------------------------------------------------
// Computer tool stories
// ---------------------------------------------------------------------------
import { DESKTOP_SCREENSHOT_BASE64 } from "./tool/__fixtures__/desktopScreenshot";
export const ComputerScreenshot: Story = {
args: {
name: "computer",
status: "completed",
result: {
data: DESKTOP_SCREENSHOT_BASE64,
text: "",
mime_type: "image/jpeg",
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Screenshot")).toBeInTheDocument();
const img = canvas.getByRole("img", {
name: "Screenshot from computer tool",
});
expect(img).toBeInTheDocument();
expect(img.getAttribute("src")).toContain("data:image/jpeg;base64,");
// Image should be wrapped in a link that opens in a new tab.
const link = img.closest("a");
expect(link).toHaveAttribute("target", "_blank");
},
};
export const ComputerRunning: Story = {
args: {
name: "computer",
status: "running",
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Taking screenshot…")).toBeInTheDocument();
expect(canvasElement.querySelector(".animate-spin")).not.toBeNull();
},
};
export const ComputerTextFallback: Story = {
args: {
name: "computer",
status: "completed",
result: {
data: "",
text: "Screen resolution: 1920x1080\nActive window: Terminal",
mime_type: "image/png",
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Text-only results are collapsed by default (no image).
const toggle = canvas.getByRole("button", { name: /Screenshot/ });
expect(toggle).toBeInTheDocument();
expect(canvas.queryByRole("img")).toBeNull();
await userEvent.click(toggle);
expect(
canvas.getByText(/Screen resolution: 1920x1080/),
).toBeInTheDocument();
},
};
export const ComputerError: Story = {
args: {
name: "computer",
status: "error",
isError: true,
result: {
data: "",
text: "",
mime_type: "image/png",
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Screenshot")).toBeInTheDocument();
// Icon and label should have the destructive color class.
const label = canvas.getByText("Screenshot");
expect(label.className).toContain("text-content-destructive");
},
};
export const ComputerArrayResult: Story = {
args: {
name: "computer",
status: "completed",
result: [
{
type: "image",
data: DESKTOP_SCREENSHOT_BASE64,
mime_type: "image/jpeg",
},
{ type: "text", text: "Clicked on button" },
],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const img = canvas.getByRole("img", {
name: "Screenshot from computer tool",
});
expect(img).toBeInTheDocument();
expect(img.getAttribute("src")).toContain("data:image/jpeg;base64,");
},
};
@@ -0,0 +1,85 @@
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import { CircleAlertIcon, LoaderIcon } from "lucide-react";
import type React from "react";
import { cn } from "utils/cn";
import { ToolCollapsible } from "./ToolCollapsible";
import type { ToolStatus } from "./utils";
/**
* Renders screenshots returned by Anthropic's computer use tool.
* When the result contains base64 image data, the actual image is
* displayed instead of raw JSON. The image is clickable and opens
* in a new tab at full resolution.
*/
export const ComputerTool: React.FC<{
imageData: string;
mimeType: string;
text: string;
status: ToolStatus;
isError: boolean;
errorMessage?: string;
}> = ({ imageData, mimeType, text, status, isError, errorMessage }) => {
const isRunning = status === "running";
const hasImage = imageData.length > 0;
const hasText = text.length > 0;
const hasContent = hasImage || hasText;
return (
<ToolCollapsible
className="w-full"
hasContent={hasContent}
defaultExpanded={hasImage}
header={
<>
<span
className={cn(
"text-sm",
isError ? "text-content-destructive" : "text-content-secondary",
)}
>
{isRunning ? "Taking screenshot…" : "Screenshot"}
</span>
{isError && (
<Tooltip>
<TooltipTrigger asChild>
<CircleAlertIcon className="h-3.5 w-3.5 shrink-0 text-content-destructive" />
</TooltipTrigger>
<TooltipContent>
{errorMessage || "Failed to take screenshot"}
</TooltipContent>
</Tooltip>
)}
{isRunning && (
<LoaderIcon className="h-3.5 w-3.5 shrink-0 animate-spin motion-reduce:animate-none text-content-secondary" />
)}
</>
}
>
{hasImage ? (
<div className="mt-1.5 overflow-hidden rounded-md border border-solid border-border-default">
<a
href={`data:${mimeType};base64,${imageData}`}
target="_blank"
rel="noopener noreferrer"
>
<img
src={`data:${mimeType};base64,${imageData}`}
alt="Screenshot from computer tool"
className="max-h-96 w-auto object-contain"
/>
</a>
</div>
) : hasText ? (
<div className="mt-1.5 rounded-md border border-solid border-border-default px-3 py-2">
<pre className="whitespace-pre-wrap text-xs text-content-secondary">
{text}
</pre>
</div>
) : null}
</ToolCollapsible>
);
};
@@ -5,6 +5,7 @@ import type { ComponentPropsWithRef, FC } from "react";
import { memo } from "react";
import { cn } from "utils/cn";
import { ChatSummarizedTool } from "./ChatSummarizedTool";
import { ComputerTool } from "./ComputerTool";
import { CreateWorkspaceTool } from "./CreateWorkspaceTool";
import { EditFilesTool } from "./EditFilesTool";
import {
@@ -366,6 +367,53 @@ const ChatSummarizedRenderer: FC<ToolRendererProps> = ({
);
};
const ComputerRenderer: FC<ToolRendererProps> = ({
status,
result,
isError,
}) => {
// The result can be a single object with {data, text, mime_type}
// or an array of content blocks.
let imageData = "";
let mimeType = "image/png";
let text = "";
if (Array.isArray(result)) {
for (const block of result) {
const blockRec = asRecord(block);
if (blockRec) {
if (blockRec.type === "image" || asString(blockRec.data)) {
imageData = asString(blockRec.data);
mimeType = asString(blockRec.mime_type) || "image/png";
}
if (
blockRec.type === "text" ||
(!imageData && asString(blockRec.text))
) {
text = asString(blockRec.text);
}
}
}
} else {
const rec = asRecord(result);
if (rec) {
imageData = asString(rec.data);
mimeType = asString(rec.mime_type) || "image/png";
text = asString(rec.text);
}
}
return (
<ComputerTool
imageData={imageData}
mimeType={mimeType}
text={text}
status={status}
isError={isError}
/>
);
};
// Generic fallback renderer — only path that needs theme, diff
// viewers, and file content helpers.
const GenericToolRenderer: FC<ToolRendererProps> = ({
@@ -461,6 +509,7 @@ const toolRenderers: Record<string, FC<ToolRendererProps>> = {
message_agent: SubagentRenderer,
close_agent: SubagentRenderer,
chat_summarized: ChatSummarizedRenderer,
computer: ComputerRenderer,
};
// ---------------------------------------------------------------------------
@@ -2,6 +2,7 @@ import {
BotIcon,
FileIcon,
FilePenIcon,
MonitorIcon,
PlusCircleIcon,
TerminalIcon,
WrenchIcon,
@@ -30,6 +31,8 @@ export const ToolIcon: React.FC<{ name: string; isError: boolean }> = ({
return <PlusCircleIcon className={base} />;
case "chat_summarized":
return <BotIcon className={base} />;
case "computer":
return <MonitorIcon className={base} />;
default:
return <WrenchIcon className={base} />;
}
@@ -154,6 +154,12 @@ export const ToolLabel: React.FC<{
Summarized
</span>
);
case "computer":
return (
<span className="truncate text-sm text-content-secondary">
Screenshot
</span>
);
default:
return (
<span className="truncate text-sm text-content-secondary">{name}</span>
File diff suppressed because one or more lines are too long