fix: improve slash and mention autocomplete (#1309)
Shows loaded skills in slash autocomplete with clear type badges Stabilizes keyboard navigation across autocomplete menus Fixes skill link rendering and skill autocomplete scrolling
This commit is contained in:
@@ -44,6 +44,7 @@ export const AgentMentionAutocomplete = React.forwardRef<AgentMentionAutocomplet
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
||||||
|
const selectedIndexRef = React.useRef(0);
|
||||||
const [agents, setAgents] = React.useState<AgentInfo[]>([]);
|
const [agents, setAgents] = React.useState<AgentInfo[]>([]);
|
||||||
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
|
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
|
||||||
const ignoreTabClickRef = React.useRef(false);
|
const ignoreTabClickRef = React.useRef(false);
|
||||||
@@ -83,9 +84,12 @@ export const AgentMentionAutocomplete = React.forwardRef<AgentMentionAutocomplet
|
|||||||
setSelectedIndex(0);
|
setSelectedIndex(0);
|
||||||
}, [getVisibleAgents, searchQuery, agentsWithMetadata]);
|
}, [getVisibleAgents, searchQuery, agentsWithMetadata]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
selectedIndexRef.current = selectedIndex;
|
||||||
|
}, [selectedIndex]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
itemRefs.current[selectedIndex]?.scrollIntoView({
|
itemRefs.current[selectedIndex]?.scrollIntoView({
|
||||||
behavior: 'smooth',
|
|
||||||
block: 'nearest',
|
block: 'nearest',
|
||||||
});
|
});
|
||||||
}, [selectedIndex]);
|
}, [selectedIndex]);
|
||||||
@@ -129,13 +133,14 @@ export const AgentMentionAutocomplete = React.forwardRef<AgentMentionAutocomplet
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (key === 'Enter' || key === 'Tab') {
|
if (key === 'Enter' || key === 'Tab') {
|
||||||
const agent = agents[(selectedIndex + agents.length) % agents.length];
|
const safeIndex = ((selectedIndexRef.current % agents.length) + agents.length) % agents.length;
|
||||||
|
const agent = agents[safeIndex];
|
||||||
if (agent) {
|
if (agent) {
|
||||||
onAgentSelect(agent.name);
|
onAgentSelect(agent.name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}), [agents, onAgentSelect, onClose, selectedIndex]);
|
}), [agents, onAgentSelect, onClose]);
|
||||||
|
|
||||||
const renderAgent = (agent: AgentInfo, index: number) => {
|
const renderAgent = (agent: AgentInfo, index: number) => {
|
||||||
const isSystem = agent.isBuiltIn;
|
const isSystem = agent.isBuiltIn;
|
||||||
@@ -150,9 +155,9 @@ export const AgentMentionAutocomplete = React.forwardRef<AgentMentionAutocomplet
|
|||||||
className={cn(
|
className={cn(
|
||||||
'flex items-start gap-2 px-3 py-1.5 cursor-pointer rounded-lg typography-ui-label',
|
'flex items-start gap-2 px-3 py-1.5 cursor-pointer rounded-lg typography-ui-label',
|
||||||
index === selectedIndex && 'bg-interactive-selection'
|
index === selectedIndex && 'bg-interactive-selection'
|
||||||
)}
|
)}
|
||||||
onClick={() => onAgentSelect(agent.name)}
|
onClick={() => onAgentSelect(agent.name)}
|
||||||
onMouseEnter={() => setSelectedIndex(index)}
|
onMouseMove={() => setSelectedIndex(index)}
|
||||||
>
|
>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -88,10 +88,8 @@ const collectInlineSkillMentions = (text: string, skillNames: Set<string>): stri
|
|||||||
INLINE_SKILL_TOKEN_PATTERN.lastIndex = 0;
|
INLINE_SKILL_TOKEN_PATTERN.lastIndex = 0;
|
||||||
let match: RegExpExecArray | null;
|
let match: RegExpExecArray | null;
|
||||||
while ((match = INLINE_SKILL_TOKEN_PATTERN.exec(text)) !== null) {
|
while ((match = INLINE_SKILL_TOKEN_PATTERN.exec(text)) !== null) {
|
||||||
const prefix = match[1] || '';
|
|
||||||
const name = match[2] || '';
|
const name = match[2] || '';
|
||||||
const slashIndex = match.index + prefix.length;
|
if (!skillNames.has(name) || mentions.includes(name)) {
|
||||||
if (slashIndex === 0 || !skillNames.has(name) || mentions.includes(name)) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
mentions.push(name);
|
mentions.push(name);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
|||||||
import { Icon } from "@/components/icon/Icon";
|
import { Icon } from "@/components/icon/Icon";
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
type CommandSource = 'openchamber' | 'opencode';
|
type CommandSource = 'openchamber' | 'opencode' | 'skill';
|
||||||
|
|
||||||
export interface CommandInfo {
|
export interface CommandInfo {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -29,6 +29,24 @@ export interface CommandAutocompleteHandle {
|
|||||||
|
|
||||||
type AutocompleteTab = 'commands' | 'agents' | 'files';
|
type AutocompleteTab = 'commands' | 'agents' | 'files';
|
||||||
|
|
||||||
|
const BASE_BADGE_CLASS = "text-[10px] leading-none uppercase font-bold tracking-tight px-1.5 py-1 rounded border flex-shrink-0";
|
||||||
|
const TYPE_BADGE_CLASS = cn(
|
||||||
|
BASE_BADGE_CLASS,
|
||||||
|
"bg-[color-mix(in_srgb,var(--primary-base)_12%,transparent)] text-[color-mix(in_srgb,var(--primary-base)_70%,transparent)] border-[color-mix(in_srgb,var(--primary-base)_24%,transparent)]"
|
||||||
|
);
|
||||||
|
const USER_BADGE_CLASS = cn(
|
||||||
|
BASE_BADGE_CLASS,
|
||||||
|
"bg-[color-mix(in_srgb,var(--status-success)_12%,transparent)] text-[color-mix(in_srgb,var(--status-success)_70%,transparent)] border-[color-mix(in_srgb,var(--status-success)_24%,transparent)]"
|
||||||
|
);
|
||||||
|
const PROJECT_BADGE_CLASS = cn(
|
||||||
|
BASE_BADGE_CLASS,
|
||||||
|
"bg-[color-mix(in_srgb,var(--status-info)_12%,transparent)] text-[color-mix(in_srgb,var(--status-info)_70%,transparent)] border-[color-mix(in_srgb,var(--status-info)_24%,transparent)]"
|
||||||
|
);
|
||||||
|
const NEUTRAL_BADGE_CLASS = cn(
|
||||||
|
BASE_BADGE_CLASS,
|
||||||
|
"bg-[var(--surface-muted)] text-muted-foreground border-[var(--interactive-border)]/60"
|
||||||
|
);
|
||||||
|
|
||||||
interface CommandAutocompleteProps {
|
interface CommandAutocompleteProps {
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
onCommandSelect: (command: CommandInfo, options?: { dismissKeyboard?: boolean }) => void;
|
onCommandSelect: (command: CommandInfo, options?: { dismissKeyboard?: boolean }) => void;
|
||||||
@@ -63,6 +81,8 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
|||||||
const skills = useSkillsStore((s) => s.skills);
|
const skills = useSkillsStore((s) => s.skills);
|
||||||
const refreshSkills = useSkillsStore((s) => s.loadSkills);
|
const refreshSkills = useSkillsStore((s) => s.loadSkills);
|
||||||
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
||||||
|
const selectedIndexRef = React.useRef(0);
|
||||||
|
const keyboardNavigationRef = React.useRef(false);
|
||||||
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
|
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
|
||||||
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
const ignoreClickRef = React.useRef(false);
|
const ignoreClickRef = React.useRef(false);
|
||||||
@@ -107,9 +127,17 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
|||||||
agent: cmd.agent ?? undefined,
|
agent: cmd.agent ?? undefined,
|
||||||
model: cmd.model ?? undefined,
|
model: cmd.model ?? undefined,
|
||||||
isBuiltIn: cmd.name === 'init' || cmd.name === 'review',
|
isBuiltIn: cmd.name === 'init' || cmd.name === 'review',
|
||||||
isSkill: skillNames.has(cmd.name),
|
isSkill: cmd.source === 'skill' || skillNames.has(cmd.name),
|
||||||
scope: cmd.scope,
|
scope: cmd.scope,
|
||||||
}));
|
}));
|
||||||
|
const skillCommands: CommandInfo[] = skills.map((skill, index) => ({
|
||||||
|
id: `skill:${skill.scope}:${skill.source ?? 'opencode'}:${skill.name}:${index}`,
|
||||||
|
name: skill.name,
|
||||||
|
source: 'skill',
|
||||||
|
description: skill.description,
|
||||||
|
isSkill: true,
|
||||||
|
scope: skill.scope,
|
||||||
|
}));
|
||||||
|
|
||||||
const builtInCommands: CommandInfo[] = [
|
const builtInCommands: CommandInfo[] = [
|
||||||
...(hasSession && !hasMessagesInCurrentSession
|
...(hasSession && !hasMessagesInCurrentSession
|
||||||
@@ -134,7 +162,7 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
|||||||
: []
|
: []
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
const allCommands = [...builtInCommands, ...customCommands];
|
const allCommands = [...builtInCommands, ...customCommands, ...skillCommands];
|
||||||
|
|
||||||
const allowInitCommand = !hasMessagesInCurrentSession;
|
const allowInitCommand = !hasMessagesInCurrentSession;
|
||||||
const filtered = (searchQuery
|
const filtered = (searchQuery
|
||||||
@@ -200,9 +228,12 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
|||||||
setSelectedIndex(0);
|
setSelectedIndex(0);
|
||||||
}, [commands]);
|
}, [commands]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
selectedIndexRef.current = selectedIndex;
|
||||||
|
}, [selectedIndex]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
itemRefs.current[selectedIndex]?.scrollIntoView({
|
itemRefs.current[selectedIndex]?.scrollIntoView({
|
||||||
behavior: 'smooth',
|
|
||||||
block: 'nearest'
|
block: 'nearest'
|
||||||
});
|
});
|
||||||
}, [selectedIndex]);
|
}, [selectedIndex]);
|
||||||
@@ -220,24 +251,26 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (key === 'ArrowDown') {
|
if (key === 'ArrowDown') {
|
||||||
|
keyboardNavigationRef.current = true;
|
||||||
setSelectedIndex((prev) => (prev + 1) % total);
|
setSelectedIndex((prev) => (prev + 1) % total);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (key === 'ArrowUp') {
|
if (key === 'ArrowUp') {
|
||||||
|
keyboardNavigationRef.current = true;
|
||||||
setSelectedIndex((prev) => (prev - 1 + total) % total);
|
setSelectedIndex((prev) => (prev - 1 + total) % total);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (key === 'Enter' || key === 'Tab') {
|
if (key === 'Enter' || key === 'Tab') {
|
||||||
const safeIndex = ((selectedIndex % total) + total) % total;
|
const safeIndex = ((selectedIndexRef.current % total) + total) % total;
|
||||||
const command = commands[safeIndex];
|
const command = commands[safeIndex];
|
||||||
if (command) {
|
if (command) {
|
||||||
onCommandSelect(command);
|
onCommandSelect(command);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}), [commands, selectedIndex, onClose, onCommandSelect]);
|
}), [commands, onClose, onCommandSelect]);
|
||||||
|
|
||||||
const getCommandIcon = (command: CommandInfo) => {
|
const getCommandIcon = (command: CommandInfo) => {
|
||||||
|
|
||||||
@@ -322,8 +355,6 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
|||||||
{commands.map((command, index) => {
|
{commands.map((command, index) => {
|
||||||
const isSystem = command.isBuiltIn;
|
const isSystem = command.isBuiltIn;
|
||||||
const isOpenChamberBadge = command.isOpenChamber;
|
const isOpenChamberBadge = command.isOpenChamber;
|
||||||
const isProject = command.scope === 'project';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={command.id}
|
key={command.id}
|
||||||
@@ -375,7 +406,10 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
|||||||
}
|
}
|
||||||
onCommandSelect(command);
|
onCommandSelect(command);
|
||||||
}}
|
}}
|
||||||
onMouseEnter={() => setSelectedIndex(index)}
|
onMouseMove={() => {
|
||||||
|
keyboardNavigationRef.current = false;
|
||||||
|
setSelectedIndex(index);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="mt-0.5">
|
<div className="mt-0.5">
|
||||||
{getCommandIcon(command)}
|
{getCommandIcon(command)}
|
||||||
@@ -384,37 +418,29 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="typography-ui-label font-medium">/{command.name}</span>
|
<span className="typography-ui-label font-medium">/{command.name}</span>
|
||||||
{command.isSkill ? (
|
{command.isSkill ? (
|
||||||
<span className="text-[10px] leading-none uppercase font-bold tracking-tight bg-[var(--status-info-background)] text-[var(--status-info)] border-[var(--status-info-border)] px-1.5 py-1 rounded border flex-shrink-0">
|
<span className={TYPE_BADGE_CLASS}>
|
||||||
{t('chat.commandAutocomplete.badge.skill')}
|
{t('chat.commandAutocomplete.badge.skill')}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : (
|
||||||
|
<span className={TYPE_BADGE_CLASS}>
|
||||||
|
{t('chat.commandAutocomplete.badge.command')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{isOpenChamberBadge ? (
|
{isOpenChamberBadge ? (
|
||||||
<span
|
<span className={NEUTRAL_BADGE_CLASS}>
|
||||||
className="text-[10px] leading-none uppercase font-bold tracking-tight px-1.5 py-1 rounded border flex-shrink-0"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'color-mix(in srgb, var(--primary-base) 14%, transparent)',
|
|
||||||
color: 'var(--primary-base)',
|
|
||||||
borderColor: 'color-mix(in srgb, var(--primary-base) 28%, transparent)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
OpenChamber
|
OpenChamber
|
||||||
</span>
|
</span>
|
||||||
) : isSystem ? (
|
) : 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">
|
<span className={NEUTRAL_BADGE_CLASS}>
|
||||||
{t('chat.commandAutocomplete.badge.system')}
|
{t('chat.commandAutocomplete.badge.system')}
|
||||||
</span>
|
</span>
|
||||||
) : command.scope ? (
|
) : command.scope ? (
|
||||||
<span className={cn(
|
<span className={command.scope === 'project' ? PROJECT_BADGE_CLASS : USER_BADGE_CLASS}>
|
||||||
"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)]"
|
|
||||||
)}>
|
|
||||||
{command.scope}
|
{command.scope}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
{command.agent && (
|
{command.agent && (
|
||||||
<span className="text-[10px] leading-none font-bold tracking-tight bg-[var(--surface-subtle)] text-[var(--surface-foreground)] border-[var(--interactive-border)] px-1.5 py-1 rounded border flex-shrink-0">
|
<span className={NEUTRAL_BADGE_CLASS}>
|
||||||
{command.agent}
|
{command.agent}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
|||||||
const [loading, setLoading] = React.useState(false);
|
const [loading, setLoading] = React.useState(false);
|
||||||
const pendingSearchRef = React.useRef(0);
|
const pendingSearchRef = React.useRef(0);
|
||||||
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
||||||
|
const selectedIndexRef = React.useRef(0);
|
||||||
const [marqueeWidth, setMarqueeWidth] = React.useState(360);
|
const [marqueeWidth, setMarqueeWidth] = React.useState(360);
|
||||||
const [overflowMap, setOverflowMap] = React.useState<Record<number, boolean>>({});
|
const [overflowMap, setOverflowMap] = React.useState<Record<number, boolean>>({});
|
||||||
const [marqueeDurations, setMarqueeDurations] = React.useState<Record<number, number>>({});
|
const [marqueeDurations, setMarqueeDurations] = React.useState<Record<number, number>>({});
|
||||||
@@ -292,9 +293,12 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
|||||||
setMarqueeDurations({});
|
setMarqueeDurations({});
|
||||||
}, [visibleFiles, visibleDirectories, visibleRecentFiles.length, visibleAgents.length]);
|
}, [visibleFiles, visibleDirectories, visibleRecentFiles.length, visibleAgents.length]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
selectedIndexRef.current = selectedIndex;
|
||||||
|
}, [selectedIndex]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
itemRefs.current[selectedIndex]?.scrollIntoView({
|
itemRefs.current[selectedIndex]?.scrollIntoView({
|
||||||
behavior: 'smooth',
|
|
||||||
block: 'nearest'
|
block: 'nearest'
|
||||||
});
|
});
|
||||||
}, [selectedIndex]);
|
}, [selectedIndex]);
|
||||||
@@ -397,7 +401,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (key === 'Enter' || key === 'Tab') {
|
if (key === 'Enter' || key === 'Tab') {
|
||||||
const safeIndex = ((selectedIndex % total) + total) % total;
|
const safeIndex = ((selectedIndexRef.current % total) + total) % total;
|
||||||
if (safeIndex < visibleAgents.length) {
|
if (safeIndex < visibleAgents.length) {
|
||||||
const agent = visibleAgents[safeIndex];
|
const agent = visibleAgents[safeIndex];
|
||||||
if (agent) {
|
if (agent) {
|
||||||
@@ -422,7 +426,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}), [visibleFiles, visibleDirectories, visibleRecentFiles, visibleAgents, selectedIndex, onClose, handleFileSelect, handleAgentPick]);
|
}), [visibleFiles, visibleDirectories, visibleRecentFiles, visibleAgents, onClose, handleFileSelect, handleAgentPick]);
|
||||||
|
|
||||||
const getFileIcon = (file: FileInfo) => {
|
const getFileIcon = (file: FileInfo) => {
|
||||||
const ext = file.extension?.toLowerCase();
|
const ext = file.extension?.toLowerCase();
|
||||||
@@ -514,7 +518,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
|||||||
isSelected && 'bg-interactive-selection',
|
isSelected && 'bg-interactive-selection',
|
||||||
)}
|
)}
|
||||||
onClick={() => handleAgentPick(agent.name)}
|
onClick={() => handleAgentPick(agent.name)}
|
||||||
onMouseEnter={() => setSelectedIndex(index)}
|
onMouseMove={() => setSelectedIndex(index)}
|
||||||
>
|
>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="font-semibold truncate">@{agent.name}</div>
|
<div className="font-semibold truncate">@{agent.name}</div>
|
||||||
@@ -548,7 +552,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
|||||||
isSelected && "bg-interactive-selection"
|
isSelected && "bg-interactive-selection"
|
||||||
)}
|
)}
|
||||||
onClick={() => handleFileSelect(dir)}
|
onClick={() => handleFileSelect(dir)}
|
||||||
onMouseEnter={() => setSelectedIndex(rowIndex)}
|
onMouseMove={() => setSelectedIndex(rowIndex)}
|
||||||
>
|
>
|
||||||
<Icon name="folder-3-fill" className="h-3.5 w-3.5 text-primary/60" />
|
<Icon name="folder-3-fill" className="h-3.5 w-3.5 text-primary/60" />
|
||||||
<span className="flex-1 min-w-0 truncate" aria-label={relativePath}>
|
<span className="flex-1 min-w-0 truncate" aria-label={relativePath}>
|
||||||
@@ -577,7 +581,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
|||||||
isSelected && "bg-interactive-selection"
|
isSelected && "bg-interactive-selection"
|
||||||
)}
|
)}
|
||||||
onClick={() => handleFileSelect(file)}
|
onClick={() => handleFileSelect(file)}
|
||||||
onMouseEnter={() => setSelectedIndex(rowIndex)}
|
onMouseMove={() => setSelectedIndex(rowIndex)}
|
||||||
>
|
>
|
||||||
{getFileIcon(file)}
|
{getFileIcon(file)}
|
||||||
<span
|
<span
|
||||||
@@ -626,9 +630,9 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
|||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 px-3 py-1.5 cursor-pointer typography-ui-label rounded-lg",
|
"flex items-center gap-2 px-3 py-1.5 cursor-pointer typography-ui-label rounded-lg",
|
||||||
isSelected && "bg-interactive-selection"
|
isSelected && "bg-interactive-selection"
|
||||||
)}
|
)}
|
||||||
onClick={() => handleFileSelect(file)}
|
onClick={() => handleFileSelect(file)}
|
||||||
onMouseEnter={() => setSelectedIndex(rowIndex)}
|
onMouseMove={() => setSelectedIndex(rowIndex)}
|
||||||
>
|
>
|
||||||
{getFileIcon(file)}
|
{getFileIcon(file)}
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ export const SkillAutocomplete = React.forwardRef<SkillAutocompleteHandle, Skill
|
|||||||
}, ref) => {
|
}, ref) => {
|
||||||
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
||||||
|
const selectedIndexRef = React.useRef(0);
|
||||||
|
const keyboardNavigationRef = React.useRef(false);
|
||||||
const [filteredSkills, setFilteredSkills] = React.useState<SkillInfo[]>([]);
|
const [filteredSkills, setFilteredSkills] = React.useState<SkillInfo[]>([]);
|
||||||
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
|
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
|
||||||
const skills = useSkillsStore((s) => s.skills);
|
const skills = useSkillsStore((s) => s.skills);
|
||||||
@@ -56,9 +58,12 @@ export const SkillAutocomplete = React.forwardRef<SkillAutocompleteHandle, Skill
|
|||||||
setSelectedIndex(0);
|
setSelectedIndex(0);
|
||||||
}, [skills, searchQuery]);
|
}, [skills, searchQuery]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
selectedIndexRef.current = selectedIndex;
|
||||||
|
}, [selectedIndex]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
itemRefs.current[selectedIndex]?.scrollIntoView({
|
itemRefs.current[selectedIndex]?.scrollIntoView({
|
||||||
behavior: 'smooth',
|
|
||||||
block: 'nearest',
|
block: 'nearest',
|
||||||
});
|
});
|
||||||
}, [selectedIndex]);
|
}, [selectedIndex]);
|
||||||
@@ -92,23 +97,26 @@ export const SkillAutocomplete = React.forwardRef<SkillAutocompleteHandle, Skill
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (key === 'ArrowDown') {
|
if (key === 'ArrowDown') {
|
||||||
|
keyboardNavigationRef.current = true;
|
||||||
setSelectedIndex((prev) => (prev + 1) % filteredSkills.length);
|
setSelectedIndex((prev) => (prev + 1) % filteredSkills.length);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (key === 'ArrowUp') {
|
if (key === 'ArrowUp') {
|
||||||
|
keyboardNavigationRef.current = true;
|
||||||
setSelectedIndex((prev) => (prev - 1 + filteredSkills.length) % filteredSkills.length);
|
setSelectedIndex((prev) => (prev - 1 + filteredSkills.length) % filteredSkills.length);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (key === 'Enter' || key === 'Tab') {
|
if (key === 'Enter' || key === 'Tab') {
|
||||||
const skill = filteredSkills[selectedIndex];
|
const safeIndex = ((selectedIndexRef.current % filteredSkills.length) + filteredSkills.length) % filteredSkills.length;
|
||||||
|
const skill = filteredSkills[safeIndex];
|
||||||
if (skill) {
|
if (skill) {
|
||||||
onSkillSelect(skill.name);
|
onSkillSelect(skill.name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}), [filteredSkills, onSkillSelect, onClose, selectedIndex]);
|
}), [filteredSkills, onSkillSelect, onClose]);
|
||||||
|
|
||||||
const renderSkill = (skill: SkillInfo, index: number) => {
|
const renderSkill = (skill: SkillInfo, index: number) => {
|
||||||
const isProject = skill.scope === 'project';
|
const isProject = skill.scope === 'project';
|
||||||
@@ -122,9 +130,12 @@ export const SkillAutocomplete = React.forwardRef<SkillAutocompleteHandle, Skill
|
|||||||
className={cn(
|
className={cn(
|
||||||
'flex items-start gap-2 px-3 py-1.5 cursor-pointer rounded-lg typography-ui-label',
|
'flex items-start gap-2 px-3 py-1.5 cursor-pointer rounded-lg typography-ui-label',
|
||||||
index === selectedIndex && 'bg-interactive-selection'
|
index === selectedIndex && 'bg-interactive-selection'
|
||||||
)}
|
)}
|
||||||
onClick={() => onSkillSelect(skill.name)}
|
onClick={() => onSkillSelect(skill.name)}
|
||||||
onMouseEnter={() => setSelectedIndex(index)}
|
onMouseMove={() => {
|
||||||
|
keyboardNavigationRef.current = false;
|
||||||
|
setSelectedIndex(index);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -154,10 +165,10 @@ export const SkillAutocomplete = React.forwardRef<SkillAutocompleteHandle, Skill
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
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"
|
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={style}
|
style={style}
|
||||||
>
|
>
|
||||||
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="px-0 pb-2" fillContainer={false}>
|
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="px-0 pb-2">
|
||||||
{filteredSkills.length ? (
|
{filteredSkills.length ? (
|
||||||
<div>
|
<div>
|
||||||
{filteredSkills.map((skill, index) => renderSkill(skill, index))}
|
{filteredSkills.map((skill, index) => renderSkill(skill, index))}
|
||||||
|
|||||||
@@ -145,9 +145,8 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
|
|||||||
content = content.replace(agentMention.token, mentionHtml);
|
content = content.replace(agentMention.token, mentionHtml);
|
||||||
}
|
}
|
||||||
|
|
||||||
content = content.replace(SKILL_TOKEN_PATTERN, (match, prefix: string, skillName: string, offset: number) => {
|
content = content.replace(SKILL_TOKEN_PATTERN, (match, prefix: string, skillName: string) => {
|
||||||
const slashIndex = offset + prefix.length;
|
if (!skillByName.has(skillName)) return match;
|
||||||
if (slashIndex === 0 || !skillByName.has(skillName)) return match;
|
|
||||||
return `${prefix}[/${skillName}](${buildSkillHref(skillName)})`;
|
return `${prefix}[/${skillName}](${buildSkillHref(skillName)})`;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -165,7 +164,7 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
|
|||||||
const prefix = match[1] || '';
|
const prefix = match[1] || '';
|
||||||
const skillName = match[2];
|
const skillName = match[2];
|
||||||
const slashIndex = match.index + prefix.length;
|
const slashIndex = match.index + prefix.length;
|
||||||
if (slashIndex === 0 || !skillByName.has(skillName)) continue;
|
if (!skillByName.has(skillName)) continue;
|
||||||
|
|
||||||
if (match.index > cursor) nodes.push(textContent.slice(cursor, match.index));
|
if (match.index > cursor) nodes.push(textContent.slice(cursor, match.index));
|
||||||
if (prefix) nodes.push(prefix);
|
if (prefix) nodes.push(prefix);
|
||||||
|
|||||||
@@ -1442,6 +1442,7 @@ export const dict = {
|
|||||||
'chat.commandAutocomplete.command.summaryDescription': 'Non-destructive session summary. Optional topic hint after the command.',
|
'chat.commandAutocomplete.command.summaryDescription': 'Non-destructive session summary. Optional topic hint after the command.',
|
||||||
'chat.commandAutocomplete.command.workspaceReviewDescription': 'Review current workspace changes for high-signal issues only.',
|
'chat.commandAutocomplete.command.workspaceReviewDescription': 'Review current workspace changes for high-signal issues only.',
|
||||||
'chat.commandAutocomplete.badge.skill': 'skill',
|
'chat.commandAutocomplete.badge.skill': 'skill',
|
||||||
|
'chat.commandAutocomplete.badge.command': 'command',
|
||||||
'chat.commandAutocomplete.badge.system': 'system',
|
'chat.commandAutocomplete.badge.system': 'system',
|
||||||
'chat.commandAutocomplete.empty': 'No commands found',
|
'chat.commandAutocomplete.empty': 'No commands found',
|
||||||
'chat.agentMentionAutocomplete.badge.system': 'system',
|
'chat.agentMentionAutocomplete.badge.system': 'system',
|
||||||
|
|||||||
@@ -1408,6 +1408,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"chat.commandAutocomplete.command.summaryDescription": "Resumen no destructivo de la sesión. Pista opcional del tema después del comando.",
|
"chat.commandAutocomplete.command.summaryDescription": "Resumen no destructivo de la sesión. Pista opcional del tema después del comando.",
|
||||||
"chat.commandAutocomplete.command.workspaceReviewDescription": "Revisar los cambios actuales del espacio de trabajo solo para problemas de alto impacto.",
|
"chat.commandAutocomplete.command.workspaceReviewDescription": "Revisar los cambios actuales del espacio de trabajo solo para problemas de alto impacto.",
|
||||||
"chat.commandAutocomplete.badge.skill": "habilidad",
|
"chat.commandAutocomplete.badge.skill": "habilidad",
|
||||||
|
"chat.commandAutocomplete.badge.command": "comando",
|
||||||
"chat.commandAutocomplete.badge.system": "sistema",
|
"chat.commandAutocomplete.badge.system": "sistema",
|
||||||
"chat.commandAutocomplete.empty": "No se encontraron comandos",
|
"chat.commandAutocomplete.empty": "No se encontraron comandos",
|
||||||
"chat.agentMentionAutocomplete.badge.system": "sistema",
|
"chat.agentMentionAutocomplete.badge.system": "sistema",
|
||||||
|
|||||||
@@ -1444,6 +1444,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'chat.commandAutocomplete.command.summaryDescription': '세션 기록을 안전하게 요약합니다. 명령 뒤에 선택적으로 주제 힌트를 넣을 수 있습니다.',
|
'chat.commandAutocomplete.command.summaryDescription': '세션 기록을 안전하게 요약합니다. 명령 뒤에 선택적으로 주제 힌트를 넣을 수 있습니다.',
|
||||||
'chat.commandAutocomplete.command.workspaceReviewDescription': '현재 워크스페이스 변경 사항에서 중요한 이슈만 리뷰합니다.',
|
'chat.commandAutocomplete.command.workspaceReviewDescription': '현재 워크스페이스 변경 사항에서 중요한 이슈만 리뷰합니다.',
|
||||||
'chat.commandAutocomplete.badge.skill': '스킬',
|
'chat.commandAutocomplete.badge.skill': '스킬',
|
||||||
|
'chat.commandAutocomplete.badge.command': '명령',
|
||||||
'chat.commandAutocomplete.badge.system': 'system',
|
'chat.commandAutocomplete.badge.system': 'system',
|
||||||
'chat.commandAutocomplete.empty': '명령 없음',
|
'chat.commandAutocomplete.empty': '명령 없음',
|
||||||
'chat.agentMentionAutocomplete.badge.system': 'system',
|
'chat.agentMentionAutocomplete.badge.system': 'system',
|
||||||
|
|||||||
@@ -489,6 +489,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'chat.commandAutocomplete.command.summaryDescription': 'Niedestrukcyjne podsumowanie sesji. Opcjonalna wskazówka tematu po poleceniu.',
|
'chat.commandAutocomplete.command.summaryDescription': 'Niedestrukcyjne podsumowanie sesji. Opcjonalna wskazówka tematu po poleceniu.',
|
||||||
'chat.commandAutocomplete.command.workspaceReviewDescription': 'Recenzja obecnych zmian w przestrzeni roboczej tylko dla problemów o wysokim sygnale.',
|
'chat.commandAutocomplete.command.workspaceReviewDescription': 'Recenzja obecnych zmian w przestrzeni roboczej tylko dla problemów o wysokim sygnale.',
|
||||||
'chat.commandAutocomplete.badge.skill': 'skill',
|
'chat.commandAutocomplete.badge.skill': 'skill',
|
||||||
|
'chat.commandAutocomplete.badge.command': 'polecenie',
|
||||||
'chat.commandAutocomplete.badge.system': 'system',
|
'chat.commandAutocomplete.badge.system': 'system',
|
||||||
'chat.commandAutocomplete.empty': 'Nie znaleziono poleceń',
|
'chat.commandAutocomplete.empty': 'Nie znaleziono poleceń',
|
||||||
'chat.agentMentionAutocomplete.badge.system': 'system',
|
'chat.agentMentionAutocomplete.badge.system': 'system',
|
||||||
|
|||||||
@@ -1408,6 +1408,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"chat.commandAutocomplete.command.summaryDescription": "Resumo não destrutivo da sessão. Dica opcional do tema após o comando.",
|
"chat.commandAutocomplete.command.summaryDescription": "Resumo não destrutivo da sessão. Dica opcional do tema após o comando.",
|
||||||
"chat.commandAutocomplete.command.workspaceReviewDescription": "Revisar as alterações atuais do workspace apenas para problemas de alto impacto.",
|
"chat.commandAutocomplete.command.workspaceReviewDescription": "Revisar as alterações atuais do workspace apenas para problemas de alto impacto.",
|
||||||
"chat.commandAutocomplete.badge.skill": "habilidade",
|
"chat.commandAutocomplete.badge.skill": "habilidade",
|
||||||
|
"chat.commandAutocomplete.badge.command": "comando",
|
||||||
"chat.commandAutocomplete.badge.system": "sistema",
|
"chat.commandAutocomplete.badge.system": "sistema",
|
||||||
"chat.commandAutocomplete.empty": "Nenhum comando encontrado",
|
"chat.commandAutocomplete.empty": "Nenhum comando encontrado",
|
||||||
"chat.agentMentionAutocomplete.badge.system": "sistema",
|
"chat.agentMentionAutocomplete.badge.system": "sistema",
|
||||||
|
|||||||
@@ -1408,6 +1408,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"chat.commandAutocomplete.command.summaryDescription": "Неруйнівний підсумок сесії. Після команди можна додати тему.",
|
"chat.commandAutocomplete.command.summaryDescription": "Неруйнівний підсумок сесії. Після команди можна додати тему.",
|
||||||
"chat.commandAutocomplete.command.workspaceReviewDescription": "Перегляньте поточні зміни в робочому середовищі лише для проблем із сильним сигналом.",
|
"chat.commandAutocomplete.command.workspaceReviewDescription": "Перегляньте поточні зміни в робочому середовищі лише для проблем із сильним сигналом.",
|
||||||
"chat.commandAutocomplete.badge.skill": "навичка",
|
"chat.commandAutocomplete.badge.skill": "навичка",
|
||||||
|
"chat.commandAutocomplete.badge.command": "команда",
|
||||||
"chat.commandAutocomplete.badge.system": "система",
|
"chat.commandAutocomplete.badge.system": "система",
|
||||||
"chat.commandAutocomplete.empty": "Команди не знайдено",
|
"chat.commandAutocomplete.empty": "Команди не знайдено",
|
||||||
"chat.agentMentionAutocomplete.badge.system": "система",
|
"chat.agentMentionAutocomplete.badge.system": "система",
|
||||||
|
|||||||
@@ -1408,6 +1408,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'chat.commandAutocomplete.command.summaryDescription': '非破坏性会话总结。命令后可选填主题提示。',
|
'chat.commandAutocomplete.command.summaryDescription': '非破坏性会话总结。命令后可选填主题提示。',
|
||||||
'chat.commandAutocomplete.command.workspaceReviewDescription': '仅审查当前工作区中高价值的问题。',
|
'chat.commandAutocomplete.command.workspaceReviewDescription': '仅审查当前工作区中高价值的问题。',
|
||||||
'chat.commandAutocomplete.badge.skill': '技能',
|
'chat.commandAutocomplete.badge.skill': '技能',
|
||||||
|
'chat.commandAutocomplete.badge.command': '命令',
|
||||||
'chat.commandAutocomplete.badge.system': '系统',
|
'chat.commandAutocomplete.badge.system': '系统',
|
||||||
'chat.commandAutocomplete.empty': '未找到命令',
|
'chat.commandAutocomplete.empty': '未找到命令',
|
||||||
'chat.agentMentionAutocomplete.badge.system': '系统',
|
'chat.agentMentionAutocomplete.badge.system': '系统',
|
||||||
|
|||||||
Reference in New Issue
Block a user