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,266 @@
import { useState, useEffect } from 'react';
import { Zap } from 'lucide-react';
import { toast } from 'sonner';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { api } from '@/api/client';
import { sessionEvents } from '@/hooks/sessionEvents';
import { cn } from '@/lib/utils';
type Band = 'small' | 'medium' | 'large';
type Category = 'Analysis' | 'Discovery' | 'Planning' | 'Authoring' | 'Review';
interface FlowMeta {
name: string;
label: string;
}
const CATEGORIES: Category[] = ['Analysis', 'Discovery', 'Planning', 'Authoring', 'Review'];
const FLOWS_BY_CATEGORY: Record<Category, FlowMeta[]> = {
Analysis: [
{ name: 'research', label: 'Research' },
{ name: 'investigate', label: 'Investigate' },
{ name: 'architectural-analysis', label: 'Architectural Analysis' },
{ name: 'security-review', label: 'Security Review' },
{ name: 'gap-analysis', label: 'Gap Analysis' },
{ name: 'data-review', label: 'Data Review' },
{ name: 'devops-review', label: 'DevOps Review' },
{ name: 'issue-triage', label: 'Issue Triage' },
],
Discovery: [
{ name: 'project-discovery', label: 'Project Discovery' },
{ name: 'project-documentation', label: 'Project Documentation' },
{ name: 'test-planning', label: 'Test Planning' },
],
Planning: [
{ name: 'plan-a-feature', label: 'Plan a Feature' },
{ name: 'plan-implementation', label: 'Plan Implementation' },
{ name: 'plan-a-phased-build', label: 'Plan a Phased Build' },
{ name: 'plan-work-items', label: 'Plan Work Items' },
{ name: 'iterative-plan-review', label: 'Iterative Plan Review' },
],
Authoring: [
{ name: 'adr', label: 'ADR' },
{ name: 'coding-standard', label: 'Coding Standard' },
{ name: 'runbook', label: 'Runbook' },
{ name: 'tdd', label: 'TDD' },
{ name: 'stakeholder-summary', label: 'Stakeholder Summary' },
],
Review: [
{ name: 'code-review', label: 'Code Review' },
],
};
const BAND_LABELS: { value: Band; label: string }[] = [
{ value: 'small', label: 'Small' },
{ value: 'medium', label: 'Medium' },
{ value: 'large', label: 'Large' },
];
export function FlowLauncherDialog() {
const [open, setOpen] = useState(false);
const [projectId, setProjectId] = useState<string>('');
const [placement, setPlacement] = useState<'new' | 'split'>('new');
const [category, setCategory] = useState<Category>('Analysis');
const [flowName, setFlowName] = useState<string>(FLOWS_BY_CATEGORY.Analysis[0]?.name ?? 'research');
const [band, setBand] = useState<Band>('small');
const [focus, setFocus] = useState('');
const [fast, setFast] = useState(false);
const [launching, setLaunching] = useState(false);
useEffect(() => {
return sessionEvents.subscribe((ev) => {
if (ev.type !== 'open_flow_launcher') return;
setProjectId(ev.project_id);
setPlacement(ev.placement ?? 'new');
// Reset to defaults each time the dialog is opened.
setCategory('Analysis');
setFlowName(FLOWS_BY_CATEGORY.Analysis[0]?.name ?? 'research');
setBand('small');
setFocus('');
setFast(false);
setOpen(true);
});
}, []);
function handleCategoryChange(cat: Category) {
setCategory(cat);
setFlowName(FLOWS_BY_CATEGORY[cat][0]?.name ?? '');
}
async function handleLaunch() {
if (!flowName || !projectId) return;
setLaunching(true);
try {
const { run_id } = await api.runs.launch({
project_id: projectId,
flow_name: flowName,
band,
input: { question: focus },
});
sessionEvents.emit({
type: 'open_orchestrator_pane',
state: { run_id, flow_name: flowName, band },
placement,
});
setOpen(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to launch flow');
} finally {
setLaunching(false);
}
}
const flows = FLOWS_BY_CATEGORY[category];
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent
className="flex flex-col gap-0 p-0 max-h-[85vh] sm:max-w-md grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden"
showCloseButton={false}
>
<DialogHeader className="px-4 pt-4 pb-3 border-b shrink-0">
<DialogTitle className="text-sm font-medium">Launch a flow</DialogTitle>
</DialogHeader>
{/* Scrollable body */}
<div className="flex flex-col gap-4 overflow-y-auto overscroll-contain px-4 py-3">
{/* Category tabs — horizontal-scroll strip on mobile */}
<div className="flex gap-1 overflow-x-auto no-scrollbar pb-0.5 shrink-0">
{CATEGORIES.map((cat) => (
<button
key={cat}
type="button"
onClick={() => handleCategoryChange(cat)}
className={cn(
'shrink-0 rounded-full border px-3 py-1 text-xs transition-colors whitespace-nowrap',
cat === category
? 'border-primary bg-primary text-primary-foreground'
: 'border-border text-muted-foreground hover:bg-muted hover:text-foreground'
)}
>
{cat}
</button>
))}
</div>
{/* Flow list */}
<div className="flex flex-col gap-0.5">
{flows.map((flow) => (
<button
key={flow.name}
type="button"
onClick={() => setFlowName(flow.name)}
className={cn(
'w-full rounded-lg px-3 py-2 text-left text-sm transition-colors',
flow.name === flowName
? 'bg-primary/10 text-primary font-medium'
: 'text-foreground hover:bg-muted'
)}
>
{flow.label}
</button>
))}
</div>
{/* Size selector */}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Size</Label>
<div className="flex gap-1.5">
{BAND_LABELS.map(({ value, label }) => (
<button
key={value}
type="button"
onClick={() => setBand(value)}
className={cn(
'flex-1 rounded-lg border py-1 text-xs transition-colors',
band === value
? 'border-primary bg-primary/10 text-primary font-medium'
: 'border-border text-muted-foreground hover:bg-muted hover:text-foreground'
)}
>
{label}
</button>
))}
</div>
</div>
{/* Focus/question field */}
<div className="flex flex-col gap-1.5">
<Label htmlFor="flow-focus" className="text-xs text-muted-foreground">
Focus / question
</Label>
<Input
id="flow-focus"
type="text"
placeholder="What should the flow focus on?"
value={focus}
onChange={(e) => setFocus(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
void handleLaunch();
}
}}
/>
</div>
{/* Fast mode toggle */}
<div className="flex items-center justify-between">
<div className="flex flex-col">
<span className="text-xs font-medium text-foreground">Fast mode</span>
<span className="text-xs text-muted-foreground">Fewer agents, quicker results</span>
</div>
<button
type="button"
role="switch"
aria-checked={fast}
onClick={() => setFast((v) => !v)}
className={cn(
'relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
fast ? 'bg-primary' : 'bg-input'
)}
>
<span
className={cn(
'pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-lg transition-transform',
fast ? 'translate-x-4' : 'translate-x-0'
)}
/>
<span className="sr-only">Fast mode</span>
</button>
</div>
</div>
<DialogFooter className="shrink-0" showCloseButton>
<Button
onClick={() => void handleLaunch()}
disabled={!flowName || launching}
size="sm"
>
{launching ? (
<>
<Zap className="size-3.5 animate-pulse" />
Launching
</>
) : (
<>
<Zap className="size-3.5" />
Launch
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}