From 67880ae32c577eed21e23dda65352e3336c9dc01 Mon Sep 17 00:00:00 2001 From: zhangmo8 Date: Fri, 18 Sep 2026 22:00:51 +0800 Subject: [PATCH 1/2] feat(chat): focus the composer on keyboard input Typing anywhere in the chat view now moves focus into the composer and keeps the character that triggered it, so a keystroke is no longer dropped after scrolling, selecting text, or returning to the window. Closes #2312. Routing lives in a pure resolver so the exemption rules are the tested contract: modifier chords, navigation and function keys, another editable target, and interactive controls (buttons, links, menus, modal focus traps) all keep their native behavior. Space is composer input now, so it also stops marking restore-time scroll intent. Input method keystrokes focus without inserting, because the composition owns the text. The new-thread composer shares the same composable, and the focus predicates are shared with the existing scroll-intent guard instead of adding a fourth copy. --- .../src/components/chat/ChatInputBox.vue | 21 ++- .../src/features/chat-page/ChatPage.vue | 36 ++-- .../composables/useComposerTypeToFocus.ts | 58 ++++++ .../features/chat-page/model/typeToFocus.ts | 67 +++++++ src/renderer/src/lib/keyboardFocus.ts | 72 ++++++++ src/renderer/src/pages/NewThreadPage.vue | 8 + test/renderer/components/ChatInputBox.test.ts | 14 ++ .../chat/chatScrollArchitecture.test.ts | 8 +- .../useComposerTypeToFocus.test.ts | 166 ++++++++++++++++++ .../chat-page/model/typeToFocus.test.ts | 123 +++++++++++++ test/renderer/lib/keyboardFocus.test.ts | 111 ++++++++++++ 11 files changed, 669 insertions(+), 15 deletions(-) create mode 100644 src/renderer/src/features/chat-page/composables/useComposerTypeToFocus.ts create mode 100644 src/renderer/src/features/chat-page/model/typeToFocus.ts create mode 100644 src/renderer/src/lib/keyboardFocus.ts create mode 100644 test/renderer/features/chat-page/composables/useComposerTypeToFocus.test.ts create mode 100644 test/renderer/features/chat-page/model/typeToFocus.test.ts create mode 100644 test/renderer/lib/keyboardFocus.test.ts diff --git a/src/renderer/src/components/chat/ChatInputBox.vue b/src/renderer/src/components/chat/ChatInputBox.vue index 25a281ee9b..78d5aab04c 100644 --- a/src/renderer/src/components/chat/ChatInputBox.vue +++ b/src/renderer/src/components/chat/ChatInputBox.vue @@ -911,6 +911,24 @@ function focusInput() { setCaretToEnd(editor) } +/** + * Focuses the composer and types `text` at the end of the existing draft. + * + * Used by type-to-focus: the triggering keystroke already happened outside the + * editor, so the character has to be inserted rather than replayed. Unlike + * `insertRecognizedText` this keeps whitespace intact. + */ +function focusAndInsertText(text: string) { + if (!props.editable || !text) { + return + } + + editor.chain().focus().scrollIntoView().run() + setCaretToEnd(editor) + // A text node (not a string) so a lone space is not collapsed by the parser. + editor.chain().insertContent({ type: 'text', text }).run() +} + defineExpose({ triggerAttach, insertRecognizedText, @@ -922,7 +940,8 @@ defineExpose({ setPendingSkills, getDocumentSnapshot, restoreDocumentSnapshot, - focusInput + focusInput, + focusAndInsertText }) diff --git a/src/renderer/src/features/chat-page/ChatPage.vue b/src/renderer/src/features/chat-page/ChatPage.vue index 6c4637df89..11601376ed 100644 --- a/src/renderer/src/features/chat-page/ChatPage.vue +++ b/src/renderer/src/features/chat-page/ChatPage.vue @@ -419,6 +419,8 @@ import { useToolInteraction } from './composables/useToolInteraction' import { useMessageActions } from './composables/useMessageActions' import { usePendingInputActions } from './composables/usePendingInputActions' import { useChatPageEventBridge } from './composables/useChatPageEventBridge' +import { useComposerTypeToFocus } from './composables/useComposerTypeToFocus' +import { isEditableKeyboardTarget } from '@/lib/keyboardFocus' import type { UserMessageInlineItem } from '@shared/types/agent-interface' import { findLatestAssistantMessageId } from '@/features/chat-page/model/displayMessage' @@ -536,15 +538,16 @@ const TOP_HISTORY_THRESHOLD = 80 const MESSAGE_JUMP_RETRY_INTERVAL = 80 const MESSAGE_HIGHLIGHT_DURATION = 2000 const MAX_MESSAGE_JUMP_RETRIES = 8 +// Space is intentionally absent: it is composer input now (type-to-focus claims +// it and prevents the native scroll), so it must not mark restore-time scroll +// intent either. const SESSION_RESTORE_SCROLL_INTENT_KEYS = new Set([ 'ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', - 'End', - ' ', - 'Spacebar' + 'End' ]) const traceMessageId = ref(null) const sidepanelStore = useSidepanelStore() @@ -607,16 +610,6 @@ const resolveChatInputBoxElement = () => '[data-testid="chat-input-box"]' ) as HTMLElement | null) ?? null -function isEditableKeyboardTarget(target: EventTarget | null): boolean { - if (!(target instanceof HTMLElement)) { - return false - } - - return Boolean( - target.closest('input, textarea, select, [contenteditable="true"], [role="textbox"]') - ) -} - function isSessionRestoreKeyboardScrollIntent(event: KeyboardEvent): boolean { return ( !event.defaultPrevented && @@ -1388,6 +1381,23 @@ watch( { immediate: true, flush: 'post' } ) +/** + * Type-to-focus only when the composer can actually take focus: a read-only + * (subagent) session does not render it at all, and a pending tool interaction + * leaves it inert. + */ +const isComposerTypeToFocusEnabled = computed( + () => + !isReadOnlySession.value && + !isSessionViewPreparing.value && + !activePendingInteraction.value && + !isHandlingInteraction.value +) +useComposerTypeToFocus({ + isEnabled: () => isComposerTypeToFocusEnabled.value, + chatInputRef +}) + // Announce state transitions, not token updates; users read response content in the transcript. const generationAnnouncement = computed(() => { if (isSessionViewPreparing.value) return '' diff --git a/src/renderer/src/features/chat-page/composables/useComposerTypeToFocus.ts b/src/renderer/src/features/chat-page/composables/useComposerTypeToFocus.ts new file mode 100644 index 0000000000..4d620caf5b --- /dev/null +++ b/src/renderer/src/features/chat-page/composables/useComposerTypeToFocus.ts @@ -0,0 +1,58 @@ +import { useEventListener } from '@vueuse/core' +import type { Ref } from 'vue' +import { hasInteractiveKeyboardFocus, isEditableKeyboardTarget } from '@/lib/keyboardFocus' +import { resolveComposerTypeToFocusIntent } from '../model/typeToFocus' + +export type ComposerTypeToFocusHandle = { + focusInput?: () => void + focusAndInsertText?: (text: string) => void +} + +type UseComposerTypeToFocusOptions = { + /** False for read-only sessions, inert composers, and blocking interactions. */ + isEnabled: () => boolean + chatInputRef: Ref +} + +/** + * Routes the first keystroke of a window-level typing session into the composer. + * + * Owns its own window listener instead of joining `useChatPageEventBridge` + * because the new-thread page has no event bridge but needs the same behavior. + * Both pages share this composable; the listener detaches with the component + * scope. + */ +export function useComposerTypeToFocus(options: UseComposerTypeToFocusOptions): void { + useEventListener(window, 'keydown', (event: KeyboardEvent) => { + const intent = resolveComposerTypeToFocusIntent(event, { + isEnabled: options.isEnabled(), + isEditableTarget: isEditableKeyboardTarget(event.target), + hasInteractiveFocus: hasInteractiveKeyboardFocus(event.target) + }) + + if (intent.kind === 'ignore') { + return + } + + const chatInput = options.chatInputRef.value + if (!chatInput?.focusInput) { + return + } + + if (intent.kind === 'focus-only') { + // Input method composition: focusing is enough, the IME owns the text. + chatInput.focusInput() + return + } + + // Suppress the native default (notably Space scrolling the transcript) + // before the character is inserted programmatically. + event.preventDefault() + if (chatInput.focusAndInsertText) { + chatInput.focusAndInsertText(intent.text) + return + } + + chatInput.focusInput() + }) +} diff --git a/src/renderer/src/features/chat-page/model/typeToFocus.ts b/src/renderer/src/features/chat-page/model/typeToFocus.ts new file mode 100644 index 0000000000..160f28fa07 --- /dev/null +++ b/src/renderer/src/features/chat-page/model/typeToFocus.ts @@ -0,0 +1,67 @@ +/** + * Decides what a window-level keydown should do for the chat composer. + * + * Kept as a pure function so the routing rules — which are the whole contract of + * type-to-focus — can be tested without mounting a page or an editor. + */ + +export type ComposerTypeToFocusIntent = + /** Leave the event alone: another handler or control owns it. */ + | { kind: 'ignore' } + /** Start the input method in the composer without inserting anything. */ + | { kind: 'focus-only' } + /** Focus the composer and insert the character that triggered the focus. */ + | { kind: 'focus-and-insert'; text: string } + +export type ComposerTypeToFocusContext = { + /** False for read-only sessions, inert composers, and blocking interactions. */ + isEnabled: boolean + isEditableTarget: boolean + hasInteractiveFocus: boolean +} + +/** C0 controls plus DEL: the non-printable single-code-unit keys. */ +function isControlCharacter(key: string): boolean { + const code = key.charCodeAt(0) + return code <= 0x1f || code === 0x7f +} + +export function resolveComposerTypeToFocusIntent( + event: KeyboardEvent, + context: ComposerTypeToFocusContext +): ComposerTypeToFocusIntent { + if (event.defaultPrevented) { + return { kind: 'ignore' } + } + + // Shortcuts and AltGr-style chords keep their native behavior. A bare Shift + // still reports a printable `key` (e.g. 'A'), so it is intentionally allowed. + if (event.metaKey || event.ctrlKey || event.altKey) { + return { kind: 'ignore' } + } + + // Ownership comes before intent: a composition already running in another + // field (the chat search box, a dialog input) must keep its keystrokes. + if (context.isEditableTarget || context.hasInteractiveFocus) { + return { kind: 'ignore' } + } + + if (!context.isEnabled) { + return { kind: 'ignore' } + } + + // Input method keystrokes arrive as a composition, keyCode 229, or 'Process'. + // Preventing them would break the input method, and inserting the reported key + // would duplicate what the composition is about to commit. + if (event.isComposing || event.keyCode === 229 || event.key === 'Process') { + return { kind: 'focus-only' } + } + + // Navigation and function keys, plus 'Dead'/'Unidentified'. Space is a single + // printable character and is deliberately included here. + if (event.key.length !== 1 || isControlCharacter(event.key)) { + return { kind: 'ignore' } + } + + return { kind: 'focus-and-insert', text: event.key } +} diff --git a/src/renderer/src/lib/keyboardFocus.ts b/src/renderer/src/lib/keyboardFocus.ts new file mode 100644 index 0000000000..1d37ba6486 --- /dev/null +++ b/src/renderer/src/lib/keyboardFocus.ts @@ -0,0 +1,72 @@ +/** + * Focus predicates for renderer-level keyboard routing. + * + * They exist so that features which intercept global keydown (type-to-focus, + * list scroll intent, session shortcuts) agree on what "the user is already + * typing somewhere" and "some control owns the keyboard" mean. + */ + +/** Anything that natively consumes typed characters. */ +const EDITABLE_SELECTOR = [ + 'input', + 'textarea', + 'select', + // `contenteditable=""` and `plaintext-only` are editable too, so match every + // value except the explicit opt-out. ProseMirror's attribute literal is not + // part of our contract. + '[contenteditable]:not([contenteditable="false"])', + '[role="textbox"]' +].join(', ') + +/** + * Controls that own keyboard interaction while focused: activation keys, + * roving tab stops, and modal focus traps. A bare `[tabindex]` is deliberately + * absent — the message scroll container is `tabindex="0"` and must stay a + * valid type-to-focus surface. + */ +const INTERACTIVE_SELECTOR = [ + 'button', + 'a[href]', + 'summary', + '[role="button"]', + '[role="link"]', + '[role="menuitem"]', + '[role="menuitemcheckbox"]', + '[role="menuitemradio"]', + '[role="option"]', + '[role="tab"]', + '[role="checkbox"]', + '[role="radio"]', + '[role="switch"]', + '[role="slider"]', + '[role="combobox"]', + '[role="listbox"]', + '[role="menu"]', + '[role="menubar"]', + '[role="toolbar"]', + // reka-ui writes role but no aria-modal, so the modal test cannot require it. + '[role="dialog"]', + '[role="alertdialog"]', + '[data-slot="dialog-content"]', + '[data-slot="alert-dialog-content"]', + '[data-reka-popper-content-wrapper]', + '[data-radix-popper-content-wrapper]' +].join(', ') + +function resolveElement(target: EventTarget | null): Element | null { + if (target instanceof Element) { + return target + } + + return document.activeElement +} + +/** True when the target (or its ancestors) natively consumes typed characters. */ +export function isEditableKeyboardTarget(target: EventTarget | null): boolean { + return Boolean(resolveElement(target)?.closest(EDITABLE_SELECTOR)) +} + +/** True when the target (or its ancestors) is a control that owns the keyboard. */ +export function hasInteractiveKeyboardFocus(target: EventTarget | null): boolean { + return Boolean(resolveElement(target)?.closest(INTERACTIVE_SELECTOR)) +} diff --git a/src/renderer/src/pages/NewThreadPage.vue b/src/renderer/src/pages/NewThreadPage.vue index 972072b7f5..ab10d7a2c1 100644 --- a/src/renderer/src/pages/NewThreadPage.vue +++ b/src/renderer/src/pages/NewThreadPage.vue @@ -214,6 +214,7 @@ import { import { DcDropdownActionItem } from '@dc-ui/components/dropdown-action-item' import { Icon } from '@iconify/vue' import ChatInputBox from '@/components/chat/ChatInputBox.vue' +import { useComposerTypeToFocus } from '@/features/chat-page/composables/useComposerTypeToFocus' import ChatInputToolbar from '@/components/chat/ChatInputToolbar.vue' import ChatStatusBar from '@/components/chat/ChatStatusBar.vue' import AcpAuthDialog from '@/components/acp/AcpAuthDialog.vue' @@ -316,9 +317,16 @@ const chatInputRef = ref< insertRecognizedText?: (text: string) => void getInlineItemsSnapshot?: () => UserMessageInlineItem[] focusInput?: () => void + focusAndInsertText?: (text: string) => void }) | null >(null) +// Same type-to-focus behavior as the chat page; the composer is the only +// editable surface here, and it locks while a submission is in flight. +useComposerTypeToFocus({ + isEnabled: () => !isSubmittingInput.value, + chatInputRef +}) const { message, attachedFiles, diff --git a/test/renderer/components/ChatInputBox.test.ts b/test/renderer/components/ChatInputBox.test.ts index 5f5c239627..69b20b7930 100644 --- a/test/renderer/components/ChatInputBox.test.ts +++ b/test/renderer/components/ChatInputBox.test.ts @@ -136,6 +136,7 @@ vi.mock('@tiptap/vue-3', () => { chain() { const api = { focus: () => api, + scrollIntoView: () => api, insertContent: (content: string) => { insertContentMock(content) return api @@ -517,6 +518,19 @@ describe('ChatInputBox attachments', () => { expect(insertContentMock).toHaveBeenCalledWith('hello world') }) + it('exposes focusAndInsertText and inserts the raw character without trimming', async () => { + const wrapper = await mountComponent() + ;(wrapper.vm as any).focusAndInsertText(' ') + expect(insertContentMock).toHaveBeenCalledWith({ type: 'text', text: ' ' }) + }) + + it('ignores focusAndInsertText when the composer is not editable', async () => { + const wrapper = await mountComponent() + await wrapper.setProps({ editable: false }) + ;(wrapper.vm as any).focusAndInsertText('a') + expect(insertContentMock).not.toHaveBeenCalled() + }) + it('exposes insertWorkspaceReference and inserts a workspace reference into the editor', async () => { const wrapper = await mountComponent() await wrapper.setProps({ workspacePath: '/repo' }) diff --git a/test/renderer/composables/chat/chatScrollArchitecture.test.ts b/test/renderer/composables/chat/chatScrollArchitecture.test.ts index 5eeb4566cb..edebc03ff0 100644 --- a/test/renderer/composables/chat/chatScrollArchitecture.test.ts +++ b/test/renderer/composables/chat/chatScrollArchitecture.test.ts @@ -22,7 +22,13 @@ const scrollWritePatterns: ReadonlyArray<[ScrollWriteKind, RegExp]> = [ // the message map rail, or document anchors. Any new direct renderer scroll API must be reviewed // explicitly. const allowedDirectScrollWrites: Record = { - 'src/renderer/src/components/chat/ChatInputBox.vue': ['scrollIntoView', 'scrollIntoView'], + // ChatInputBox: focusInput(), focusAndInsertText(), and the Shift-Enter hard break all + // scroll the composer itself into view rather than the message list. + 'src/renderer/src/components/chat/ChatInputBox.vue': [ + 'scrollIntoView', + 'scrollIntoView', + 'scrollIntoView' + ], 'src/renderer/src/components/chat/ChatMinimap.vue': ['scrollTop'], 'src/renderer/src/components/chat/mentions/SuggestionList.vue': ['scrollIntoView'], 'src/renderer/src/components/markdown/useMarkdownLinkNavigation.ts': [ diff --git a/test/renderer/features/chat-page/composables/useComposerTypeToFocus.test.ts b/test/renderer/features/chat-page/composables/useComposerTypeToFocus.test.ts new file mode 100644 index 0000000000..dbdbaacb8e --- /dev/null +++ b/test/renderer/features/chat-page/composables/useComposerTypeToFocus.test.ts @@ -0,0 +1,166 @@ +import { effectScope, ref, type EffectScope } from 'vue' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useComposerTypeToFocus } from '@/features/chat-page/composables/useComposerTypeToFocus' + +function createHarness(handle: Record | null = null) { + const chatInputRef = ref(handle) + const focusInput = vi.fn() + const focusAndInsertText = vi.fn() + chatInputRef.value = handle ?? { focusInput, focusAndInsertText } + + const scope: EffectScope = effectScope() + const isEnabled = ref(true) + scope.run(() => { + useComposerTypeToFocus({ isEnabled: () => isEnabled.value, chatInputRef }) + }) + + return { scope, chatInputRef, focusInput, focusAndInsertText, isEnabled } +} + +function dispatchKeydown( + key: string, + init: Partial & { target?: EventTarget } = {} +): KeyboardEvent { + const { target, ...rest } = init + const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...rest }) + ;(target ?? window).dispatchEvent(event) + return event +} + +describe('useComposerTypeToFocus', () => { + let harness: ReturnType | null = null + + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + harness?.scope.stop() + harness = null + document.body.innerHTML = '' + }) + + it('focuses the composer and inserts the triggering character', () => { + harness = createHarness() + + const event = dispatchKeydown('n') + + expect(harness.focusAndInsertText).toHaveBeenCalledWith('n') + expect(event.defaultPrevented).toBe(true) + }) + + it('inserts a space instead of letting it scroll the transcript', () => { + harness = createHarness() + + const event = dispatchKeydown(' ') + + expect(harness.focusAndInsertText).toHaveBeenCalledWith(' ') + expect(event.defaultPrevented).toBe(true) + }) + + it('leaves shortcuts, navigation keys, and modified keys untouched', () => { + harness = createHarness() + + for (const [key, init] of [ + ['c', { metaKey: true }], + ['v', { ctrlKey: true }], + ['ArrowDown', {}], + ['PageUp', {}], + ['Escape', {}], + ['Tab', {}], + ['F5', {}], + ['Shift', {}] + ] as const) { + const event = dispatchKeydown(key, init) + expect(event.defaultPrevented).toBe(false) + } + + expect(harness.focusAndInsertText).not.toHaveBeenCalled() + expect(harness.focusInput).not.toHaveBeenCalled() + }) + + it('does not steal focus from another text field', () => { + harness = createHarness() + const input = document.createElement('input') + document.body.appendChild(input) + input.focus() + + dispatchKeydown('a', { target: input }) + + expect(harness.focusAndInsertText).not.toHaveBeenCalled() + expect(harness.focusInput).not.toHaveBeenCalled() + }) + + it('does not steal focus from an interactive control', () => { + harness = createHarness() + const button = document.createElement('button') + document.body.appendChild(button) + button.focus() + + dispatchKeydown('a', { target: button }) + + expect(harness.focusAndInsertText).not.toHaveBeenCalled() + }) + + it('keeps typing in the message viewport, which is focusable but not interactive', () => { + harness = createHarness() + const viewport = document.createElement('div') + viewport.tabIndex = 0 + viewport.setAttribute('role', 'region') + document.body.appendChild(viewport) + viewport.focus() + + dispatchKeydown('a', { target: viewport }) + + expect(harness.focusAndInsertText).toHaveBeenCalledWith('a') + }) + + it('only focuses when the input method owns the keystroke', () => { + harness = createHarness() + + const event = dispatchKeydown('Process') + + expect(harness.focusInput).toHaveBeenCalledTimes(1) + expect(harness.focusAndInsertText).not.toHaveBeenCalled() + expect(event.defaultPrevented).toBe(false) + }) + + it('does nothing while the composer cannot take focus', () => { + harness = createHarness() + harness.isEnabled.value = false + + dispatchKeydown('a') + + expect(harness.focusAndInsertText).not.toHaveBeenCalled() + expect(harness.focusInput).not.toHaveBeenCalled() + }) + + it('falls back to focusing when the composer cannot insert text directly', () => { + harness = createHarness({ focusInput: vi.fn() }) + + dispatchKeydown('a') + + expect(harness.chatInputRef.value.focusInput).toHaveBeenCalledTimes(1) + }) + + it('survives a missing composer handle', () => { + const chatInputRef = ref(null) + const scope = effectScope() + scope.run(() => { + useComposerTypeToFocus({ isEnabled: () => true, chatInputRef }) + }) + + expect(() => dispatchKeydown('a')).not.toThrow() + + scope.stop() + }) + + it('detaches when its scope is disposed', () => { + harness = createHarness() + harness.scope.stop() + + dispatchKeydown('a') + + expect(harness.focusAndInsertText).not.toHaveBeenCalled() + }) +}) diff --git a/test/renderer/features/chat-page/model/typeToFocus.test.ts b/test/renderer/features/chat-page/model/typeToFocus.test.ts new file mode 100644 index 0000000000..9247a48acc --- /dev/null +++ b/test/renderer/features/chat-page/model/typeToFocus.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vitest' +import { + resolveComposerTypeToFocusIntent, + type ComposerTypeToFocusContext +} from '@/features/chat-page/model/typeToFocus' + +const ENABLED_CONTEXT: ComposerTypeToFocusContext = { + isEnabled: true, + isEditableTarget: false, + hasInteractiveFocus: false +} + +function keyEvent(init: Partial & { key: string }): KeyboardEvent { + return { + defaultPrevented: false, + metaKey: false, + ctrlKey: false, + altKey: false, + isComposing: false, + keyCode: 0, + ...init + } as KeyboardEvent +} + +function resolve( + init: Partial & { key: string }, + context: Partial = {} +) { + return resolveComposerTypeToFocusIntent(keyEvent(init), { ...ENABLED_CONTEXT, ...context }) +} + +describe('resolveComposerTypeToFocusIntent', () => { + it('focuses and inserts a printable character', () => { + expect(resolve({ key: 'n' })).toEqual({ kind: 'focus-and-insert', text: 'n' }) + expect(resolve({ key: '你' })).toEqual({ kind: 'focus-and-insert', text: '你' }) + }) + + it('treats Space as input rather than a scroll key', () => { + expect(resolve({ key: ' ' })).toEqual({ kind: 'focus-and-insert', text: ' ' }) + }) + + it('keeps Shift-modified characters, including uppercase', () => { + expect(resolve({ key: 'A', shiftKey: true })).toEqual({ kind: 'focus-and-insert', text: 'A' }) + }) + + it('ignores events another handler already claimed', () => { + expect(resolve({ key: 'a', defaultPrevented: true })).toEqual({ kind: 'ignore' }) + }) + + it('leaves shortcut chords to the platform', () => { + expect(resolve({ key: 'c', metaKey: true })).toEqual({ kind: 'ignore' }) + expect(resolve({ key: 'v', ctrlKey: true })).toEqual({ kind: 'ignore' }) + expect(resolve({ key: 'f', altKey: true })).toEqual({ kind: 'ignore' }) + expect(resolve({ key: 'Shift' })).toEqual({ kind: 'ignore' }) + }) + + it('ignores navigation and function keys', () => { + for (const key of [ + 'ArrowUp', + 'ArrowDown', + 'ArrowLeft', + 'ArrowRight', + 'PageUp', + 'PageDown', + 'Home', + 'End', + 'Escape', + 'Tab', + 'Enter', + 'Backspace', + 'Delete', + 'F5', + 'Dead', + 'Unidentified' + ]) { + expect(resolve({ key })).toEqual({ kind: 'ignore' }) + } + }) + + it('ignores control characters that report a single code unit', () => { + expect(resolve({ key: '\u007f' })).toEqual({ kind: 'ignore' }) + expect(resolve({ key: '\u0000' })).toEqual({ kind: 'ignore' }) + }) + + it('never steals focus from another editable target, even mid-composition', () => { + const context = { isEditableTarget: true } + + expect(resolve({ key: 'a' }, context)).toEqual({ kind: 'ignore' }) + expect(resolve({ key: 'n', keyCode: 229 }, context)).toEqual({ kind: 'ignore' }) + expect(resolve({ key: 'n', isComposing: true }, context)).toEqual({ kind: 'ignore' }) + expect(resolve({ key: 'Process' }, context)).toEqual({ kind: 'ignore' }) + }) + + it('never steals focus from an interactive control', () => { + const context = { hasInteractiveFocus: true } + + expect(resolve({ key: 'a' }, context)).toEqual({ kind: 'ignore' }) + expect(resolve({ key: 'n', keyCode: 229 }, context)).toEqual({ kind: 'ignore' }) + }) + + it('ignores everything while the composer cannot take focus', () => { + const context = { isEnabled: false } + + expect(resolve({ key: 'a' }, context)).toEqual({ kind: 'ignore' }) + expect(resolve({ key: ' ' }, context)).toEqual({ kind: 'ignore' }) + expect(resolve({ key: 'n', keyCode: 229 }, context)).toEqual({ kind: 'ignore' }) + }) + + it('focuses without inserting while the input method owns the keystroke', () => { + expect(resolve({ key: 'Process' })).toEqual({ kind: 'focus-only' }) + expect(resolve({ key: 'n', keyCode: 229 })).toEqual({ kind: 'focus-only' }) + expect(resolve({ key: 'n', isComposing: true })).toEqual({ kind: 'focus-only' }) + }) + + it('does not treat keyCode 229 as a printable character', () => { + // 'Process' is seven code units long, so the IME branch must win over the + // single-character test. + expect(resolve({ key: 'Process', keyCode: 229 })).not.toEqual({ + kind: 'focus-and-insert', + text: 'Process' + }) + }) +}) diff --git a/test/renderer/lib/keyboardFocus.test.ts b/test/renderer/lib/keyboardFocus.test.ts new file mode 100644 index 0000000000..ea41490b71 --- /dev/null +++ b/test/renderer/lib/keyboardFocus.test.ts @@ -0,0 +1,111 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { hasInteractiveKeyboardFocus, isEditableKeyboardTarget } from '@/lib/keyboardFocus' + +function mountMarkup(html: string): HTMLElement { + const host = document.createElement('div') + host.innerHTML = html + document.body.appendChild(host) + return host +} + +describe('keyboardFocus', () => { + afterEach(() => { + document.body.innerHTML = '' + }) + + describe('isEditableKeyboardTarget', () => { + it('matches native text controls', () => { + const host = mountMarkup('') + + for (const element of Array.from(host.children)) { + expect(isEditableKeyboardTarget(element)).toBe(true) + } + }) + + it('matches every editable contenteditable form, including the editor surface', () => { + const host = mountMarkup( + '
' + + '
' + + '
' + ) + + for (const element of Array.from(host.children)) { + expect(isEditableKeyboardTarget(element)).toBe(true) + } + }) + + it('matches descendants of an editable surface', () => { + const host = mountMarkup('
') + + expect(isEditableKeyboardTarget(host.querySelector('#inner'))).toBe(true) + }) + + it('ignores contenteditable="false" node views and plain elements', () => { + const host = mountMarkup( + '
' + ) + + for (const element of Array.from(host.children)) { + expect(isEditableKeyboardTarget(element)).toBe(false) + } + }) + + it('falls back to the active element when the target is not an element', () => { + const host = mountMarkup('') + const input = host.querySelector('#search') + input?.focus() + + expect(isEditableKeyboardTarget(null)).toBe(true) + }) + }) + + describe('hasInteractiveKeyboardFocus', () => { + it('matches controls that own activation keys', () => { + const host = mountMarkup( + '
' + + '
' + ) + + for (const element of Array.from(host.children)) { + expect(hasInteractiveKeyboardFocus(element)).toBe(true) + } + }) + + it('matches descendants of modal dialogs and popovers', () => { + const host = mountMarkup( + '
' + + '
' + + '
' + ) + + expect(hasInteractiveKeyboardFocus(host.querySelector('#dialog-body'))).toBe(true) + expect(hasInteractiveKeyboardFocus(host.querySelector('#slot-body'))).toBe(true) + expect(hasInteractiveKeyboardFocus(host.querySelector('#popper-body'))).toBe(true) + }) + + it('does not treat the focusable message scroll container as interactive', () => { + const host = mountMarkup( + '
' + + '' + ) + + expect(hasInteractiveKeyboardFocus(host.querySelector('#viewport'))).toBe(false) + expect(hasInteractiveKeyboardFocus(host.querySelector('#skip-link'))).toBe(false) + }) + + it('ignores plain content and links without an href', () => { + const host = mountMarkup('

hello

no href') + + expect(hasInteractiveKeyboardFocus(host.querySelector('#text'))).toBe(false) + expect(hasInteractiveKeyboardFocus(host.querySelector('#anchor'))).toBe(false) + }) + + it('falls back to the active element when the target is not an element', () => { + const host = mountMarkup('') + const button = host.querySelector('#action') + button?.focus() + + expect(hasInteractiveKeyboardFocus(null)).toBe(true) + }) + }) +}) From 093b9741c588f0e0376c3e3e73b3b702e35a4608 Mon Sep 17 00:00:00 2001 From: zhangmo8 Date: Sun, 20 Sep 2026 10:51:53 +0800 Subject: [PATCH 2/2] fix(chat): focus without inserting on a dead key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dead key produces no text of its own, so inserting the reported key was never right. Ignoring it left the follow-up letter to be inserted as a bare character, turning "dead key + e" into "e" rather than "é" on layouts that compose accents that way. Route it to focus-only, the same branch the input method uses: no insertion and no preventDefault, so the platform keeps its pending state and the next keystroke reaches an already focused editor and composes natively. --- .../features/chat-page/model/typeToFocus.ts | 18 +++++++++++++----- .../composables/useComposerTypeToFocus.test.ts | 10 ++++++++++ .../chat-page/model/typeToFocus.test.ts | 11 ++++++++++- 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/features/chat-page/model/typeToFocus.ts b/src/renderer/src/features/chat-page/model/typeToFocus.ts index 160f28fa07..f7c93dd7b0 100644 --- a/src/renderer/src/features/chat-page/model/typeToFocus.ts +++ b/src/renderer/src/features/chat-page/model/typeToFocus.ts @@ -50,14 +50,22 @@ export function resolveComposerTypeToFocusIntent( return { kind: 'ignore' } } - // Input method keystrokes arrive as a composition, keyCode 229, or 'Process'. - // Preventing them would break the input method, and inserting the reported key - // would duplicate what the composition is about to commit. - if (event.isComposing || event.keyCode === 229 || event.key === 'Process') { + // Keystrokes whose text is produced later rather than by this key: input + // method compositions (isComposing / keyCode 229 / 'Process') and dead keys + // waiting for a following letter. Focus without inserting and without + // preventDefault, so the platform keeps its pending state and the *next* + // keystroke reaches an already focused editor and composes natively — which is + // what turns a dead key + 'e' into 'é' instead of a bare 'e'. + if ( + event.isComposing || + event.keyCode === 229 || + event.key === 'Process' || + event.key === 'Dead' + ) { return { kind: 'focus-only' } } - // Navigation and function keys, plus 'Dead'/'Unidentified'. Space is a single + // Navigation and function keys, plus 'Unidentified'. Space is a single // printable character and is deliberately included here. if (event.key.length !== 1 || isControlCharacter(event.key)) { return { kind: 'ignore' } diff --git a/test/renderer/features/chat-page/composables/useComposerTypeToFocus.test.ts b/test/renderer/features/chat-page/composables/useComposerTypeToFocus.test.ts index dbdbaacb8e..99d5a75e3d 100644 --- a/test/renderer/features/chat-page/composables/useComposerTypeToFocus.test.ts +++ b/test/renderer/features/chat-page/composables/useComposerTypeToFocus.test.ts @@ -125,6 +125,16 @@ describe('useComposerTypeToFocus', () => { expect(event.defaultPrevented).toBe(false) }) + it('only focuses on a dead key, leaving the platform free to compose', () => { + harness = createHarness() + + const event = dispatchKeydown('Dead') + + expect(harness.focusInput).toHaveBeenCalledTimes(1) + expect(harness.focusAndInsertText).not.toHaveBeenCalled() + expect(event.defaultPrevented).toBe(false) + }) + it('does nothing while the composer cannot take focus', () => { harness = createHarness() harness.isEnabled.value = false diff --git a/test/renderer/features/chat-page/model/typeToFocus.test.ts b/test/renderer/features/chat-page/model/typeToFocus.test.ts index 9247a48acc..0f69ad7c65 100644 --- a/test/renderer/features/chat-page/model/typeToFocus.test.ts +++ b/test/renderer/features/chat-page/model/typeToFocus.test.ts @@ -70,7 +70,6 @@ describe('resolveComposerTypeToFocusIntent', () => { 'Backspace', 'Delete', 'F5', - 'Dead', 'Unidentified' ]) { expect(resolve({ key })).toEqual({ kind: 'ignore' }) @@ -112,6 +111,16 @@ describe('resolveComposerTypeToFocusIntent', () => { expect(resolve({ key: 'n', isComposing: true })).toEqual({ kind: 'focus-only' }) }) + it('focuses without inserting on a dead key so the next keystroke composes', () => { + // Dead key + 'e' should produce 'é': the dead key only moves focus, and the + // following letter is then handled natively by the focused editor. + expect(resolve({ key: 'Dead' })).toEqual({ kind: 'focus-only' }) + }) + + it('does not treat a dead key as a printable character', () => { + expect(resolve({ key: 'Dead' })).not.toEqual({ kind: 'focus-and-insert', text: 'Dead' }) + }) + it('does not treat keyCode 229 as a printable character', () => { // 'Process' is seven code units long, so the IME branch must win over the // single-character test.