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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user