fix(ui): restore composer focus after CodeMirror migration

This commit is contained in:
Bohdan Triapitsyn
2026-07-28 19:29:34 +03:00
parent 8ff6525906
commit 673951cb3a
7 changed files with 43 additions and 50 deletions
@@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import { focusChatInput } from './composer/editor/dom';
import type { EditPermissionMode } from '@/stores/types/sessionTypes'; import type { EditPermissionMode } from '@/stores/types/sessionTypes';
import type { ModelMetadata } from '@/types'; import type { ModelMetadata } from '@/types';
import { import {
@@ -478,10 +479,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
// Restore focus to chat input when model selector closes // Restore focus to chat input when model selector closes
if (wasOpen && !isCompact) { if (wasOpen && !isCompact) {
requestAnimationFrame(() => { requestAnimationFrame(focusChatInput);
const textarea = document.querySelector<HTMLTextAreaElement>('textarea[data-chat-input="true"]');
textarea?.focus();
});
} }
} }
}, [isModelSelectorOpen, isCompact]); }, [isModelSelectorOpen, isCompact]);
@@ -492,10 +490,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
if (!isAgentSelectorOpen) { if (!isAgentSelectorOpen) {
setAgentSearchQuery(''); setAgentSearchQuery('');
if (!isCompact) { if (!isCompact) {
requestAnimationFrame(() => { requestAnimationFrame(focusChatInput);
const textarea = document.querySelector<HTMLTextAreaElement>('textarea[data-chat-input="true"]');
textarea?.focus();
});
} }
} }
}, [isAgentSelectorOpen, isCompact]); }, [isAgentSelectorOpen, isCompact]);
@@ -1264,10 +1259,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
closeMobilePanel(); closeMobilePanel();
} }
// Restore focus to chat input after model selection. // Restore focus to chat input after model selection.
requestAnimationFrame(() => { requestAnimationFrame(focusChatInput);
const textarea = document.querySelector<HTMLTextAreaElement>('textarea[data-chat-input="true"]');
textarea?.focus();
});
} catch (error) { } catch (error) {
console.error('[ModelControls] Handle model change error:', error); console.error('[ModelControls] Handle model change error:', error);
} }
@@ -1605,13 +1597,6 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
} }
} }
const focusMobileComposer = () => {
requestAnimationFrame(() => {
const textarea = document.querySelector<HTMLTextAreaElement>('textarea[data-chat-input="true"]');
textarea?.focus();
});
};
const handleMobileModelApply = (providerId: string, modelId: string, variant: string | undefined) => { const handleMobileModelApply = (providerId: string, modelId: string, variant: string | undefined) => {
const result = applyModelSelectionWithVariant(providerId, modelId, variant); const result = applyModelSelectionWithVariant(providerId, modelId, variant);
if (result !== 'applied') { if (result !== 'applied') {
@@ -1625,7 +1610,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
setExpandedMobileModelKey(null); setExpandedMobileModelKey(null);
closeMobilePanel(); closeMobilePanel();
focusMobileComposer(); requestAnimationFrame(focusChatInput);
}; };
const openMobileVariantOverflow = (providerId: string, modelId: string) => { const openMobileVariantOverflow = (providerId: string, modelId: string) => {
@@ -1961,10 +1946,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
} }
closeMobilePanel(); closeMobilePanel();
requestAnimationFrame(() => { requestAnimationFrame(focusChatInput);
const textarea = document.querySelector<HTMLTextAreaElement>('textarea[data-chat-input="true"]');
textarea?.focus();
});
}; };
return ( return (
@@ -504,6 +504,7 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
<div <div
ref={hostRef} ref={hostRef}
data-testid={props['data-testid']} data-testid={props['data-testid']}
data-chat-input="true"
onMouseDown={handleHostMouseDown} onMouseDown={handleHostMouseDown}
className={cn( className={cn(
'composer-editor w-full', 'composer-editor w-full',
@@ -0,0 +1,25 @@
import { afterEach, expect, test } from 'bun:test';
import { focusChatInput } from '../dom';
const originalDocument = globalThis.document;
afterEach(() => {
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);
});
@@ -0,0 +1,5 @@
const CHAT_INPUT_EDITOR_SELECTOR = '[data-chat-input="true"] .cm-content';
export function focusChatInput(): void {
document.querySelector<HTMLElement>(CHAT_INPUT_EDITOR_SELECTOR)?.focus();
}
@@ -15,6 +15,7 @@ import { readEmbeddedThemeSearchParams } from '@/contexts/theme-embedded-bootstr
import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useProjectsStore } from '@/stores/useProjectsStore'; import { useProjectsStore } from '@/stores/useProjectsStore';
import { getCycledPrimaryAgentName } from '@/components/chat/mobileControlsUtils'; import { getCycledPrimaryAgentName } from '@/components/chat/mobileControlsUtils';
import { focusChatInput } from '@/components/chat/composer/editor/dom';
export const useKeyboardShortcuts = () => { export const useKeyboardShortcuts = () => {
const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft); const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft);
@@ -218,7 +219,7 @@ export const useKeyboardShortcuts = () => {
} }
const isChatInputTarget = (target: EventTarget | null) => { 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'))) { if (eventMatchesShortcut(e, combo('open_command_palette'))) {
@@ -355,8 +356,7 @@ export const useKeyboardShortcuts = () => {
if (eventMatchesShortcut(e, combo('focus_input'))) { if (eventMatchesShortcut(e, combo('focus_input'))) {
e.preventDefault(); e.preventDefault();
const textarea = document.querySelector<HTMLTextAreaElement>('textarea[data-chat-input="true"]'); focusChatInput();
textarea?.focus();
return; return;
} }
@@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import { focusChatInput } from '@/components/chat/composer/editor/dom';
import { canUseElectronDesktopIPC, invokeDesktop } from '@/lib/desktop'; import { canUseElectronDesktopIPC, invokeDesktop } from '@/lib/desktop';
import { eventMatchesShortcut, getEffectiveShortcutCombo } from '@/lib/shortcuts'; import { eventMatchesShortcut, getEffectiveShortcutCombo } from '@/lib/shortcuts';
import { useConfigStore } from '@/stores/useConfigStore'; import { useConfigStore } from '@/stores/useConfigStore';
@@ -8,11 +9,6 @@ import { useUIStore } from '@/stores/useUIStore';
import { useSelectionStore } from '@/sync/selection-store'; import { useSelectionStore } from '@/sync/selection-store';
import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessionUIStore } from '@/sync/session-ui-store';
const focusChatInput = () => {
const textarea = document.querySelector<HTMLTextAreaElement>('textarea[data-chat-input="true"]');
textarea?.focus();
};
export const useMiniChatKeyboardShortcuts = () => { export const useMiniChatKeyboardShortcuts = () => {
const shortcutOverrides = useUIStore((state) => state.shortcutOverrides); const shortcutOverrides = useUIStore((state) => state.shortcutOverrides);
const currentDirectory = useDirectoryStore((state) => state.currentDirectory); const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
+2 -18
View File
@@ -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 */ /* Ensure interactive controls consistently show pointer cursor */
:where( :where(
button, 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%); 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); 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 */ /* 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; background: transparent !important;
border: none; border: none;
box-shadow: none; box-shadow: none;