diff --git a/src/components/CommandMenu.jsx b/src/components/CommandMenu.jsx index 4420aed..d8f344d 100644 --- a/src/components/CommandMenu.jsx +++ b/src/components/CommandMenu.jsx @@ -4,53 +4,61 @@ import React, { useEffect, useRef } from 'react'; * CommandMenu - Autocomplete dropdown for slash commands * * @param {Array} commands - Array of command objects to display - * @param {number} selectedIndex - Currently selected command index + * @param {number} selectedIndex - Currently selected command index (index in `commands`) * @param {Function} onSelect - Callback when a command is selected * @param {Function} onClose - Callback when menu should close * @param {Object} position - Position object { top, left } for absolute positioning * @param {boolean} isOpen - Whether the menu is open * @param {Array} frequentCommands - Array of frequently used command objects */ -const CommandMenu = ({ commands = [], selectedIndex = -1, onSelect, onClose, position = { top: 0, left: 0 }, isOpen = false, frequentCommands = [] }) => { +const CommandMenu = ({ + commands = [], + selectedIndex = -1, + onSelect, + onClose, + position = { top: 0, left: 0 }, + isOpen = false, + frequentCommands = [], +}) => { const menuRef = useRef(null); const selectedItemRef = useRef(null); - // Calculate responsive positioning + // Calculate responsive menu positioning. + // Mobile: dock above chat input. Desktop: clamp to viewport. const getMenuPosition = () => { const isMobile = window.innerWidth < 640; const viewportHeight = window.innerHeight; - const menuHeight = 300; // Max height of menu if (isMobile) { - // On mobile, calculate bottom position dynamically to appear above the input - // Use the bottom value which is calculated as: window.innerHeight - textarea.top + spacing - const inputBottom = position.bottom || 90; // Use provided bottom or default + // On mobile, calculate bottom position dynamically to appear above the input. + // Use the bottom value calculated as: window.innerHeight - textarea.top + spacing. + const inputBottom = position.bottom || 90; return { position: 'fixed', - bottom: `${inputBottom}px`, // Position above the input with spacing already included + bottom: `${inputBottom}px`, // Position above the input with spacing already included. left: '16px', right: '16px', width: 'auto', maxWidth: 'calc(100vw - 32px)', - maxHeight: 'min(50vh, 300px)' // Limit to smaller of 50vh or 300px + maxHeight: 'min(50vh, 300px)', // Limit to smaller of 50vh or 300px. }; } - // On desktop, use provided position but ensure it stays on screen + // On desktop, use provided position but ensure it stays on screen. return { position: 'fixed', top: `${Math.max(16, Math.min(position.top, viewportHeight - 316))}px`, left: `${position.left}px`, width: 'min(400px, calc(100vw - 32px))', maxWidth: 'calc(100vw - 32px)', - maxHeight: '300px' + maxHeight: '300px', }; }; const menuPosition = getMenuPosition(); - // Close menu when clicking outside + // Close menu when clicking outside. useEffect(() => { const handleClickOutside = (event) => { if (menuRef.current && !menuRef.current.contains(event.target) && isOpen) { @@ -64,9 +72,11 @@ const CommandMenu = ({ commands = [], selectedIndex = -1, onSelect, onClose, pos document.removeEventListener('mousedown', handleClickOutside); }; } + + return undefined; }, [isOpen, onClose]); - // Scroll selected item into view + // Keep selected keyboard item visible while navigating. useEffect(() => { if (selectedItemRef.current && menuRef.current) { const menuRect = menuRef.current.getBoundingClientRect(); @@ -84,7 +94,7 @@ const CommandMenu = ({ commands = [], selectedIndex = -1, onSelect, onClose, pos return null; } - // Show a message if no commands are available + // Show a message if no commands are available. if (commands.length === 0) { return (
No commands available @@ -108,11 +118,20 @@ const CommandMenu = ({ commands = [], selectedIndex = -1, onSelect, onClose, pos ); } - // Add frequent commands as a special group if provided + // Add frequent commands as a special group if provided. const hasFrequentCommands = frequentCommands.length > 0; - // Group commands by namespace + const getCommandKey = (command) => + `${command.name}::${command.namespace || command.type || 'other'}::${command.path || ''}`; + const frequentCommandKeys = new Set(frequentCommands.map(getCommandKey)); + + // Group commands by namespace for section rendering. + // When frequent commands are shown, avoid duplicate rows in other sections. const groupedCommands = commands.reduce((groups, command) => { + if (hasFrequentCommands && frequentCommandKeys.has(getCommandKey(command))) { + return groups; + } + const namespace = command.namespace || command.type || 'other'; if (!groups[namespace]) { groups[namespace] = []; @@ -121,36 +140,33 @@ const CommandMenu = ({ commands = [], selectedIndex = -1, onSelect, onClose, pos return groups; }, {}); - // Add frequent commands as a separate group + // Add frequent commands as a separate group. if (hasFrequentCommands) { - groupedCommands['frequent'] = frequentCommands; + groupedCommands.frequent = frequentCommands; } - // Order: frequent, builtin, project, user, other + // Order: frequent, builtin, project, user, other. const namespaceOrder = hasFrequentCommands ? ['frequent', 'builtin', 'project', 'user', 'other'] : ['builtin', 'project', 'user', 'other']; - const orderedNamespaces = namespaceOrder.filter(ns => groupedCommands[ns]); + const orderedNamespaces = namespaceOrder.filter((ns) => groupedCommands[ns]); const namespaceLabels = { - frequent: '⭐ Frequently Used', + frequent: '\u2B50 Frequently Used', builtin: 'Built-in Commands', project: 'Project Commands', user: 'User Commands', - other: 'Other Commands' + other: 'Other Commands', }; - // Calculate global index for each command - let globalIndex = 0; - const commandsWithIndex = []; - orderedNamespaces.forEach(namespace => { - groupedCommands[namespace].forEach(command => { - commandsWithIndex.push({ - ...command, - globalIndex: globalIndex++, - namespace - }); - }); + // Keep all selection indices aligned to `commands` (filteredCommands from the hook). + // This prevents mismatches between mouse selection (rendered list) and keyboard selection. + const commandIndexByKey = new Map(); + commands.forEach((command, index) => { + const key = getCommandKey(command); + if (!commandIndexByKey.has(key)) { + commandIndexByKey.set(key, index); + } }); return ( @@ -169,7 +185,7 @@ const CommandMenu = ({ commands = [], selectedIndex = -1, onSelect, onClose, pos padding: '8px', opacity: isOpen ? 1 : 0, transform: isOpen ? 'translateY(0)' : 'translateY(-10px)', - transition: 'opacity 150ms ease-in-out, transform 150ms ease-in-out' + transition: 'opacity 150ms ease-in-out, transform 150ms ease-in-out', }} > {orderedNamespaces.map((namespace) => ( @@ -182,25 +198,35 @@ const CommandMenu = ({ commands = [], selectedIndex = -1, onSelect, onClose, pos textTransform: 'uppercase', color: '#6b7280', padding: '8px 12px 4px', - letterSpacing: '0.05em' + letterSpacing: '0.05em', }} > {namespaceLabels[namespace] || namespace}
)} + {groupedCommands[namespace].map((command) => { - const cmdWithIndex = commandsWithIndex.find(c => c.name === command.name && c.namespace === namespace); - const isSelected = cmdWithIndex && cmdWithIndex.globalIndex === selectedIndex; + const commandKey = getCommandKey(command); + const commandIndex = commandIndexByKey.get(commandKey) ?? -1; + const isSelected = commandIndex === selectedIndex; return (
onSelect && onSelect(command, cmdWithIndex.globalIndex, true)} - onClick={() => onSelect && onSelect(command, cmdWithIndex.globalIndex, false)} + onMouseEnter={() => { + if (onSelect && commandIndex >= 0) { + onSelect(command, commandIndex, true); + } + }} + onClick={() => { + if (onSelect) { + onSelect(command, commandIndex, false); + } + }} style={{ display: 'flex', alignItems: 'flex-start', @@ -209,9 +235,10 @@ const CommandMenu = ({ commands = [], selectedIndex = -1, onSelect, onClose, pos cursor: 'pointer', backgroundColor: isSelected ? '#eff6ff' : 'transparent', transition: 'background-color 100ms ease-in-out', - marginBottom: '2px' + marginBottom: '2px', }} - onMouseDown={(e) => e.preventDefault()} // Prevent textarea blur + // Prevent textarea blur when clicking a menu item. + onMouseDown={(e) => e.preventDefault()} >
{/* Command icon based on namespace */} - - {namespace === 'builtin' && '⚡'} - {namespace === 'project' && '📁'} - {namespace === 'user' && '👤'} - {namespace === 'other' && '📝'} + + {namespace === 'builtin' && '\u26A1'} + {namespace === 'project' && '\uD83D\uDCC1'} + {namespace === 'user' && '\uD83D\uDC64'} + {namespace === 'other' && '\uD83D\uDCDD'} + {namespace === 'frequent' && '\u2B50'} {/* Command name */} @@ -241,7 +264,7 @@ const CommandMenu = ({ commands = [], selectedIndex = -1, onSelect, onClose, pos fontWeight: 600, fontSize: '14px', color: '#111827', - fontFamily: 'monospace' + fontFamily: 'monospace', }} > {command.name} @@ -257,7 +280,7 @@ const CommandMenu = ({ commands = [], selectedIndex = -1, onSelect, onClose, pos borderRadius: '4px', backgroundColor: '#f3f4f6', color: '#6b7280', - fontWeight: 500 + fontWeight: 500, }} > {command.metadata.type} @@ -274,7 +297,7 @@ const CommandMenu = ({ commands = [], selectedIndex = -1, onSelect, onClose, pos marginLeft: '24px', whiteSpace: 'nowrap', overflow: 'hidden', - textOverflow: 'ellipsis' + textOverflow: 'ellipsis', }} > {command.description} @@ -289,10 +312,10 @@ const CommandMenu = ({ commands = [], selectedIndex = -1, onSelect, onClose, pos marginLeft: '8px', color: '#3b82f6', fontSize: '12px', - fontWeight: 600 + fontWeight: 600, }} > - ↵ + {'\u21B5'} )}