Command, file/agent, skill, and snippet autocompletes now stop at the top of the chat area (Capacitor) or the visible viewport edge (mobile browsers, which pan the page for the keyboard) and may grow that far, measured live across keyboard settles and viewport changes. On mobile the keyboard-hint footer and description lines are gone, rows center their icons, and list overscroll no longer bounces the page behind. Selecting a command no longer dismisses the keyboard (its rows now block the tap's focus steal like the composer buttons), and the dead dismissKeyboard option is removed.
197 lines
6.6 KiB
TypeScript
197 lines
6.6 KiB
TypeScript
import React from 'react';
|
|
import { cn, fuzzyMatch } from '@/lib/utils';
|
|
import { useSkillsStore } from '@/stores/useSkillsStore';
|
|
import { useUIStore } from '@/stores/useUIStore';
|
|
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
|
import { useMobileAutocompleteMaxHeight } from './useMobileAutocompleteMaxHeight';
|
|
|
|
interface SkillInfo {
|
|
name: string;
|
|
scope: string;
|
|
source?: string;
|
|
description?: string;
|
|
}
|
|
|
|
export interface SkillAutocompleteHandle {
|
|
handleKeyDown: (key: string) => void;
|
|
}
|
|
|
|
interface SkillAutocompleteProps {
|
|
searchQuery: string;
|
|
onSkillSelect: (skillName: string) => void;
|
|
onClose: () => void;
|
|
style?: React.CSSProperties;
|
|
}
|
|
|
|
export const SkillAutocomplete = React.forwardRef<SkillAutocompleteHandle, SkillAutocompleteProps>(({
|
|
searchQuery,
|
|
onSkillSelect,
|
|
onClose,
|
|
style,
|
|
}, ref) => {
|
|
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
|
const isMobile = useUIStore((state) => state.isMobile);
|
|
const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, isMobile);
|
|
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
|
const selectedIndexRef = React.useRef(0);
|
|
const keyboardNavigationRef = React.useRef(false);
|
|
const [filteredSkills, setFilteredSkills] = React.useState<SkillInfo[]>([]);
|
|
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
|
|
const skills = useSkillsStore((s) => s.skills);
|
|
const loadSkills = useSkillsStore((s) => s.loadSkills);
|
|
|
|
React.useEffect(() => {
|
|
// Always trigger loadSkills when autocomplete opens to ensure project context is fresh
|
|
void loadSkills();
|
|
}, [loadSkills]);
|
|
|
|
React.useEffect(() => {
|
|
const normalizedQuery = searchQuery.trim();
|
|
const matches = normalizedQuery.length
|
|
? skills.filter((skill) => fuzzyMatch(skill.name, normalizedQuery))
|
|
: skills;
|
|
|
|
const sorted = [...matches].sort((a, b) => {
|
|
// Sort by project scope first, then name
|
|
if (a.scope === 'project' && b.scope !== 'project') return -1;
|
|
if (a.scope !== 'project' && b.scope === 'project') return 1;
|
|
return a.name.localeCompare(b.name);
|
|
});
|
|
|
|
setFilteredSkills(sorted);
|
|
setSelectedIndex(0);
|
|
}, [skills, searchQuery]);
|
|
|
|
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 (!filteredSkills.length) {
|
|
return;
|
|
}
|
|
|
|
if (key === 'ArrowDown') {
|
|
keyboardNavigationRef.current = true;
|
|
setSelectedIndex((prev) => (prev + 1) % filteredSkills.length);
|
|
return;
|
|
}
|
|
|
|
if (key === 'ArrowUp') {
|
|
keyboardNavigationRef.current = true;
|
|
setSelectedIndex((prev) => (prev - 1 + filteredSkills.length) % filteredSkills.length);
|
|
return;
|
|
}
|
|
|
|
if (key === 'Enter' || key === 'Tab') {
|
|
const safeIndex = ((selectedIndexRef.current % filteredSkills.length) + filteredSkills.length) % filteredSkills.length;
|
|
const skill = filteredSkills[safeIndex];
|
|
if (skill) {
|
|
onSkillSelect(skill.name);
|
|
}
|
|
}
|
|
},
|
|
}), [filteredSkills, onSkillSelect, onClose]);
|
|
|
|
const renderSkill = (skill: SkillInfo, index: number) => {
|
|
const isProject = skill.scope === 'project';
|
|
const source = skill.source || 'opencode';
|
|
return (
|
|
<div
|
|
key={`${skill.name}-${skill.scope}`}
|
|
ref={(el) => {
|
|
itemRefs.current[index] = el;
|
|
}}
|
|
className={cn(
|
|
'flex gap-2 px-3 py-1.5 cursor-pointer rounded-lg typography-ui-label',
|
|
isMobile ? 'items-center' : 'items-start',
|
|
index === selectedIndex && 'bg-interactive-selection'
|
|
)}
|
|
onClick={() => onSkillSelect(skill.name)}
|
|
onMouseMove={() => {
|
|
keyboardNavigationRef.current = false;
|
|
setSelectedIndex(index);
|
|
}}
|
|
>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-semibold truncate">{skill.name}</span>
|
|
<span className={cn(
|
|
"text-[10px] leading-none uppercase font-bold tracking-tight px-1.5 py-1 rounded border flex-shrink-0 transition-colors",
|
|
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)]"
|
|
)}>
|
|
{skill.scope}
|
|
</span>
|
|
<span className="text-[10px] leading-none uppercase font-bold tracking-tight px-1.5 py-1 rounded border flex-shrink-0 bg-[var(--surface-muted)] text-muted-foreground border-[var(--interactive-border)]/60">
|
|
{source}
|
|
</span>
|
|
</div>
|
|
{skill.description && !isMobile && (
|
|
<div className="typography-meta text-muted-foreground mt-0.5 truncate">
|
|
{skill.description}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
className="absolute z-[100] min-w-0 w-full max-w-[450px] max-h-60 bg-background border-2 border-border/60 rounded-xl shadow-none bottom-full mb-2 left-0 flex flex-col"
|
|
style={mobileMaxHeight !== undefined ? { ...style, maxHeight: mobileMaxHeight } : style}
|
|
>
|
|
<ScrollableOverlay preventOverscroll outerClassName="flex-1 min-h-0" className="px-0 pb-2">
|
|
{filteredSkills.length ? (
|
|
<div>
|
|
{filteredSkills.map((skill, index) => renderSkill(skill, index))}
|
|
</div>
|
|
) : (
|
|
<div className="px-3 py-2 typography-ui-label text-muted-foreground">
|
|
No skills found
|
|
</div>
|
|
)}
|
|
</ScrollableOverlay>
|
|
{!isMobile && (
|
|
<div className="px-3 pt-1 pb-1.5 border-t typography-meta text-muted-foreground">
|
|
↑↓ navigate • Enter select • Esc close
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
});
|
|
|
|
SkillAutocomplete.displayName = 'SkillAutocomplete';
|