feat: in-app Orchestrator (Phase 2) — multi-agent conductor

Brings the deterministic Han-flow conductor into BooCode: launch any read-only
flow from BooChat or BooCoder, watch each agent stream live in a Paseo-style
run pane, get an evidence-disciplined report — on local Qwen, persisted and
resumable. Read-only enforced hard via qwen --approval-mode plan (orchestrator
tasks fail closed if qwen is unavailable; never fall to write-capable native).

Backend (apps/coder): re-homed conductor defs, flow_runs/flow_steps schema,
flow-runner + dispatcher onTaskTerminal hook, restart-resume, runs routes
(launch/list/get/cancel), user-channel WS. Contracts: two flow_run_* frames.
Web: orchestrator pane kind + OrchestratorPane, Workflow button + slash flows
(BooChat/BooCoder parity), FlowLauncherDialog, "New Orchestrator" in the + and
split menus, runs history + export. Plan: openspec/changes/orchestrator.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 14:59:07 +00:00
parent 519b1d2ca1
commit 1937af8df9
118 changed files with 15723 additions and 27 deletions

View File

@@ -0,0 +1,458 @@
// OrchestratorPane — run view for a flow run (Phase 8).
//
// Subscribes to the coder user channel (via useCoderUserEvents → sessionEvents)
// for run-level frames (flow_run_started / flow_run_step_updated). Per-step
// content streams ride the existing coder per-session WS frames (delta /
// tool_call / message_complete), connected on demand when a step is expanded.
//
// Layout per D-7:
// - Run header (flow name + band + status + stop button)
// - Final report at top when completed
// - Collapsed agent roster (one row per step, status dot + label)
// - Expand-one-at-a-time detail well (step's live stream via CoderMessageList)
// - Mobile: single column, inline expand
import { useCallback, useEffect, useRef, useState } from 'react';
import { ChevronDown, ChevronRight, MoreHorizontal, Square, Workflow, X } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api/client';
import type { FlowRunRow, FlowStepRow, OrchestratorState } from '@/api/types';
import { sessionEvents } from '@/hooks/sessionEvents';
import { chatInputsRegistry, sendToChat } from '@/lib/events';
import { CoderMessageList } from '@/components/panes/CoderMessageList';
import type { CoderTimelineWire } from '@/components/panes/CoderMessageList';
import { mergeWireToolCall } from '@/lib/coder-tools';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
// ---- step status dot (same visual language as AgentStatusDot in AgentComposerBar) --
function FlowStepStatusDot({ status }: { status: FlowStepRow['status'] }) {
if (status === 'running') {
return (
<span
aria-label="running"
className="inline-block w-3 h-3 rounded-full border-2 border-emerald-500 border-t-transparent animate-spin shrink-0"
/>
);
}
const cls =
status === 'completed'
? 'bg-emerald-500'
: status === 'failed'
? 'bg-destructive'
: status === 'cancelled'
? 'bg-muted-foreground/20'
: 'bg-muted-foreground/40'; // pending / skipped
return <span aria-label={status} className={cn('inline-block w-1.5 h-1.5 rounded-full shrink-0', cls)} />;
}
// ---- per-step stream hook ---------------------------------------------------
// Connects to the synthetic session WS for the expanded step. Returns messages
// suitable for CoderMessageList. Disconnects and clears when sessionId/chatId
// are null (collapsed step). Reuses the same frame-handling logic as CoderPane.
type RawFrame = Record<string, unknown>;
function useStepStream(sessionId: string | null, chatId: string | null): CoderTimelineWire[] {
const [messages, setMessages] = useState<CoderTimelineWire[]>([]);
const chatIdRef = useRef(chatId);
chatIdRef.current = chatId;
useEffect(() => {
if (!sessionId || !chatId) {
setMessages([]);
return;
}
setMessages([]);
// Initial REST fetch for any already-persisted messages.
api.coder.listMessages(sessionId, chatId).then((rows) => setMessages(rows)).catch(() => {});
// Live stream from the step's synthetic session.
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(`${proto}//${window.location.host}/api/coder/ws/sessions/${sessionId}`);
ws.onmessage = (ev) => {
try {
const frame = JSON.parse(ev.data as string) as RawFrame;
const scopedChatId = chatIdRef.current;
// Drop frames for other chats (except the snapshot which we filter below).
if (scopedChatId && frame.chat_id && frame.chat_id !== scopedChatId && frame.type !== 'snapshot') return;
if (frame.type === 'snapshot' && Array.isArray(frame.messages)) {
const rows = (frame.messages as Array<Record<string, unknown>>).filter(
(m) => !scopedChatId || m.chat_id === scopedChatId,
);
setMessages(rows as unknown as CoderTimelineWire[]);
} else if (frame.type === 'message_started') {
const role = (frame.role ?? 'assistant') as string;
setMessages((prev) => {
if (prev.some((m) => m.id === frame.message_id)) return prev;
return [
...prev,
{ id: frame.message_id as string, role, content: '', status: 'streaming' } as CoderTimelineWire,
];
});
} else if (frame.type === 'delta') {
setMessages((prev) =>
prev.map((m) => {
if (m.id !== frame.message_id || m.role === 'tool') return m;
const msg = m as { content: string };
return { ...m, content: msg.content + ((frame.content as string) ?? '') };
}),
);
} else if (frame.type === 'reasoning_delta') {
setMessages((prev) =>
prev.map((m) => {
if (m.id !== frame.message_id || m.role === 'tool') return m;
const msg = m as { reasoning_text?: string };
return { ...m, reasoning_text: (msg.reasoning_text ?? '') + ((frame.content as string) ?? '') };
}),
);
} else if (frame.type === 'message_complete') {
setMessages((prev) =>
prev.map((m) =>
m.id === frame.message_id && m.role !== 'tool'
? {
...m,
status: ((frame.status as string) ?? 'complete') as 'complete' | 'failed' | 'cancelled',
model: (frame.model as string | null | undefined) ?? null,
}
: m,
),
);
} else if (frame.type === 'tool_call' && frame.tool_call) {
const tc = frame.tool_call as { id: string; name: string; args?: Record<string, unknown> };
if (tc.id) {
setMessages((prev) =>
prev.map((m) => {
if (m.role !== 'assistant' || m.id !== frame.message_id) return m;
const msg = m as { tool_calls?: import('@/lib/coder-tools').CoderToolCallWire[] };
return { ...m, tool_calls: mergeWireToolCall(msg.tool_calls, { ...tc, args: tc.args ?? {} }) };
}),
);
}
} else if (frame.type === 'tool_result') {
const result = {
tool_call_id: frame.tool_call_id as string,
output: frame.output,
truncated: (frame.truncated as boolean) ?? false,
...((frame.error as string | undefined) ? { error: frame.error as string } : {}),
};
setMessages((prev) => {
const exists = prev.some((m) => m.id === frame.tool_message_id);
if (exists) {
return prev.map((m) =>
m.role === 'tool' && m.id === frame.tool_message_id ? { ...m, tool_results: result } : m,
);
}
return [
...prev,
{ id: frame.tool_message_id as string, role: 'tool' as const, tool_results: result },
];
});
}
} catch {
// bad frame — ignore
}
};
return () => {
try { ws.close(); } catch {}
};
}, [sessionId, chatId]);
return messages;
}
// ---- helpers ---------------------------------------------------------------
function humanize(slug: string): string {
return slug.replace(/[-_]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
// ---- StepRow ---------------------------------------------------------------
function StepRow({
step,
isExpanded,
onToggle,
}: {
step: FlowStepRow;
isExpanded: boolean;
onToggle: () => void;
}) {
// Only connect when expanded; null inputs cause the hook to return [] immediately.
const streamMessages = useStepStream(
isExpanded ? step.session_id : null,
isExpanded ? step.chat_id : null,
);
return (
<div>
<button
type="button"
onClick={onToggle}
className="w-full flex items-center gap-2.5 px-3 py-2.5 text-left hover:bg-muted/30 transition-colors"
>
<FlowStepStatusDot status={step.status} />
<span className="text-sm flex-1 truncate">{humanize(step.step_id)}</span>
{step.agent && (
<span className="text-xs text-muted-foreground shrink-0 hidden sm:block">{step.agent}</span>
)}
{isExpanded
? <ChevronDown size={12} className="shrink-0 text-muted-foreground" />
: <ChevronRight size={12} className="shrink-0 text-muted-foreground" />}
</button>
{isExpanded && (
<div className="border-t border-border/50 bg-muted/10 max-h-[55vh] flex flex-col overflow-hidden">
{streamMessages.length === 0 ? (
<div className="flex items-center justify-center py-6 text-sm text-muted-foreground">
{step.status === 'pending' ? 'Waiting to start…'
: step.status === 'completed' || step.status === 'failed' ? 'Loading output…'
: 'Connecting…'}
</div>
) : (
<CoderMessageList messages={streamMessages} chatId={step.chat_id ?? undefined} />
)}
</div>
)}
</div>
);
}
// ---- OrchestratorPane ------------------------------------------------------
interface Props {
state: OrchestratorState;
onClose: () => void;
}
export function OrchestratorPane({ state, onClose }: Props) {
const [run, setRun] = useState<FlowRunRow | null>(null);
const [steps, setSteps] = useState<FlowStepRow[]>([]);
const [expandedStepId, setExpandedStepId] = useState<string | null>(null);
const [stopping, setStopping] = useState(false);
// Fetch current run state on mount (handles both new runs and reopen).
useEffect(() => {
setRun(null);
setSteps([]);
setExpandedStepId(null);
api.runs.get(state.run_id)
.then(({ run: r, steps: s }) => {
setRun(r);
setSteps(s);
// Auto-expand first running step.
const firstRunning = s.find((step) => step.status === 'running');
if (firstRunning) setExpandedStepId(firstRunning.step_id);
})
.catch(() => {});
}, [state.run_id]);
// Subscribe to live run-level frames from the coder user channel
// (forwarded by useCoderUserEvents → sessionEvents).
// Idempotent: flow_run_started only seeds the roster when empty (the API
// fetch above is authoritative; the frame is a fallback for the race where
// the pane opens before the GET resolves).
useEffect(() => {
return sessionEvents.subscribe((ev) => {
if (ev.type === 'flow_run_started' && ev.run_id === state.run_id) {
setSteps((prev) => {
if (prev.length > 0) return prev;
return ev.steps.map((s) => ({
id: s.step_id,
run_id: state.run_id,
step_id: s.step_id,
kind: s.kind,
agent: s.agent,
status: 'pending' as const,
task_id: null,
chat_id: s.chat_id,
session_id: null,
input: null,
output: null,
error: null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
}));
});
} else if (ev.type === 'flow_run_step_updated' && ev.run_id === state.run_id) {
// Idempotent status update — no double-emit risk (useCoderUserEvents
// owns the WS → sessionEvents bridge; we don't also emit locally).
setSteps((prev) =>
prev.map((s) => (s.step_id === ev.step_id ? { ...s, status: ev.status } : s)),
);
if (ev.run_status) {
setRun((prev) =>
prev ? { ...prev, status: ev.run_status!, report: ev.report ?? prev.report } : prev,
);
}
// Auto-expand the step that just went running.
if (ev.status === 'running') setExpandedStepId(ev.step_id);
}
});
}, [state.run_id]);
const toggleExpand = useCallback((stepId: string) => {
setExpandedStepId((prev) => (prev === stepId ? null : stepId));
}, []);
const handleStop = useCallback(async () => {
if (stopping) return;
setStopping(true);
try {
await api.runs.cancel(state.run_id);
} catch {
// non-fatal
} finally {
setStopping(false);
}
}, [state.run_id, stopping]);
const runStatus = run?.status ?? 'running';
const isRunning = runStatus === 'running';
const agentSteps = steps.filter((s) => s.kind === 'agent');
const hasReport = runStatus === 'completed' && !!run?.report;
function handleCopyReport() {
if (!run?.report) return;
navigator.clipboard.writeText(run.report).catch(() => toast.error('Clipboard write failed'));
}
function handleSaveReport() {
if (!run?.report) return;
const blob = new Blob([run.report], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${run.flow_name}-report.md`;
a.click();
URL.revokeObjectURL(url);
}
function handleSendToChat() {
if (!run?.report) return;
const chats = chatInputsRegistry.list();
const first = chats[0];
if (!first) {
toast.error('No open chat to send to');
return;
}
sendToChat.emit({ chat_id: first.chatId, text: run.report });
}
return (
<div className="flex flex-col h-full min-h-0 overflow-hidden">
{/* Header */}
<div className="flex items-center gap-2 border-b border-border bg-muted/20 px-3 py-2 shrink-0">
<Workflow size={14} className="text-muted-foreground shrink-0" />
<span className="text-sm font-medium truncate">{humanize(state.flow_name)}</span>
<span className="text-xs text-muted-foreground shrink-0 capitalize">{state.band}</span>
<div className="ml-auto flex items-center gap-1.5 shrink-0">
{isRunning ? (
<button
type="button"
onClick={() => void handleStop()}
disabled={stopping}
className="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded border border-border text-muted-foreground hover:text-foreground hover:bg-muted disabled:opacity-50"
title="Stop run"
>
<Square size={10} />
Stop
</button>
) : (
<span
className={cn(
'text-xs px-1.5 py-0.5 rounded',
runStatus === 'completed'
? 'text-emerald-600 bg-emerald-500/10'
: runStatus === 'failed'
? 'text-destructive bg-destructive/10'
: 'text-muted-foreground bg-muted/40',
)}
>
{runStatus}
</span>
)}
{hasReport && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label="Export options"
title="Export report"
>
<MoreHorizontal size={14} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={handleCopyReport}>
Copy report
</DropdownMenuItem>
<DropdownMenuItem onSelect={handleSaveReport}>
Save to file
</DropdownMenuItem>
<DropdownMenuItem onSelect={handleSendToChat}>
Send to chat
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
<button
type="button"
onClick={onClose}
className="inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label="Close pane"
title="Close pane"
>
<X size={12} />
</button>
</div>
</div>
{/* Body */}
<div className="flex-1 min-h-0 overflow-y-auto">
{/* Final report — at the top when completed */}
{run?.report && (
<div className="border-b border-border p-4">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2 pb-1 border-b border-border/50">
Report
</div>
<div className="text-sm text-foreground whitespace-pre-wrap leading-relaxed">
{run.report}
</div>
</div>
)}
{/* Empty state */}
{agentSteps.length === 0 && !run?.report && (
<div className="flex items-center justify-center h-24 text-sm text-muted-foreground">
Starting run
</div>
)}
{/* Agent roster — collapsed by default, expand one at a time */}
<div className="divide-y divide-border">
{agentSteps.map((step) => (
<StepRow
key={step.step_id}
step={step}
isExpanded={expandedStepId === step.step_id}
onToggle={() => toggleExpand(step.step_id)}
/>
))}
</div>
</div>
</div>
);
}