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.
This commit is contained in:
2026-06-03 16:17:37 +00:00
parent dc86ad65bf
commit 4f3bd1e06a
4 changed files with 422 additions and 151 deletions

View File

@@ -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 ? (