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.
This commit is contained in:
2026-06-03 14:59:07 +00:00
parent 7ff99238c9
commit fa8d707923
118 changed files with 15723 additions and 27 deletions

View File

@@ -1,22 +1,62 @@
import { Code, MessageSquare, Plus, Terminal } from 'lucide-react';
import { useState } from 'react';
import { Code, History, MessageSquare, Plus, Terminal, Workflow } from 'lucide-react';
import { api } from '@/api/client';
import type { FlowRunRow } from '@/api/types';
import { sessionEvents } from '@/hooks/sessionEvents';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
interface Props {
onAddPane: (kind: 'chat' | 'terminal' | 'coder') => void;
disabled?: boolean;
projectId?: string;
}
function statusSymbol(status: FlowRunRow['status']): string {
if (status === 'completed') return '✓';
if (status === 'failed') return '✗';
if (status === 'running') return '⟳';
return '';
}
function relativeTime(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const mins = Math.floor(diff / 60_000);
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
return `${Math.floor(hrs / 24)}d ago`;
}
function humanize(slug: string): string {
return slug.replace(/[-_]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
// v1.8 row-2 right cluster: mirrors the desktop Workspace.tsx Split dropdown.
// Terminal + Coder items pass through to addSplitPane which creates panes
// of the appropriate kind.
export function NewPaneMenu({ onAddPane, disabled }: Props) {
// of the appropriate kind. Phase 11: optional projectId enables "Recent Flows"
// section (runs history).
export function NewPaneMenu({ onAddPane, disabled, projectId }: Props) {
const [runs, setRuns] = useState<FlowRunRow[] | null>(null);
const [loadingRuns, setLoadingRuns] = useState(false);
function handleOpenChange(open: boolean) {
if (!open || !projectId || runs !== null) return;
setLoadingRuns(true);
api.runs.list(projectId)
.then(({ runs: r }) => setRuns(r.slice(0, 8)))
.catch(() => setRuns([]))
.finally(() => setLoadingRuns(false));
}
return (
<DropdownMenu>
<DropdownMenu onOpenChange={handleOpenChange}>
<DropdownMenuTrigger asChild>
<button
type="button"
@@ -37,6 +77,62 @@ export function NewPaneMenu({ onAddPane, disabled }: Props) {
<DropdownMenuItem onSelect={() => onAddPane('coder')}>
<Code size={14} /> New BooCode
</DropdownMenuItem>
{projectId && (
<DropdownMenuItem
onSelect={() =>
sessionEvents.emit({
type: 'open_flow_launcher',
project_id: projectId,
placement: 'new',
})
}
>
<Workflow size={14} /> New Orchestrator
</DropdownMenuItem>
)}
{projectId && (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel className="flex items-center gap-1.5 text-xs">
<History size={12} />
Recent Flows
</DropdownMenuLabel>
{loadingRuns && (
<DropdownMenuItem disabled>
<span className="text-muted-foreground text-xs">Loading</span>
</DropdownMenuItem>
)}
{!loadingRuns && runs !== null && runs.length === 0 && (
<DropdownMenuItem disabled>
<span className="text-muted-foreground text-xs">No recent flows</span>
</DropdownMenuItem>
)}
{!loadingRuns && runs !== null && runs.map((run) => (
<DropdownMenuItem
key={run.id}
onSelect={() => {
sessionEvents.emit({
type: 'open_orchestrator_pane',
state: { run_id: run.id, flow_name: run.flow_name, band: run.band },
});
}}
className="flex flex-col items-start gap-0.5 max-w-[260px]"
>
<div className="flex items-center gap-1.5 w-full min-w-0">
<Workflow size={12} className="shrink-0 text-muted-foreground" />
<span className="font-medium truncate text-sm">{humanize(run.flow_name)}</span>
<span className="text-muted-foreground text-xs shrink-0 capitalize ml-auto">{run.band}</span>
</div>
<div className="flex items-center gap-1.5 w-full text-xs text-muted-foreground pl-[20px]">
<span>{statusSymbol(run.status)}</span>
<span className="truncate flex-1">{run.input.question}</span>
<span className="shrink-0">{relativeTime(run.created_at)}</span>
</div>
</DropdownMenuItem>
))}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);