web+coder: segmented per-agent slash menu (agent commands + skills) + cross-agent skill execution
Coder / menu now shows two groups: the active agent's commands first (manifest + live ACP available_commands), BooCoder skills second. SlashCommandPicker gains an opt-in groups prop (flat items path unchanged -> BooChat byte-identical, parity verified); ChatInput takes slashGroups; CoderPane builds the groups. Skills run under the selected agent: coder skill_invoke accepts a provider and, when external, injects the server-side skill body into a dispatched task instead of native inference. Also folds in the initial-chat skill fix (handleLandingSkill: create chat -> assign to pane -> invoke, same transition as a text send) that resolves the landing-page blank screen. BooChat slash menu + skill invocation unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,9 +8,18 @@ export interface SlashCommandItem {
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface SlashCommandGroup {
|
||||
label: string;
|
||||
items: SlashCommandItem[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
query: string;
|
||||
items: SlashCommandItem[];
|
||||
// Optional segmented rendering. When provided, items are shown under labeled
|
||||
// group headers (in order). `items` is ignored. BooChat passes only `items`
|
||||
// (flat) so its menu is unchanged — grouping is opt-in.
|
||||
groups?: SlashCommandGroup[];
|
||||
inputRef: RefObject<HTMLElement | null>;
|
||||
onSelect: (name: string) => void;
|
||||
onClose: () => void;
|
||||
@@ -28,6 +37,7 @@ function filterByPrefix(items: SlashCommandItem[], query: string): SlashCommandI
|
||||
export function SlashCommandPicker({
|
||||
query,
|
||||
items,
|
||||
groups,
|
||||
inputRef,
|
||||
onSelect,
|
||||
onClose,
|
||||
@@ -35,7 +45,21 @@ export function SlashCommandPicker({
|
||||
}: Props) {
|
||||
const [highlightIndex, setHighlightIndex] = useState(0);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
const filtered = useMemo(() => filterByPrefix(items, query), [items, query]);
|
||||
// When grouped, filter each group and drop empties; otherwise the flat list.
|
||||
const filteredGroups = useMemo(
|
||||
() =>
|
||||
groups
|
||||
? groups
|
||||
.map((g) => ({ label: g.label, items: filterByPrefix(g.items, query) }))
|
||||
.filter((g) => g.items.length > 0)
|
||||
: null,
|
||||
[groups, query],
|
||||
);
|
||||
// Flat list drives keyboard nav + Enter selection across all groups.
|
||||
const filtered = useMemo(
|
||||
() => (filteredGroups ? filteredGroups.flatMap((g) => g.items) : filterByPrefix(items, query)),
|
||||
[filteredGroups, items, query],
|
||||
);
|
||||
|
||||
const [rect, setRect] = useState<DOMRect | null>(
|
||||
() => inputRef.current?.getBoundingClientRect() ?? null,
|
||||
@@ -130,6 +154,36 @@ 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}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
let runningIndex = -1;
|
||||
const popover = filtered.length === 0 ? (
|
||||
<div
|
||||
ref={popoverRef}
|
||||
@@ -146,34 +200,16 @@ export function SlashCommandPicker({
|
||||
className="z-50 bg-popover border border-border rounded-md shadow min-w-[320px] max-w-[420px] max-h-[320px] overflow-y-auto overscroll-contain touch-pan-y"
|
||||
style={style}
|
||||
>
|
||||
{filtered.map((item, i) => (
|
||||
<div
|
||||
key={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}
|
||||
{filteredGroups
|
||||
? filteredGroups.map((g) => (
|
||||
<div key={g.label}>
|
||||
<div className="px-2.5 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground/70">
|
||||
{g.label}
|
||||
</div>
|
||||
{g.items.map((item) => renderItem(item, (runningIndex += 1)))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
))
|
||||
: filtered.map((item, i) => renderItem(item, i))}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user