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

@@ -9,11 +9,13 @@ import { Session } from '@/pages/Session';
import { Settings } from '@/pages/Settings';
import { Toaster } from '@/components/ui/sonner';
import { useUserEvents } from '@/hooks/useUserEvents';
import { useCoderUserEvents } from '@/hooks/useCoderUserEvents';
import { useTheme } from '@/lib/theme';
import { SidebarDrawerProvider, useSidebarDrawer } from '@/hooks/useSidebarDrawer';
import { RightRailDrawerProvider, useRightRailDrawer } from '@/hooks/useRightRailDrawer';
import { useViewport } from '@/hooks/useViewport';
import { ThemeFx } from '@/components/fx/ThemeFx';
import { FlowLauncherDialog } from '@/components/FlowLauncherDialog';
function SessionRightRail() {
const { id } = useParams<{ id: string }>();
@@ -69,6 +71,7 @@ function AppShell() {
// theme class on <html> is correct before any child renders.
useTheme();
useUserEvents();
useCoderUserEvents();
// v1.10.8c: h-dvh (dynamic viewport) instead of h-screen (100vh) so the
// root height excludes the iOS URL-bar overlay area. Without this, every
// descendant — including the terminal pane — measures itself against a
@@ -98,6 +101,7 @@ function AppShell() {
<Route path="/session/:id" element={<SessionRightRail />} />
</Routes>
<Toaster position="bottom-right" />
<FlowLauncherDialog />
</div>
</>
);

View File

@@ -25,6 +25,8 @@ import type {
PermissionPrompt,
AgentCommand,
WorkspaceState,
FlowRunRow,
FlowStepRow,
} from './types';
// v2.6 Phase 1-UX §9b: chat-scoped agent-session rows. Returned by
@@ -483,6 +485,34 @@ export const api = {
}),
},
// Orchestrator run API — proxied to boocoder at /api/coder/runs/*.
runs: {
launch: (body: {
project_id: string;
flow_name: string;
band: 'small' | 'medium' | 'large';
input: { question: string };
model?: string;
}) =>
request<{ run_id: string }>('/api/coder/runs', {
method: 'POST',
body: JSON.stringify(body),
}),
list: (projectId: string) =>
request<{ runs: FlowRunRow[] }>(
`/api/coder/runs?project_id=${encodeURIComponent(projectId)}`,
),
get: (runId: string) =>
request<{ run: FlowRunRow; steps: FlowStepRow[] }>(
`/api/coder/runs/${encodeURIComponent(runId)}`,
),
cancel: (runId: string) =>
request<{ cancelled: boolean }>(
`/api/coder/runs/${encodeURIComponent(runId)}/cancel`,
{ method: 'POST' },
),
},
agents: {
list: (projectId: string) =>
request<AgentsResponse>(`/api/projects/${projectId}/agents`),

View File

@@ -390,7 +390,8 @@ export type WorkspacePaneKind =
| 'empty'
| 'settings'
| 'markdown_artifact'
| 'html_artifact';
| 'html_artifact'
| 'orchestrator';
// Mixed tabs: a pane can hold tabs of different kinds (a BooChat tab next to a
// BooCode tab next to a Terminal tab). Each tab carries its own kind; the active
@@ -416,6 +417,45 @@ export interface HtmlArtifactState {
title: string;
}
// Orchestrator pane state — carries run identity for fetch-on-mount + reopen.
export interface OrchestratorState {
run_id: string;
flow_name: string;
band: 'small' | 'medium' | 'large';
}
// Orchestrator run API types (returned by GET /api/coder/runs/:id).
export interface FlowRunRow {
id: string;
project_id: string;
flow_name: string;
band: 'small' | 'medium' | 'large';
model: string;
status: 'running' | 'completed' | 'failed' | 'cancelled';
input: { question: string; band?: string; [key: string]: unknown };
report: string | null;
error: string | null;
created_at: string;
updated_at: string;
}
export interface FlowStepRow {
id: string;
run_id: string;
step_id: string;
kind: 'agent' | 'code';
agent: string | null;
status: 'pending' | 'running' | 'completed' | 'failed' | 'skipped' | 'cancelled';
task_id: string | null;
chat_id: string | null;
session_id: string | null;
input: string | null;
output: string | null;
error: string | null;
created_at: string;
updated_at: string;
}
export interface WorkspacePane {
id: string;
// For a tabbed pane (chat/coder/terminal) this mirrors the ACTIVE tab's kind,
@@ -433,6 +473,8 @@ export interface WorkspacePane {
// v1.14.x: populated only when kind === 'markdown_artifact' / 'html_artifact'.
markdown_artifact_state?: MarkdownArtifactState;
html_artifact_state?: HtmlArtifactState;
// orchestrator pane: populated only when kind === 'orchestrator'.
orchestrator_state?: OrchestratorState;
}
// Reopen LIFO stack entry. Shape unchanged from the prior module-level stack;
@@ -527,4 +569,27 @@ export type WsFrame =
status: 'working' | 'blocked' | 'idle' | 'error';
reason?: string;
at: string;
}
// orchestrator frames ([D-6]): run lifecycle. The per-agent content stream
// reuses existing delta/tool_call/message_complete frames keyed by chat_id.
| {
type: 'flow_run_started';
run_id: string;
flow_name: string;
band: 'small' | 'medium' | 'large';
steps: Array<{
step_id: string;
agent: string;
kind: 'agent' | 'code';
chat_id: string;
label: string;
}>;
}
| {
type: 'flow_run_step_updated';
run_id: string;
step_id: string;
status: 'pending' | 'running' | 'completed' | 'failed' | 'skipped' | 'cancelled';
run_status?: 'running' | 'completed' | 'failed' | 'cancelled';
report?: string;
};

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>

View File

@@ -36,6 +36,7 @@ interface Props {
// Mixed tabs: the "+" adds a tab of the chosen kind to THIS pane.
onNewTab: (kind: WorkspaceTabKind) => void;
onSplitPane: (kind: 'chat' | 'terminal' | 'coder') => void;
onNewOrchestrator?: () => void;
onReopenPane?: () => void;
onShowHistory: () => void;
onRename: (chatId: string, name: string) => Promise<void>;
@@ -67,6 +68,7 @@ export function ChatTabBar({
onCloseAll,
onNewTab,
onSplitPane,
onNewOrchestrator,
onReopenPane,
onShowHistory,
onRename,
@@ -227,6 +229,7 @@ export function ChatTabBar({
<PaneHeaderActions
onNewTab={onNewTab}
onSplitPane={onSplitPane}
onNewOrchestrator={onNewOrchestrator}
onReopenPane={onReopenPane}
onShowHistory={onShowHistory}
onRemovePane={onRemovePane}

View File

@@ -0,0 +1,266 @@
import { useState, useEffect } from 'react';
import { Zap } from 'lucide-react';
import { toast } from 'sonner';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { api } from '@/api/client';
import { sessionEvents } from '@/hooks/sessionEvents';
import { cn } from '@/lib/utils';
type Band = 'small' | 'medium' | 'large';
type Category = 'Analysis' | 'Discovery' | 'Planning' | 'Authoring' | 'Review';
interface FlowMeta {
name: string;
label: string;
}
const CATEGORIES: Category[] = ['Analysis', 'Discovery', 'Planning', 'Authoring', 'Review'];
const FLOWS_BY_CATEGORY: Record<Category, FlowMeta[]> = {
Analysis: [
{ name: 'research', label: 'Research' },
{ name: 'investigate', label: 'Investigate' },
{ name: 'architectural-analysis', label: 'Architectural Analysis' },
{ name: 'security-review', label: 'Security Review' },
{ name: 'gap-analysis', label: 'Gap Analysis' },
{ name: 'data-review', label: 'Data Review' },
{ name: 'devops-review', label: 'DevOps Review' },
{ name: 'issue-triage', label: 'Issue Triage' },
],
Discovery: [
{ name: 'project-discovery', label: 'Project Discovery' },
{ name: 'project-documentation', label: 'Project Documentation' },
{ name: 'test-planning', label: 'Test Planning' },
],
Planning: [
{ name: 'plan-a-feature', label: 'Plan a Feature' },
{ name: 'plan-implementation', label: 'Plan Implementation' },
{ name: 'plan-a-phased-build', label: 'Plan a Phased Build' },
{ name: 'plan-work-items', label: 'Plan Work Items' },
{ name: 'iterative-plan-review', label: 'Iterative Plan Review' },
],
Authoring: [
{ name: 'adr', label: 'ADR' },
{ name: 'coding-standard', label: 'Coding Standard' },
{ name: 'runbook', label: 'Runbook' },
{ name: 'tdd', label: 'TDD' },
{ name: 'stakeholder-summary', label: 'Stakeholder Summary' },
],
Review: [
{ name: 'code-review', label: 'Code Review' },
],
};
const BAND_LABELS: { value: Band; label: string }[] = [
{ value: 'small', label: 'Small' },
{ value: 'medium', label: 'Medium' },
{ value: 'large', label: 'Large' },
];
export function FlowLauncherDialog() {
const [open, setOpen] = useState(false);
const [projectId, setProjectId] = useState<string>('');
const [placement, setPlacement] = useState<'new' | 'split'>('new');
const [category, setCategory] = useState<Category>('Analysis');
const [flowName, setFlowName] = useState<string>(FLOWS_BY_CATEGORY.Analysis[0]?.name ?? 'research');
const [band, setBand] = useState<Band>('small');
const [focus, setFocus] = useState('');
const [fast, setFast] = useState(false);
const [launching, setLaunching] = useState(false);
useEffect(() => {
return sessionEvents.subscribe((ev) => {
if (ev.type !== 'open_flow_launcher') return;
setProjectId(ev.project_id);
setPlacement(ev.placement ?? 'new');
// Reset to defaults each time the dialog is opened.
setCategory('Analysis');
setFlowName(FLOWS_BY_CATEGORY.Analysis[0]?.name ?? 'research');
setBand('small');
setFocus('');
setFast(false);
setOpen(true);
});
}, []);
function handleCategoryChange(cat: Category) {
setCategory(cat);
setFlowName(FLOWS_BY_CATEGORY[cat][0]?.name ?? '');
}
async function handleLaunch() {
if (!flowName || !projectId) return;
setLaunching(true);
try {
const { run_id } = await api.runs.launch({
project_id: projectId,
flow_name: flowName,
band,
input: { question: focus },
});
sessionEvents.emit({
type: 'open_orchestrator_pane',
state: { run_id, flow_name: flowName, band },
placement,
});
setOpen(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to launch flow');
} finally {
setLaunching(false);
}
}
const flows = FLOWS_BY_CATEGORY[category];
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent
className="flex flex-col gap-0 p-0 max-h-[85vh] sm:max-w-md grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden"
showCloseButton={false}
>
<DialogHeader className="px-4 pt-4 pb-3 border-b shrink-0">
<DialogTitle className="text-sm font-medium">Launch a flow</DialogTitle>
</DialogHeader>
{/* Scrollable body */}
<div className="flex flex-col gap-4 overflow-y-auto overscroll-contain px-4 py-3">
{/* Category tabs — horizontal-scroll strip on mobile */}
<div className="flex gap-1 overflow-x-auto no-scrollbar pb-0.5 shrink-0">
{CATEGORIES.map((cat) => (
<button
key={cat}
type="button"
onClick={() => handleCategoryChange(cat)}
className={cn(
'shrink-0 rounded-full border px-3 py-1 text-xs transition-colors whitespace-nowrap',
cat === category
? 'border-primary bg-primary text-primary-foreground'
: 'border-border text-muted-foreground hover:bg-muted hover:text-foreground'
)}
>
{cat}
</button>
))}
</div>
{/* Flow list */}
<div className="flex flex-col gap-0.5">
{flows.map((flow) => (
<button
key={flow.name}
type="button"
onClick={() => setFlowName(flow.name)}
className={cn(
'w-full rounded-lg px-3 py-2 text-left text-sm transition-colors',
flow.name === flowName
? 'bg-primary/10 text-primary font-medium'
: 'text-foreground hover:bg-muted'
)}
>
{flow.label}
</button>
))}
</div>
{/* Size selector */}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Size</Label>
<div className="flex gap-1.5">
{BAND_LABELS.map(({ value, label }) => (
<button
key={value}
type="button"
onClick={() => setBand(value)}
className={cn(
'flex-1 rounded-lg border py-1 text-xs transition-colors',
band === value
? 'border-primary bg-primary/10 text-primary font-medium'
: 'border-border text-muted-foreground hover:bg-muted hover:text-foreground'
)}
>
{label}
</button>
))}
</div>
</div>
{/* Focus/question field */}
<div className="flex flex-col gap-1.5">
<Label htmlFor="flow-focus" className="text-xs text-muted-foreground">
Focus / question
</Label>
<Input
id="flow-focus"
type="text"
placeholder="What should the flow focus on?"
value={focus}
onChange={(e) => setFocus(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
void handleLaunch();
}
}}
/>
</div>
{/* Fast mode toggle */}
<div className="flex items-center justify-between">
<div className="flex flex-col">
<span className="text-xs font-medium text-foreground">Fast mode</span>
<span className="text-xs text-muted-foreground">Fewer agents, quicker results</span>
</div>
<button
type="button"
role="switch"
aria-checked={fast}
onClick={() => setFast((v) => !v)}
className={cn(
'relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
fast ? 'bg-primary' : 'bg-input'
)}
>
<span
className={cn(
'pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-lg transition-transform',
fast ? 'translate-x-4' : 'translate-x-0'
)}
/>
<span className="sr-only">Fast mode</span>
</button>
</div>
</div>
<DialogFooter className="shrink-0" showCloseButton>
<Button
onClick={() => void handleLaunch()}
disabled={!flowName || launching}
size="sm"
>
{launching ? (
<>
<Zap className="size-3.5 animate-pulse" />
Launching
</>
) : (
<>
<Zap className="size-3.5" />
Launch
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -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>
);

View File

@@ -1,4 +1,4 @@
import { Code, Columns2, History, MessageSquare, Plus, RotateCcw, Terminal, X } from 'lucide-react';
import { Code, Columns2, History, MessageSquare, Plus, RotateCcw, Terminal, Workflow, X } from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
@@ -16,6 +16,9 @@ interface Props {
// (the second control) adds a new pane.
onNewTab: (kind: 'chat' | 'terminal' | 'coder') => void;
onSplitPane: (kind: 'chat' | 'terminal' | 'coder') => void;
// When provided, shows a "New Orchestrator" item that opens the flow launcher.
// Orchestrators are always split (run-bound; can't live as a tab in another pane).
onNewOrchestrator?: () => void;
onReopenPane?: () => void;
onShowHistory: () => void;
onRemovePane?: () => void;
@@ -31,6 +34,7 @@ const BTN =
export function PaneHeaderActions({
onNewTab,
onSplitPane,
onNewOrchestrator,
onReopenPane,
onShowHistory,
onRemovePane,
@@ -62,6 +66,11 @@ export function PaneHeaderActions({
<DropdownMenuItem onSelect={() => onNewTab('coder')}>
<Code size={14} /> New BooCode
</DropdownMenuItem>
{onNewOrchestrator && (
<DropdownMenuItem onSelect={onNewOrchestrator}>
<Workflow size={14} /> New Orchestrator
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
@@ -87,6 +96,11 @@ export function PaneHeaderActions({
<DropdownMenuItem onSelect={() => onSplitPane('coder')}>
<Code size={14} /> New BooCode
</DropdownMenuItem>
{onNewOrchestrator && (
<DropdownMenuItem onSelect={onNewOrchestrator}>
<Workflow size={14} /> New Orchestrator
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { api } from '@/api/client';
import type { Project, Session, WorkspacePane, WorkspaceTabKind } from '@/api/types';
import { sessionEvents } from '@/hooks/sessionEvents';
import { activePaneChatId, type UseWorkspacePanesResult } from '@/hooks/useWorkspacePanes';
import type { UseSessionChatsResult } from '@/hooks/useSessionChats';
import { useViewport } from '@/hooks/useViewport';
@@ -11,6 +12,7 @@ import { TerminalPane } from '@/components/panes/TerminalPane';
import { CoderPane } from '@/components/panes/CoderPane';
import { MarkdownArtifactPane } from '@/components/MarkdownArtifactPane';
import { HtmlArtifactPane } from '@/components/HtmlArtifactPane';
import { OrchestratorPane } from '@/components/panes/OrchestratorPane';
import { ChatTabBar, type TabDescriptor } from '@/components/ChatTabBar';
import { SessionLandingPage } from '@/components/SessionLandingPage';
import { cn } from '@/lib/utils';
@@ -124,6 +126,14 @@ export function Workspace({
if (maximized && settingsIdx < 0) setMaximized(false);
}, [maximized, settingsIdx]);
function handleNewOrchestrator() {
sessionEvents.emit({
type: 'open_flow_launcher',
project_id: projectId,
placement: 'split',
});
}
// v1.10 booterm + mixed tabs: per-terminal-TAB label, keyed by the terminal
// tab id (which keys its tmux session). Numbered across the workspace.
const terminalLabels = useMemo(() => {
@@ -169,6 +179,7 @@ export function Workspace({
const isSettings = pane.kind === 'settings';
const isTerminal = pane.kind === 'terminal';
const isCoder = pane.kind === 'coder';
const isOrchestrator = pane.kind === 'orchestrator';
const isArtifact = pane.kind === 'markdown_artifact' || pane.kind === 'html_artifact';
// v1.9: when maximized, hide every pane except the settings one.
// display:none keeps the React tree mounted so streams / drafts
@@ -181,8 +192,8 @@ export function Workspace({
}
return null;
}
// Terminal + coder panes own their tab strip (no chats, no ChatTabBar).
const isChromeless = isSettings || isTerminal || isCoder || isArtifact;
// Terminal + coder + orchestrator panes own their tab strip (no chats, no ChatTabBar).
const isChromeless = isSettings || isTerminal || isCoder || isArtifact || isOrchestrator;
return (
<div
key={pane.id}
@@ -207,7 +218,7 @@ export function Workspace({
(chat / coder / terminal / empty-landing). The "+" adds a tab
of any kind; Split adds a pane. Settings/artifact panes own
their own headers. Hidden on mobile (mobile uses pane panes). */}
{!isMobile && !isSettings && !isArtifact && (
{!isMobile && !isSettings && !isArtifact && !isOrchestrator && (
<ChatTabBar
pane={pane}
tabs={paneTabs(pane)}
@@ -219,6 +230,7 @@ export function Workspace({
onCloseAll={() => closeAllTabs(idx)}
onNewTab={(kind) => void createTab(idx, kind)}
onSplitPane={(kind) => onAddPane(kind)}
onNewOrchestrator={handleNewOrchestrator}
onReopenPane={hasClosedPanes ? reopenPane : undefined}
onShowHistory={() => openSessionHistory(idx)}
onRename={renameChat}
@@ -260,6 +272,11 @@ export function Workspace({
chatPending={isPaneChatPending(pane.id)}
projectPath={project?.path}
/>
) : pane.kind === 'orchestrator' && pane.orchestrator_state ? (
<OrchestratorPane
state={pane.orchestrator_state}
onClose={() => removePane(idx)}
/>
) : pane.kind === 'markdown_artifact' && pane.markdown_artifact_state ? (
<MarkdownArtifactPane
chatId={pane.markdown_artifact_state.chat_id}

View File

@@ -0,0 +1,458 @@
// OrchestratorPane — run view for a flow run (Phase 8).
//
// Subscribes to the coder user channel (via useCoderUserEvents → sessionEvents)
// for run-level frames (flow_run_started / flow_run_step_updated). Per-step
// content streams ride the existing coder per-session WS frames (delta /
// tool_call / message_complete), connected on demand when a step is expanded.
//
// Layout per D-7:
// - Run header (flow name + band + status + stop button)
// - Final report at top when completed
// - Collapsed agent roster (one row per step, status dot + label)
// - Expand-one-at-a-time detail well (step's live stream via CoderMessageList)
// - Mobile: single column, inline expand
import { useCallback, useEffect, useRef, useState } from 'react';
import { ChevronDown, ChevronRight, MoreHorizontal, Square, Workflow, X } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api/client';
import type { FlowRunRow, FlowStepRow, OrchestratorState } from '@/api/types';
import { sessionEvents } from '@/hooks/sessionEvents';
import { chatInputsRegistry, sendToChat } from '@/lib/events';
import { CoderMessageList } from '@/components/panes/CoderMessageList';
import type { CoderTimelineWire } from '@/components/panes/CoderMessageList';
import { mergeWireToolCall } from '@/lib/coder-tools';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
// ---- step status dot (same visual language as AgentStatusDot in AgentComposerBar) --
function FlowStepStatusDot({ status }: { status: FlowStepRow['status'] }) {
if (status === 'running') {
return (
<span
aria-label="running"
className="inline-block w-3 h-3 rounded-full border-2 border-emerald-500 border-t-transparent animate-spin shrink-0"
/>
);
}
const cls =
status === 'completed'
? 'bg-emerald-500'
: status === 'failed'
? 'bg-destructive'
: status === 'cancelled'
? 'bg-muted-foreground/20'
: 'bg-muted-foreground/40'; // pending / skipped
return <span aria-label={status} className={cn('inline-block w-1.5 h-1.5 rounded-full shrink-0', cls)} />;
}
// ---- per-step stream hook ---------------------------------------------------
// Connects to the synthetic session WS for the expanded step. Returns messages
// suitable for CoderMessageList. Disconnects and clears when sessionId/chatId
// are null (collapsed step). Reuses the same frame-handling logic as CoderPane.
type RawFrame = Record<string, unknown>;
function useStepStream(sessionId: string | null, chatId: string | null): CoderTimelineWire[] {
const [messages, setMessages] = useState<CoderTimelineWire[]>([]);
const chatIdRef = useRef(chatId);
chatIdRef.current = chatId;
useEffect(() => {
if (!sessionId || !chatId) {
setMessages([]);
return;
}
setMessages([]);
// Initial REST fetch for any already-persisted messages.
api.coder.listMessages(sessionId, chatId).then((rows) => setMessages(rows)).catch(() => {});
// Live stream from the step's synthetic session.
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(`${proto}//${window.location.host}/api/coder/ws/sessions/${sessionId}`);
ws.onmessage = (ev) => {
try {
const frame = JSON.parse(ev.data as string) as RawFrame;
const scopedChatId = chatIdRef.current;
// Drop frames for other chats (except the snapshot which we filter below).
if (scopedChatId && frame.chat_id && frame.chat_id !== scopedChatId && frame.type !== 'snapshot') return;
if (frame.type === 'snapshot' && Array.isArray(frame.messages)) {
const rows = (frame.messages as Array<Record<string, unknown>>).filter(
(m) => !scopedChatId || m.chat_id === scopedChatId,
);
setMessages(rows as unknown as CoderTimelineWire[]);
} else if (frame.type === 'message_started') {
const role = (frame.role ?? 'assistant') as string;
setMessages((prev) => {
if (prev.some((m) => m.id === frame.message_id)) return prev;
return [
...prev,
{ id: frame.message_id as string, role, content: '', status: 'streaming' } as CoderTimelineWire,
];
});
} else if (frame.type === 'delta') {
setMessages((prev) =>
prev.map((m) => {
if (m.id !== frame.message_id || m.role === 'tool') return m;
const msg = m as { content: string };
return { ...m, content: msg.content + ((frame.content as string) ?? '') };
}),
);
} else if (frame.type === 'reasoning_delta') {
setMessages((prev) =>
prev.map((m) => {
if (m.id !== frame.message_id || m.role === 'tool') return m;
const msg = m as { reasoning_text?: string };
return { ...m, reasoning_text: (msg.reasoning_text ?? '') + ((frame.content as string) ?? '') };
}),
);
} else if (frame.type === 'message_complete') {
setMessages((prev) =>
prev.map((m) =>
m.id === frame.message_id && m.role !== 'tool'
? {
...m,
status: ((frame.status as string) ?? 'complete') as 'complete' | 'failed' | 'cancelled',
model: (frame.model as string | null | undefined) ?? null,
}
: m,
),
);
} else if (frame.type === 'tool_call' && frame.tool_call) {
const tc = frame.tool_call as { id: string; name: string; args?: Record<string, unknown> };
if (tc.id) {
setMessages((prev) =>
prev.map((m) => {
if (m.role !== 'assistant' || m.id !== frame.message_id) return m;
const msg = m as { tool_calls?: import('@/lib/coder-tools').CoderToolCallWire[] };
return { ...m, tool_calls: mergeWireToolCall(msg.tool_calls, { ...tc, args: tc.args ?? {} }) };
}),
);
}
} else if (frame.type === 'tool_result') {
const result = {
tool_call_id: frame.tool_call_id as string,
output: frame.output,
truncated: (frame.truncated as boolean) ?? false,
...((frame.error as string | undefined) ? { error: frame.error as string } : {}),
};
setMessages((prev) => {
const exists = prev.some((m) => m.id === frame.tool_message_id);
if (exists) {
return prev.map((m) =>
m.role === 'tool' && m.id === frame.tool_message_id ? { ...m, tool_results: result } : m,
);
}
return [
...prev,
{ id: frame.tool_message_id as string, role: 'tool' as const, tool_results: result },
];
});
}
} catch {
// bad frame — ignore
}
};
return () => {
try { ws.close(); } catch {}
};
}, [sessionId, chatId]);
return messages;
}
// ---- helpers ---------------------------------------------------------------
function humanize(slug: string): string {
return slug.replace(/[-_]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
// ---- StepRow ---------------------------------------------------------------
function StepRow({
step,
isExpanded,
onToggle,
}: {
step: FlowStepRow;
isExpanded: boolean;
onToggle: () => void;
}) {
// Only connect when expanded; null inputs cause the hook to return [] immediately.
const streamMessages = useStepStream(
isExpanded ? step.session_id : null,
isExpanded ? step.chat_id : null,
);
return (
<div>
<button
type="button"
onClick={onToggle}
className="w-full flex items-center gap-2.5 px-3 py-2.5 text-left hover:bg-muted/30 transition-colors"
>
<FlowStepStatusDot status={step.status} />
<span className="text-sm flex-1 truncate">{humanize(step.step_id)}</span>
{step.agent && (
<span className="text-xs text-muted-foreground shrink-0 hidden sm:block">{step.agent}</span>
)}
{isExpanded
? <ChevronDown size={12} className="shrink-0 text-muted-foreground" />
: <ChevronRight size={12} className="shrink-0 text-muted-foreground" />}
</button>
{isExpanded && (
<div className="border-t border-border/50 bg-muted/10 max-h-[55vh] flex flex-col overflow-hidden">
{streamMessages.length === 0 ? (
<div className="flex items-center justify-center py-6 text-sm text-muted-foreground">
{step.status === 'pending' ? 'Waiting to start…'
: step.status === 'completed' || step.status === 'failed' ? 'Loading output…'
: 'Connecting…'}
</div>
) : (
<CoderMessageList messages={streamMessages} chatId={step.chat_id ?? undefined} />
)}
</div>
)}
</div>
);
}
// ---- OrchestratorPane ------------------------------------------------------
interface Props {
state: OrchestratorState;
onClose: () => void;
}
export function OrchestratorPane({ state, onClose }: Props) {
const [run, setRun] = useState<FlowRunRow | null>(null);
const [steps, setSteps] = useState<FlowStepRow[]>([]);
const [expandedStepId, setExpandedStepId] = useState<string | null>(null);
const [stopping, setStopping] = useState(false);
// Fetch current run state on mount (handles both new runs and reopen).
useEffect(() => {
setRun(null);
setSteps([]);
setExpandedStepId(null);
api.runs.get(state.run_id)
.then(({ run: r, steps: s }) => {
setRun(r);
setSteps(s);
// Auto-expand first running step.
const firstRunning = s.find((step) => step.status === 'running');
if (firstRunning) setExpandedStepId(firstRunning.step_id);
})
.catch(() => {});
}, [state.run_id]);
// Subscribe to live run-level frames from the coder user channel
// (forwarded by useCoderUserEvents → sessionEvents).
// Idempotent: flow_run_started only seeds the roster when empty (the API
// fetch above is authoritative; the frame is a fallback for the race where
// the pane opens before the GET resolves).
useEffect(() => {
return sessionEvents.subscribe((ev) => {
if (ev.type === 'flow_run_started' && ev.run_id === state.run_id) {
setSteps((prev) => {
if (prev.length > 0) return prev;
return ev.steps.map((s) => ({
id: s.step_id,
run_id: state.run_id,
step_id: s.step_id,
kind: s.kind,
agent: s.agent,
status: 'pending' as const,
task_id: null,
chat_id: s.chat_id,
session_id: null,
input: null,
output: null,
error: null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
}));
});
} else if (ev.type === 'flow_run_step_updated' && ev.run_id === state.run_id) {
// Idempotent status update — no double-emit risk (useCoderUserEvents
// owns the WS → sessionEvents bridge; we don't also emit locally).
setSteps((prev) =>
prev.map((s) => (s.step_id === ev.step_id ? { ...s, status: ev.status } : s)),
);
if (ev.run_status) {
setRun((prev) =>
prev ? { ...prev, status: ev.run_status!, report: ev.report ?? prev.report } : prev,
);
}
// Auto-expand the step that just went running.
if (ev.status === 'running') setExpandedStepId(ev.step_id);
}
});
}, [state.run_id]);
const toggleExpand = useCallback((stepId: string) => {
setExpandedStepId((prev) => (prev === stepId ? null : stepId));
}, []);
const handleStop = useCallback(async () => {
if (stopping) return;
setStopping(true);
try {
await api.runs.cancel(state.run_id);
} catch {
// non-fatal
} finally {
setStopping(false);
}
}, [state.run_id, stopping]);
const runStatus = run?.status ?? 'running';
const isRunning = runStatus === 'running';
const agentSteps = steps.filter((s) => s.kind === 'agent');
const hasReport = runStatus === 'completed' && !!run?.report;
function handleCopyReport() {
if (!run?.report) return;
navigator.clipboard.writeText(run.report).catch(() => toast.error('Clipboard write failed'));
}
function handleSaveReport() {
if (!run?.report) return;
const blob = new Blob([run.report], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${run.flow_name}-report.md`;
a.click();
URL.revokeObjectURL(url);
}
function handleSendToChat() {
if (!run?.report) return;
const chats = chatInputsRegistry.list();
const first = chats[0];
if (!first) {
toast.error('No open chat to send to');
return;
}
sendToChat.emit({ chat_id: first.chatId, text: run.report });
}
return (
<div className="flex flex-col h-full min-h-0 overflow-hidden">
{/* Header */}
<div className="flex items-center gap-2 border-b border-border bg-muted/20 px-3 py-2 shrink-0">
<Workflow size={14} className="text-muted-foreground shrink-0" />
<span className="text-sm font-medium truncate">{humanize(state.flow_name)}</span>
<span className="text-xs text-muted-foreground shrink-0 capitalize">{state.band}</span>
<div className="ml-auto flex items-center gap-1.5 shrink-0">
{isRunning ? (
<button
type="button"
onClick={() => void handleStop()}
disabled={stopping}
className="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded border border-border text-muted-foreground hover:text-foreground hover:bg-muted disabled:opacity-50"
title="Stop run"
>
<Square size={10} />
Stop
</button>
) : (
<span
className={cn(
'text-xs px-1.5 py-0.5 rounded',
runStatus === 'completed'
? 'text-emerald-600 bg-emerald-500/10'
: runStatus === 'failed'
? 'text-destructive bg-destructive/10'
: 'text-muted-foreground bg-muted/40',
)}
>
{runStatus}
</span>
)}
{hasReport && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label="Export options"
title="Export report"
>
<MoreHorizontal size={14} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={handleCopyReport}>
Copy report
</DropdownMenuItem>
<DropdownMenuItem onSelect={handleSaveReport}>
Save to file
</DropdownMenuItem>
<DropdownMenuItem onSelect={handleSendToChat}>
Send to chat
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
<button
type="button"
onClick={onClose}
className="inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label="Close pane"
title="Close pane"
>
<X size={12} />
</button>
</div>
</div>
{/* Body */}
<div className="flex-1 min-h-0 overflow-y-auto">
{/* Final report — at the top when completed */}
{run?.report && (
<div className="border-b border-border p-4">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2 pb-1 border-b border-border/50">
Report
</div>
<div className="text-sm text-foreground whitespace-pre-wrap leading-relaxed">
{run.report}
</div>
</div>
)}
{/* Empty state */}
{agentSteps.length === 0 && !run?.report && (
<div className="flex items-center justify-center h-24 text-sm text-muted-foreground">
Starting run
</div>
)}
{/* Agent roster — collapsed by default, expand one at a time */}
<div className="divide-y divide-border">
{agentSteps.map((step) => (
<StepRow
key={step.step_id}
step={step}
isExpanded={expandedStepId === step.step_id}
onToggle={() => toggleExpand(step.step_id)}
/>
))}
</div>
</div>
</div>
);
}

View File

@@ -7,6 +7,7 @@ import type {
ErrorReason,
HtmlArtifactState,
MarkdownArtifactState,
OrchestratorState,
Project,
Session,
} from '@/api/types';
@@ -184,6 +185,52 @@ export interface GitDiffRefreshEvent {
type: 'git_diff_refresh';
}
// Orchestrator: emitted client-side to open an orchestrator pane.
// useWorkspacePanes subscribes and inserts the pane (or focuses an existing one).
// placement carries the surface context: 'new' (+ menu) or 'split' (split-pane
// menu). addOrchestratorPane appends in both cases; the hint is available for
// future positional differentiation.
export interface OpenOrchestratorPaneEvent {
type: 'open_orchestrator_pane';
state: OrchestratorState;
placement?: 'new' | 'split';
}
// Orchestrator: emitted by the Workflow button on ChatInput (or "New Orchestrator"
// menu items) to request the flow launcher dialog. Carries the current pane's
// project and the placement context ('new' from the + menu, 'split' from the
// split-pane menu) so the resulting open_orchestrator_pane can be placed correctly.
export interface OpenFlowLauncherEvent {
type: 'open_flow_launcher';
project_id: string;
placement?: 'new' | 'split';
}
// Orchestrator: run-level frames forwarded from the coder user channel by
// useCoderUserEvents. OrchestratorPane subscribes to update its roster/report.
export interface FlowRunStartedEvent {
type: 'flow_run_started';
run_id: string;
flow_name: string;
band: 'small' | 'medium' | 'large';
steps: Array<{
step_id: string;
agent: string;
kind: 'agent' | 'code';
chat_id: string;
label: string;
}>;
}
export interface FlowRunStepUpdatedEvent {
type: 'flow_run_step_updated';
run_id: string;
step_id: string;
status: 'pending' | 'running' | 'completed' | 'failed' | 'skipped' | 'cancelled';
run_status?: 'running' | 'completed' | 'failed' | 'cancelled';
report?: string;
}
export type SessionEvent =
| SessionRenamedEvent
| ProjectCreatedEvent
@@ -211,7 +258,11 @@ export type SessionEvent =
| ProjectUpdatedEvent
| ChatStatusEvent
| RefetchMessagesEvent
| GitDiffRefreshEvent;
| GitDiffRefreshEvent
| OpenOrchestratorPaneEvent
| FlowRunStartedEvent
| FlowRunStepUpdatedEvent
| OpenFlowLauncherEvent;
type Listener = (event: SessionEvent) => void;
const listeners = new Set<Listener>();

View File

@@ -0,0 +1,75 @@
// Coder user-channel WS — mirrors useUserEvents but connects to the BooCoder
// host service's /api/coder/ws/user endpoint. Forwards flow_run_started and
// flow_run_step_updated frames onto the sessionEvents bus so OrchestratorPane
// can subscribe to run-level lifecycle updates without a per-session WS.
//
// Event-dedup discipline: do NOT additionally emit these frames locally after
// a POST /api/coder/runs call — this hook forwards the authoritative WS frame.
import { useEffect } from 'react';
import { WsFrameSchema } from '@boocode/contracts/ws-frames';
import { sessionEvents } from './sessionEvents';
import type { FlowRunStartedEvent, FlowRunStepUpdatedEvent } from './sessionEvents';
const RECONNECT_INITIAL_MS = 1000;
const RECONNECT_MAX_MS = 30_000;
export function useCoderUserEvents(): void {
useEffect(() => {
let ws: WebSocket | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let reconnectDelay = RECONNECT_INITIAL_MS;
let unmounted = false;
const connect = () => {
if (unmounted) return;
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${proto}//${window.location.host}/api/coder/ws/user`);
ws.onopen = () => {
reconnectDelay = RECONNECT_INITIAL_MS;
};
ws.onmessage = (ev) => {
let raw: unknown;
try {
raw = JSON.parse(ev.data as string);
} catch {
return;
}
const validated = WsFrameSchema.safeParse(raw);
if (!validated.success) {
console.error('ws-frame-validation-failed (coder user channel)', {
frame_type: (raw as { type?: unknown })?.type,
errors: validated.error.flatten(),
});
return;
}
const frame = validated.data;
if (frame.type === 'flow_run_started') {
sessionEvents.emit(frame as unknown as FlowRunStartedEvent);
} else if (frame.type === 'flow_run_step_updated') {
sessionEvents.emit(frame as unknown as FlowRunStepUpdatedEvent);
}
};
ws.onclose = () => {
if (unmounted) return;
const delay = reconnectDelay;
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS);
reconnectTimer = setTimeout(connect, delay);
};
ws.onerror = () => {
try { ws?.close(); } catch {}
};
};
connect();
return () => {
unmounted = true;
if (reconnectTimer) clearTimeout(reconnectTimer);
if (ws) try { ws.close(); } catch {}
};
}, []);
}

View File

@@ -198,6 +198,12 @@ function applyFrame(state: State, frame: WsFrame): State {
// TS exhaustiveness satisfied (native sessions never emit it).
return state;
}
case 'flow_run_started':
case 'flow_run_step_updated': {
// Orchestrator frames consumed by OrchestratorPane's own subscription.
// No-op here to keep TS exhaustiveness satisfied.
return state;
}
}
}

View File

@@ -189,6 +189,12 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess
case 'git_diff_refresh':
// Consumed by useGitDiff; no sidebar state change needed.
return prev;
case 'open_orchestrator_pane':
case 'open_flow_launcher':
case 'flow_run_started':
case 'flow_run_step_updated':
// Consumed by useWorkspacePanes / OrchestratorPane / FlowLauncherDialog; sidebar has no stake.
return prev;
case 'project_archived': {
const next = prev.projects.filter((p) => p.id !== event.project_id);
if (next.length === prev.projects.length) return prev;

View File

@@ -6,6 +6,7 @@ import type {
ClosedPaneEntry,
HtmlArtifactState,
MarkdownArtifactState,
OrchestratorState,
WorkspacePane,
WorkspaceState,
WorkspaceTabKind,
@@ -176,6 +177,16 @@ function htmlArtifactPane(state: HtmlArtifactState): WorkspacePane {
};
}
function orchestratorPane(state: OrchestratorState): WorkspacePane {
return {
id: generateId(),
kind: 'orchestrator',
chatIds: [],
activeChatIdx: -1,
orchestrator_state: state,
};
}
// v1.9: settings panes are ephemeral. Filter them out before persisting so a
// page reload always returns to a clean workspace; the user re-opens via the
// sidebar Settings button when needed.
@@ -277,6 +288,8 @@ export interface UseWorkspacePanesResult {
addSplitPane: (kind: 'chat' | 'terminal' | 'coder') => string | null;
/** Mixed tabs: add a tab of any kind to a pane (the "+" menu). */
createTab: (paneIdx: number, kind: WorkspaceTabKind) => Promise<void>;
/** Open an orchestrator run pane (or focus an existing one for the same run_id). */
addOrchestratorPane: (state: OrchestratorState) => string | null;
/** Back-compat alias for createTab(paneIdx, 'coder'). */
createCoderTab: (paneIdx: number) => Promise<void>;
// Open-on-first-click, close-on-second-click. Singleton — settings panes
@@ -831,6 +844,39 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
return success ? newPaneId : null;
}, [seedPaneChat]);
const addOrchestratorPane = useCallback((state: OrchestratorState): string | null => {
let openedId: string | null = null;
setPanes((prev) => {
// Dedup: focus an existing pane for the same run.
const existingIdx = prev.findIndex(
(p) => p.kind === 'orchestrator' && p.orchestrator_state?.run_id === state.run_id,
);
if (existingIdx >= 0) {
setActivePaneIdx(existingIdx);
openedId = prev[existingIdx]!.id;
return prev;
}
if (nonSettingsCount(prev) >= MAX_PANES) {
toast.error(`Maximum ${MAX_PANES} panes`);
return prev;
}
const newPane = orchestratorPane(state);
openedId = newPane.id;
const next = [...prev, newPane];
setActivePaneIdx(next.length - 1);
return next;
});
return openedId;
}, []);
// Orchestrator pane: open via sessionEvents (fired by ChatInput slash/button).
useEffect(() => {
return sessionEvents.subscribe((ev) => {
if (ev.type !== 'open_orchestrator_pane') return;
addOrchestratorPane(ev.state);
});
}, [addOrchestratorPane]);
// Returns the new settings pane id when one is OPENED (so mobile callers can
// push ?pane= atomically — see addPaneAndSwitch), or null when it was closed.
// Id generated outside the updater so a strict-mode double-invoke agrees.
@@ -1074,6 +1120,7 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
closeSessionHistory,
addSplitPane,
createTab,
addOrchestratorPane,
createCoderTab,
toggleSettingsPane,
removePane,

View File

@@ -420,6 +420,7 @@ function SessionInner({ sessionId }: { sessionId: string }) {
<NewPaneMenu
onAddPane={addPaneAndSwitch}
disabled={panes.length >= MAX_PANES}
projectId={session?.project_id}
/>
{activePane && panes.length > 1 && (
<button