Files
openchamber/packages/ui/src/components/chat/SkillAutocomplete.tsx
T
Bohdan Triapitsyn 5f201bb4ba fix(chat): scope command, skill, and file autocomplete to the chat's own directory
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
2026-08-29 17:02:05 +03:00

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';