diff --git a/interface/src/api/client.ts b/interface/src/api/client.ts index 97679dd47..51f4038f8 100644 --- a/interface/src/api/client.ts +++ b/interface/src/api/client.ts @@ -195,16 +195,31 @@ export interface ToolStartedEvent { channel_id: string | null; process_type: ProcessType; process_id: string; + call_id: string; tool_name: string; args: string; } +export interface ToolOutputEvent { + type: "tool_output"; + agent_id: string; + channel_id: string | null; + process_type: ProcessType; + process_id: string; + /** Stable identifier matching the tool_call that initiated this stream. */ + call_id: string; + tool_name: string; + line: string; + stream: "stdout" | "stderr"; +} + export interface ToolCompletedEvent { type: "tool_completed"; agent_id: string; channel_id: string | null; process_type: ProcessType; process_id: string; + call_id: string; tool_name: string; result: string; } @@ -267,6 +282,7 @@ export type ApiEvent = | BranchCompletedEvent | ToolStartedEvent | ToolCompletedEvent + | ToolOutputEvent | OpenCodePartUpdatedEvent | WorkerTextEvent | CortexChatMessageEvent; diff --git a/interface/src/components/ToolCall.tsx b/interface/src/components/ToolCall.tsx index 531f01296..f2ee1658c 100644 --- a/interface/src/components/ToolCall.tsx +++ b/interface/src/components/ToolCall.tsx @@ -1,12 +1,24 @@ import {useState} from "react"; import {cx} from "class-variance-authority"; -import type {TranscriptStep, OpenCodePart} from "@/api/client"; +import type {OpenCodePart} from "@/api/client"; +import type { TranscriptStep as SchemaTranscriptStep } from "@/api/types"; + +// Extended TranscriptStep with live_output for streaming shell output +type ToolResultStatus = "pending" | "final" | "waiting_for_input"; + +type ExtendedTranscriptStep = SchemaTranscriptStep & { + live_output?: string; + status?: ToolResultStatus; +}; + +// Use the extended type for pairing +type TranscriptStep = ExtendedTranscriptStep; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- -export type ToolCallStatus = "running" | "completed" | "error"; +export type ToolCallStatus = "running" | "completed" | "error" | "waiting_for_input"; export interface ToolCallPair { /** The call_id linking tool_call to tool_result */ @@ -25,6 +37,8 @@ export interface ToolCallPair { status: ToolCallStatus; /** Human-readable summary provided by live opencode parts */ title?: string | null; + /** Live streaming output from tool_output SSE events (running tools only) */ + liveOutput?: string; } // --------------------------------------------------------------------------- @@ -42,12 +56,21 @@ export type TranscriptItem = export function pairTranscriptSteps(steps: TranscriptStep[]): TranscriptItem[] { const items: TranscriptItem[] = []; - const resultsById = new Map(); + const resultsById = new Map< + string, + {name: string; text: string; status: ToolResultStatus; liveOutput?: string} + >(); // First pass: index all tool_result steps by call_id for (const step of steps) { if (step.type === "tool_result") { - resultsById.set(step.call_id, {name: step.name, text: step.text}); + const liveOutput = step.live_output; + resultsById.set(step.call_id, { + name: step.name, + text: step.text, + status: step.status ?? "final", + liveOutput, + }); } } @@ -60,12 +83,21 @@ export function pairTranscriptSteps(steps: TranscriptStep[]): TranscriptItem[] { } else if (content.type === "tool_call") { const result = resultsById.get(content.id); const parsedArgs = tryParseJson(content.args); - const parsedResult = result ? tryParseJson(result.text) : null; + const resultStatus = result?.status ?? "final"; + const hasFinalResult = !!result && resultStatus !== "pending"; + const parsedResult = hasFinalResult ? tryParseJson(result.text) : null; // Detect error: result text starts with "Error" or contains error indicators - const isError = result + const isError = hasFinalResult ? isErrorResult(result.text, parsedResult) : false; + const status: ToolCallStatus = resultStatus === "pending" + ? "running" + : resultStatus === "waiting_for_input" + ? "waiting_for_input" + : isError + ? "error" + : "completed"; items.push({ kind: "tool", @@ -74,9 +106,10 @@ export function pairTranscriptSteps(steps: TranscriptStep[]): TranscriptItem[] { name: content.name, argsRaw: content.args, args: parsedArgs, - resultRaw: result?.text ?? null, + resultRaw: hasFinalResult ? result.text : null, result: parsedResult, - status: result ? (isError ? "error" : "completed") : "running", + status, + liveOutput: result?.liveOutput, }, }); } @@ -977,12 +1010,14 @@ const STATUS_ICONS: Record = { running: "\u25B6", // ▶ completed: "\u2713", // ✓ error: "\u2717", // ✗ + waiting_for_input: "!", }; const STATUS_COLORS: Record = { running: "text-accent", completed: "text-status-success", error: "text-status-error", + waiting_for_input: "text-blue-500", }; /** Human-readable tool name: browser_navigate → Navigate */ @@ -1031,7 +1066,11 @@ export function ToolCall({pair}: {pair: ToolCallPair}) {
{/* Header — always visible */} @@ -1057,6 +1096,9 @@ export function ToolCall({pair}: {pair: ToolCallPair}) { {pair.status === "running" && ( )} + {pair.status === "waiting_for_input" && !expanded && ( + Waiting for input + )} {/* Expanded body */} @@ -1118,6 +1160,15 @@ function renderResult( renderer: ToolRenderer, ): React.ReactNode { if (pair.status === "running") { + if (pair.liveOutput) { + return ( +
+
+						{pair.liveOutput}
+					
+
+ ); + } return (
@@ -1126,6 +1177,15 @@ function renderResult( ); } + if (pair.status === "waiting_for_input" && !pair.resultRaw) { + return ( +
+ + Waiting for input +
+ ); + } + // Try custom result view first if (renderer.resultView) { const custom = renderer.resultView(pair); diff --git a/interface/src/hooks/useChannelLiveState.ts b/interface/src/hooks/useChannelLiveState.ts index 912ea71f8..97401144b 100644 --- a/interface/src/hooks/useChannelLiveState.ts +++ b/interface/src/hooks/useChannelLiveState.ts @@ -614,7 +614,7 @@ export function useChannelLiveState(channels: ChannelInfo[]) { // Skip conversation/routing tools — they're infrastructure, not user-visible. if (HIDDEN_CHANNEL_TOOLS.has(event.tool_name)) return; - const toolCallId = `tool-${generateId()}`; + const toolCallId = event.call_id || `tool-${generateId()}`; const queueKey = `${channelId}:${event.tool_name}`; const queue = pendingChannelToolCallsRef.current[queueKey] ?? []; pendingChannelToolCallsRef.current[queueKey] = [...queue, toolCallId]; @@ -710,9 +710,11 @@ export function useChannelLiveState(channels: ChannelInfo[]) { if (channelId && event.process_type === "channel") { const queueKey = `${channelId}:${event.tool_name}`; const queue = pendingChannelToolCallsRef.current[queueKey] ?? []; - const toolCallId = queue[0]; + const toolCallId = event.call_id || queue[0]; if (toolCallId) { - pendingChannelToolCallsRef.current[queueKey] = queue.slice(1); + pendingChannelToolCallsRef.current[queueKey] = queue.filter( + (id) => id !== toolCallId, + ); updateItem(channelId, toolCallId, (item) => { if (item.type !== "tool_call_run") return item; return { ...item, result: event.result, status: "completed", completed_at: new Date().toISOString() }; diff --git a/interface/src/hooks/useLiveContext.tsx b/interface/src/hooks/useLiveContext.tsx index 9eebc48ad..4758bc8bb 100644 --- a/interface/src/hooks/useLiveContext.tsx +++ b/interface/src/hooks/useLiveContext.tsx @@ -1,7 +1,15 @@ import { createContext, useContext, useCallback, useEffect, useRef, useState, useMemo, type ReactNode } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { api, type AgentMessageEvent, type ChannelInfo, type ToolStartedEvent, type ToolCompletedEvent, type TranscriptStep, type OpenCodePart, type OpenCodePartUpdatedEvent, type WorkerTextEvent } from "@/api/client"; -import { generateId } from "@/lib/id"; +import { api, type AgentMessageEvent, type ChannelInfo, type ToolStartedEvent, type ToolCompletedEvent, type ToolOutputEvent, type OpenCodePart, type OpenCodePartUpdatedEvent, type WorkerTextEvent } from "@/api/client"; +import type { TranscriptStep as SchemaTranscriptStep } from "@/api/types"; + +type ToolResultStatus = "pending" | "final" | "waiting_for_input"; + +/** Extended TranscriptStep with live_output for streaming shell output */ +type TranscriptStep = SchemaTranscriptStep & { + live_output?: string; + status?: ToolResultStatus; +}; import { useEventSource, type ConnectionState } from "@/hooks/useEventSource"; import { useChannelLiveState, type ChannelLiveState, type ActiveWorker } from "@/hooks/useChannelLiveState"; import { useServer } from "@/hooks/useServer"; @@ -48,6 +56,22 @@ export function useLiveContext() { /** Duration (ms) an edge stays "active" after a message flows through it. */ const LINK_ACTIVE_DURATION = 3000; +function toolResultStatusFromText(result: string): ToolResultStatus { + if ( + result.includes('"waiting_for_input":true') || + result.includes('"waiting_for_input": true') || + result.includes("Command appears to be waiting for interactive input.") + ) { + return "waiting_for_input"; + } + return "final"; +} + +function appendLiveOutput(existingOutput: string | undefined, line: string) { + if (!existingOutput) return `${line}\n`; + return `${existingOutput}${line}\n`; +} + export function LiveContextProvider({ children, onBootstrapped }: { children: ReactNode; onBootstrapped?: () => void }) { const queryClient = useQueryClient(); @@ -78,8 +102,6 @@ export function LiveContextProvider({ children, onBootstrapped }: { children: Re const [liveOpenCodeParts, setLiveOpenCodeParts] = useState>>({}); // Derive flat active workers from channel live states - const pendingToolCallIdsRef = useRef>>({}); - const activeWorkers = useMemo(() => { const channelAgentIds = new Map(channels.map((channel) => [channel.id, channel.agent_id])); const map: Record = {}; @@ -143,7 +165,6 @@ export function LiveContextProvider({ children, onBootstrapped }: { children: Re const event = data as { worker_id: string }; setLiveTranscripts((prev) => ({ ...prev, [event.worker_id]: [] })); setLiveOpenCodeParts((prev) => ({ ...prev, [event.worker_id]: new Map() })); - delete pendingToolCallIdsRef.current[event.worker_id]; bumpWorkerVersion(); }, [channelHandlers, bumpWorkerVersion]); @@ -163,7 +184,6 @@ export function LiveContextProvider({ children, onBootstrapped }: { children: Re const wrappedWorkerCompleted = useCallback((data: unknown) => { channelHandlers.worker_completed(data); const event = data as { worker_id: string }; - delete pendingToolCallIdsRef.current[event.worker_id]; // Clean up live OpenCode parts — persisted transcript takes over setLiveOpenCodeParts((prev) => { const next = { ...prev }; @@ -177,13 +197,25 @@ export function LiveContextProvider({ children, onBootstrapped }: { children: Re channelHandlers.tool_started(data); const event = data as ToolStartedEvent; if (event.process_type === "worker") { - const callId = generateId(); - const pendingByTool = pendingToolCallIdsRef.current[event.process_id] ?? {}; - const queue = pendingByTool[event.tool_name] ?? []; - pendingByTool[event.tool_name] = [...queue, callId]; - pendingToolCallIdsRef.current[event.process_id] = pendingByTool; + const callId = event.call_id || `${event.process_id}:${event.tool_name}:started`; setLiveTranscripts((prev) => { const steps = prev[event.process_id] ?? []; + const pendingResultIndexByCallId = steps.findIndex( + (step) => step.type === "tool_result" && step.call_id === callId, + ); + const pendingResultIndex = pendingResultIndexByCallId >= 0 + ? pendingResultIndexByCallId + : steps.findIndex( + (step) => + step.type === "tool_result" && + step.name === event.tool_name && + (step as TranscriptStep).status === "pending" && + step.text === "", + ); + const pendingResult = pendingResultIndex >= 0 ? steps[pendingResultIndex] : null; + const nextSteps = pendingResult + ? steps.filter((_, index) => index !== pendingResultIndex) + : steps; const step: TranscriptStep = { type: "action", content: [{ @@ -193,7 +225,15 @@ export function LiveContextProvider({ children, onBootstrapped }: { children: Re args: event.args || "", }], }; - return { ...prev, [event.process_id]: [...steps, step] }; + const retargetedResult = pendingResult + ? {...pendingResult, call_id: callId} + : null; + return { + ...prev, + [event.process_id]: retargetedResult + ? [...nextSteps, step, retargetedResult] + : [...nextSteps, step], + }; }); bumpWorkerVersion(); } @@ -203,33 +243,69 @@ export function LiveContextProvider({ children, onBootstrapped }: { children: Re channelHandlers.tool_completed(data); const event = data as ToolCompletedEvent; if (event.process_type === "worker") { - const pendingByTool = pendingToolCallIdsRef.current[event.process_id]; - const queue = pendingByTool?.[event.tool_name] ?? []; - const [callId, ...rest] = queue; - if (pendingByTool) { - if (rest.length > 0) { - pendingByTool[event.tool_name] = rest; - } else { - delete pendingByTool[event.tool_name]; - } - if (Object.keys(pendingByTool).length === 0) { - delete pendingToolCallIdsRef.current[event.process_id]; - } - } + const callId = event.call_id || `${event.process_id}:${event.tool_name}:completed`; setLiveTranscripts((prev) => { const steps = prev[event.process_id] ?? []; + const existingIndex = steps.findIndex( + (step) => step.type === "tool_result" && step.call_id === callId, + ); const step: TranscriptStep = { type: "tool_result", - call_id: callId ?? `${event.process_id}:${event.tool_name}:${steps.length}`, + call_id: callId, name: event.tool_name, text: event.result || "", + status: toolResultStatusFromText(event.result || ""), }; + if (existingIndex >= 0) { + const newSteps = [...steps]; + newSteps[existingIndex] = step; + return { ...prev, [event.process_id]: newSteps }; + } return { ...prev, [event.process_id]: [...steps, step] }; }); bumpWorkerVersion(); } }, [channelHandlers, bumpWorkerVersion]); + const handleToolOutput = useCallback((data: unknown) => { + const event = data as ToolOutputEvent; + if (event.process_type === "worker") { + setLiveTranscripts((prev) => { + const steps = prev[event.process_id] ?? []; + // Use the stable call_id from the event to find or create the result step + const existingIndex = steps.findIndex( + (s) => s.type === "tool_result" && s.call_id === event.call_id + ); + if (existingIndex >= 0) { + // Append to existing result step with buffer size limit + const step = steps[existingIndex]; + const existingOutput = (step as TranscriptStep).live_output ?? ""; + const combined = appendLiveOutput(existingOutput, event.line); + // Cap at ~50KB to prevent unbounded growth during long-running commands + const MAX_LIVE_OUTPUT_SIZE = 50000; + const newOutput = combined.length > MAX_LIVE_OUTPUT_SIZE + ? combined.slice(-MAX_LIVE_OUTPUT_SIZE) + : combined; + const updatedStep = { ...step, live_output: newOutput, status: "pending" as const }; + const newSteps = [...steps]; + newSteps[existingIndex] = updatedStep; + return { ...prev, [event.process_id]: newSteps }; + } + // Create new result step with the event's call_id + const step: TranscriptStep = { + type: "tool_result", + call_id: event.call_id, + name: event.tool_name, + text: "", + live_output: appendLiveOutput(undefined, event.line), + status: "pending", + }; + return { ...prev, [event.process_id]: [...steps, step] }; + }); + bumpWorkerVersion(); + } + }, [bumpWorkerVersion]); + // Handle OpenCode part updates — upsert parts into the per-worker ordered map const handleOpenCodePartUpdated = useCallback((data: unknown) => { const event = data as OpenCodePartUpdatedEvent; @@ -280,6 +356,7 @@ export function LiveContextProvider({ children, onBootstrapped }: { children: Re worker_completed: wrappedWorkerCompleted, tool_started: wrappedToolStarted, tool_completed: wrappedToolCompleted, + tool_output: handleToolOutput, opencode_part_updated: handleOpenCodePartUpdated, worker_text: handleWorkerText, agent_message_sent: handleAgentMessage, @@ -289,7 +366,7 @@ export function LiveContextProvider({ children, onBootstrapped }: { children: Re notification_created: handleNotificationCreated, notification_updated: handleNotificationUpdated, }), - [channelHandlers, wrappedWorkerStarted, wrappedWorkerStatus, wrappedWorkerIdle, wrappedWorkerCompleted, wrappedToolStarted, wrappedToolCompleted, handleOpenCodePartUpdated, handleWorkerText, handleAgentMessage, bumpTaskVersion, handleCortexChatMessage, handleNotificationCreated, handleNotificationUpdated], + [channelHandlers, wrappedWorkerStarted, wrappedWorkerStatus, wrappedWorkerIdle, wrappedWorkerCompleted, wrappedToolStarted, wrappedToolCompleted, handleToolOutput, handleOpenCodePartUpdated, handleWorkerText, handleAgentMessage, bumpTaskVersion, handleCortexChatMessage, handleNotificationCreated, handleNotificationUpdated], ); const onReconnect = useCallback(() => { diff --git a/interface/src/lib/primitives.tsx b/interface/src/lib/primitives.tsx new file mode 100644 index 000000000..ba41e6984 --- /dev/null +++ b/interface/src/lib/primitives.tsx @@ -0,0 +1,215 @@ +import * as React from "react"; +import {cx} from "class-variance-authority"; +import { + Banner as PrimitiveBanner, + Button as PrimitiveButton, + FilterButton as PrimitiveFilterButton, + NumberStepper as PrimitiveNumberStepper, +} from "@spacedrive/primitives"; + +export * from "@spacedrive/primitives"; + +type LegacyButtonVariant = "ghost" | "secondary" | "destructive"; +type PrimitiveButtonVariant = NonNullable< + React.ComponentProps["variant"] +>; +type PrimitiveButtonSize = React.ComponentProps["size"]; +type PrimitiveButtonRounding = + React.ComponentProps["rounding"]; +type ButtonVariant = PrimitiveButtonVariant | LegacyButtonVariant; +type CompatButtonBaseProps = { + children?: React.ReactNode; + className?: string; + loading?: boolean; + rounding?: PrimitiveButtonRounding; + size?: PrimitiveButtonSize; + variant?: ButtonVariant; +}; +type CompatActionButtonProps = CompatButtonBaseProps & + Omit, "children"> & { + href?: undefined; + }; +type CompatLinkButtonProps = CompatButtonBaseProps & + Omit, "children"> & { + href: string; + }; +type CompatButtonProps = CompatActionButtonProps | CompatLinkButtonProps; + +function hasHref( + props: CompatButtonProps, +): props is CompatLinkButtonProps { + return "href" in props && props.href !== undefined; +} + +function mapButtonVariant( + variant: ButtonVariant | undefined, +): PrimitiveButtonVariant | undefined { + switch (variant) { + case "ghost": + return "subtle"; + case "secondary": + return "gray"; + case "destructive": + return "outline"; + default: + return variant; + } +} + +function legacyButtonClassName(variant: ButtonVariant | undefined) { + if (variant === "destructive") { + return "border-red-500/40 text-red-300 hover:border-red-500/60 hover:bg-red-500/10"; + } + + return undefined; +} + +function LoadingSpinner() { + return ( +