mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
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:
@@ -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
Reference in New Issue
Block a user