diff --git a/site/src/components/ai-elements/tool.stories.tsx b/site/src/components/ai-elements/tool.stories.tsx index 7a40bd1f6a..28fd3ff130 100644 --- a/site/src/components/ai-elements/tool.stories.tsx +++ b/site/src/components/ai-elements/tool.stories.tsx @@ -1,7 +1,8 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, spyOn, userEvent, waitFor, within } from "storybook/test"; +import { expect, fn, spyOn, userEvent, waitFor, within } from "storybook/test"; import { reactRouterParameters } from "storybook-addon-remix-react-router"; import { Tool } from "./tool"; +import { DesktopPanelContext } from "./tool/DesktopPanelContext"; const executeCommand = "git fetch origin"; const meta: Meta = { @@ -1094,3 +1095,117 @@ export const MCPToolFailedUnifiedStyle: Story = { expect(canvasElement.querySelector(".text-content-destructive")).toBeNull(); }, }; + +// --------------------------------------------------------------------------- +// spawn_computer_use_agent stories +// --------------------------------------------------------------------------- + +export const SpawnComputerUseAgentRunning: Story = { + args: { + name: "spawn_computer_use_agent", + status: "running", + args: { + title: "Visual regression check", + prompt: + "Open the browser and check for visual regressions on the dashboard page.", + }, + result: { + chat_id: "desktop-child-1", + title: "Visual regression check", + status: "pending", + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText(/Spawning/)).toBeInTheDocument(); + expect(canvasElement.querySelector(".animate-spin")).not.toBeNull(); + }, +}; + +export const SpawnComputerUseAgentCompleted: Story = { + args: { + name: "spawn_computer_use_agent", + status: "completed", + args: { + title: "Visual regression check", + prompt: + "Open the browser and check for visual regressions on the dashboard page.", + }, + result: { + chat_id: "desktop-child-1", + title: "Visual regression check", + status: "completed", + duration_ms: "12400", + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText(/Spawned/)).toBeInTheDocument(); + expect(canvas.getByText(/Visual regression check/)).toBeInTheDocument(); + expect(canvas.getByText("Worked for 12s")).toBeInTheDocument(); + expect(canvas.getByRole("link", { name: "View agent" })).toHaveAttribute( + "href", + "/agents/desktop-child-1", + ); + }, +}; + +export const SpawnComputerUseAgentError: Story = { + args: { + name: "spawn_computer_use_agent", + status: "error", + isError: true, + result: { + chat_id: "desktop-child-1", + status: "error", + }, + }, + play: async ({ canvasElement }) => { + expect(canvasElement.querySelector(".lucide-circle-x")).not.toBeNull(); + }, +}; + +// --------------------------------------------------------------------------- +// wait_agent with computer-use subagent stories +// --------------------------------------------------------------------------- + +export const WaitAgentComputerUseRunning: Story = { + args: { + name: "wait_agent", + status: "running", + args: { + chat_id: "desktop-child-1", + }, + result: { + chat_id: "desktop-child-1", + status: "pending", + }, + computerUseSubagentIds: new Set(["desktop-child-1"]), + }, + decorators: [ + (Story) => ( + + + + ), + ], + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText(/Waiting for/)).toBeInTheDocument(); + // Running state shows the spinner icon. + expect(canvasElement.querySelector(".lucide-loader")).not.toBeNull(); + // The VNC preview container should mount (the connection will + // stay in "connecting" state without a real WebSocket, which + // is expected — we only verify the container renders). + await waitFor(() => { + expect( + canvas.getByRole("button", { name: "Open desktop tab" }), + ).toBeInTheDocument(); + }); + }, +}; diff --git a/site/src/components/ai-elements/tool/DesktopPanelContext.tsx b/site/src/components/ai-elements/tool/DesktopPanelContext.tsx new file mode 100644 index 0000000000..d179e2af6c --- /dev/null +++ b/site/src/components/ai-elements/tool/DesktopPanelContext.tsx @@ -0,0 +1,12 @@ +import { createContext, useContext } from "react"; + +interface DesktopPanelContextValue { + /** The parent chat ID used for the desktop VNC connection. */ + desktopChatId?: string; + /** Opens the right sidebar panel and switches to the Desktop tab. */ + onOpenDesktop?: () => void; +} + +export const DesktopPanelContext = createContext({}); + +export const useDesktopPanel = () => useContext(DesktopPanelContext); diff --git a/site/src/components/ai-elements/tool/InlineDesktopPreview.stories.tsx b/site/src/components/ai-elements/tool/InlineDesktopPreview.stories.tsx new file mode 100644 index 0000000000..02b3dae311 --- /dev/null +++ b/site/src/components/ai-elements/tool/InlineDesktopPreview.stories.tsx @@ -0,0 +1,130 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, fn, within } from "storybook/test"; +import { InlineDesktopPreview } from "./InlineDesktopPreview"; + +const meta: Meta = { + title: "components/ai-elements/InlineDesktopPreview", + component: InlineDesktopPreview, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + args: { + chatId: "desktop-chat-1", + onClick: fn(), + }, +}; +export default meta; +type Story = StoryObj; + +// --------------------------------------------------------------------------- +// Idle — hook has not started connecting yet. +// --------------------------------------------------------------------------- + +export const Idle: Story = { + args: { + connectionOverride: { + status: "idle", + hasConnected: false, + reconnect: fn(), + attach: fn(), + rfb: null, + remoteClipboardText: null, + }, + }, + play: async ({ canvasElement }) => { + // The idle state shows a loading spinner. + const canvas = within(canvasElement); + expect(canvas.getByTitle("Loading spinner")).toBeInTheDocument(); + }, +}; + +// --------------------------------------------------------------------------- +// Connecting — WebSocket handshake in progress. +// --------------------------------------------------------------------------- + +export const Connecting: Story = { + args: { + connectionOverride: { + status: "connecting", + hasConnected: false, + reconnect: fn(), + attach: fn(), + rfb: null, + remoteClipboardText: null, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByTitle("Loading spinner")).toBeInTheDocument(); + }, +}; + +// --------------------------------------------------------------------------- +// Connected — VNC canvas attached. +// --------------------------------------------------------------------------- + +export const Connected: Story = { + args: { + connectionOverride: { + status: "connected", + hasConnected: true, + reconnect: fn(), + attach: fn(), + rfb: null, + remoteClipboardText: null, + }, + }, + play: async ({ canvasElement }) => { + // The connected state renders the VNC container with + // pointer-events-none to act as a read-only preview. + expect(canvasElement.querySelector(".pointer-events-none")).not.toBeNull(); + }, +}; + +// --------------------------------------------------------------------------- +// Disconnected — connection dropped, auto-reconnecting. +// --------------------------------------------------------------------------- + +export const Disconnected: Story = { + args: { + connectionOverride: { + status: "disconnected", + hasConnected: true, + reconnect: fn(), + attach: fn(), + rfb: null, + remoteClipboardText: null, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText(/Desktop disconnected/)).toBeInTheDocument(); + }, +}; + +// --------------------------------------------------------------------------- +// Error — connection failed permanently. +// --------------------------------------------------------------------------- + +export const ErrorState: Story = { + args: { + connectionOverride: { + status: "error", + hasConnected: false, + reconnect: fn(), + attach: fn(), + rfb: null, + remoteClipboardText: null, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + canvas.getByText(/Could not connect to desktop/), + ).toBeInTheDocument(); + }, +}; diff --git a/site/src/components/ai-elements/tool/InlineDesktopPreview.tsx b/site/src/components/ai-elements/tool/InlineDesktopPreview.tsx new file mode 100644 index 0000000000..09d16fb370 --- /dev/null +++ b/site/src/components/ai-elements/tool/InlineDesktopPreview.tsx @@ -0,0 +1,141 @@ +import { ExternalLinkIcon } from "lucide-react"; +import { + type UseDesktopConnectionResult, + useDesktopConnection, +} from "pages/AgentsPage/hooks/useDesktopConnection"; +import type React from "react"; +import { useEffect, useRef, useState } from "react"; +import { Spinner } from "#/components/Spinner/Spinner"; + +/** Default aspect ratio used before the remote framebuffer size is known. */ +const DEFAULT_ASPECT = "16 / 9"; + +/** + * Non-interactive inline VNC desktop preview. The noVNC canvas is + * blocked from receiving pointer/keyboard events so it acts as a + * read-only thumbnail. An invisible overlay captures clicks and + * forwards them to `onClick` (e.g. opens the sidebar Desktop tab). + * + * The container's aspect-ratio is derived from the remote desktop's + * framebuffer dimensions so there is no dead space around the + * preview. + */ +export const InlineDesktopPreview: React.FC<{ + chatId: string; + onClick?: () => void; + /** Optional override for the desktop connection hook result. + * When provided, the real hook is skipped entirely. Used by + * Storybook stories to inject mock connection states without + * relying on module-level spies. */ + connectionOverride?: UseDesktopConnectionResult; +}> = ({ chatId, onClick, connectionOverride }) => { + // Pass undefined chatId when the override is provided so the + // real hook skips its WebSocket connection logic entirely. + const realConnection = useDesktopConnection({ + chatId: connectionOverride ? undefined : chatId, + }); + const { status, attach } = connectionOverride ?? realConnection; + const [aspectRatio, setAspectRatio] = useState(DEFAULT_ASPECT); + const containerRef = useRef(null); + + // Derive the aspect ratio from the noVNC canvas once connected. + // noVNC renders into a whose intrinsic width/height + // attributes match the remote framebuffer dimensions (when + // clipViewport is disabled, which is the case here since + // scaleViewport is enabled). Querying the canvas from the DOM + // avoids accessing noVNC's private _fbWidth/_fbHeight fields. + useEffect(() => { + if (status !== "connected" || !containerRef.current) { + return; + } + + let timeoutId: ReturnType | null = null; + + const readDimensions = () => { + const canvas = containerRef.current?.querySelector("canvas"); + if (canvas && canvas.width > 0 && canvas.height > 0) { + setAspectRatio(`${canvas.width} / ${canvas.height}`); + return true; + } + return false; + }; + + if (!readDimensions()) { + // The canvas dimensions may not be set immediately after + // the status transitions to "connected". Retry once after + // a short delay as a fallback. + timeoutId = setTimeout(readDimensions, 500); + } + + return () => { + if (timeoutId !== null) { + clearTimeout(timeoutId); + } + }; + }, [status]); + + const wrapWithOverlay = (children: React.ReactNode) => ( +
+ {children} + {/* Transparent overlay — dims the preview on hover and shows + an external-link icon so it's clear clicking opens the + sidebar desktop tab. */} + {onClick && ( + + )} +
+ ); + + if (status === "idle" || status === "connecting") { + return wrapWithOverlay( +
+ +
, + ); + } + + if (status === "disconnected") { + return wrapWithOverlay( +
+ Desktop disconnected. Reconnecting… +
, + ); + } + + if (status === "error") { + return wrapWithOverlay( +
+ Could not connect to desktop. +
, + ); + } + + // status === "connected" — pointer-events-none on the VNC + // container prevents noVNC from capturing any input. + return wrapWithOverlay( +
{ + containerRef.current = el; + if (el) attach(el); + }} + className="pointer-events-none w-full" + style={{ aspectRatio }} + />, + ); +}; diff --git a/site/src/components/ai-elements/tool/SubagentTool.tsx b/site/src/components/ai-elements/tool/SubagentTool.tsx index d15f55f2db..0a13528527 100644 --- a/site/src/components/ai-elements/tool/SubagentTool.tsx +++ b/site/src/components/ai-elements/tool/SubagentTool.tsx @@ -5,6 +5,7 @@ import { ClockIcon, ExternalLinkIcon, LoaderIcon, + MonitorIcon, } from "lucide-react"; import type React from "react"; import { useState } from "react"; @@ -12,6 +13,8 @@ import { Link } from "react-router"; import { cn } from "utils/cn"; import { ScrollArea } from "#/components/ScrollArea/ScrollArea"; import { Response } from "../response"; +import { useDesktopPanel } from "./DesktopPanelContext"; +import { InlineDesktopPreview } from "./InlineDesktopPreview"; import { isSubagentSuccessStatus, shortDurationMs, @@ -46,6 +49,12 @@ const SUBAGENT_VERBS: Record< error: "Failed to terminate ", timeout: "Timed out terminating ", }, + spawn_computer_use_agent: { + completed: "Spawned ", + running: "Spawning ", + error: "Failed to spawn ", + timeout: "Timed out spawning ", + }, }; /** @@ -60,8 +69,16 @@ const SubagentStatusIcon: React.FC<{ toolStatus: ToolStatus; isError: boolean; isTimeout: boolean; -}> = ({ subagentStatus, toolStatus, isError, isTimeout }) => { + variant?: "default" | "computer-use"; +}> = ({ + subagentStatus, + toolStatus, + isError, + isTimeout, + variant = "default", +}) => { const subagentCompleted = isSubagentSuccessStatus(subagentStatus); + const DefaultIcon = variant === "computer-use" ? MonitorIcon : BotIcon; if (isTimeout && !subagentCompleted) { return ; } @@ -73,7 +90,7 @@ const SubagentStatusIcon: React.FC<{ ); } - return ; + return ; }; /** @@ -94,6 +111,9 @@ export const SubagentTool: React.FC<{ toolStatus: ToolStatus; isError: boolean; isTimeout?: boolean; + /** Show an inline VNC desktop preview (for computer-use subagents). */ + showDesktopPreview?: boolean; + variant?: "default" | "computer-use"; }> = ({ toolName, title, @@ -106,8 +126,11 @@ export const SubagentTool: React.FC<{ toolStatus, isError, isTimeout = false, + showDesktopPreview, + variant = "default", }) => { const [expanded, setExpanded] = useState(false); + const { desktopChatId, onOpenDesktop } = useDesktopPanel(); const hasPrompt = Boolean(prompt?.trim()); const hasMessage = Boolean(message?.trim()); const hasReport = Boolean(report?.trim()); @@ -118,7 +141,7 @@ export const SubagentTool: React.FC<{
+ {showDesktopPreview && desktopChatId && ( +
+ +
+ )} + {expanded && hasPrompt && ( , "children"> { isError?: boolean; /** Maps sub-agent chat IDs to their titles, built from spawn tool results. */ subagentTitles?: Map; + /** Set of chat IDs spawned by `spawn_computer_use_agent`. */ + computerUseSubagentIds?: Set; + /** When false, suppresses inline VNC previews while still + * allowing the MonitorIcon variant to render. */ + showDesktopPreviews?: boolean; /** Maps sub-agent chat IDs to real-time status updates from stream events. */ subagentStatusOverrides?: Map; /** MCP server config ID associated with this tool call. */ @@ -75,6 +80,8 @@ type ToolRendererProps = { result: unknown; isError: boolean; subagentTitles?: Map; + computerUseSubagentIds?: Set; + showDesktopPreviews?: boolean; subagentStatusOverrides?: Map; mcpServerConfigId?: string; mcpServers?: readonly TypesGen.MCPServerConfig[]; @@ -268,6 +275,8 @@ const SubagentRenderer: FC = ({ result, isError, subagentTitles, + computerUseSubagentIds, + showDesktopPreviews = true, subagentStatusOverrides, }) => { const parsedArgs = parseArgs(args); @@ -293,7 +302,9 @@ const SubagentRenderer: FC = ({ (rec ? asString(rec.title) : "") || (parsedArgs ? asString(parsedArgs.title) : "") || (chatId && subagentTitles?.get(chatId)) || - "Sub-agent"; + (name === "spawn_computer_use_agent" + ? "Computer use sub-agent" + : "Sub-agent"); const subagentCompleted = isSubagentSuccessStatus(subagentStatus); const subagentToolStatus = mapSubagentStatusToToolStatus( subagentStatus, @@ -313,6 +324,10 @@ const SubagentRenderer: FC = ({ (resultStr.toLowerCase().includes("timed out") || errorStr.toLowerCase().includes("timed out")); + const variant = + name === "spawn_computer_use_agent" || computerUseSubagentIds?.has(chatId) + ? "computer-use" + : "default"; return ( = ({ toolStatus={subagentToolStatus} isError={subagentIsError} isTimeout={isTimeout} + showDesktopPreview={ + showDesktopPreviews && computerUseSubagentIds?.has(chatId) + } + variant={variant} /> ); }; @@ -603,6 +622,7 @@ const toolRenderers: Record> = { wait_agent: SubagentRenderer, message_agent: SubagentRenderer, close_agent: SubagentRenderer, + spawn_computer_use_agent: SubagentRenderer, chat_summarized: ChatSummarizedRenderer, propose_plan: ProposePlanRenderer, computer: ComputerRenderer, @@ -621,6 +641,8 @@ export const Tool = memo( result, isError = false, subagentTitles, + computerUseSubagentIds, + showDesktopPreviews, subagentStatusOverrides, mcpServerConfigId, mcpServers, @@ -649,6 +671,8 @@ export const Tool = memo( result={result} isError={isError} subagentTitles={subagentTitles} + computerUseSubagentIds={computerUseSubagentIds} + showDesktopPreviews={showDesktopPreviews} subagentStatusOverrides={subagentStatusOverrides} mcpServerConfigId={mcpServerConfigId} mcpServers={mcpServers} diff --git a/site/src/components/ai-elements/tool/ToolIcon.tsx b/site/src/components/ai-elements/tool/ToolIcon.tsx index ed798236bb..a3c5cb954c 100644 --- a/site/src/components/ai-elements/tool/ToolIcon.tsx +++ b/site/src/components/ai-elements/tool/ToolIcon.tsx @@ -83,6 +83,7 @@ export const ToolIcon: React.FC<{ case "propose_plan": return ; case "computer": + case "spawn_computer_use_agent": return ; default: return ; diff --git a/site/src/pages/AgentsPage/AgentDetail.stories.tsx b/site/src/pages/AgentsPage/AgentDetail.stories.tsx index 964e38b153..9de997964c 100644 --- a/site/src/pages/AgentsPage/AgentDetail.stories.tsx +++ b/site/src/pages/AgentsPage/AgentDetail.stories.tsx @@ -673,6 +673,92 @@ export const WithSubagentCards: Story = { }, }; +/** spawn_computer_use_agent tool renders with an "Open Desktop" button + * that opens the right sidebar panel and switches to the Desktop tab. */ +export const WithComputerUseAgent: Story = { + parameters: { + queries: [ + ...buildQueries( + { + id: CHAT_ID, + ...baseChatFields, + title: "Desktop automation task", + status: "running", + }, + { + messages: [ + { + id: 1, + chat_id: CHAT_ID, + created_at: "2026-02-18T00:00:01.000Z", + role: "user", + content: [ + { + type: "text", + text: "Can you check the browser for visual regressions?", + }, + ], + }, + { + id: 2, + chat_id: CHAT_ID, + created_at: "2026-02-18T00:00:02.000Z", + role: "assistant", + content: [ + { + type: "text", + text: "I'll spawn a computer use agent to visually inspect the browser.", + }, + { + type: "tool-call", + tool_call_id: "tool-desktop-1", + tool_name: "spawn_computer_use_agent", + args: { + title: "Visual regression check", + prompt: + "Open the browser and check for visual regressions on the dashboard page.", + }, + }, + { + type: "tool-result", + tool_call_id: "tool-desktop-1", + tool_name: "spawn_computer_use_agent", + result: { + chat_id: "desktop-child-1", + title: "Visual regression check", + status: "completed", + duration_ms: "12400", + }, + }, + { + type: "text", + text: "The desktop agent has finished its visual inspection. No regressions found. You can click **Open Desktop** above to view the desktop session.", + }, + ], + }, + ], + queued_messages: [], + has_more: false, + }, + { diffUrl: undefined }, + ), + // Enable the desktop feature so the Desktop tab appears in the sidebar. + { + key: ["chat-desktop-enabled"], + data: { enable_desktop: true }, + }, + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // The tool should show "Spawned ... Visual regression check". + await waitFor(() => { + expect(canvas.getByText(/Visual regression check/)).toBeInTheDocument(); + }); + }, +}; + /** Completed reasoning part renders inline. */ export const WithReasoningInline: Story = { parameters: { @@ -1188,3 +1274,88 @@ export const FailedSendWithActiveStream: Story = { ).toBeInTheDocument(); }, }; + +/** wait_agent for a computer-use subagent renders the VNC preview card + * (SubagentTool with computer-use variant) instead of the plain SubagentTool card. */ +export const WithWaitAgentComputerUseVNC: Story = { + parameters: { + queries: [ + ...buildQueries( + { + id: CHAT_ID, + ...baseChatFields, + title: "Wait agent computer use", + status: "running", + }, + { + messages: [ + { + id: 1, + chat_id: CHAT_ID, + created_at: "2026-02-18T00:00:01.000Z", + role: "assistant", + content: [ + { + type: "tool-call", + tool_call_id: "tool-spawn-desktop", + tool_name: "spawn_computer_use_agent", + args: { + title: "Visual check", + prompt: "Check the browser.", + }, + }, + { + type: "tool-result", + tool_call_id: "tool-spawn-desktop", + tool_name: "spawn_computer_use_agent", + result: { + chat_id: "desktop-child-1", + title: "Visual check", + status: "completed", + }, + }, + ], + }, + ], + queued_messages: [], + has_more: false, + }, + { diffUrl: undefined }, + ), + { + key: ["chat-desktop-enabled"], + data: { enable_desktop: true }, + }, + ], + // The wait_agent arrives via WebSocket so it renders in + // the streaming/running state (no tool-result yet). + webSocket: { + "/chats/": [ + { + event: "message", + data: wrapSSE({ + type: "message_part", + chat_id: CHAT_ID, + message_part: { + part: { + type: "tool-call", + tool_call_id: "tool-wait-desktop", + tool_name: "wait_agent", + args_delta: '{"chat_id":"desktop-child-1"}', + }, + }, + }), + }, + ], + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // The wait_agent card should show "Waiting for" (running state) + // rendered via SubagentTool with VNC preview. + await waitFor(() => { + expect(canvas.getByText(/Waiting for/)).toBeInTheDocument(); + }); + }, +}; diff --git a/site/src/pages/AgentsPage/components/AgentDetail/ConversationTimeline.tsx b/site/src/pages/AgentsPage/components/AgentDetail/ConversationTimeline.tsx index 7ae42dede2..11ebdab030 100644 --- a/site/src/pages/AgentsPage/components/AgentDetail/ConversationTimeline.tsx +++ b/site/src/pages/AgentsPage/components/AgentDetail/ConversationTimeline.tsx @@ -249,6 +249,8 @@ const BlockList: FC<{ keyPrefix: string; isStreaming?: boolean; subagentTitles?: Map; + computerUseSubagentIds?: Set; + showDesktopPreviews?: boolean; subagentStatusOverrides?: Map; mcpServers?: readonly TypesGen.MCPServerConfig[]; onImageClick?: (src: string) => void; @@ -260,6 +262,8 @@ const BlockList: FC<{ keyPrefix, isStreaming = false, subagentTitles, + computerUseSubagentIds, + showDesktopPreviews, subagentStatusOverrides, mcpServers, onImageClick, @@ -353,6 +357,8 @@ const BlockList: FC<{ status={tool.status} isError={tool.isError} subagentTitles={subagentTitles} + computerUseSubagentIds={computerUseSubagentIds} + showDesktopPreviews={showDesktopPreviews} subagentStatusOverrides={ isStreaming ? subagentStatusOverrides : undefined } @@ -391,6 +397,8 @@ const BlockList: FC<{ status={tool.status} isError={tool.isError} subagentTitles={subagentTitles} + computerUseSubagentIds={computerUseSubagentIds} + showDesktopPreviews={showDesktopPreviews} subagentStatusOverrides={ isStreaming ? subagentStatusOverrides : undefined } @@ -401,7 +409,6 @@ const BlockList: FC<{ ); }; - const ChatMessageItem = memo<{ message: TypesGen.ChatMessage; parsed: ParsedMessageContent; @@ -420,6 +427,8 @@ const ChatMessageItem = memo<{ urlTransform?: UrlTransform; mcpServers?: readonly TypesGen.MCPServerConfig[]; subagentTitles?: Map; + computerUseSubagentIds?: Set; + showDesktopPreviews?: boolean; }>( ({ message, @@ -432,6 +441,8 @@ const ChatMessageItem = memo<{ urlTransform, mcpServers, subagentTitles, + computerUseSubagentIds, + showDesktopPreviews, }) => { const isUser = message.role === "user"; const isSavingMessage = savingMessageId === message.id; @@ -622,6 +633,8 @@ const ChatMessageItem = memo<{ tools={parsed.tools} keyPrefix={String(message.id)} subagentTitles={subagentTitles} + computerUseSubagentIds={computerUseSubagentIds} + showDesktopPreviews={showDesktopPreviews} onImageClick={setPreviewImage} onTextFileClick={setPreviewText} urlTransform={urlTransform} @@ -663,6 +676,7 @@ export const StreamingOutput: FC<{ streamState: StreamState | null; streamTools: readonly MergedTool[]; subagentTitles?: Map; + computerUseSubagentIds?: Set; subagentStatusOverrides?: Map; liveStatus: LiveStatusModel; startingResetKey?: string; @@ -672,6 +686,7 @@ export const StreamingOutput: FC<{ streamState, streamTools, subagentTitles, + computerUseSubagentIds, subagentStatusOverrides, liveStatus, startingResetKey, @@ -705,6 +720,7 @@ export const StreamingOutput: FC<{ keyPrefix="stream" isStreaming={isStreaming} subagentTitles={subagentTitles} + computerUseSubagentIds={computerUseSubagentIds} subagentStatusOverrides={subagentStatusOverrides} urlTransform={urlTransform} mcpServers={mcpServers} @@ -1019,6 +1035,8 @@ interface ConversationTimelineProps { savingMessageId?: number | null; urlTransform?: UrlTransform; mcpServers?: readonly TypesGen.MCPServerConfig[]; + computerUseSubagentIds?: Set; + showDesktopPreviews?: boolean; } export const ConversationTimeline: FC = ({ @@ -1028,6 +1046,8 @@ export const ConversationTimeline: FC = ({ savingMessageId, urlTransform, mcpServers, + computerUseSubagentIds, + showDesktopPreviews, }) => { const subagentTitles = buildSubagentTitles(parsedMessages); @@ -1074,6 +1094,8 @@ export const ConversationTimeline: FC = ({ isAfterEditingMessage={afterEditingMessageIds.has(message.id)} mcpServers={mcpServers} subagentTitles={subagentTitles} + computerUseSubagentIds={computerUseSubagentIds} + showDesktopPreviews={showDesktopPreviews} /> ), )} diff --git a/site/src/pages/AgentsPage/components/AgentDetail/LiveStreamTail.tsx b/site/src/pages/AgentsPage/components/AgentDetail/LiveStreamTail.tsx index 220f042e10..90cb828b3a 100644 --- a/site/src/pages/AgentsPage/components/AgentDetail/LiveStreamTail.tsx +++ b/site/src/pages/AgentsPage/components/AgentDetail/LiveStreamTail.tsx @@ -36,6 +36,7 @@ interface LiveStreamTailContentProps { liveStatus: LiveStatusModel; startingResetKey?: string; subagentTitles: Map; + computerUseSubagentIds?: Set; subagentStatusOverrides: Map; urlTransform?: UrlTransform; mcpServers?: readonly TypesGen.MCPServerConfig[]; @@ -48,6 +49,7 @@ export const LiveStreamTailContent = ({ liveStatus, startingResetKey, subagentTitles, + computerUseSubagentIds, subagentStatusOverrides, urlTransform, mcpServers, @@ -81,6 +83,7 @@ export const LiveStreamTailContent = ({ liveStatus={liveStatus} startingResetKey={startingResetKey} subagentTitles={subagentTitles} + computerUseSubagentIds={computerUseSubagentIds} subagentStatusOverrides={subagentStatusOverrides} urlTransform={urlTransform} mcpServers={mcpServers} @@ -111,6 +114,7 @@ interface LiveStreamTailProps { isTranscriptEmpty: boolean; startingResetKey?: string; subagentTitles: Map; + computerUseSubagentIds?: Set; urlTransform?: UrlTransform; mcpServers?: readonly TypesGen.MCPServerConfig[]; } @@ -121,6 +125,7 @@ export const LiveStreamTail = ({ isTranscriptEmpty, startingResetKey, subagentTitles, + computerUseSubagentIds, urlTransform, mcpServers, }: LiveStreamTailProps) => { @@ -157,6 +162,7 @@ export const LiveStreamTail = ({ liveStatus={liveStatus} startingResetKey={startingResetKey} subagentTitles={subagentTitles} + computerUseSubagentIds={computerUseSubagentIds} subagentStatusOverrides={subagentStatusOverrides} urlTransform={urlTransform} mcpServers={mcpServers} diff --git a/site/src/pages/AgentsPage/components/AgentDetail/messageParsing.test.ts b/site/src/pages/AgentsPage/components/AgentDetail/messageParsing.test.ts index 0a2e1de5d7..785ee3a510 100644 --- a/site/src/pages/AgentsPage/components/AgentDetail/messageParsing.test.ts +++ b/site/src/pages/AgentsPage/components/AgentDetail/messageParsing.test.ts @@ -45,6 +45,13 @@ describe("parseToolResultIsError", () => { { status: "completed" }, ), ).toBe(false); + expect( + parseToolResultIsError( + "spawn_computer_use_agent", + { error: "metadata" }, + { status: "completed" }, + ), + ).toBe(false); }); }); diff --git a/site/src/pages/AgentsPage/components/AgentDetail/messageParsing.ts b/site/src/pages/AgentsPage/components/AgentDetail/messageParsing.ts index df4f3e134a..762f3adf8d 100644 --- a/site/src/pages/AgentsPage/components/AgentDetail/messageParsing.ts +++ b/site/src/pages/AgentsPage/components/AgentDetail/messageParsing.ts @@ -19,7 +19,10 @@ const appendText = (current: string, next: string): string => { }; const isSubagentToolName = (name: string): boolean => - name === "spawn_agent" || name === "wait_agent" || name === "message_agent"; + name === "spawn_agent" || + name === "spawn_computer_use_agent" || + name === "wait_agent" || + name === "message_agent"; const isCompletedSubagentResult = ( toolName: string, @@ -264,7 +267,10 @@ export const buildSubagentTitles = ( const map = new Map(); for (const { parsed } of parsedMessages) { for (const tool of parsed.tools) { - if (tool.name !== "spawn_agent") { + if ( + tool.name !== "spawn_agent" && + tool.name !== "spawn_computer_use_agent" + ) { continue; } const rec = asRecord(tool.result); @@ -280,3 +286,25 @@ export const buildSubagentTitles = ( } return map; }; + +export const buildComputerUseSubagentIds = ( + parsedMessages: readonly ParsedMessageEntry[], +): Set => { + const ids = new Set(); + for (const { parsed } of parsedMessages) { + for (const tool of parsed.tools) { + if (tool.name !== "spawn_computer_use_agent") { + continue; + } + const rec = asRecord(tool.result); + if (!rec) { + continue; + } + const chatId = asString(rec.chat_id); + if (chatId) { + ids.add(chatId); + } + } + } + return ids; +}; diff --git a/site/src/pages/AgentsPage/components/AgentDetailContent.tsx b/site/src/pages/AgentsPage/components/AgentDetailContent.tsx index a72a73d698..43a69f9270 100644 --- a/site/src/pages/AgentsPage/components/AgentDetailContent.tsx +++ b/site/src/pages/AgentsPage/components/AgentDetailContent.tsx @@ -24,6 +24,7 @@ import { ConversationTimeline } from "./AgentDetail/ConversationTimeline"; import { getLatestContextUsage } from "./AgentDetail/chatHelpers"; import { LiveStreamTail } from "./AgentDetail/LiveStreamTail"; import { + buildComputerUseSubagentIds, buildSubagentTitles, parseMessagesWithMergedTools, } from "./AgentDetail/messageParsing"; @@ -68,11 +69,17 @@ export const AgentDetailTimeline: FC = ({ .filter(isChatMessage); const parsedMessages = parseMessagesWithMergedTools(messages); const subagentTitles = buildSubagentTitles(parsedMessages); + const computerUseSubagentIds = buildComputerUseSubagentIds(parsedMessages); const onRenderProfiler = useOnRenderProfiler(); return (
+ {/* VNC sessions for completed agents may already be + terminated, so inline desktop previews are disabled + via showDesktopPreviews={false} to avoid a perpetual + "disconnected" state. The MonitorIcon variant still + renders correctly. */} = ({ savingMessageId={savingMessageId} urlTransform={urlTransform} mcpServers={mcpServers} + computerUseSubagentIds={computerUseSubagentIds} + showDesktopPreviews={false} /> = ({ startingResetKey={chatID} isTranscriptEmpty={parsedMessages.length === 0} subagentTitles={subagentTitles} + computerUseSubagentIds={computerUseSubagentIds} urlTransform={urlTransform} mcpServers={mcpServers} /> diff --git a/site/src/pages/AgentsPage/components/AgentDetailView.tsx b/site/src/pages/AgentsPage/components/AgentDetailView.tsx index b1e4dc8eb6..b4d7a35c74 100644 --- a/site/src/pages/AgentsPage/components/AgentDetailView.tsx +++ b/site/src/pages/AgentsPage/components/AgentDetailView.tsx @@ -6,6 +6,7 @@ import { pageTitle } from "utils/page"; import type * as TypesGen from "#/api/typesGenerated"; import type { ChatDiffStatus, ChatMessagePart } from "#/api/typesGenerated"; import type { ModelSelectorOption } from "#/components/ai-elements"; +import { DesktopPanelContext } from "#/components/ai-elements/tool/DesktopPanelContext"; import { Button } from "#/components/Button/Button"; import type { ChatDetailError } from "../utils/usageLimitMessage"; import { AgentChatInput, type ChatMessageInputRef } from "./AgentChatInput"; @@ -187,6 +188,20 @@ export const AgentDetailView: FC = ({ ); const visualExpanded = dragVisualExpanded ?? isRightPanelExpanded; + // State for programmatically switching the sidebar tab (e.g. when + // the user clicks the inline desktop preview card). + const [sidebarTabId, setSidebarTabId] = useState(null); + + const handleOpenDesktop = () => { + onSetShowSidebarPanel(true); + setSidebarTabId("desktop"); + }; + + const desktopPanelCtx = { + desktopChatId, + onOpenDesktop: desktopChatId ? handleOpenDesktop : undefined, + }; + // Compute local diff stats from git watcher unified diffs. const titleElement = ( @@ -198,155 +213,161 @@ export const AgentDetailView: FC = ({ const shouldShowSidebar = showSidebarPanel; return ( -
- {titleElement} +
-
- onSetShowSidebarPanel((prev) => !prev), - }} - workspace={{ - canOpenEditors, - canOpenWorkspace, - onOpenInEditor: handleOpenInEditor, - onViewWorkspace: handleViewWorkspace, - onOpenTerminal: handleOpenTerminal, - sshCommand, - }} - onArchiveAgent={handleArchiveAgentAction} - onUnarchiveAgent={handleUnarchiveAgentAction} - onArchiveAndDeleteWorkspace={handleArchiveAndDeleteWorkspaceAction} - hasWorkspace={hasWorkspace} - isArchived={isArchived} - diffStatusData={diffStatusData} - isSidebarCollapsed={isSidebarCollapsed} - onToggleSidebarCollapsed={onToggleSidebarCollapsed} - /> - {isArchived && ( -
- - This agent has been archived and is read-only. -
+ {titleElement} +
-
- -
- + onSetShowSidebarPanel((prev) => !prev), + }} + workspace={{ + canOpenEditors, + canOpenWorkspace, + onOpenInEditor: handleOpenInEditor, + onViewWorkspace: handleViewWorkspace, + onOpenTerminal: handleOpenTerminal, + sshCommand, + }} + onArchiveAgent={handleArchiveAgentAction} + onUnarchiveAgent={handleUnarchiveAgentAction} + onArchiveAndDeleteWorkspace={ + handleArchiveAndDeleteWorkspaceAction + } + hasWorkspace={hasWorkspace} + isArchived={isArchived} + diffStatusData={diffStatusData} + isSidebarCollapsed={isSidebarCollapsed} + onToggleSidebarCollapsed={onToggleSidebarCollapsed} + /> + {isArchived && ( +
+ + This agent has been archived and is read-only. +
+ )} +
+
+ +
+ +
+
+
+
- -
-
-
- setIsRightPanelExpanded((prev) => !prev)} - onClose={() => onSetShowSidebarPanel(false)} - onVisualExpandedChange={setDragVisualExpanded} - isSidebarCollapsed={isSidebarCollapsed} - onToggleSidebarCollapsed={onToggleSidebarCollapsed} - > - - ), - }, - ]} - onClose={() => onSetShowSidebarPanel(false)} - isExpanded={visualExpanded} + setIsRightPanelExpanded((prev) => !prev)} + onClose={() => onSetShowSidebarPanel(false)} + onVisualExpandedChange={setDragVisualExpanded} isSidebarCollapsed={isSidebarCollapsed} onToggleSidebarCollapsed={onToggleSidebarCollapsed} - chatTitle={chatTitle} - desktopChatId={desktopChatId} - /> - -
+ > + + ), + }, + ]} + onClose={() => onSetShowSidebarPanel(false)} + isExpanded={visualExpanded} + onToggleExpanded={() => setIsRightPanelExpanded((prev) => !prev)} + isSidebarCollapsed={isSidebarCollapsed} + onToggleSidebarCollapsed={onToggleSidebarCollapsed} + chatTitle={chatTitle} + desktopChatId={desktopChatId} + /> + +
+
); }; diff --git a/site/src/pages/AgentsPage/components/Sidebar/SidebarTabView.stories.tsx b/site/src/pages/AgentsPage/components/Sidebar/SidebarTabView.stories.tsx index 01b20178f2..d76979dcea 100644 --- a/site/src/pages/AgentsPage/components/Sidebar/SidebarTabView.stories.tsx +++ b/site/src/pages/AgentsPage/components/Sidebar/SidebarTabView.stories.tsx @@ -36,6 +36,8 @@ const meta: Meta = { component: SidebarTabView, args: { tabs: [gitTab], + activeTabId: "git", + onActiveTabChange: fn(), isExpanded: false, onToggleExpanded: fn(), }, diff --git a/site/src/pages/AgentsPage/components/Sidebar/SidebarTabView.tsx b/site/src/pages/AgentsPage/components/Sidebar/SidebarTabView.tsx index 2b7c507f6a..c080e097ae 100644 --- a/site/src/pages/AgentsPage/components/Sidebar/SidebarTabView.tsx +++ b/site/src/pages/AgentsPage/components/Sidebar/SidebarTabView.tsx @@ -42,6 +42,10 @@ interface SidebarTabViewProps { onClose?: () => void; /** Desktop chat ID. Omitted if desktop is not available. */ desktopChatId?: string; + /** The currently active tab ID (controlled by the parent). */ + activeTabId: string | null; + /** Called when the user switches tabs. */ + onActiveTabChange: (tabId: string) => void; } /** How far (px) each chevron click scrolls the tab strip. */ @@ -107,12 +111,10 @@ export const SidebarTabView: FC = ({ chatTitle, onClose, desktopChatId, + activeTabId, + onActiveTabChange, }) => { const tabIdPrefix = useId(); - const [activeTabId, setActiveTabId] = useState( - tabs.length > 0 ? tabs[0].id : null, - ); - // Build the full list of tab IDs including the desktop tab // so that effectiveTabId validation covers it. const allTabIds = new Set(tabs.map((t) => t.id)); @@ -227,7 +229,7 @@ export const SidebarTabView: FC = ({ id={`${tabIdPrefix}-tab-${tab.id}`} role="tab" aria-selected={isActive} - onClick={() => setActiveTabId(tab.id)} + onClick={() => onActiveTabChange(tab.id)} variant="outline" size="lg" className={cn( @@ -257,7 +259,7 @@ export const SidebarTabView: FC = ({ id={`${tabIdPrefix}-tab-desktop`} role="tab" aria-selected={effectiveTabId === "desktop"} - onClick={() => setActiveTabId("desktop")} + onClick={() => onActiveTabChange("desktop")} variant="outline" size="lg" className={cn( diff --git a/site/src/pages/AgentsPage/hooks/useDesktopConnection.ts b/site/src/pages/AgentsPage/hooks/useDesktopConnection.ts index 7a30da349f..cde6df86b8 100644 --- a/site/src/pages/AgentsPage/hooks/useDesktopConnection.ts +++ b/site/src/pages/AgentsPage/hooks/useDesktopConnection.ts @@ -15,7 +15,7 @@ type DesktopConnectionStatus = | "disconnected" | "error"; -interface UseDesktopConnectionResult { +export interface UseDesktopConnectionResult { /** Current connection status. */ status: DesktopConnectionStatus; /** Whether the connection has ever been established. */