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>
140 lines
5.2 KiB
TypeScript
140 lines
5.2 KiB
TypeScript
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>
|
||
);
|
||
}
|