Shows loaded skills in slash autocomplete with clear type badges Stabilizes keyboard navigation across autocomplete menus Fixes skill link rendering and skill autocomplete scrolling
256 lines
8.6 KiB
TypeScript
256 lines
8.6 KiB
TypeScript
import React from 'react';
|
|
import { cn, fuzzyMatch } from '@/lib/utils';
|
|
import { useConfigStore } from '@/stores/useConfigStore';
|
|
import { useAgentsStore, isAgentBuiltIn, type AgentWithExtras } from '@/stores/useAgentsStore';
|
|
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
|
import { useI18n } from '@/lib/i18n';
|
|
|
|
interface AgentInfo {
|
|
name: string;
|
|
description?: string;
|
|
mode?: string | null;
|
|
scope?: string;
|
|
isBuiltIn?: boolean;
|
|
}
|
|
|
|
export interface AgentMentionAutocompleteHandle {
|
|
handleKeyDown: (key: string) => void;
|
|
}
|
|
|
|
type AutocompleteTab = 'commands' | 'agents' | 'files';
|
|
|
|
const isMentionableAgentMode = (mode?: string | null): boolean => {
|
|
if (!mode) return false;
|
|
return mode !== 'primary';
|
|
};
|
|
|
|
interface AgentMentionAutocompleteProps {
|
|
searchQuery: string;
|
|
onAgentSelect: (agentName: string) => void;
|
|
onClose: () => void;
|
|
showTabs?: boolean;
|
|
activeTab?: AutocompleteTab;
|
|
onTabSelect?: (tab: AutocompleteTab) => void;
|
|
}
|
|
|
|
export const AgentMentionAutocomplete = React.forwardRef<AgentMentionAutocompleteHandle, AgentMentionAutocompleteProps>(({
|
|
searchQuery,
|
|
onAgentSelect,
|
|
onClose,
|
|
showTabs,
|
|
activeTab = 'agents',
|
|
onTabSelect,
|
|
}, ref) => {
|
|
const { t } = useI18n();
|
|
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
|
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
|
const selectedIndexRef = React.useRef(0);
|
|
const [agents, setAgents] = React.useState<AgentInfo[]>([]);
|
|
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
|
|
const ignoreTabClickRef = React.useRef(false);
|
|
const getVisibleAgents = useConfigStore((state) => state.getVisibleAgents);
|
|
const agentsWithMetadata = useAgentsStore((state) => state.agents);
|
|
const loadAgents = useAgentsStore((state) => state.loadAgents);
|
|
|
|
React.useEffect(() => {
|
|
if (agentsWithMetadata.length === 0) {
|
|
void loadAgents();
|
|
}
|
|
}, [loadAgents, agentsWithMetadata.length]);
|
|
|
|
React.useEffect(() => {
|
|
const visibleAgents = getVisibleAgents();
|
|
const filtered = visibleAgents
|
|
.filter((agent) => isMentionableAgentMode(agent.mode))
|
|
.map((agent) => {
|
|
const metadata = agentsWithMetadata.find(a => a.name === agent.name) as (AgentWithExtras & { scope?: string }) | undefined;
|
|
return {
|
|
name: agent.name,
|
|
description: agent.description,
|
|
mode: agent.mode ?? undefined,
|
|
scope: metadata?.scope,
|
|
isBuiltIn: metadata ? isAgentBuiltIn(metadata) : false,
|
|
};
|
|
});
|
|
|
|
const normalizedQuery = searchQuery.trim();
|
|
const matches = normalizedQuery.length
|
|
? filtered.filter((agent) => fuzzyMatch(agent.name, normalizedQuery))
|
|
: filtered;
|
|
|
|
matches.sort((a, b) => a.name.localeCompare(b.name));
|
|
|
|
setAgents(matches);
|
|
setSelectedIndex(0);
|
|
}, [getVisibleAgents, searchQuery, agentsWithMetadata]);
|
|
|
|
React.useEffect(() => {
|
|
selectedIndexRef.current = selectedIndex;
|
|
}, [selectedIndex]);
|
|
|
|
React.useEffect(() => {
|
|
itemRefs.current[selectedIndex]?.scrollIntoView({
|
|
block: 'nearest',
|
|
});
|
|
}, [selectedIndex]);
|
|
|
|
React.useEffect(() => {
|
|
const handlePointerDown = (event: MouseEvent | TouchEvent) => {
|
|
const target = event.target as Node | null;
|
|
if (!target || !containerRef.current) {
|
|
return;
|
|
}
|
|
if (!containerRef.current.contains(target)) {
|
|
onClose();
|
|
}
|
|
};
|
|
|
|
document.addEventListener('pointerdown', handlePointerDown, true);
|
|
return () => {
|
|
document.removeEventListener('pointerdown', handlePointerDown, true);
|
|
};
|
|
}, [onClose]);
|
|
|
|
React.useImperativeHandle(ref, () => ({
|
|
handleKeyDown: (key: string) => {
|
|
if (key === 'Escape') {
|
|
onClose();
|
|
return;
|
|
}
|
|
|
|
if (!agents.length) {
|
|
return;
|
|
}
|
|
|
|
if (key === 'ArrowDown') {
|
|
setSelectedIndex((prev) => (prev + 1) % agents.length);
|
|
return;
|
|
}
|
|
|
|
if (key === 'ArrowUp') {
|
|
setSelectedIndex((prev) => (prev - 1 + agents.length) % agents.length);
|
|
return;
|
|
}
|
|
|
|
if (key === 'Enter' || key === 'Tab') {
|
|
const safeIndex = ((selectedIndexRef.current % agents.length) + agents.length) % agents.length;
|
|
const agent = agents[safeIndex];
|
|
if (agent) {
|
|
onAgentSelect(agent.name);
|
|
}
|
|
}
|
|
},
|
|
}), [agents, onAgentSelect, onClose]);
|
|
|
|
const renderAgent = (agent: AgentInfo, index: number) => {
|
|
const isSystem = agent.isBuiltIn;
|
|
const isProject = agent.scope === 'project';
|
|
|
|
return (
|
|
<div
|
|
key={agent.name}
|
|
ref={(el) => {
|
|
itemRefs.current[index] = el;
|
|
}}
|
|
className={cn(
|
|
'flex items-start gap-2 px-3 py-1.5 cursor-pointer rounded-lg typography-ui-label',
|
|
index === selectedIndex && 'bg-interactive-selection'
|
|
)}
|
|
onClick={() => onAgentSelect(agent.name)}
|
|
onMouseMove={() => setSelectedIndex(index)}
|
|
>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-semibold">#{agent.name}</span>
|
|
{isSystem ? (
|
|
<span className="text-[10px] leading-none uppercase font-bold tracking-tight bg-[var(--status-warning-background)] text-[var(--status-warning)] border-[var(--status-warning-border)] px-1.5 py-1 rounded border flex-shrink-0">
|
|
{t('chat.agentMentionAutocomplete.badge.system')}
|
|
</span>
|
|
) : agent.scope ? (
|
|
<span className={cn(
|
|
"text-[10px] leading-none uppercase font-bold tracking-tight px-1.5 py-1 rounded border flex-shrink-0",
|
|
isProject
|
|
? "bg-[var(--status-info-background)] text-[var(--status-info)] border-[var(--status-info-border)]"
|
|
: "bg-[var(--status-success-background)] text-[var(--status-success)] border-[var(--status-success-border)]"
|
|
)}>
|
|
{agent.scope}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
{agent.description && (
|
|
<div className="typography-meta text-muted-foreground mt-0.5 truncate">
|
|
{agent.description}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const tabs = React.useMemo(() => ([
|
|
{ id: 'commands' as const, label: t('chat.autocomplete.tabs.commands') },
|
|
{ id: 'agents' as const, label: t('chat.autocomplete.tabs.agents') },
|
|
{ id: 'files' as const, label: t('chat.autocomplete.tabs.files') },
|
|
]), [t]);
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
className="absolute z-[100] min-w-0 w-full max-w-[360px] max-h-60 bg-background border-2 border-border/60 rounded-xl shadow-none bottom-full mb-2 left-0 flex flex-col"
|
|
>
|
|
{showTabs ? (
|
|
<div className="px-2 pt-2 pb-1 border-b border-border/60">
|
|
<div className="flex items-center gap-1 rounded-lg bg-[var(--surface-elevated)] p-1">
|
|
{tabs.map((tab) => (
|
|
<button
|
|
key={tab.id}
|
|
type="button"
|
|
className={cn(
|
|
'flex-1 px-2.5 py-1 rounded-md typography-meta font-semibold transition-none',
|
|
activeTab === tab.id
|
|
? 'bg-interactive-selection text-interactive-selection-foreground shadow-none'
|
|
: 'text-muted-foreground hover:bg-interactive-hover/50'
|
|
)}
|
|
onPointerDown={(event) => {
|
|
if (event.pointerType !== 'touch') {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
ignoreTabClickRef.current = true;
|
|
onTabSelect?.(tab.id);
|
|
}}
|
|
onClick={() => {
|
|
if (ignoreTabClickRef.current) {
|
|
ignoreTabClickRef.current = false;
|
|
return;
|
|
}
|
|
onTabSelect?.(tab.id);
|
|
}}
|
|
>
|
|
{tab.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="px-0 pb-2">
|
|
{agents.length ? (
|
|
<div>
|
|
{agents.map((agent, index) => renderAgent(agent, index))}
|
|
</div>
|
|
) : (
|
|
<div className="px-3 py-2 typography-ui-label text-muted-foreground">
|
|
{t('chat.agentMentionAutocomplete.empty')}
|
|
</div>
|
|
)}
|
|
</ScrollableOverlay>
|
|
<div className="px-3 pt-1 pb-1.5 border-t typography-meta text-muted-foreground">
|
|
{t('chat.autocomplete.keyboardHint')}
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
AgentMentionAutocomplete.displayName = 'AgentMentionAutocomplete';
|