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:
@@ -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 ? (
|
||||
|
||||
Reference in New Issue
Block a user