diff --git a/ui/desktop/eslint.config.js b/ui/desktop/eslint.config.js index a1587d15f..cae0f57ea 100644 --- a/ui/desktop/eslint.config.js +++ b/ui/desktop/eslint.config.js @@ -69,6 +69,7 @@ module.exports = [ setInterval: 'readonly', clearTimeout: 'readonly', CustomEvent: 'readonly', + Element: 'readonly', HTMLElement: 'readonly', HTMLInputElement: 'readonly', HTMLSelectElement: 'readonly', diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index fc075d943..83ddea4f9 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -18,6 +18,7 @@ import { AlertType, useAlerts } from './alerts'; import { useModelAndProvider } from './ModelAndProviderContext'; import { acpGetProviderDetails } from '../acp/providers'; import { useAudioRecorder } from '../hooks/useAudioRecorder'; +import { useFocusOnTyping } from '../hooks/useFocusOnTyping'; import { toastError } from '../toasts'; import MentionPopover, { DisplayItemWithMatch } from './MentionPopover'; import { COST_TRACKING_ENABLED } from '../updates'; @@ -575,6 +576,8 @@ export default function ChatInput({ } }, [textAreaRef]); + useFocusOnTyping(textAreaRef, !isRecording); + // Load providers and get current model's token limit const loadProviderDetails = async () => { try { diff --git a/ui/desktop/src/hooks/useFocusOnTyping.ts b/ui/desktop/src/hooks/useFocusOnTyping.ts new file mode 100644 index 000000000..fcfe55fef --- /dev/null +++ b/ui/desktop/src/hooks/useFocusOnTyping.ts @@ -0,0 +1,82 @@ +import { RefObject, useEffect } from 'react'; + +function isEditableElement(element: Element | null): boolean { + if (!element) return false; + if ( + element instanceof HTMLInputElement || + element instanceof HTMLTextAreaElement || + element instanceof HTMLSelectElement + ) { + return true; + } + return element instanceof HTMLElement && element.isContentEditable; +} + +const SPACE_ACTIVATABLE_INPUT_TYPES = new Set([ + 'checkbox', + 'radio', + 'submit', + 'button', + 'reset', + 'file', + 'range', + 'color', +]); + +const SPACE_ACTIVATABLE_ROLES = new Set([ + 'button', + 'checkbox', + 'radio', + 'switch', + 'menuitem', + 'menuitemcheckbox', + 'menuitemradio', + 'tab', + 'link', +]); + +function isSpaceActivatableControl(element: Element | null): boolean { + if (!(element instanceof HTMLElement)) return false; + const tag = element.tagName; + if (tag === 'BUTTON' || tag === 'A' || tag === 'SUMMARY') return true; + if (tag === 'INPUT') { + return SPACE_ACTIVATABLE_INPUT_TYPES.has((element as HTMLInputElement).type); + } + const role = element.getAttribute('role'); + return role !== null && SPACE_ACTIVATABLE_ROLES.has(role); +} + +const COMPOSITE_WIDGET_SELECTOR = + '[role="menu"], [role="listbox"], [role="tree"], [role="grid"], [role="combobox"], [data-radix-collection-item]'; + +function isInsideCompositeWidget(element: Element | null): boolean { + return element instanceof HTMLElement && element.closest(COMPOSITE_WIDGET_SELECTOR) !== null; +} + +export function useFocusOnTyping( + targetRef: RefObject, + enabled: boolean +) { + useEffect(() => { + if (!enabled) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.defaultPrevented) return; + if (e.metaKey || e.ctrlKey) return; + if (e.key.length !== 1) return; + if (isEditableElement(document.activeElement)) return; + if (isInsideCompositeWidget(document.activeElement)) return; + if (e.key === ' ' && isSpaceActivatableControl(document.activeElement)) return; + + const selection = window.getSelection(); + if (selection && !selection.isCollapsed) return; + + targetRef.current?.focus(); + }; + + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + }, [targetRef, enabled]); +}