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>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Zap } from 'lucide-react';
|
||||
import { ChevronRight, Play, Loader2, ShieldCheck } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -14,6 +14,7 @@ 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';
|
||||
@@ -71,11 +72,13 @@ export function FlowLauncherDialog() {
|
||||
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);
|
||||
// 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) => {
|
||||
@@ -84,39 +87,42 @@ export function FlowLauncherDialog() {
|
||||
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);
|
||||
setExpandedFlow(null);
|
||||
setLaunchingFlow(null);
|
||||
setOpen(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
function handleCategoryChange(cat: Category) {
|
||||
setCategory(cat);
|
||||
setFlowName(FLOWS_BY_CATEGORY[cat][0]?.name ?? '');
|
||||
setExpandedFlow(null);
|
||||
}
|
||||
|
||||
async function handleLaunch() {
|
||||
if (!flowName || !projectId) return;
|
||||
setLaunching(true);
|
||||
// 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: flowName,
|
||||
flow_name: name,
|
||||
band,
|
||||
input: { question: focus },
|
||||
input: { question: focus, ...(fast ? { concise: true } : {}) },
|
||||
});
|
||||
sessionEvents.emit({
|
||||
type: 'open_orchestrator_pane',
|
||||
state: { run_id, flow_name: flowName, band },
|
||||
state: { run_id, flow_name: name, band },
|
||||
placement,
|
||||
});
|
||||
setOpen(false);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to launch flow');
|
||||
} finally {
|
||||
setLaunching(false);
|
||||
setLaunchingFlow(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,138 +134,192 @@ export function FlowLauncherDialog() {
|
||||
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">
|
||||
<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">
|
||||
{/* 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) => (
|
||||
{/* 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
|
||||
key={cat}
|
||||
type="button"
|
||||
onClick={() => handleCategoryChange(cat)}
|
||||
role="switch"
|
||||
aria-checked={fast}
|
||||
onClick={() => setFast((v) => !v)}
|
||||
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'
|
||||
'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'
|
||||
)}
|
||||
>
|
||||
{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)}
|
||||
<span
|
||||
className={cn(
|
||||
'flex-1 rounded-lg border py-1 text-xs transition-colors',
|
||||
band === value
|
||||
? 'border-primary bg-primary/10 text-primary font-medium'
|
||||
'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'
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
{cat}
|
||||
</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>
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
{/* 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>
|
||||
<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>
|
||||
<DialogFooter className="shrink-0" showCloseButton />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user