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