Commands and skills for the composer were read from the ambient store, which follows the project selected in the sidebar; opening a managed chat or a chat draft never changes that selection, so the previous project's commands, skills, and (for drafts) files leaked into chats. The autocompletes now load and read commands and skills for the effective directory, and a chat draft resolves its effective directory to the prepared chat directory or the Chats root instead of the last project. Claude-Session: https://claude.ai/code/session_017TK5JAYDfT3Fotc23UEg98
199 lines
7.0 KiB
TypeScript
199 lines
7.0 KiB
TypeScript
import React from 'react';
|
|
import { cn, fuzzyMatch } from '@/lib/utils';
|
|
import { selectSkillsForDirectory, useSkillsStore } from '@/stores/useSkillsStore';
|
|
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
|
import { useUIStore } from '@/stores/useUIStore';
|
|
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
|
import { useMobileAutocompleteMaxHeight } from './useMobileAutocompleteMaxHeight';
|
|
import { AutocompleteRowTooltip } from './composer/ui/AutocompleteRowTooltip';
|
|
|
|
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, true, 240);
|
|
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)[]>([]);
|
|
// Skills of the directory the composer sends to (session directory, or the
|
|
// Chats root for a chat draft), not of the project the app was on last.
|
|
const effectiveDirectory = useEffectiveDirectory();
|
|
const skills = useSkillsStore((s) => selectSkillsForDirectory(s, effectiveDirectory));
|
|
const loadSkills = useSkillsStore((s) => s.loadSkills);
|
|
|
|
React.useEffect(() => {
|
|
// Always trigger loadSkills when autocomplete opens to ensure the directory's skills are fresh
|
|
void loadSkills(effectiveDirectory);
|
|
}, [effectiveDirectory, 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 (
|
|
<AutocompleteRowTooltip description={skill.description} active={!isMobile && index === selectedIndex}>
|
|
<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>
|
|
</div>
|
|
</div>
|
|
</AutocompleteRowTooltip>
|
|
);
|
|
};
|
|
|
|
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';
|