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

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type KeyboardEvent } from 'react';
import { Globe, ListPlus, Paperclip, Send, Square, SquareSlash } from 'lucide-react';
import { Globe, ListPlus, Paperclip, Send, Square, SquareSlash, Workflow } from 'lucide-react';
import { toast } from 'sonner';
import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button';
@@ -17,7 +17,7 @@ import { FileMentionPopover } from '@/components/FileMentionPopover';
import { DropOverlay } from '@/components/DropOverlay';
import { AgentPicker } from '@/components/AgentPicker';
import { ContextMeter } from '@/components/ContextMeter';
import { SlashCommandPicker, type SlashCommandGroup } from '@/components/SlashCommandPicker';
import { SlashCommandPicker, type SlashCommandGroup, type SlashCommandItem } from '@/components/SlashCommandPicker';
import { isSlashCommandToken, parseSlashInput, slashQuery } from '@/lib/slash-command';
import { api } from '@/api/client';
import type { Message } from '@/api/types';
@@ -28,6 +28,23 @@ import { useViewport } from '@/hooks/useViewport';
const MAX_ATTACHMENTS = 10;
// Read-only analysis/review flows surfaced as slash commands in both panes.
// Full 22-flow catalog is exposed via the Workflow button (FlowLauncherDialog, Phase 10).
const FLOW_SLASH_ITEMS: SlashCommandItem[] = [
{ name: 'research', description: 'options, prior art, trade-offs → recommendation' },
{ name: 'investigate', description: 'root-cause a bug/failure from evidence' },
{ name: 'code-review', description: 'per-dimension review → adversarially verify findings' },
{ name: 'architectural-analysis',description: 'structure + behaviour + concurrency → architecture synthesis' },
{ name: 'security-review', description: 'adversarial security analysis (exploit-path proof standard)' },
{ name: 'gap-analysis', description: 'gaps between two artifacts (impl vs spec, etc.)' },
{ name: 'data-review', description: 'schema / query / data-access audit' },
{ name: 'devops-review', description: 'production-readiness / operability review' },
{ name: 'issue-triage', description: 'assess + prioritise a reported issue' },
{ name: 'project-discovery', description: 'discover a repo: stack, structure, tooling' },
{ name: 'test-planning', description: 'behaviour-focused test plan' },
];
const FLOW_COMMAND_NAMES = new Set(FLOW_SLASH_ITEMS.map((c) => c.name));
interface Props {
disabled?: boolean;
projectId: string;
@@ -108,14 +125,27 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
for (const s of skills) m.set(s.name, true);
return m;
}, [skills]);
// Flat display source for the hint (and the picker's no-groups fallback):
// caller-provided groups flattened, else the BooChat skills.
// Effective slash groups: caller groups (CoderPane) or skills group (BooChat),
// always with the Flows group appended — gives both panes the orchestrator flows
// in their slash menus from one shared definition.
const effectiveSlashGroups = useMemo<SlashCommandGroup[]>(() => {
const flowGroup: SlashCommandGroup = {
label: 'Flows',
items: FLOW_SLASH_ITEMS,
icon: <Workflow className="size-3 shrink-0" />,
};
if (slashGroups) return [...slashGroups, flowGroup];
const skillItems = skills.map((s) => ({ name: s.name, description: s.description }));
if (skillItems.length > 0) {
return [{ label: 'Skills', items: skillItems }, flowGroup];
}
return [flowGroup];
}, [slashGroups, skills]);
// Flat list used for the chip count and keyboard-nav across all groups.
const slashItems = useMemo(
() =>
slashGroups
? slashGroups.flatMap((g) => g.items)
: skills.map((s) => ({ name: s.name, description: s.description })),
[slashGroups, skills],
() => effectiveSlashGroups.flatMap((g) => g.items),
[effectiveSlashGroups],
);
const [fileIndex, setFileIndex] = useState<string[] | null>(null);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
@@ -203,6 +233,35 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
if (!text && attachments.length === 0) return;
if (disabled || busy) return;
// Orchestrator flow slash commands: launch immediately via api.runs (band=small).
// Runs before skill dispatch so flow names are never misrouted as skills.
if (attachments.length === 0 && text.startsWith('/')) {
const flowParsed = parseSlashInput(text);
if (flowParsed && FLOW_COMMAND_NAMES.has(flowParsed.cmdName)) {
setBusy(true);
try {
const { run_id } = await api.runs.launch({
project_id: projectId,
flow_name: flowParsed.cmdName,
band: 'small',
input: { question: flowParsed.args.length > 0 ? flowParsed.args : flowParsed.cmdName },
});
setValue('');
setAttachments([]);
setSlashState(null);
sessionEvents.emit({
type: 'open_orchestrator_pane',
state: { run_id, flow_name: flowParsed.cmdName, band: 'small' },
});
} catch (err) {
toast.error(err instanceof Error ? err.message : `/${flowParsed.cmdName} failed`);
} finally {
setBusy(false);
}
return;
}
}
// Batch 9.6: slash-command dispatch. Only when no attachments and the
// input parses to a known skill. Falls through to onSend for unknown
// slash names (literal text) or when slash dispatch isn't wired.
@@ -654,6 +713,18 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
<span className="max-md:hidden">{slashItems.length}</span>
</button>
)}
{/* Orchestrator Workflow button — emits open_flow_launcher (Phase 10 dialog listens).
Between SquareSlash and Globe; icon-only on mobile matching the slash chip. */}
<button
type="button"
onClick={() => sessionEvents.emit({ type: 'open_flow_launcher', project_id: projectId })}
aria-label="Flow launcher"
title="Open flow launcher"
className="inline-flex items-center gap-1.5 rounded-full border border-border px-2.5 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground max-md:min-h-[36px] max-md:min-w-[36px]"
>
<Workflow className="size-3.5" />
<span className="max-md:hidden">Flows</span>
</button>
{sessionId && (
<button
type="button"
@@ -737,11 +808,11 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
<SlashCommandPicker
query={slashState.query}
items={slashItems}
groups={slashGroups}
groups={effectiveSlashGroups}
inputRef={textareaRef}
onSelect={handleSlashSelect}
onClose={() => setSlashState(null)}
emptyLabel={slashGroups ? 'No commands available' : 'No skills available'}
emptyLabel="No commands available"
/>
)}
{/* Slash-commands chip menu (click-opened); anchored to the chip. */}
@@ -749,14 +820,14 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
<SlashCommandPicker
query=""
items={slashItems}
groups={slashGroups}
groups={effectiveSlashGroups}
inputRef={cmdChipRef}
onSelect={(name) => {
setCmdMenuOpen(false);
handleSlashSelect(name);
}}
onClose={() => setCmdMenuOpen(false)}
emptyLabel={slashGroups ? 'No commands available' : 'No skills available'}
emptyLabel="No commands available"
/>
)}
</div>