Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
16 changes: 16 additions & 0 deletions interface/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: string;
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;
}
Expand Down Expand Up @@ -267,6 +282,7 @@ export type ApiEvent =
| BranchCompletedEvent
| ToolStartedEvent
| ToolCompletedEvent
| ToolOutputEvent
| OpenCodePartUpdatedEvent
| WorkerTextEvent
| CortexChatMessageEvent;
Expand Down
78 changes: 69 additions & 9 deletions interface/src/components/ToolCall.tsx
Original file line number Diff line number Diff line change
@@ -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 */
Expand All @@ -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;
}

// ---------------------------------------------------------------------------
Expand All @@ -42,12 +56,21 @@ export type TranscriptItem =

export function pairTranscriptSteps(steps: TranscriptStep[]): TranscriptItem[] {
const items: TranscriptItem[] = [];
const resultsById = new Map<string, {name: string; text: string}>();
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,
});
}
}

Expand All @@ -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",
Expand All @@ -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,
},
});
}
Expand Down Expand Up @@ -977,12 +1010,14 @@ const STATUS_ICONS: Record<ToolCallStatus, string> = {
running: "\u25B6", // ▶
completed: "\u2713", // ✓
error: "\u2717", // ✗
waiting_for_input: "!",
};

const STATUS_COLORS: Record<ToolCallStatus, string> = {
running: "text-accent",
completed: "text-status-success",
error: "text-status-error",
waiting_for_input: "text-blue-500",
};

/** Human-readable tool name: browser_navigate → Navigate */
Expand Down Expand Up @@ -1031,7 +1066,11 @@ export function ToolCall({pair}: {pair: ToolCallPair}) {
<div
className={cx(
"rounded-md border bg-app-dark-box/30",
pair.status === "error" ? "border-status-error/30" : "border-app-line/50",
pair.status === "error"
? "border-status-error/30"
: pair.status === "waiting_for_input"
? "border-blue-500/30"
: "border-app-line/50",
)}
>
{/* Header — always visible */}
Expand All @@ -1057,6 +1096,9 @@ export function ToolCall({pair}: {pair: ToolCallPair}) {
{pair.status === "running" && (
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-accent" />
)}
{pair.status === "waiting_for_input" && !expanded && (
<span className="text-tiny text-blue-500">Waiting for input</span>
)}
</button>

{/* Expanded body */}
Expand Down Expand Up @@ -1118,6 +1160,15 @@ function renderResult(
renderer: ToolRenderer,
): React.ReactNode {
if (pair.status === "running") {
if (pair.liveOutput) {
return (
<div className="px-3 py-2">
<pre className="max-h-60 overflow-auto whitespace-pre-wrap font-mono text-tiny text-ink-dull">
{pair.liveOutput}
</pre>
</div>
);
}
return (
<div className="flex items-center gap-2 px-3 py-2 text-tiny text-ink-faint">
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-accent" />
Expand All @@ -1126,6 +1177,15 @@ function renderResult(
);
}

if (pair.status === "waiting_for_input" && !pair.resultRaw) {
return (
<div className="flex items-center gap-2 px-3 py-2 text-tiny text-blue-500">
<span className="h-1.5 w-1.5 rounded-full bg-blue-500" />
Waiting for input
</div>
);
}

// Try custom result view first
if (renderer.resultView) {
const custom = renderer.resultView(pair);
Expand Down
8 changes: 5 additions & 3 deletions interface/src/hooks/useChannelLiveState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -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() };
Expand Down
Loading
Loading