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>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { CSSProperties, ReactNode, RefObject } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface SlashCommandItem {
|
||||
name: string;
|
||||
// One-liner shown always; also the fallback body when the row is expanded.
|
||||
description?: string;
|
||||
// Optional fuller explanation revealed when the row's chevron is expanded.
|
||||
// When absent, the expanded panel falls back to `description` (un-truncated)
|
||||
// — we never fabricate detail for commands that don't carry it (e.g. raw
|
||||
// agent `/help` passthrough rows).
|
||||
details?: string;
|
||||
}
|
||||
|
||||
export interface SlashCommandGroup {
|
||||
@@ -45,6 +52,9 @@ export function SlashCommandPicker({
|
||||
emptyLabel = 'No commands available',
|
||||
}: Props) {
|
||||
const [highlightIndex, setHighlightIndex] = useState(0);
|
||||
// At most one row's detail panel is open at a time; null = all collapsed.
|
||||
// Indexed against the flat `filtered` list (same index space as highlight).
|
||||
const [expandedIndex, setExpandedIndex] = useState<number | null>(null);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
// When grouped, filter each group and drop empties; otherwise the flat list.
|
||||
const filteredGroups = useMemo(
|
||||
@@ -67,7 +77,8 @@ export function SlashCommandPicker({
|
||||
);
|
||||
const [vvTick, setVvTick] = useState(0);
|
||||
|
||||
useEffect(() => { setHighlightIndex(0); }, [query]);
|
||||
// Filtering reshuffles indices, so any open detail panel is stale — collapse it.
|
||||
useEffect(() => { setHighlightIndex(0); setExpandedIndex(null); }, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
function recalc() {
|
||||
@@ -99,6 +110,20 @@ export function SlashCommandPicker({
|
||||
e.preventDefault();
|
||||
const target = filtered[highlightIndex] ?? filtered[0];
|
||||
if (target) onSelect(target.name);
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
// Expand the highlighted row in place (separate affordance from select).
|
||||
// Only acts when the row has something to reveal, so caret-right still
|
||||
// works on non-expandable rows; Enter/Tab selection is untouched.
|
||||
const target = filtered[highlightIndex];
|
||||
if (!target || !(target.details ?? target.description)) return;
|
||||
e.preventDefault();
|
||||
setExpandedIndex(highlightIndex);
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
// Collapse the highlighted row if it's the open one; otherwise let the
|
||||
// caret move as usual.
|
||||
if (expandedIndex !== highlightIndex) return;
|
||||
e.preventDefault();
|
||||
setExpandedIndex(null);
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
@@ -106,7 +131,7 @@ export function SlashCommandPicker({
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [filtered, highlightIndex, onSelect, onClose]);
|
||||
}, [filtered, highlightIndex, expandedIndex, onSelect, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleMouseDown(e: MouseEvent) {
|
||||
@@ -123,6 +148,12 @@ export function SlashCommandPicker({
|
||||
if (el) el.scrollIntoView({ block: 'nearest' });
|
||||
}, [highlightIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (expandedIndex == null) return;
|
||||
const el = popoverRef.current?.querySelector('[data-expanded="true"]');
|
||||
if (el) el.scrollIntoView({ block: 'nearest' });
|
||||
}, [expandedIndex]);
|
||||
|
||||
const style = useMemo<CSSProperties>(() => {
|
||||
if (!rect) return { display: 'none' };
|
||||
const vv = window.visualViewport;
|
||||
@@ -155,34 +186,72 @@ export function SlashCommandPicker({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [rect, vvTick]);
|
||||
|
||||
const renderItem = (item: SlashCommandItem, i: number) => (
|
||||
<div
|
||||
key={`${i}-${item.name}`}
|
||||
role="option"
|
||||
aria-selected={i === highlightIndex}
|
||||
data-highlighted={i === highlightIndex}
|
||||
className={cn(
|
||||
'w-full text-left px-2.5 py-2 cursor-pointer block',
|
||||
i === highlightIndex && 'bg-muted',
|
||||
)}
|
||||
onMouseEnter={() => setHighlightIndex(i)}
|
||||
onClick={() => onSelect(item.name)}
|
||||
>
|
||||
<div className="font-mono text-xs font-bold text-foreground">/{item.name}</div>
|
||||
{item.description && (
|
||||
<div
|
||||
className="text-xs text-muted-foreground overflow-hidden"
|
||||
style={{
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
}}
|
||||
>
|
||||
{item.description}
|
||||
const renderItem = (item: SlashCommandItem, i: number) => {
|
||||
const expanded = i === expandedIndex;
|
||||
// Fuller text when provided, else the one-liner shown un-truncated. Never
|
||||
// invented — a row with neither shows an honest placeholder.
|
||||
const detailText = item.details ?? item.description ?? null;
|
||||
return (
|
||||
<div
|
||||
key={`${i}-${item.name}`}
|
||||
role="option"
|
||||
aria-selected={i === highlightIndex}
|
||||
data-highlighted={i === highlightIndex}
|
||||
data-expanded={expanded}
|
||||
className={cn(
|
||||
'w-full text-left px-2.5 py-2 cursor-pointer block',
|
||||
i === highlightIndex && 'bg-muted',
|
||||
)}
|
||||
onMouseEnter={() => setHighlightIndex(i)}
|
||||
onClick={() => onSelect(item.name)}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-mono text-xs font-bold text-foreground">/{item.name}</div>
|
||||
{item.description && (
|
||||
<div className="text-xs text-muted-foreground truncate">{item.description}</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Chevron is a SEPARATE affordance: stopPropagation keeps the row's
|
||||
click (select) from firing; preventDefault on mousedown keeps focus
|
||||
on the composer so typing/arrow-nav continues uninterrupted. */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={expanded ? `Hide details for /${item.name}` : `Show details for /${item.name}`}
|
||||
aria-expanded={expanded}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setHighlightIndex(i);
|
||||
setExpandedIndex((prev) => (prev === i ? null : i));
|
||||
}}
|
||||
className="-mr-1 -mt-0.5 flex shrink-0 items-center justify-center rounded p-1 text-muted-foreground/60 transition-colors hover:bg-foreground/10 hover:text-foreground max-md:min-h-[36px] max-md:min-w-[36px]"
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'size-3.5 transition-transform duration-200 motion-reduce:transition-none',
|
||||
expanded && 'rotate-90',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
{/* grid-rows 0fr→1fr animates height with no fixed value; motion-reduce
|
||||
disables the transition for users who ask for less motion. */}
|
||||
<div
|
||||
className={cn(
|
||||
'grid transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none',
|
||||
expanded ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]',
|
||||
)}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="mt-1.5 border-t border-border/40 pt-1.5 text-xs leading-relaxed text-muted-foreground whitespace-pre-wrap break-words">
|
||||
{detailText ?? <span className="italic text-muted-foreground/70">No description provided.</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
let runningIndex = -1;
|
||||
const popover = filtered.length === 0 ? (
|
||||
|
||||
142
apps/web/src/components/flowBlurbs.ts
Normal file
142
apps/web/src/components/flowBlurbs.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Per-flow copy for the Orchestrator flow launcher.
|
||||
*
|
||||
* - `oneliner` is the flow's own backend `description` (see
|
||||
* apps/coder/src/conductor/flows/*), shown always-on under the flow name.
|
||||
* - `blurb` is a 1–2 sentence expansion (what it does + when to use, plus a
|
||||
* "not for…" clause where the underlying Han skill has one), condensed from
|
||||
* each Han skill's SKILL.md frontmatter `description` in /opt/skills/han/<name>.
|
||||
* For the four bespoke flows with no Han skill (security-review, data-review,
|
||||
* devops-review, stakeholder-summary) and the two empty SKILL.md files
|
||||
* (runbook, test-planning) the blurb is condensed from the flow definition.
|
||||
*
|
||||
* Keyed by flow name — keep in sync with apps/coder/src/conductor/flows.
|
||||
*/
|
||||
export interface FlowInfo {
|
||||
/** Always-on one-liner (the flow's backend `description`). */
|
||||
oneliner: string;
|
||||
/** Expanded 1–2 sentence blurb: what it does + when to use / not. */
|
||||
blurb: string;
|
||||
}
|
||||
|
||||
export const FLOW_INFO: Record<string, FlowInfo> = {
|
||||
// ── Analysis ──────────────────────────────────────────────────────────
|
||||
research: {
|
||||
oneliner: 'options, prior art, trade-offs → recommendation',
|
||||
blurb:
|
||||
'Surveys options, prior art, and trade-offs across the codebase, the web, and any material you provide, then recommends a direction backed by checkable evidence. Use to weigh approaches or understand how something works before committing — not to diagnose a bug (use Investigate) or compare two concrete artifacts (use Gap Analysis).',
|
||||
},
|
||||
investigate: {
|
||||
oneliner: 'root-cause a bug/failure from evidence',
|
||||
blurb:
|
||||
'An evidence-based deep dive that traces a bug, failure, or integration issue to its root cause and adversarially validates the proposed fix. Use to debug, troubleshoot, or diagnose why something is broken — not to audit code quality (use Code Review) or assess architecture (use Architectural Analysis).',
|
||||
},
|
||||
'architectural-analysis': {
|
||||
oneliner: 'structure + behaviour + concurrency → architecture synthesis',
|
||||
blurb:
|
||||
'Assesses an existing module or feature across structural coupling, data flow, concurrency, risk, and SOLID alignment, then synthesises recommended changes. Use to evaluate the design quality, dependency structure, or technical debt of a specific area — not to investigate a bug (use Investigate) or review code line-by-line (use Code Review).',
|
||||
},
|
||||
'security-review': {
|
||||
oneliner: 'adversarial security analysis (exploit-path proof standard)',
|
||||
blurb:
|
||||
'An adversarial hunt for real, exploitable vulnerabilities — every finding needs a file:line plus a demonstrated exploit path or a CVE, never a theoretical risk. At medium and above it also flags code-level resilience failures such as missing timeouts, swallowed errors, and unbounded results.',
|
||||
},
|
||||
'gap-analysis': {
|
||||
oneliner: 'gaps between two artifacts (impl vs spec, etc.)',
|
||||
blurb:
|
||||
'Compares a current state against a desired one (implementation vs spec, PRD vs shipped feature, design vs build) and reports what is missing, incomplete, conflicting, or assumed. Use to reconcile two artifacts — not to investigate a runtime bug (use Investigate) or assess architecture (use Architectural Analysis).',
|
||||
},
|
||||
'data-review': {
|
||||
oneliner: 'schema / query / data-access audit',
|
||||
blurb:
|
||||
'Audits schemas, migrations, queries, and data-access code against normalization, indexing strategy, access patterns, migration safety, and PII/regulated-data handling, citing each location and its data-level impact. Use before shipping schema or query changes.',
|
||||
},
|
||||
'devops-review': {
|
||||
oneliner: 'production-readiness / operability review',
|
||||
blurb:
|
||||
'Checks a change or feature for production readiness — twelve-factor concerns, observability via the four golden signals, rollout safety, secrets/PII handling, scale, and cost — citing each location and its blast radius. At medium and above it adds a code-level resilience pass.',
|
||||
},
|
||||
'issue-triage': {
|
||||
oneliner: 'assess + prioritise a reported issue',
|
||||
blurb:
|
||||
'Turns a raw, vague issue or bug report into a structured document naming what is known, what is missing, severity, and reproducibility, then recommends the right next step. Use to prep an incoming issue for work — not to find root causes (use Investigate) or plan a build (use a Planning flow).',
|
||||
},
|
||||
|
||||
// ── Discovery ─────────────────────────────────────────────────────────
|
||||
'project-discovery': {
|
||||
oneliner: 'discover a repo: stack, structure, tooling',
|
||||
blurb:
|
||||
'Scans a repository and reports its languages, frameworks, build and test tooling, configuration, entry points, and directory structure as a map a newcomer could navigate. Use to detect a project’s tech stack and layout — not to write feature docs (use Project Documentation).',
|
||||
},
|
||||
'project-documentation': {
|
||||
oneliner: 'draft docs for a feature/system (one-pass)',
|
||||
blurb:
|
||||
'Drafts clear documentation for a feature, system, or component, grounded in the real implementation so every claim traces to evidence. Use to document how something works — not to scan the tech stack (use Project Discovery), record a decision (use ADR), or write a standard (use Coding Standard).',
|
||||
},
|
||||
'test-planning': {
|
||||
oneliner: 'behaviour-focused test plan',
|
||||
blurb:
|
||||
'Produces a prioritised, behaviour-focused test plan — observable inputs/outputs and collaborator interactions, recommended test doubles and test levels — rather than internal code paths. At medium and above it catalogs the edge cases and boundary values the plan must cover. Use to plan tests, not to write them (use TDD).',
|
||||
},
|
||||
|
||||
// ── Planning ──────────────────────────────────────────────────────────
|
||||
'plan-a-feature': {
|
||||
oneliner: 'feature spec draft (one-pass; human-in-loop intended)',
|
||||
blurb:
|
||||
'Builds a first-draft feature specification — the problem, the user, in/out of scope, acceptance criteria, a build approach, and open questions — grounded in the codebase. Use to scope or design a new capability before implementation — not to refine an existing plan (use Iterative Plan Review) or document a built feature (use Project Documentation).',
|
||||
},
|
||||
'plan-implementation': {
|
||||
oneliner: 'implementation plan draft (one-pass)',
|
||||
blurb:
|
||||
'Turns a feature specification into an implementation blueprint — the specific files to create or modify, component designs, data flow, an ordered build sequence, and a test strategy. Use once behaviour is specified — not to decide what the feature should do (use Plan a Feature) or review code (use Code Review).',
|
||||
},
|
||||
'plan-a-phased-build': {
|
||||
oneliner: 'phased build plan draft (one-pass)',
|
||||
blurb:
|
||||
'Splits an initiative into a sequence of independently shippable, vertical-slice phases — each with a goal, its slice of work, dependencies, and a demoable outcome a real person can be shown. Use to sequence or roadmap a build — not for implementation detail (use Plan Implementation).',
|
||||
},
|
||||
'plan-work-items': {
|
||||
oneliner: 'break work into tracked items (one-pass)',
|
||||
blurb:
|
||||
'Breaks a trusted implementation plan into independently-grabbable, atomic work items — each with a title, a one-line outcome, dependencies, and a rough size, ordered by dependency. Use to convert a plan into tickets — not when there is no trusted plan yet (produce one with Plan Implementation first).',
|
||||
},
|
||||
'iterative-plan-review': {
|
||||
oneliner: 'stress-test an existing plan (one pass of the loop)',
|
||||
blurb:
|
||||
'Stress-tests an existing plan against the codebase — surfacing hidden assumptions, unstated prerequisites, muddied scope, and risk — to sharpen it before commitment. Use to refine, tighten, or verify the soundness of a plan — not to generate one from scratch (use a Planning flow) or implement it.',
|
||||
},
|
||||
|
||||
// ── Authoring ─────────────────────────────────────────────────────────
|
||||
adr: {
|
||||
oneliner: 'architecture decision record draft (one-pass)',
|
||||
blurb:
|
||||
'Drafts an Architecture Decision Record — Context, the Decision, the Options considered with trade-offs, Consequences, and status — grounded in the real constraints. Use to record or update an architecture or design decision — not to write an enforceable standard (use Coding Standard) or feature docs (use Project Documentation).',
|
||||
},
|
||||
'coding-standard': {
|
||||
oneliner: 'coding standard draft (one-pass)',
|
||||
blurb:
|
||||
'Drafts an enforceable coding standard — the rule stated imperatively, the failure it prevents, a correct and an incorrect example, and its scope. Use to create or update a convention — not to record a one-off decision (use ADR) or write feature documentation (use Project Documentation).',
|
||||
},
|
||||
runbook: {
|
||||
oneliner: 'operational runbook draft (one-pass)',
|
||||
blurb:
|
||||
'Drafts an operational runbook for an incident scenario — detection signals, immediate mitigation, the diagnosis path, rollback/recovery, and escalation, with concrete commands where known. At medium and above it adds the failure modes and earliest signals the runbook must cover.',
|
||||
},
|
||||
tdd: {
|
||||
oneliner: 'failing-test scaffold + plan (one-pass; not the full red-green loop)',
|
||||
blurb:
|
||||
'Writes the failing ("red") tests that specify a behaviour — observable inputs/outputs and collaborator interactions — and outlines the smallest implementation that would make them pass. A single pass, not the full interactive red-green-refactor loop. Use to drive code test-first — not to produce a test plan document (use Test Planning).',
|
||||
},
|
||||
'stakeholder-summary': {
|
||||
oneliner: 'plain-language stakeholder summary (Han reporting)',
|
||||
blurb:
|
||||
'Writes a plain-language summary of a feature or piece of work for a non-technical stakeholder — what it is, why it matters, what changes for users, and the rough shape of the effort. No jargon, no implementation detail.',
|
||||
},
|
||||
|
||||
// ── Review ────────────────────────────────────────────────────────────
|
||||
'code-review': {
|
||||
oneliner: 'per-dimension review → adversarially verify each dimension (drops false positives)',
|
||||
blurb:
|
||||
'Reviews changed code across multiple dimensions — correctness, structure, security, resilience, and concurrency — then adversarially verifies each finding so false positives are dropped before the report. Use to audit code quality, correctness, or security — not to analyse architecture (use Architectural Analysis) or investigate a bug (use Investigate).',
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user