diff --git a/packages/ui/src/components/chat/ModelControls.tsx b/packages/ui/src/components/chat/ModelControls.tsx index a325b05d..3fbf959b 100644 --- a/packages/ui/src/components/chat/ModelControls.tsx +++ b/packages/ui/src/components/chat/ModelControls.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { focusChatInput } from './composer/editor/dom'; import type { EditPermissionMode } from '@/stores/types/sessionTypes'; import type { ModelMetadata } from '@/types'; import { @@ -478,10 +479,7 @@ export const ModelControls: React.FC = ({ // Restore focus to chat input when model selector closes if (wasOpen && !isCompact) { - requestAnimationFrame(() => { - const textarea = document.querySelector('textarea[data-chat-input="true"]'); - textarea?.focus(); - }); + requestAnimationFrame(focusChatInput); } } }, [isModelSelectorOpen, isCompact]); @@ -492,10 +490,7 @@ export const ModelControls: React.FC = ({ if (!isAgentSelectorOpen) { setAgentSearchQuery(''); if (!isCompact) { - requestAnimationFrame(() => { - const textarea = document.querySelector('textarea[data-chat-input="true"]'); - textarea?.focus(); - }); + requestAnimationFrame(focusChatInput); } } }, [isAgentSelectorOpen, isCompact]); @@ -1264,10 +1259,7 @@ export const ModelControls: React.FC = ({ closeMobilePanel(); } // Restore focus to chat input after model selection. - requestAnimationFrame(() => { - const textarea = document.querySelector('textarea[data-chat-input="true"]'); - textarea?.focus(); - }); + requestAnimationFrame(focusChatInput); } catch (error) { console.error('[ModelControls] Handle model change error:', error); } @@ -1605,13 +1597,6 @@ export const ModelControls: React.FC = ({ } } - const focusMobileComposer = () => { - requestAnimationFrame(() => { - const textarea = document.querySelector('textarea[data-chat-input="true"]'); - textarea?.focus(); - }); - }; - const handleMobileModelApply = (providerId: string, modelId: string, variant: string | undefined) => { const result = applyModelSelectionWithVariant(providerId, modelId, variant); if (result !== 'applied') { @@ -1625,7 +1610,7 @@ export const ModelControls: React.FC = ({ setExpandedMobileModelKey(null); closeMobilePanel(); - focusMobileComposer(); + requestAnimationFrame(focusChatInput); }; const openMobileVariantOverflow = (providerId: string, modelId: string) => { @@ -1961,10 +1946,7 @@ export const ModelControls: React.FC = ({ } closeMobilePanel(); - requestAnimationFrame(() => { - const textarea = document.querySelector('textarea[data-chat-input="true"]'); - textarea?.focus(); - }); + requestAnimationFrame(focusChatInput); }; return ( diff --git a/packages/ui/src/components/chat/composer/editor/ComposerEditor.tsx b/packages/ui/src/components/chat/composer/editor/ComposerEditor.tsx index 20cd762a..898dd2f7 100644 --- a/packages/ui/src/components/chat/composer/editor/ComposerEditor.tsx +++ b/packages/ui/src/components/chat/composer/editor/ComposerEditor.tsx @@ -504,6 +504,7 @@ export const ComposerEditor = React.forwardRef { + globalThis.document = originalDocument; +}); + +test('focuses the CodeMirror chat input content', () => { + let selector = ''; + let focused = false; + globalThis.document = { + querySelector: (value: string) => { + selector = value; + return { focus: () => { focused = true; } }; + }, + } as unknown as Document; + + focusChatInput(); + + expect(selector).toBe('[data-chat-input="true"] .cm-content'); + expect(focused).toBe(true); +}); diff --git a/packages/ui/src/components/chat/composer/editor/dom.ts b/packages/ui/src/components/chat/composer/editor/dom.ts new file mode 100644 index 00000000..482eafa8 --- /dev/null +++ b/packages/ui/src/components/chat/composer/editor/dom.ts @@ -0,0 +1,5 @@ +const CHAT_INPUT_EDITOR_SELECTOR = '[data-chat-input="true"] .cm-content'; + +export function focusChatInput(): void { + document.querySelector(CHAT_INPUT_EDITOR_SELECTOR)?.focus(); +} diff --git a/packages/ui/src/hooks/useKeyboardShortcuts.ts b/packages/ui/src/hooks/useKeyboardShortcuts.ts index 397650e3..16cf0b2c 100644 --- a/packages/ui/src/hooks/useKeyboardShortcuts.ts +++ b/packages/ui/src/hooks/useKeyboardShortcuts.ts @@ -15,6 +15,7 @@ import { readEmbeddedThemeSearchParams } from '@/contexts/theme-embedded-bootstr import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { getCycledPrimaryAgentName } from '@/components/chat/mobileControlsUtils'; +import { focusChatInput } from '@/components/chat/composer/editor/dom'; export const useKeyboardShortcuts = () => { const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft); @@ -218,7 +219,7 @@ export const useKeyboardShortcuts = () => { } const isChatInputTarget = (target: EventTarget | null) => { - return target instanceof HTMLTextAreaElement && target.getAttribute('data-chat-input') === 'true'; + return target instanceof Element && Boolean(target.closest('[data-chat-input="true"]')); }; if (eventMatchesShortcut(e, combo('open_command_palette'))) { @@ -355,8 +356,7 @@ export const useKeyboardShortcuts = () => { if (eventMatchesShortcut(e, combo('focus_input'))) { e.preventDefault(); - const textarea = document.querySelector('textarea[data-chat-input="true"]'); - textarea?.focus(); + focusChatInput(); return; } diff --git a/packages/ui/src/hooks/useMiniChatKeyboardShortcuts.ts b/packages/ui/src/hooks/useMiniChatKeyboardShortcuts.ts index 4ba5d75d..f3b24003 100644 --- a/packages/ui/src/hooks/useMiniChatKeyboardShortcuts.ts +++ b/packages/ui/src/hooks/useMiniChatKeyboardShortcuts.ts @@ -1,4 +1,5 @@ import React from 'react'; +import { focusChatInput } from '@/components/chat/composer/editor/dom'; import { canUseElectronDesktopIPC, invokeDesktop } from '@/lib/desktop'; import { eventMatchesShortcut, getEffectiveShortcutCombo } from '@/lib/shortcuts'; import { useConfigStore } from '@/stores/useConfigStore'; @@ -8,11 +9,6 @@ import { useUIStore } from '@/stores/useUIStore'; import { useSelectionStore } from '@/sync/selection-store'; import { useSessionUIStore } from '@/sync/session-ui-store'; -const focusChatInput = () => { - const textarea = document.querySelector('textarea[data-chat-input="true"]'); - textarea?.focus(); -}; - export const useMiniChatKeyboardShortcuts = () => { const shortcutOverrides = useUIStore((state) => state.shortcutOverrides); const currentDirectory = useDirectoryStore((state) => state.currentDirectory); diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css index ceb0b975..347533fc 100644 --- a/packages/ui/src/index.css +++ b/packages/ui/src/index.css @@ -109,22 +109,6 @@ html[data-oc-vibrancy] .oc-vibrancy-pill { } } -/* Suppress WebKit-specific hover/focus adornments on the chat textarea */ -textarea[data-chat-input="true"] { - -webkit-appearance: none; - background: transparent !important; - border: none; - box-shadow: none; -} - -textarea[data-chat-input="true"]:hover, -textarea[data-chat-input="true"]:focus, -textarea[data-chat-input="true"]:focus-visible { - outline: none; - border: none; - box-shadow: none; -} - /* Ensure interactive controls consistently show pointer cursor */ :where( button, @@ -303,12 +287,12 @@ textarea[data-chat-input="true"]:focus-visible { color: color-mix(in srgb, var(--syntax-comment) 85%, var(--syntax-foreground) 15%); } -:root.vscode-runtime textarea[data-chat-input="true"]::placeholder { +:root.vscode-runtime [data-chat-input="true"] .cm-placeholder { color: color-mix(in srgb, var(--vscode-input-placeholderForeground, var(--muted-foreground)) 65%, transparent); } /* Match chat footer background to input in WebKit to avoid dual-tone hover/focus */ -[data-chat-input="true"] + div[data-chat-input-footer="true"] { +div[data-chat-input-footer="true"] { background: transparent !important; border: none; box-shadow: none;