From 56a23199342132d498838e3e84dfe5687b1e4fa9 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 18 May 2026 19:11:29 +0300 Subject: [PATCH] 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 --- .../chat/AgentMentionAutocomplete.tsx | 15 ++-- packages/ui/src/components/chat/ChatInput.tsx | 4 +- .../components/chat/CommandAutocomplete.tsx | 80 ++++++++++++------- .../chat/FileMentionAutocomplete.tsx | 20 +++-- .../src/components/chat/SkillAutocomplete.tsx | 25 ++++-- .../chat/message/parts/UserTextPart.tsx | 7 +- packages/ui/src/lib/i18n/messages/en.ts | 1 + packages/ui/src/lib/i18n/messages/es.ts | 1 + packages/ui/src/lib/i18n/messages/ko.ts | 1 + packages/ui/src/lib/i18n/messages/pl.ts | 1 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 1 + packages/ui/src/lib/i18n/messages/uk.ts | 1 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 1 + 13 files changed, 104 insertions(+), 54 deletions(-) diff --git a/packages/ui/src/components/chat/AgentMentionAutocomplete.tsx b/packages/ui/src/components/chat/AgentMentionAutocomplete.tsx index 98cc461b..808b1384 100644 --- a/packages/ui/src/components/chat/AgentMentionAutocomplete.tsx +++ b/packages/ui/src/components/chat/AgentMentionAutocomplete.tsx @@ -44,6 +44,7 @@ export const AgentMentionAutocomplete = React.forwardRef(null); const [selectedIndex, setSelectedIndex] = React.useState(0); + const selectedIndexRef = React.useRef(0); const [agents, setAgents] = React.useState([]); const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]); const ignoreTabClickRef = React.useRef(false); @@ -83,9 +84,12 @@ export const AgentMentionAutocomplete = React.forwardRef { + selectedIndexRef.current = selectedIndex; + }, [selectedIndex]); + React.useEffect(() => { itemRefs.current[selectedIndex]?.scrollIntoView({ - behavior: 'smooth', block: 'nearest', }); }, [selectedIndex]); @@ -129,13 +133,14 @@ export const AgentMentionAutocomplete = React.forwardRef { const isSystem = agent.isBuiltIn; @@ -150,9 +155,9 @@ export const AgentMentionAutocomplete = React.forwardRef onAgentSelect(agent.name)} - onMouseEnter={() => setSelectedIndex(index)} + onMouseMove={() => setSelectedIndex(index)} >
diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 7e87e7e5..e06f9c46 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -88,10 +88,8 @@ const collectInlineSkillMentions = (text: string, skillNames: Set): stri INLINE_SKILL_TOKEN_PATTERN.lastIndex = 0; let match: RegExpExecArray | null; while ((match = INLINE_SKILL_TOKEN_PATTERN.exec(text)) !== null) { - const prefix = match[1] || ''; const name = match[2] || ''; - const slashIndex = match.index + prefix.length; - if (slashIndex === 0 || !skillNames.has(name) || mentions.includes(name)) { + if (!skillNames.has(name) || mentions.includes(name)) { continue; } mentions.push(name); diff --git a/packages/ui/src/components/chat/CommandAutocomplete.tsx b/packages/ui/src/components/chat/CommandAutocomplete.tsx index ff5caceb..fb23b5eb 100644 --- a/packages/ui/src/components/chat/CommandAutocomplete.tsx +++ b/packages/ui/src/components/chat/CommandAutocomplete.tsx @@ -8,7 +8,7 @@ import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { Icon } from "@/components/icon/Icon"; import { useI18n } from '@/lib/i18n'; -type CommandSource = 'openchamber' | 'opencode'; +type CommandSource = 'openchamber' | 'opencode' | 'skill'; export interface CommandInfo { id: string; @@ -29,6 +29,24 @@ export interface CommandAutocompleteHandle { 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 { searchQuery: string; onCommandSelect: (command: CommandInfo, options?: { dismissKeyboard?: boolean }) => void; @@ -63,6 +81,8 @@ export const CommandAutocomplete = React.forwardRef s.skills); const refreshSkills = useSkillsStore((s) => s.loadSkills); const [selectedIndex, setSelectedIndex] = React.useState(0); + const selectedIndexRef = React.useRef(0); + const keyboardNavigationRef = React.useRef(false); const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]); const containerRef = React.useRef(null); const ignoreClickRef = React.useRef(false); @@ -107,9 +127,17 @@ export const CommandAutocomplete = React.forwardRef ({ + 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[] = [ ...(hasSession && !hasMessagesInCurrentSession @@ -134,7 +162,7 @@ export const CommandAutocomplete = React.forwardRef { + selectedIndexRef.current = selectedIndex; + }, [selectedIndex]); + React.useEffect(() => { itemRefs.current[selectedIndex]?.scrollIntoView({ - behavior: 'smooth', block: 'nearest' }); }, [selectedIndex]); @@ -220,24 +251,26 @@ export const CommandAutocomplete = React.forwardRef (prev + 1) % total); return; } if (key === 'ArrowUp') { + keyboardNavigationRef.current = true; setSelectedIndex((prev) => (prev - 1 + total) % total); return; } if (key === 'Enter' || key === 'Tab') { - const safeIndex = ((selectedIndex % total) + total) % total; + const safeIndex = ((selectedIndexRef.current % total) + total) % total; const command = commands[safeIndex]; if (command) { onCommandSelect(command); } } } - }), [commands, selectedIndex, onClose, onCommandSelect]); + }), [commands, onClose, onCommandSelect]); const getCommandIcon = (command: CommandInfo) => { @@ -322,8 +355,6 @@ export const CommandAutocomplete = React.forwardRef { const isSystem = command.isBuiltIn; const isOpenChamberBadge = command.isOpenChamber; - const isProject = command.scope === 'project'; - return (
setSelectedIndex(index)} + onMouseMove={() => { + keyboardNavigationRef.current = false; + setSelectedIndex(index); + }} >
{getCommandIcon(command)} @@ -384,37 +418,29 @@ export const CommandAutocomplete = React.forwardRef /{command.name} {command.isSkill ? ( - + {t('chat.commandAutocomplete.badge.skill')} - ) : null} + ) : ( + + {t('chat.commandAutocomplete.badge.command')} + + )} {isOpenChamberBadge ? ( - + OpenChamber ) : isSystem ? ( - + {t('chat.commandAutocomplete.badge.system')} ) : command.scope ? ( - + {command.scope} ) : null} {command.agent && ( - + {command.agent} )} diff --git a/packages/ui/src/components/chat/FileMentionAutocomplete.tsx b/packages/ui/src/components/chat/FileMentionAutocomplete.tsx index e607fca4..24fba907 100644 --- a/packages/ui/src/components/chat/FileMentionAutocomplete.tsx +++ b/packages/ui/src/components/chat/FileMentionAutocomplete.tsx @@ -79,6 +79,7 @@ export const FileMentionAutocomplete = React.forwardRef>({}); const [marqueeDurations, setMarqueeDurations] = React.useState>({}); @@ -292,9 +293,12 @@ export const FileMentionAutocomplete = React.forwardRef { + selectedIndexRef.current = selectedIndex; + }, [selectedIndex]); + React.useEffect(() => { itemRefs.current[selectedIndex]?.scrollIntoView({ - behavior: 'smooth', block: 'nearest' }); }, [selectedIndex]); @@ -397,7 +401,7 @@ export const FileMentionAutocomplete = React.forwardRef { const ext = file.extension?.toLowerCase(); @@ -514,7 +518,7 @@ export const FileMentionAutocomplete = React.forwardRef handleAgentPick(agent.name)} - onMouseEnter={() => setSelectedIndex(index)} + onMouseMove={() => setSelectedIndex(index)} >
@{agent.name}
@@ -548,7 +552,7 @@ export const FileMentionAutocomplete = React.forwardRef handleFileSelect(dir)} - onMouseEnter={() => setSelectedIndex(rowIndex)} + onMouseMove={() => setSelectedIndex(rowIndex)} > @@ -577,7 +581,7 @@ export const FileMentionAutocomplete = React.forwardRef handleFileSelect(file)} - onMouseEnter={() => setSelectedIndex(rowIndex)} + onMouseMove={() => setSelectedIndex(rowIndex)} > {getFileIcon(file)} handleFileSelect(file)} - onMouseEnter={() => setSelectedIndex(rowIndex)} + onMouseMove={() => setSelectedIndex(rowIndex)} > {getFileIcon(file)} { const containerRef = React.useRef(null); const [selectedIndex, setSelectedIndex] = React.useState(0); + const selectedIndexRef = React.useRef(0); + const keyboardNavigationRef = React.useRef(false); const [filteredSkills, setFilteredSkills] = React.useState([]); const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]); const skills = useSkillsStore((s) => s.skills); @@ -56,9 +58,12 @@ export const SkillAutocomplete = React.forwardRef { + selectedIndexRef.current = selectedIndex; + }, [selectedIndex]); + React.useEffect(() => { itemRefs.current[selectedIndex]?.scrollIntoView({ - behavior: 'smooth', block: 'nearest', }); }, [selectedIndex]); @@ -92,23 +97,26 @@ export const SkillAutocomplete = React.forwardRef (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 skill = filteredSkills[selectedIndex]; + const safeIndex = ((selectedIndexRef.current % filteredSkills.length) + filteredSkills.length) % filteredSkills.length; + const skill = filteredSkills[safeIndex]; if (skill) { onSkillSelect(skill.name); } } }, - }), [filteredSkills, onSkillSelect, onClose, selectedIndex]); + }), [filteredSkills, onSkillSelect, onClose]); const renderSkill = (skill: SkillInfo, index: number) => { const isProject = skill.scope === 'project'; @@ -122,9 +130,12 @@ export const SkillAutocomplete = React.forwardRef onSkillSelect(skill.name)} - onMouseEnter={() => setSelectedIndex(index)} + onMouseMove={() => { + keyboardNavigationRef.current = false; + setSelectedIndex(index); + }} >
@@ -154,10 +165,10 @@ export const SkillAutocomplete = React.forwardRef - + {filteredSkills.length ? (
{filteredSkills.map((skill, index) => renderSkill(skill, index))} diff --git a/packages/ui/src/components/chat/message/parts/UserTextPart.tsx b/packages/ui/src/components/chat/message/parts/UserTextPart.tsx index c2153640..20bcb5eb 100644 --- a/packages/ui/src/components/chat/message/parts/UserTextPart.tsx +++ b/packages/ui/src/components/chat/message/parts/UserTextPart.tsx @@ -145,9 +145,8 @@ const UserTextPart: React.FC = ({ part, messageId, agentMenti content = content.replace(agentMention.token, mentionHtml); } - content = content.replace(SKILL_TOKEN_PATTERN, (match, prefix: string, skillName: string, offset: number) => { - const slashIndex = offset + prefix.length; - if (slashIndex === 0 || !skillByName.has(skillName)) return match; + content = content.replace(SKILL_TOKEN_PATTERN, (match, prefix: string, skillName: string) => { + if (!skillByName.has(skillName)) return match; return `${prefix}[/${skillName}](${buildSkillHref(skillName)})`; }); @@ -165,7 +164,7 @@ const UserTextPart: React.FC = ({ part, messageId, agentMenti const prefix = match[1] || ''; const skillName = match[2]; 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 (prefix) nodes.push(prefix); diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 1bd7945d..1715bb6b 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -1442,6 +1442,7 @@ export const dict = { '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.badge.skill': 'skill', + 'chat.commandAutocomplete.badge.command': 'command', 'chat.commandAutocomplete.badge.system': 'system', 'chat.commandAutocomplete.empty': 'No commands found', 'chat.agentMentionAutocomplete.badge.system': 'system', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 01b04e3f..cb8ef35b 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -1408,6 +1408,7 @@ export const dict: Record = { "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.badge.skill": "habilidad", + "chat.commandAutocomplete.badge.command": "comando", "chat.commandAutocomplete.badge.system": "sistema", "chat.commandAutocomplete.empty": "No se encontraron comandos", "chat.agentMentionAutocomplete.badge.system": "sistema", diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index b01055a1..33eab82c 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -1444,6 +1444,7 @@ export const dict: Record = { 'chat.commandAutocomplete.command.summaryDescription': '세션 기록을 안전하게 요약합니다. 명령 뒤에 선택적으로 주제 힌트를 넣을 수 있습니다.', 'chat.commandAutocomplete.command.workspaceReviewDescription': '현재 워크스페이스 변경 사항에서 중요한 이슈만 리뷰합니다.', 'chat.commandAutocomplete.badge.skill': '스킬', + 'chat.commandAutocomplete.badge.command': '명령', 'chat.commandAutocomplete.badge.system': 'system', 'chat.commandAutocomplete.empty': '명령 없음', 'chat.agentMentionAutocomplete.badge.system': 'system', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 1f7c1a8d..56ac028b 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -489,6 +489,7 @@ export const dict: Record = { '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.badge.skill': 'skill', + 'chat.commandAutocomplete.badge.command': 'polecenie', 'chat.commandAutocomplete.badge.system': 'system', 'chat.commandAutocomplete.empty': 'Nie znaleziono poleceń', 'chat.agentMentionAutocomplete.badge.system': 'system', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 7c961044..cc771730 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -1408,6 +1408,7 @@ export const dict: Record = { "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.badge.skill": "habilidade", + "chat.commandAutocomplete.badge.command": "comando", "chat.commandAutocomplete.badge.system": "sistema", "chat.commandAutocomplete.empty": "Nenhum comando encontrado", "chat.agentMentionAutocomplete.badge.system": "sistema", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 69caaad8..f9700005 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -1408,6 +1408,7 @@ export const dict: Record = { "chat.commandAutocomplete.command.summaryDescription": "Неруйнівний підсумок сесії. Після команди можна додати тему.", "chat.commandAutocomplete.command.workspaceReviewDescription": "Перегляньте поточні зміни в робочому середовищі лише для проблем із сильним сигналом.", "chat.commandAutocomplete.badge.skill": "навичка", + "chat.commandAutocomplete.badge.command": "команда", "chat.commandAutocomplete.badge.system": "система", "chat.commandAutocomplete.empty": "Команди не знайдено", "chat.agentMentionAutocomplete.badge.system": "система", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 2b68bd3e..052d9b6f 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -1408,6 +1408,7 @@ export const dict: Record = { 'chat.commandAutocomplete.command.summaryDescription': '非破坏性会话总结。命令后可选填主题提示。', 'chat.commandAutocomplete.command.workspaceReviewDescription': '仅审查当前工作区中高价值的问题。', 'chat.commandAutocomplete.badge.skill': '技能', + 'chat.commandAutocomplete.badge.command': '命令', 'chat.commandAutocomplete.badge.system': '系统', 'chat.commandAutocomplete.empty': '未找到命令', 'chat.agentMentionAutocomplete.badge.system': '系统',