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>
327 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|