Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
929c6f2
fix: expand tilde in OpenCode worker directory paths
jamiepine Mar 4, 2026
e8aa3b1
merge: resolve conflict with main, keep both expand_tilde and tests
jamiepine Mar 4, 2026
4746d4f
feat: embed OpenCode web UI in worker detail view
jamiepine Mar 4, 2026
1d54c4f
fix: resolve clippy warning in opencode proxy error mapping
jamiepine Mar 4, 2026
ecf9d61
fix: export base64UrlEncode to resolve unused declaration TS error
jamiepine Mar 4, 2026
75e0cc9
Merge branch 'main' into feat/opencode-web-embed
jamiepine Mar 5, 2026
23f6305
fix: inject base href and SDK URL override into proxied OpenCode HTML
jamiepine Mar 5, 2026
a6dcd95
fix: deliver interactive OpenCode worker results without waiting for …
jamiepine Mar 5, 2026
1da3d2c
fix: enrich OpenCode worker status messages with tool context
jamiepine Mar 5, 2026
ece2ae4
fix: accumulate full OpenCode output and persist transcript
jamiepine Mar 5, 2026
4313f6f
feat: real-time OpenCode transcript mirroring via SSE
jamiepine Mar 5, 2026
b8c1fc9
feat: add worker idle state for interactive workers
jamiepine Mar 5, 2026
74a6f1f
fix: persist OpenCode transcript via SSE fallback when get_messages()…
jamiepine Mar 5, 2026
6d5a3a6
Merge branch 'main' into feat/opencode-web-embed
jamiepine Mar 5, 2026
b48a20f
fix: handle new ProcessEvent variants in exhaustive matches after mai…
jamiepine Mar 5, 2026
e6bf1a1
Merge branch 'main' into feat/opencode-web-embed
jamiepine Mar 5, 2026
1ba07e3
Merge branch 'main' into feat/opencode-web-embed
jamiepine Mar 5, 2026
4c73e55
fix: cortex supervisor exempts idle workers from timeout
jamiepine Mar 5, 2026
bb82385
feat: thread interactive flag through full stack and differentiate op…
jamiepine Mar 5, 2026
34f92b2
Merge branch 'main' into feat/opencode-web-embed
jamiepine Mar 6, 2026
5435ed7
fix: emit WorkerInitialResult on interactive follow-up completions
jamiepine Mar 6, 2026
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
39 changes: 38 additions & 1 deletion interface/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export interface WorkerStartedEvent {
worker_id: string;
task: string;
worker_type?: string;
interactive?: boolean;
}

export interface WorkerStatusEvent {
Expand All @@ -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;
Expand Down Expand Up @@ -117,18 +125,41 @@ 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
| OutboundMessageDeltaEvent
| TypingStateEvent
| WorkerStartedEvent
| WorkerStatusEvent
| WorkerIdleEvent
| WorkerCompletedEvent
| BranchStartedEvent
| BranchCompletedEvent
| ToolStartedEvent
| ToolCompletedEvent;
| ToolCompletedEvent
| OpenCodePartUpdatedEvent;

async function fetchJson<T>(path: string): Promise<T> {
const response = await fetch(`${API_BASE}${path}`);
Expand Down Expand Up @@ -181,6 +212,7 @@ export interface WorkerStatusInfo {
started_at: string;
notify_on_complete: boolean;
tool_calls: number;
interactive: boolean;
}

export interface BranchStatusInfo {
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
13 changes: 8 additions & 5 deletions interface/src/components/ChannelCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,30 @@ 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 (
<div className="flex items-center gap-2 rounded-md bg-amber-500/10 px-2.5 py-1.5 text-tiny">
<div className="h-1.5 w-1.5 animate-pulse rounded-full bg-amber-400" />
<div className={`flex items-center gap-2 rounded-md px-2.5 py-1.5 text-tiny ${
oc ? "bg-zinc-500/10" : "bg-amber-500/10"
}`}>
<div className={`h-1.5 w-1.5 animate-pulse rounded-full ${oc ? "bg-zinc-400" : "bg-amber-400"}`} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="font-medium text-amber-300">Worker</span>
<span className={`font-medium ${oc ? "text-zinc-300" : "text-amber-300"}`}>Worker</span>
<span className="truncate text-ink-dull">{worker.task}</span>
</div>
<div className="mt-0.5 flex items-center gap-2 text-ink-faint">
<span>{worker.status}</span>
{worker.currentTool && (
<>
<span className="text-ink-faint/50">·</span>
<span className="text-amber-400/70">{worker.currentTool}</span>
<span className={oc ? "text-zinc-400/70" : "text-amber-400/70"}>{worker.currentTool}</span>
</>
)}
{worker.toolCalls > 0 && (
Expand Down
51 changes: 31 additions & 20 deletions interface/src/components/WebChatPanel.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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 (
<div className="rounded-lg border border-amber-500/25 bg-amber-500/5 px-3 py-2">
<div className="mb-2 flex items-center gap-1.5 text-tiny text-amber-200">
<div className="h-1.5 w-1.5 animate-pulse rounded-full bg-amber-400" />
<div className={`rounded-lg border px-3 py-2 ${borderColor}`}>
<div className={`mb-2 flex items-center gap-1.5 text-tiny ${headerColor}`}>
<div className={`h-1.5 w-1.5 animate-pulse rounded-full ${dotColor}`} />
<span>
{workers.length} active worker{workers.length !== 1 ? "s" : ""}
</span>
</div>
<div className="flex flex-col gap-1.5">
{workers.map((worker) => (
<div
key={worker.id}
className="flex min-w-0 items-center gap-2 rounded-md bg-amber-500/10 px-2.5 py-1.5 text-tiny"
>
<span className="font-medium text-amber-300">Worker</span>
<span className="min-w-0 flex-1 truncate text-ink-dull">
{worker.task}
</span>
<span className="shrink-0 text-ink-faint">{worker.status}</span>
{worker.currentTool && (
<span className="max-w-40 shrink-0 truncate text-amber-400/80">
{worker.currentTool}
{workers.map((worker) => {
const oc = isOpenCodeWorker(worker);
return (
<div
key={worker.id}
className={`flex min-w-0 items-center gap-2 rounded-md px-2.5 py-1.5 text-tiny ${
oc ? "bg-zinc-500/10" : "bg-amber-500/10"
}`}
>
<span className={`font-medium ${oc ? "text-zinc-300" : "text-amber-300"}`}>Worker</span>
<span className="min-w-0 flex-1 truncate text-ink-dull">
{worker.task}
</span>
)}
</div>
))}
<span className="shrink-0 text-ink-faint">{worker.status}</span>
{worker.currentTool && (
<span className={`max-w-40 shrink-0 truncate ${oc ? "text-zinc-400/80" : "text-amber-400/80"}`}>
{worker.currentTool}
</span>
)}
</div>
);
})}
</div>
</div>
);
Expand Down
84 changes: 74 additions & 10 deletions interface/src/hooks/useChannelLiveState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
type WorkerCompletedEvent,
type WorkerStartedEvent,
type WorkerStatusEvent,
type WorkerIdleEvent,
type ChannelInfo,
} from "../api/client";

Expand All @@ -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 {
Expand Down Expand Up @@ -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<string, ActiveBranch> = {};
Expand Down Expand Up @@ -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",
},
},
},
};
Expand Down Expand Up @@ -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 },
},
},
};
Expand All @@ -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 },
},
},
};
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading