diff --git a/interface/src/api/client.ts b/interface/src/api/client.ts index 2c9130ec7..d2c15719e 100644 --- a/interface/src/api/client.ts +++ b/interface/src/api/client.ts @@ -62,6 +62,7 @@ export interface WorkerStartedEvent { worker_id: string; task: string; worker_type?: string; + interactive?: boolean; } export interface WorkerStatusEvent { @@ -72,6 +73,13 @@ export interface WorkerStatusEvent { status: string; } +export interface WorkerIdleEvent { + type: "worker_idle"; + agent_id: string; + channel_id: string | null; + worker_id: string; +} + export interface WorkerCompletedEvent { type: "worker_completed"; agent_id: string; @@ -117,6 +125,27 @@ export interface ToolCompletedEvent { result: string; } +// -- OpenCode live transcript part types -- + +export type OpenCodeToolState = + | { status: "pending" } + | { status: "running"; title?: string; input?: string } + | { status: "completed"; title?: string; input?: string; output?: string } + | { status: "error"; error?: string }; + +export type OpenCodePart = + | { type: "text"; id: string; text: string } + | { type: "tool"; id: string; tool: string } & OpenCodeToolState + | { type: "step_start"; id: string } + | { type: "step_finish"; id: string; reason?: string }; + +export interface OpenCodePartUpdatedEvent { + type: "opencode_part_updated"; + agent_id: string; + worker_id: string; + part: OpenCodePart; +} + export type ApiEvent = | InboundMessageEvent | OutboundMessageEvent @@ -124,11 +153,13 @@ export type ApiEvent = | TypingStateEvent | WorkerStartedEvent | WorkerStatusEvent + | WorkerIdleEvent | WorkerCompletedEvent | BranchStartedEvent | BranchCompletedEvent | ToolStartedEvent - | ToolCompletedEvent; + | ToolCompletedEvent + | OpenCodePartUpdatedEvent; async function fetchJson(path: string): Promise { const response = await fetch(`${API_BASE}${path}`); @@ -181,6 +212,7 @@ export interface WorkerStatusInfo { started_at: string; notify_on_complete: boolean; tool_calls: number; + interactive: boolean; } export interface BranchStatusInfo { @@ -228,6 +260,8 @@ export interface WorkerRunInfo { has_transcript: boolean; live_status: string | null; tool_calls: number; + opencode_port: number | null; + interactive: boolean; } export interface WorkerDetailResponse { @@ -242,6 +276,9 @@ export interface WorkerDetailResponse { completed_at: string | null; transcript: TranscriptStep[] | null; tool_calls: number; + opencode_session_id: string | null; + opencode_port: number | null; + interactive: boolean; } export interface WorkerListResponse { diff --git a/interface/src/components/ChannelCard.tsx b/interface/src/components/ChannelCard.tsx index 27e7e6084..b49be17de 100644 --- a/interface/src/components/ChannelCard.tsx +++ b/interface/src/components/ChannelCard.tsx @@ -3,19 +3,22 @@ import { AnimatePresence, motion } from "framer-motion"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { api } from "@/api/client"; import type { ChannelInfo } from "@/api/client"; -import type { ActiveBranch, ActiveWorker, ChannelLiveState } from "@/hooks/useChannelLiveState"; +import { isOpenCodeWorker, type ActiveBranch, type ActiveWorker, type ChannelLiveState } from "@/hooks/useChannelLiveState"; import { LiveDuration } from "@/components/LiveDuration"; import { formatTimeAgo, formatTimestamp, platformIcon, platformColor } from "@/lib/format"; const VISIBLE_MESSAGES = 6; function WorkerBadge({ worker }: { worker: ActiveWorker }) { + const oc = isOpenCodeWorker(worker); return ( -
-
+
+
- Worker + Worker {worker.task}
@@ -23,7 +26,7 @@ function WorkerBadge({ worker }: { worker: ActiveWorker }) { {worker.currentTool && ( <> · - {worker.currentTool} + {worker.currentTool} )} {worker.toolCalls > 0 && ( diff --git a/interface/src/components/WebChatPanel.tsx b/interface/src/components/WebChatPanel.tsx index c6899fc1f..ef38c20c3 100644 --- a/interface/src/components/WebChatPanel.tsx +++ b/interface/src/components/WebChatPanel.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from "react"; import { useWebChat } from "@/hooks/useWebChat"; -import type { ActiveWorker } from "@/hooks/useChannelLiveState"; +import { isOpenCodeWorker, type ActiveWorker } from "@/hooks/useChannelLiveState"; import { useLiveContext } from "@/hooks/useLiveContext"; import { Markdown } from "@/components/Markdown"; @@ -11,32 +11,43 @@ interface WebChatPanelProps { function ActiveWorkersPanel({ workers }: { workers: ActiveWorker[] }) { if (workers.length === 0) return null; + // Use neutral chrome when all workers are opencode, amber when all builtin, mixed stays amber + const allOpenCode = workers.every(isOpenCodeWorker); + const borderColor = allOpenCode ? "border-zinc-500/25 bg-zinc-500/5" : "border-amber-500/25 bg-amber-500/5"; + const headerColor = allOpenCode ? "text-zinc-200" : "text-amber-200"; + const dotColor = allOpenCode ? "bg-zinc-400" : "bg-amber-400"; + return ( -
-
-
+
+
+
{workers.length} active worker{workers.length !== 1 ? "s" : ""}
- {workers.map((worker) => ( -
- Worker - - {worker.task} - - {worker.status} - {worker.currentTool && ( - - {worker.currentTool} + {workers.map((worker) => { + const oc = isOpenCodeWorker(worker); + return ( +
+ Worker + + {worker.task} - )} -
- ))} + {worker.status} + {worker.currentTool && ( + + {worker.currentTool} + + )} +
+ ); + })}
); diff --git a/interface/src/hooks/useChannelLiveState.ts b/interface/src/hooks/useChannelLiveState.ts index 4db1a0fb2..40f73bc7b 100644 --- a/interface/src/hooks/useChannelLiveState.ts +++ b/interface/src/hooks/useChannelLiveState.ts @@ -14,6 +14,7 @@ import { type WorkerCompletedEvent, type WorkerStartedEvent, type WorkerStatusEvent, + type WorkerIdleEvent, type ChannelInfo, } from "../api/client"; @@ -24,6 +25,17 @@ export interface ActiveWorker { startedAt: number; toolCalls: number; currentTool: string | null; + /** Whether the worker is idle (waiting for follow-up input). */ + isIdle: boolean; + /** Whether this worker accepts follow-up input via route. */ + interactive: boolean; + /** Worker type: "builtin", "opencode", "task", etc. */ + workerType: string; +} + +/** Check whether a worker is an opencode worker (by type or task prefix). */ +export function isOpenCodeWorker(worker: { workerType?: string; task?: string }): boolean { + return worker.workerType === "opencode" || (worker.task?.startsWith("[opencode]") ?? false); } export interface ActiveBranch { @@ -156,6 +168,9 @@ export function useChannelLiveState(channels: ChannelInfo[]) { startedAt: new Date(w.started_at).getTime(), toolCalls: w.tool_calls, currentTool: existingWorker?.currentTool ?? null, + isIdle: w.status === "idle", + interactive: w.interactive, + workerType: existingWorker?.workerType ?? (w.task.startsWith("[opencode]") ? "opencode" : "builtin"), }; } const branches: Record = {}; @@ -368,14 +383,17 @@ export function useChannelLiveState(channels: ChannelInfo[]) { ...existing, workers: { ...existing.workers, - [event.worker_id]: { - id: event.worker_id, - task: event.task, - status: "starting", - startedAt: Date.now(), - toolCalls: 0, - currentTool: null, - }, + [event.worker_id]: { + id: event.worker_id, + task: event.task, + status: "starting", + startedAt: Date.now(), + toolCalls: 0, + currentTool: null, + isIdle: false, + interactive: event.interactive ?? false, + workerType: event.worker_type ?? "builtin", + }, }, }, }; @@ -407,7 +425,7 @@ export function useChannelLiveState(channels: ChannelInfo[]) { ...state, workers: { ...state.workers, - [event.worker_id]: { ...worker, status: event.status }, + [event.worker_id]: { ...worker, status: event.status, isIdle: false }, }, }, }; @@ -429,7 +447,52 @@ export function useChannelLiveState(channels: ChannelInfo[]) { ...state, workers: { ...state.workers, - [event.worker_id]: { ...worker, status: event.status }, + [event.worker_id]: { ...worker, status: event.status, isIdle: false }, + }, + }, + }; + } + } + return prev; + }); + } + }, [updateItem]); + + const handleWorkerIdle = useCallback((data: unknown) => { + const event = data as WorkerIdleEvent; + if (event.channel_id) { + setLiveStates((prev) => { + const state = prev[event.channel_id!]; + const worker = state?.workers[event.worker_id]; + if (!worker) return prev; + return { + ...prev, + [event.channel_id!]: { + ...state, + workers: { + ...state.workers, + [event.worker_id]: { ...worker, isIdle: true }, + }, + }, + }; + }); + // Update timeline item status to idle + updateItem(event.channel_id, event.worker_id, (item) => { + if (item.type !== "worker_run") return item; + return { ...item, status: "idle" }; + }); + } else { + setLiveStates((prev) => { + for (const [channelId, state] of Object.entries(prev)) { + const worker = state.workers[event.worker_id]; + if (worker) { + return { + ...prev, + [channelId]: { + ...state, + workers: { + ...state.workers, + [event.worker_id]: { ...worker, isIdle: true }, }, }, }; @@ -746,6 +809,7 @@ export function useChannelLiveState(channels: ChannelInfo[]) { typing_state: handleTypingState, worker_started: handleWorkerStarted, worker_status: handleWorkerStatus, + worker_idle: handleWorkerIdle, worker_completed: handleWorkerCompleted, branch_started: handleBranchStarted, branch_completed: handleBranchCompleted, diff --git a/interface/src/hooks/useLiveContext.tsx b/interface/src/hooks/useLiveContext.tsx index f78804220..0fc58d4c8 100644 --- a/interface/src/hooks/useLiveContext.tsx +++ b/interface/src/hooks/useLiveContext.tsx @@ -1,6 +1,6 @@ import { createContext, useContext, useCallback, 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 WorkerStatusEvent, type TranscriptStep } from "@/api/client"; +import { api, type AgentMessageEvent, type ChannelInfo, type ToolStartedEvent, type ToolCompletedEvent, type WorkerStatusEvent, type TranscriptStep, type OpenCodePart, type OpenCodePartUpdatedEvent } from "@/api/client"; import { generateId } from "@/lib/id"; import { useEventSource, type ConnectionState } from "@/hooks/useEventSource"; import { useChannelLiveState, type ChannelLiveState, type ActiveWorker } from "@/hooks/useChannelLiveState"; @@ -21,6 +21,8 @@ interface LiveContextValue { taskEventVersion: number; /** Live transcript steps for running workers, keyed by worker_id. Built from SSE tool events. */ liveTranscripts: Record; + /** Live OpenCode parts for running workers, keyed by worker_id. Parts are insertion-ordered Maps keyed by part ID. */ + liveOpenCodeParts: Record>; } const LiveContext = createContext({ @@ -34,6 +36,7 @@ const LiveContext = createContext({ workerEventVersion: 0, taskEventVersion: 0, liveTranscripts: {}, + liveOpenCodeParts: {}, }); export function useLiveContext() { @@ -68,6 +71,10 @@ export function LiveContextProvider({ children }: { children: ReactNode }) { // for running workers. Cleared when worker completes. const [liveTranscripts, setLiveTranscripts] = useState>({}); + // Live OpenCode parts: per-worker insertion-ordered Map keyed by part ID. + // Updated via opencode_part_updated SSE events. Cleared when worker completes. + const [liveOpenCodeParts, setLiveOpenCodeParts] = useState>>({}); + // Derive flat active workers from channel live states const pendingToolCallIdsRef = useRef>>({}); @@ -133,6 +140,7 @@ export function LiveContextProvider({ children }: { children: ReactNode }) { channelHandlers.worker_started(data); 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]); @@ -154,10 +162,21 @@ export function LiveContextProvider({ children }: { children: ReactNode }) { bumpWorkerVersion(); }, [channelHandlers, bumpWorkerVersion]); + const wrappedWorkerIdle = useCallback((data: unknown) => { + channelHandlers.worker_idle(data); + bumpWorkerVersion(); + }, [channelHandlers, bumpWorkerVersion]); + 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 }; + delete next[event.worker_id]; + return next; + }); bumpWorkerVersion(); }, [channelHandlers, bumpWorkerVersion]); @@ -218,20 +237,34 @@ export function LiveContextProvider({ children }: { children: ReactNode }) { } }, [channelHandlers, bumpWorkerVersion]); + // Handle OpenCode part updates — upsert parts into the per-worker ordered map + const handleOpenCodePartUpdated = useCallback((data: unknown) => { + const event = data as OpenCodePartUpdatedEvent; + setLiveOpenCodeParts((prev) => { + const existing = prev[event.worker_id] ?? new Map(); + const next = new Map(existing); + next.set(event.part.id, event.part); + return { ...prev, [event.worker_id]: next }; + }); + bumpWorkerVersion(); + }, [bumpWorkerVersion]); + // Merge channel handlers with agent message + task handlers const handlers = useMemo( () => ({ ...channelHandlers, worker_started: wrappedWorkerStarted, worker_status: wrappedWorkerStatus, + worker_idle: wrappedWorkerIdle, worker_completed: wrappedWorkerCompleted, tool_started: wrappedToolStarted, tool_completed: wrappedToolCompleted, + opencode_part_updated: handleOpenCodePartUpdated, agent_message_sent: handleAgentMessage, agent_message_received: handleAgentMessage, task_updated: bumpTaskVersion, }), - [channelHandlers, wrappedWorkerStarted, wrappedWorkerStatus, wrappedWorkerCompleted, wrappedToolStarted, wrappedToolCompleted, handleAgentMessage, bumpTaskVersion], + [channelHandlers, wrappedWorkerStarted, wrappedWorkerStatus, wrappedWorkerIdle, wrappedWorkerCompleted, wrappedToolStarted, wrappedToolCompleted, handleOpenCodePartUpdated, handleAgentMessage, bumpTaskVersion], ); const onReconnect = useCallback(() => { @@ -253,7 +286,7 @@ export function LiveContextProvider({ children }: { children: ReactNode }) { const hasData = channels.length > 0 || channelsData !== undefined; return ( - + {children} ); diff --git a/interface/src/routes/AgentWorkers.tsx b/interface/src/routes/AgentWorkers.tsx index f52718f90..1dd9deaea 100644 --- a/interface/src/routes/AgentWorkers.tsx +++ b/interface/src/routes/AgentWorkers.tsx @@ -9,6 +9,7 @@ import { type WorkerDetailResponse, type TranscriptStep, type ActionContent, + type OpenCodePart, } from "@/api/client"; import {Badge} from "@/ui/Badge"; import {formatTimeAgo, formatDuration} from "@/lib/format"; @@ -16,10 +17,17 @@ import {LiveDuration} from "@/components/LiveDuration"; import {useLiveContext} from "@/hooks/useLiveContext"; import {cx} from "@/ui/utils"; -const STATUS_FILTERS = ["all", "running", "done", "failed"] as const; +/** RFC 4648 base64url encoding (no padding), matching OpenCode's directory encoding. */ +export function base64UrlEncode(value: string): string { + const bytes = new TextEncoder().encode(value); + const binary = Array.from(bytes, (b) => String.fromCharCode(b)).join(""); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); +} + +const STATUS_FILTERS = ["all", "running", "idle", "done", "failed"] as const; type StatusFilter = (typeof STATUS_FILTERS)[number]; -const KNOWN_STATUSES = new Set(["running", "done", "failed"]); +const KNOWN_STATUSES = new Set(["running", "idle", "done", "failed"]); function normalizeStatus(status: string): string { if (KNOWN_STATUSES.has(status)) return status; @@ -32,6 +40,8 @@ function statusBadgeVariant(status: string) { switch (status) { case "running": return "amber" as const; + case "idle": + return "blue" as const; case "failed": return "red" as const; default: @@ -58,7 +68,7 @@ export function AgentWorkers({agentId}: {agentId: string}) { const navigate = useNavigate(); const routeSearch = useSearch({strict: false}) as {worker?: string}; const selectedWorkerId = routeSearch.worker ?? null; - const {activeWorkers, workerEventVersion, liveTranscripts} = useLiveContext(); + const {activeWorkers, workerEventVersion, liveTranscripts, liveOpenCodeParts} = useLiveContext(); // Invalidate worker queries when SSE events fire const prevVersion = useRef(workerEventVersion); @@ -118,7 +128,7 @@ export function AgentWorkers({agentId}: {agentId: string}) { if (!live) return worker; return { ...worker, - status: "running", + status: live.isIdle ? "idle" : "running", live_status: live.status, tool_calls: live.toolCalls, }; @@ -130,8 +140,8 @@ export function AgentWorkers({agentId}: {agentId: string}) { .map((live) => ({ id: live.id, task: live.task, - status: "running", - worker_type: "builtin", + status: live.isIdle ? "idle" : "running", + worker_type: live.workerType ?? "builtin", channel_id: live.channelId ?? null, channel_name: null, started_at: new Date(live.startedAt).toISOString(), @@ -139,6 +149,8 @@ export function AgentWorkers({agentId}: {agentId: string}) { has_transcript: false, live_status: live.status, tool_calls: live.toolCalls, + opencode_port: null, + interactive: live.interactive, })); return [...synthetic, ...merged]; @@ -160,7 +172,7 @@ export function AgentWorkers({agentId}: {agentId: string}) { if (detailData) { // DB data exists — overlay live status if worker is still running if (!live) return detailData; - return { ...detailData, status: "running" }; + return { ...detailData, status: live.isIdle ? "idle" : "running" }; } // No DB data yet — synthesize from SSE state @@ -169,14 +181,17 @@ export function AgentWorkers({agentId}: {agentId: string}) { id: live.id, task: live.task, result: null, - status: "running", - worker_type: "builtin", + status: live.isIdle ? "idle" : "running", + worker_type: live.workerType ?? "builtin", channel_id: live.channelId ?? null, channel_name: null, started_at: new Date(live.startedAt).toISOString(), completed_at: null, transcript: null, tool_calls: live.toolCalls, + opencode_session_id: null, + opencode_port: null, + interactive: live.interactive, }; }, [detailData, scopedActiveWorkers, selectedWorkerId]); @@ -252,6 +267,7 @@ export function AgentWorkers({agentId}: {agentId: string}) { detail={mergedDetail} liveWorker={scopedActiveWorkers[selectedWorkerId]} liveTranscript={liveTranscripts[selectedWorkerId]} + liveOpenCodeParts={liveOpenCodeParts[selectedWorkerId]} /> ) : (
@@ -272,6 +288,9 @@ interface LiveWorker { startedAt: number; toolCalls: number; currentTool: string | null; + isIdle: boolean; + interactive: boolean; + workerType: string; } function WorkerCard({ @@ -285,7 +304,10 @@ function WorkerCard({ selected: boolean; onClick: () => void; }) { - const isRunning = worker.status === "running" || !!liveWorker; + const isLive = worker.status === "running" || !!liveWorker; + const isIdle = liveWorker?.isIdle ?? worker.status === "idle"; + const isInteractive = liveWorker?.interactive ?? worker.interactive; + const displayStatus = isIdle ? "idle" : isLive ? "running" : normalizeStatus(worker.status); const toolCalls = liveWorker?.toolCalls ?? worker.tool_calls; return ( @@ -300,16 +322,23 @@ function WorkerCard({

{worker.task}

- - {isRunning && ( - +
+ {isInteractive && ( + + interactive + )} - {isRunning ? "running" : normalizeStatus(worker.status)} - + + {isLive && !isIdle && ( + + )} + {displayStatus} + +
{worker.channel_name && ( @@ -318,7 +347,7 @@ function WorkerCard({ {worker.channel_name && ·} {worker.worker_type} · - {isRunning ? ( + {isLive && !isIdle ? ( ; }) { - const isRunning = detail.status === "running" || !!liveWorker; + const isLive = detail.status === "running" || !!liveWorker; + const isIdle = liveWorker?.isIdle ?? detail.status === "idle"; const duration = durationBetween(detail.started_at, detail.completed_at); const displayStatus = liveWorker?.status; const currentTool = liveWorker?.currentTool; const toolCalls = liveWorker?.toolCalls ?? detail.tool_calls ?? 0; + + const isOpenCode = detail.worker_type === "opencode"; + const hasOpenCodeEmbed = + isOpenCode && + detail.opencode_port != null && + detail.opencode_session_id != null; + + // Convert the insertion-ordered Map to an array for rendering + const openCodeParts: OpenCodePart[] = useMemo( + () => (liveOpenCodeParts ? Array.from(liveOpenCodeParts.values()) : []), + [liveOpenCodeParts], + ); + + const [activeTab, setActiveTab] = useState( + hasOpenCodeEmbed ? "opencode" : "transcript", + ); + + // Reset tab when switching workers + useEffect(() => { + setActiveTab(hasOpenCodeEmbed ? "opencode" : "transcript"); + }, [detail.id, hasOpenCodeEmbed]); + // Use persisted transcript if available, otherwise fall back to live SSE transcript. // Strip the final action step if it duplicates the result text shown above. - const rawTranscript = detail.transcript ?? (isRunning ? liveTranscript : null); + const rawTranscript = detail.transcript ?? (isLive ? liveTranscript : null); const transcript = useMemo(() => { if (!rawTranscript || !detail.result) return rawTranscript; const last = rawTranscript[rawTranscript.length - 1]; @@ -371,12 +427,13 @@ function WorkerDetail({ }, [rawTranscript, detail.result]); const transcriptRef = useRef(null); - // Auto-scroll to latest transcript step for running workers + // Auto-scroll to latest transcript step for running workers (not idle) + const isRunning = isLive && !isIdle; useEffect(() => { - if (isRunning && transcriptRef.current) { + if (isRunning && activeTab === "transcript" && transcriptRef.current) { transcriptRef.current.scrollTop = transcriptRef.current.scrollHeight; } - }, [isRunning, transcript?.length]); + }, [isRunning, activeTab, transcript?.length]); return (
@@ -385,12 +442,17 @@ function WorkerDetail({
- {isRunning && detail.channel_id && ( + {isLive && detail.channel_id && ( )} + {detail.interactive && ( + + interactive + + )} - {isRunning && ( + {isLive && !isIdle && ( )} - {isRunning ? "running" : normalizeStatus(detail.status)} + {isIdle ? "idle" : isLive ? "running" : normalizeStatus(detail.status)}
@@ -422,10 +484,12 @@ function WorkerDetail({ } /> + ) : isIdle ? ( + Idle — waiting for follow-up ) : ( duration && {duration} )} - {!isRunning && {formatTimeAgo(detail.started_at)}} + {!isLive && {formatTimeAgo(detail.started_at)}} {toolCalls > 0 && ( {toolCalls} tool calls )} @@ -444,60 +508,185 @@ function WorkerDetail({ )}
+ {/* Tab bar (only for OpenCode workers with embed data) */} + {hasOpenCodeEmbed && ( +
+ + +
+ )} + {/* Content */} -
- {/* Result section */} - {detail.result && ( -
-

- Result -

-
- {detail.result} + {activeTab === "opencode" && hasOpenCodeEmbed ? ( + + ) : ( +
+ {/* Result section */} + {detail.result && ( +
+

+ Result +

+
+ {detail.result} +
-
- )} + )} - {/* Transcript section */} - {transcript && transcript.length > 0 ? ( -
-

- {isRunning ? "Live Transcript" : "Transcript"} -

-
- {transcript.map((step, index) => ( - - - - ))} - {isRunning && currentTool && ( -
- - Running {currentTool}... -
- )} + {/* OpenCode live parts (for running/idle OpenCode workers) */} + {isOpenCode && isLive && openCodeParts.length > 0 ? ( +
+

+ {isIdle ? "Transcript" : "Live Transcript"} +

+
+ {openCodeParts.map((part) => ( + + + + ))} + {isRunning && currentTool && ( +
+ + Running {currentTool}... +
+ )} + {isIdle && ( +
+ Waiting for follow-up input... +
+ )} +
-
- ) : isRunning ? ( -
-
-

Waiting for first tool call...

-
- ) : ( -
- Full transcript not available for this worker -
- )} -
+ ) : transcript && transcript.length > 0 ? ( +
+

+ {isLive && !isIdle ? "Live Transcript" : "Transcript"} +

+
+ {transcript.map((step, index) => ( + + + + ))} + {isRunning && currentTool && ( +
+ + Running {currentTool}... +
+ )} + {isIdle && ( +
+ Waiting for follow-up input... +
+ )} +
+
+ ) : liveWorker && !isIdle ? ( +
+
+

Waiting for first tool call...

+
+ ) : ( +
+ No transcript available for this worker +
+ )} +
+ )}
); } +function OpenCodeEmbed({port, sessionId}: {port: number; sessionId: string}) { + const [state, setState] = useState<"loading" | "ready" | "error">("loading"); + + useEffect(() => { + setState("loading"); + const controller = new AbortController(); + + fetch(`/api/opencode/${port}/global/health`, {signal: controller.signal}) + .then((response) => { + setState(response.ok ? "ready" : "error"); + }) + .catch(() => { + setState("error"); + }); + + return () => controller.abort(); + }, [port, sessionId]); + + if (state === "loading") { + return ( +
+
+ + Connecting to OpenCode... +
+
+ ); + } + + if (state === "error") { + return ( +
+

OpenCode server is not reachable

+

+ The server may have been stopped. Try the Transcript tab for available data. +

+
+ ); + } + + // Build the iframe URL. OpenCode uses base64url-encoded directory paths + // in its SPA routing. We load the root and let the app navigate — the + // server knows its directory, and the session list will show this session. + // Direct deep-linking: /api/opencode/{port}/{base64dir}/session/{sessionId} + const iframeSrc = `/api/opencode/${port}/`; + + return ( +