Files
boocode/apps/web/src/components/NewPaneMenu.tsx
indifferentketchup 1937af8df9 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>
2026-06-03 15:22:48 +00:00

140 lines
5.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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. 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 onOpenChange={handleOpenChange}>
<DropdownMenuTrigger asChild>
<button
type="button"
disabled={disabled}
className="inline-flex items-center justify-center min-h-[36px] min-w-[44px] rounded-full bg-muted/40 hover:bg-muted/70 text-foreground disabled:opacity-40 disabled:cursor-not-allowed shrink-0"
aria-label="New pane"
>
<Plus size={16} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-fit">
<DropdownMenuItem onSelect={() => onAddPane('chat')}>
<MessageSquare size={14} /> New BooChat
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onAddPane('terminal')}>
<Terminal size={14} /> New BooTerm
</DropdownMenuItem>
<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>
);
}