feat: add SkillAutocomplete component and integrate skill selection in ChatInput
This commit is contained in:
@@ -132,7 +132,7 @@ export const AgentMentionAutocomplete = React.forwardRef<AgentMentionAutocomplet
|
||||
ref={containerRef}
|
||||
className="absolute z-[100] min-w-0 w-full max-w-[360px] max-h-60 bg-background border border-border rounded-xl shadow-none bottom-full mb-2 left-0 flex flex-col"
|
||||
>
|
||||
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="px-0">
|
||||
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="px-0 pb-2" fillContainer={false}>
|
||||
{agents.length ? (
|
||||
<div>
|
||||
{agents.map((agent, index) => renderAgent(agent, index))}
|
||||
|
||||
@@ -19,6 +19,7 @@ import { QueuedMessageChips } from './QueuedMessageChips';
|
||||
import { FileMentionAutocomplete, type FileMentionHandle } from './FileMentionAutocomplete';
|
||||
import { CommandAutocomplete, type CommandAutocompleteHandle } from './CommandAutocomplete';
|
||||
import { AgentMentionAutocomplete, type AgentMentionAutocompleteHandle } from './AgentMentionAutocomplete';
|
||||
import { SkillAutocomplete, type SkillAutocompleteHandle } from './SkillAutocomplete';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ServerFilePicker } from './ServerFilePicker';
|
||||
import { ModelControls } from './ModelControls';
|
||||
@@ -121,12 +122,15 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
const [commandQuery, setCommandQuery] = React.useState('');
|
||||
const [showAgentAutocomplete, setShowAgentAutocomplete] = React.useState(false);
|
||||
const [agentQuery, setAgentQuery] = React.useState('');
|
||||
const [showSkillAutocomplete, setShowSkillAutocomplete] = React.useState(false);
|
||||
const [skillQuery, setSkillQuery] = React.useState('');
|
||||
const [textareaSize, setTextareaSize] = React.useState<{ height: number; maxHeight: number } | null>(null);
|
||||
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
|
||||
const dropZoneRef = React.useRef<HTMLDivElement>(null);
|
||||
const mentionRef = React.useRef<FileMentionHandle>(null);
|
||||
const commandRef = React.useRef<CommandAutocompleteHandle>(null);
|
||||
const agentRef = React.useRef<AgentMentionAutocompleteHandle>(null);
|
||||
const skillRef = React.useRef<SkillAutocompleteHandle>(null);
|
||||
|
||||
const sendMessage = useSessionStore((state) => state.sendMessage);
|
||||
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
||||
@@ -567,6 +571,14 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
}
|
||||
}
|
||||
|
||||
if (showSkillAutocomplete && skillRef.current) {
|
||||
if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
skillRef.current.handleKeyDown(e.key);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (showFileMention && mentionRef.current) {
|
||||
if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
@@ -704,10 +716,9 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
setShowCommandAutocomplete(true);
|
||||
setShowFileMention(false);
|
||||
setShowAgentAutocomplete(false);
|
||||
} else {
|
||||
setShowCommandAutocomplete(false);
|
||||
setShowSkillAutocomplete(false);
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setShowCommandAutocomplete(false);
|
||||
@@ -732,6 +743,25 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
setShowAgentAutocomplete(false);
|
||||
setAgentQuery('');
|
||||
|
||||
const lastSlashSymbol = textBeforeCursor.lastIndexOf('/');
|
||||
if (lastSlashSymbol !== -1) {
|
||||
const charBefore = lastSlashSymbol > 0 ? textBeforeCursor[lastSlashSymbol - 1] : null;
|
||||
const textAfterSlash = textBeforeCursor.substring(lastSlashSymbol + 1);
|
||||
const hasSeparator = textAfterSlash.includes(' ') || textAfterSlash.includes('\n');
|
||||
const isWordBoundary = !charBefore || /\s/.test(charBefore);
|
||||
|
||||
if (isWordBoundary && !hasSeparator) {
|
||||
setSkillQuery(textAfterSlash);
|
||||
setShowSkillAutocomplete(true);
|
||||
setShowFileMention(false);
|
||||
setShowAgentAutocomplete(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setShowSkillAutocomplete(false);
|
||||
setSkillQuery('');
|
||||
|
||||
const lastAtSymbol = textBeforeCursor.lastIndexOf('@');
|
||||
if (lastAtSymbol !== -1) {
|
||||
const textAfterAt = textBeforeCursor.substring(lastAtSymbol + 1);
|
||||
@@ -744,7 +774,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
} else {
|
||||
setShowFileMention(false);
|
||||
}
|
||||
}, [setAgentQuery, setCommandQuery, setMentionQuery, setShowAgentAutocomplete, setShowCommandAutocomplete, setShowFileMention]);
|
||||
}, [setAgentQuery, setCommandQuery, setMentionQuery, setShowAgentAutocomplete, setShowCommandAutocomplete, setShowFileMention, setShowSkillAutocomplete, setSkillQuery]);
|
||||
|
||||
const insertTextAtSelection = React.useCallback((text: string) => {
|
||||
if (!text) {
|
||||
@@ -891,6 +921,36 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
textareaRef.current?.focus();
|
||||
};
|
||||
|
||||
const handleSkillSelect = (skillName: string) => {
|
||||
const textarea = textareaRef.current;
|
||||
const cursorPosition = textarea?.selectionStart ?? message.length;
|
||||
const textBeforeCursor = message.substring(0, cursorPosition);
|
||||
const lastSlashSymbol = textBeforeCursor.lastIndexOf('/');
|
||||
|
||||
if (lastSlashSymbol !== -1) {
|
||||
const newMessage =
|
||||
message.substring(0, lastSlashSymbol) +
|
||||
`${skillName} ` +
|
||||
message.substring(cursorPosition);
|
||||
setMessage(newMessage);
|
||||
|
||||
const nextCursor = lastSlashSymbol + skillName.length + 1;
|
||||
requestAnimationFrame(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.selectionStart = nextCursor;
|
||||
textareaRef.current.selectionEnd = nextCursor;
|
||||
}
|
||||
adjustTextareaHeight();
|
||||
updateAutocompleteState(newMessage, nextCursor);
|
||||
});
|
||||
}
|
||||
|
||||
setShowSkillAutocomplete(false);
|
||||
setSkillQuery('');
|
||||
|
||||
textareaRef.current?.focus();
|
||||
};
|
||||
|
||||
const handleCommandSelect = (command: { name: string; description?: string; agent?: string; model?: string }) => {
|
||||
|
||||
setMessage(`/${command.name} `);
|
||||
@@ -1341,11 +1401,21 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
ref={agentRef}
|
||||
searchQuery={agentQuery}
|
||||
onAgentSelect={handleAgentSelect}
|
||||
onClose={() => setShowAgentAutocomplete(false)}
|
||||
/>
|
||||
)}
|
||||
{}
|
||||
{showFileMention && (
|
||||
onClose={() => setShowAgentAutocomplete(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showSkillAutocomplete && (
|
||||
<SkillAutocomplete
|
||||
ref={skillRef}
|
||||
searchQuery={skillQuery}
|
||||
onSkillSelect={handleSkillSelect}
|
||||
onClose={() => setShowSkillAutocomplete(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showFileMention && (
|
||||
|
||||
<FileMentionAutocomplete
|
||||
ref={mentionRef}
|
||||
searchQuery={mentionQuery}
|
||||
|
||||
@@ -229,7 +229,7 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
||||
ref={containerRef}
|
||||
className="absolute z-[100] min-w-0 w-full max-w-[450px] max-h-64 bg-background border border-border rounded-xl shadow-none bottom-full mb-2 left-0 flex flex-col"
|
||||
>
|
||||
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="px-0">
|
||||
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="px-0 pb-2" fillContainer={false}>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<RiRefreshLine className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useSkillsStore } from '@/stores/useSkillsStore';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
|
||||
interface SkillInfo {
|
||||
name: string;
|
||||
scope: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface SkillAutocompleteHandle {
|
||||
handleKeyDown: (key: string) => void;
|
||||
}
|
||||
|
||||
interface SkillAutocompleteProps {
|
||||
searchQuery: string;
|
||||
onSkillSelect: (skillName: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const SkillAutocomplete = React.forwardRef<SkillAutocompleteHandle, SkillAutocompleteProps>(({
|
||||
searchQuery,
|
||||
onSkillSelect,
|
||||
onClose,
|
||||
}, ref) => {
|
||||
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
||||
const [filteredSkills, setFilteredSkills] = React.useState<SkillInfo[]>([]);
|
||||
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
|
||||
const { skills, loadSkills } = useSkillsStore();
|
||||
|
||||
React.useEffect(() => {
|
||||
// Always trigger loadSkills when autocomplete opens to ensure project context is fresh
|
||||
void loadSkills();
|
||||
}, [loadSkills]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const normalizedQuery = searchQuery.trim().toLowerCase();
|
||||
const matches = normalizedQuery.length
|
||||
? skills.filter((skill) => skill.name.toLowerCase().includes(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(() => {
|
||||
itemRefs.current[selectedIndex]?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
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') {
|
||||
setSelectedIndex((prev) => (prev + 1) % filteredSkills.length);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === 'ArrowUp') {
|
||||
setSelectedIndex((prev) => (prev - 1 + filteredSkills.length) % filteredSkills.length);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === 'Enter' || key === 'Tab') {
|
||||
const skill = filteredSkills[selectedIndex];
|
||||
if (skill) {
|
||||
onSkillSelect(skill.name);
|
||||
}
|
||||
}
|
||||
},
|
||||
}), [filteredSkills, onSkillSelect, onClose, selectedIndex]);
|
||||
|
||||
const renderSkill = (skill: SkillInfo, index: number) => (
|
||||
<div
|
||||
key={`${skill.name}-${skill.scope}`}
|
||||
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-muted'
|
||||
)}
|
||||
onClick={() => onSkillSelect(skill.name)}
|
||||
onMouseEnter={() => 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="text-[10px] leading-none uppercase font-bold tracking-tight text-muted-foreground/70 bg-muted/50 px-1 py-0.5 rounded border border-border/40 flex-shrink-0">
|
||||
{skill.scope}
|
||||
</span>
|
||||
</div>
|
||||
{skill.description && (
|
||||
<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-[360px] max-h-60 bg-background border border-border rounded-xl shadow-none bottom-full mb-2 left-0 flex flex-col"
|
||||
>
|
||||
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="px-0 pb-2" fillContainer={false}>
|
||||
{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>
|
||||
<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';
|
||||
@@ -62,6 +62,7 @@ export interface DiscoveredSkill {
|
||||
path: string;
|
||||
scope: SkillScope;
|
||||
source: SkillSource;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface SkillConfig {
|
||||
@@ -154,7 +155,14 @@ export const useSkillsStore = create<SkillsStore>()(
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const skills = (data.skills || []) as DiscoveredSkill[];
|
||||
const rawSkills = data.skills || [];
|
||||
const skills = rawSkills.map((s: any) => ({
|
||||
name: s.name,
|
||||
path: s.path,
|
||||
scope: s.scope,
|
||||
source: s.source,
|
||||
description: s.sources?.md?.description || '',
|
||||
})) as DiscoveredSkill[];
|
||||
|
||||
set({ skills, isLoading: false });
|
||||
return true;
|
||||
|
||||
Reference in New Issue
Block a user