diff --git a/ui/src/components/chat/hooks/useChatComposerState.ts b/ui/src/components/chat/hooks/useChatComposerState.ts index 13acbf3d..b6433eb3 100644 --- a/ui/src/components/chat/hooks/useChatComposerState.ts +++ b/ui/src/components/chat/hooks/useChatComposerState.ts @@ -519,9 +519,7 @@ export function useChatComposerState({ input, setInput, textareaRef, - onExecuteCommand: executeCommand, inputValueRef, - handleSubmitRef, }); const { diff --git a/ui/src/components/chat/hooks/useSlashCommands.ts b/ui/src/components/chat/hooks/useSlashCommands.ts index d6927de5..58f59433 100644 --- a/ui/src/components/chat/hooks/useSlashCommands.ts +++ b/ui/src/components/chat/hooks/useSlashCommands.ts @@ -23,9 +23,7 @@ interface UseSlashCommandsOptions { input: string; setInput: Dispatch>; textareaRef: RefObject; - onExecuteCommand: (command: SlashCommand, rawInput?: string) => void | Promise; inputValueRef?: { current: string }; - handleSubmitRef?: { current: ((event: any) => Promise) | null }; } const getCommandHistoryKey = (projectName: string) => `command_history_${projectName}`; @@ -48,17 +46,56 @@ const saveCommandHistory = (projectName: string, history: Record safeLocalStorage.setItem(getCommandHistoryKey(projectName), JSON.stringify(history)); }; -const isPromiseLike = (value: unknown): value is Promise => - Boolean(value) && typeof (value as Promise).then === 'function'; +const getCommandKey = (command: SlashCommand) => + `${command.name}::${command.namespace || command.type || 'other'}::${command.path || ''}`; + +const getCommandNamespace = (command: SlashCommand) => + command.namespace || command.type || 'other'; + +const groupCommandsForDisplay = ( + commands: SlashCommand[], + frequentCommands: SlashCommand[], +): SlashCommand[] => { + const preferredOrder = frequentCommands.length > 0 + ? ['pinned', 'frequent', 'builtin', 'project', 'user', 'other'] + : ['pinned', 'builtin', 'project', 'user', 'other']; + const groups = new Map(); + const frequentCommandKeys = new Set(frequentCommands.map(getCommandKey)); + + for (const command of commands) { + if (frequentCommandKeys.has(getCommandKey(command))) { + continue; + } + const namespace = getCommandNamespace(command); + const group = groups.get(namespace) || []; + group.push(command); + groups.set(namespace, group); + } + + if (frequentCommands.length > 0) { + groups.set( + 'frequent', + frequentCommands.map((command) => ({ + ...command, + namespace: 'frequent', + })), + ); + } + + const extraNamespaces = [...groups.keys()].filter( + (namespace) => !preferredOrder.includes(namespace), + ); + return [...preferredOrder, ...extraNamespaces].flatMap( + (namespace) => groups.get(namespace) || [], + ); +}; export function useSlashCommands({ selectedProject, input, setInput, textareaRef, - onExecuteCommand, inputValueRef: externalInputValueRef, - handleSubmitRef: externalHandleSubmitRef, }: UseSlashCommandsOptions) { const [slashCommands, setSlashCommands] = useState([]); const [filteredCommands, setFilteredCommands] = useState([]); @@ -223,6 +260,29 @@ export function useSlashCommands({ .slice(0, 5); }, [selectedProject, slashCommands]); + const displayedCommands = useMemo(() => { + return groupCommandsForDisplay( + filteredCommands, + commandQuery ? [] : frequentCommands, + ); + }, [commandQuery, filteredCommands, frequentCommands]); + + useEffect(() => { + if (!showCommandMenu) { + return; + } + + setSelectedCommandIndex((previousIndex) => { + if (displayedCommands.length === 0) { + return -1; + } + if (previousIndex >= displayedCommands.length) { + return displayedCommands.length - 1; + } + return previousIndex; + }); + }, [displayedCommands.length, showCommandMenu]); + const trackCommandUsage = useCallback( (command: SlashCommand) => { if (!selectedProject) { @@ -236,34 +296,10 @@ export function useSlashCommands({ [selectedProject], ); - const shouldAutoExecute = useCallback((command: SlashCommand): boolean => { - const type = command.metadata?.type as string | undefined; - const hasArgHint = Boolean(command.metadata?.argumentHint); - return !hasArgHint && (type === 'skill' || type === 'bundled-skill'); - }, []); - - const autoExecuteCommand = useCallback( - (command: SlashCommand) => { - trackCommandUsage(command); - resetCommandMenuState(); - const commandText = command.name; - setInput(commandText); - if (externalInputValueRef) { - externalInputValueRef.current = commandText; - } - setTimeout(() => { - if (externalHandleSubmitRef?.current) { - externalHandleSubmitRef.current({ preventDefault: () => {} }); - } - }, 0); - }, - [trackCommandUsage, resetCommandMenuState, setInput, externalInputValueRef, externalHandleSubmitRef], - ); - // Insert the picked command name into the textarea and leave the caret right // after ` `. We DO NOT auto-submit — the user reviews/edits args // and presses Enter themselves, mirroring how the TUI behaves and avoiding - // surprise sends (e.g. /add-project with no path runs blindly). + // surprise sends from slash suggestions that still need arguments. // // The replacement spans from the active `/` to the next whitespace so a // partial query like `hello /skill_inst` becomes `hello /skill_install ` and @@ -282,6 +318,9 @@ export function useSlashCommands({ const newInput = `${head}${textAfterQuery}`; setInput(newInput); + if (externalInputValueRef) { + externalInputValueRef.current = newInput; + } resetCommandMenuState(); // Defer focus + caret placement until after React commits the new input @@ -298,18 +337,15 @@ export function useSlashCommands({ } }, 0); }, - [input, slashPosition, setInput, resetCommandMenuState, textareaRef], + [externalInputValueRef, input, slashPosition, setInput, resetCommandMenuState, textareaRef], ); const selectCommandFromKeyboard = useCallback( (command: SlashCommand) => { - if (shouldAutoExecute(command)) { - autoExecuteCommand(command); - return; - } + trackCommandUsage(command); insertCommandIntoInput(command); }, - [shouldAutoExecute, autoExecuteCommand, insertCommandIntoInput], + [trackCommandUsage, insertCommandIntoInput], ); const handleCommandSelect = useCallback( @@ -324,13 +360,9 @@ export function useSlashCommands({ } trackCommandUsage(command); - if (shouldAutoExecute(command)) { - autoExecuteCommand(command); - return; - } insertCommandIntoInput(command); }, - [selectedProject, trackCommandUsage, shouldAutoExecute, autoExecuteCommand, insertCommandIntoInput], + [selectedProject, trackCommandUsage, insertCommandIntoInput], ); const handleToggleCommandMenu = useCallback(() => { @@ -382,7 +414,7 @@ export function useSlashCommands({ return false; } - if (!filteredCommands.length) { + if (!displayedCommands.length) { if (event.key === 'Escape') { event.preventDefault(); resetCommandMenuState(); @@ -394,7 +426,7 @@ export function useSlashCommands({ if (event.key === 'ArrowDown') { event.preventDefault(); setSelectedCommandIndex((previousIndex) => - previousIndex < filteredCommands.length - 1 ? previousIndex + 1 : 0, + previousIndex < displayedCommands.length - 1 ? previousIndex + 1 : 0, ); return true; } @@ -402,7 +434,7 @@ export function useSlashCommands({ if (event.key === 'ArrowUp') { event.preventDefault(); setSelectedCommandIndex((previousIndex) => - previousIndex > 0 ? previousIndex - 1 : filteredCommands.length - 1, + previousIndex > 0 ? previousIndex - 1 : displayedCommands.length - 1, ); return true; } @@ -413,9 +445,9 @@ export function useSlashCommands({ } event.preventDefault(); if (selectedCommandIndex >= 0) { - selectCommandFromKeyboard(filteredCommands[selectedCommandIndex]); - } else if (filteredCommands.length > 0) { - selectCommandFromKeyboard(filteredCommands[0]); + selectCommandFromKeyboard(displayedCommands[selectedCommandIndex]); + } else if (displayedCommands.length > 0) { + selectCommandFromKeyboard(displayedCommands[0]); } return true; } @@ -428,7 +460,14 @@ export function useSlashCommands({ return false; }, - [showCommandMenu, filteredCommands, dismissCommandMenu, selectCommandFromKeyboard, selectedCommandIndex], + [ + showCommandMenu, + displayedCommands, + resetCommandMenuState, + dismissCommandMenu, + selectCommandFromKeyboard, + selectedCommandIndex, + ], ); useEffect( @@ -441,7 +480,7 @@ export function useSlashCommands({ return { slashCommands, slashCommandsCount: slashCommands.length, - filteredCommands, + filteredCommands: displayedCommands, frequentCommands, commandQuery, showCommandMenu, diff --git a/ui/src/components/chat/view/subcomponents/CommandMenu.tsx b/ui/src/components/chat/view/subcomponents/CommandMenu.tsx index 67243580..072c8c4b 100644 --- a/ui/src/components/chat/view/subcomponents/CommandMenu.tsx +++ b/ui/src/components/chat/view/subcomponents/CommandMenu.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from 'react'; +import { Fragment, useEffect, useRef } from 'react'; import type { CSSProperties } from 'react'; import { useTranslation } from 'react-i18next'; import { @@ -51,6 +51,9 @@ const getCommandKey = (command: CommandMenuCommand) => const getNamespace = (command: CommandMenuCommand) => command.namespace || command.type || 'other'; +const getNamespaceLabel = (namespace: string) => + namespace.charAt(0).toUpperCase() + namespace.slice(1); + // Anchor the menu to the textarea: above on desktop, full-bleed bottom sheet on // mobile. Returns inline styles so we can mix calculated coords with Tailwind // classes for visual treatment. @@ -83,7 +86,6 @@ export default function CommandMenu({ onClose, position = { top: 0, left: 0 }, isOpen = false, - frequentCommands = [], }: CommandMenuProps) { const { t } = useTranslation('chat'); const menuRef = useRef(null); @@ -138,42 +140,7 @@ export default function CommandMenu({ ); } - const hasFrequentCommands = frequentCommands.length > 0; - const frequentCommandKeys = new Set(frequentCommands.map(getCommandKey)); - const groupedCommands = commands.reduce>((groups, command) => { - if (hasFrequentCommands && frequentCommandKeys.has(getCommandKey(command))) { - return groups; - } - const namespace = getNamespace(command); - if (!groups[namespace]) { - groups[namespace] = []; - } - groups[namespace].push(command); - return groups; - }, {}); - if (hasFrequentCommands) { - groupedCommands.frequent = frequentCommands; - } - - const preferredOrder = hasFrequentCommands - ? ['pinned', 'frequent', 'builtin', 'project', 'user', 'other'] - : ['pinned', 'builtin', 'project', 'user', 'other']; - const extraNamespaces = Object.keys(groupedCommands).filter( - (namespace) => !preferredOrder.includes(namespace), - ); - const orderedNamespaces = [...preferredOrder, ...extraNamespaces].filter( - (namespace) => groupedCommands[namespace], - ); - - const commandIndexByKey = new Map(); - commands.forEach((command, index) => { - const key = getCommandKey(command); - if (!commandIndexByKey.has(key)) { - commandIndexByKey.set(key, index); - } - }); - - const showGroupHeaders = orderedNamespaces.length > 1; + const showGroupHeaders = new Set(commands.map(getNamespace)).size > 1; return (
- {orderedNamespaces.map((namespace, groupIdx) => { + {commands.map((command, commandIndex) => { + const namespace = getNamespace(command); + const previousNamespace = commandIndex > 0 ? getNamespace(commands[commandIndex - 1]) : null; + const showHeader = showGroupHeaders && namespace !== previousNamespace; const Icon = namespaceIcons[namespace] || namespaceIcons.other; + const isSelected = commandIndex === selectedIndex; return ( -
0 && showGroupHeaders && 'mt-1 border-t border-neutral-100 pt-2 dark:border-neutral-800', - )} - > - {showGroupHeaders ? ( -
+ + {showHeader ? ( +
0 && 'mt-1 border-t border-neutral-100 pt-2 dark:border-neutral-800', + )} + > {t(`commandMenu.groups.${namespace}`, { - defaultValue: namespace.charAt(0).toUpperCase() + namespace.slice(1), + defaultValue: getNamespaceLabel(namespace), })}
) : null} - - {(groupedCommands[namespace] || []).map((command) => { - const commandKey = getCommandKey(command); - const commandIndex = commandIndexByKey.get(commandKey) ?? -1; - const isSelected = commandIndex === selectedIndex; - return ( -
- onSelect && commandIndex >= 0 && onSelect(command, commandIndex, true) - } - onClick={() => - onSelect && commandIndex >= 0 && onSelect(command, commandIndex, false) - } - onMouseDown={(event) => event.preventDefault()} - > - -
-
- - {command.name} - - {command.metadata?.type ? ( - - {command.metadata.type} - - ) : null} -
- {command.description ? ( -
- {command.description} -
- ) : null} -
- {isSelected ? ( - +
onSelect?.(command, commandIndex, true)} + onClick={() => onSelect?.(command, commandIndex, false)} + onMouseDown={(event) => event.preventDefault()} + > + +
+
+ + {command.name} + + {command.metadata?.type ? ( + + {command.metadata.type} + ) : null}
- ); - })} -
+ {command.description ? ( +
+ {command.description} +
+ ) : null} +
+ {isSelected ? ( + + ) : null} +
+
); })}