Files
boocode/apps/web/src/components/FlowLauncherDialog.tsx
indifferentketchup 868d9db3f2 feat(orchestrator): expandable flow + slash-menu explanations
Each flow row in the launcher and each command in the / slash picker now
shows an always-on one-liner with a chevron that expands a 1-2 sentence
what/when blurb (condensed from the Han skill descriptions). Launcher gets a
read-only pill and a per-row Run separate from expand; the fast/concise
toggle is now wired through to the conductor workers. Shared ChatInput, so
the slash explanations cover both BooChat and BooCoder. Web tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:17:37 +00:00

327 lines
13 KiB
TypeScript

import { useState, useEffect } from 'react';
import { ChevronRight, Play, Loader2, ShieldCheck } 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';
import { FLOW_INFO } from './flowBlurbs';
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 [band, setBand] = useState<Band>('small');
const [focus, setFocus] = useState('');
const [fast, setFast] = useState(false);
// Which flow row is expanded (one at a time) vs. which flow is mid-launch.
// Expand (chevron) and launch (Run) are deliberately separate actions.
const [expandedFlow, setExpandedFlow] = useState<string | null>(null);
const [launchingFlow, setLaunchingFlow] = useState<string | null>(null);
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');
setBand('small');
setFocus('');
setFast(false);
setExpandedFlow(null);
setLaunchingFlow(null);
setOpen(true);
});
}, []);
function handleCategoryChange(cat: Category) {
setCategory(cat);
setExpandedFlow(null);
}
// Per-row launch. Behaviour is identical to the old bottom Launch button:
// POST the run, then emit open_orchestrator_pane carrying the placement.
async function handleRun(name: string) {
if (!name || !projectId || launchingFlow) return;
setLaunchingFlow(name);
try {
const { run_id } = await api.runs.launch({
project_id: projectId,
flow_name: name,
band,
input: { question: focus, ...(fast ? { concise: true } : {}) },
});
sessionEvents.emit({
type: 'open_orchestrator_pane',
state: { run_id, flow_name: name, band },
placement,
});
setOpen(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to launch flow');
} finally {
setLaunchingFlow(null);
}
}
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="gap-2 px-4 pt-4 pb-3 border-b shrink-0">
<DialogTitle className="text-sm font-medium">Launch a flow</DialogTitle>
{/* Read-only assurance — flows never touch your code. */}
<div className="inline-flex w-fit items-center gap-1.5 rounded-md bg-muted/60 px-2 py-1 text-xs text-muted-foreground">
<ShieldCheck className="size-3.5 shrink-0 text-primary/70" />
<span>Flows are read-only they analyze and report, never modify your code.</span>
</div>
</DialogHeader>
{/* Scrollable body */}
<div className="flex flex-col gap-4 overflow-y-auto overscroll-contain px-4 py-3">
{/* Run options — apply to whichever flow you Run below. */}
<div className="flex flex-col gap-3">
{/* 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)}
/>
</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)}
aria-pressed={band === 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>
{/* 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>
<div className="border-t border-border/60" />
{/* Flow picker */}
<div className="flex flex-col gap-2">
<span className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
Choose a flow
</span>
{/* 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-1">
{flows.map((flow) => {
const info = FLOW_INFO[flow.name];
const isExpanded = expandedFlow === flow.name;
const isLaunching = launchingFlow === flow.name;
const panelId = `flow-blurb-${flow.name}`;
return (
<div
key={flow.name}
className={cn(
'rounded-lg border transition-colors',
isExpanded ? 'border-border bg-muted/40' : 'border-transparent hover:bg-muted/40'
)}
>
<div className="flex items-center gap-1.5 pr-2">
{/* Expand toggle — reveals the blurb; never launches. */}
<button
type="button"
onClick={() => setExpandedFlow(isExpanded ? null : flow.name)}
aria-expanded={isExpanded}
aria-controls={panelId}
aria-label={
isExpanded ? `Hide details for ${flow.label}` : `Show details for ${flow.label}`
}
className="flex min-w-0 flex-1 items-start gap-2 rounded-lg px-2.5 py-2 text-left max-md:min-h-[44px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<ChevronRight
className={cn(
'mt-0.5 size-4 shrink-0 text-muted-foreground transition-transform duration-200 motion-reduce:transition-none',
isExpanded && 'rotate-90'
)}
/>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium text-foreground">{flow.label}</span>
<span className="block text-xs text-muted-foreground">
{info?.oneliner ?? ''}
</span>
</span>
</button>
{/* Run — the only launch action; clearly separate from expand. */}
<Button
type="button"
size="sm"
onClick={() => void handleRun(flow.name)}
disabled={launchingFlow !== null}
aria-label={`Run ${flow.label}`}
className="shrink-0 max-md:min-h-[44px]"
>
{isLaunching ? (
<Loader2 className="animate-spin" />
) : (
<Play />
)}
Run
</Button>
</div>
{/* In-place blurb — grid-rows trick for a smooth, reduced-motion-safe expand. */}
<div
id={panelId}
role="region"
className={cn(
'grid transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none',
isExpanded ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'
)}
>
<div className="overflow-hidden">
<p className="px-2.5 pb-2.5 pl-9 text-xs leading-relaxed text-muted-foreground">
{info?.blurb ?? ''}
</p>
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
<DialogFooter className="shrink-0" showCloseButton />
</DialogContent>
</Dialog>
);
}