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:
266
apps/web/src/components/FlowLauncherDialog.tsx
Normal file
266
apps/web/src/components/FlowLauncherDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user