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.
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>
|
|
);
|
|
}
|