fix(renderer): guard remaining IME text-entry handlers

This commit is contained in:
Neil
2026-08-02 17:06:26 -07:00
parent ce391bdd0a
commit b6bd1f8bd6
35 changed files with 667 additions and 20 deletions
@@ -5,6 +5,7 @@ import { toast } from 'sonner'
import { LinearIssueMarkdownDescriptionEditor } from '@/components/LinearIssueMarkdownDescriptionEditor'
import { useMountedRef } from '@/hooks/useMountedRef'
import { cn } from '@/lib/utils'
import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event'
import { useAppStore } from '@/store'
import { getScreenSubmitShortcutLabel, isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut'
import { linearUpdateIssue } from '@/runtime/runtime-linear-client'
@@ -153,6 +154,9 @@ export function LinearIssueTextEditor({
const handleDescriptionKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (isImeCompositionKeyDown(event)) {
return
}
if (!isScreenSubmitShortcut(event)) {
return
}
@@ -172,6 +176,9 @@ export function LinearIssueTextEditor({
const handleTitleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (isImeCompositionKeyDown(event)) {
return
}
if (event.key === 'Enter') {
event.preventDefault()
event.currentTarget.blur()
@@ -38,6 +38,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
import { createBrowserUuid } from '@/lib/browser-uuid'
import { buildLinearIssueContextSnapshot } from '@/lib/linear-issue-context-snapshot'
import { buildContainedLinkedContextBlock } from '@/lib/linked-work-item-context'
import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event'
import { useMountedRef } from '@/hooks/useMountedRef'
import { useAppStore } from '@/store'
import {
@@ -289,6 +290,9 @@ function LinearIssueSubIssueButton({
value={title}
onChange={(event) => setTitle(event.target.value)}
onKeyDown={(event) => {
if (isImeCompositionKeyDown(event)) {
return
}
if (event.key === 'Enter') {
event.preventDefault()
void handleCreate()
@@ -23,6 +23,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
import { VisuallyHidden } from 'radix-ui'
import CommentMarkdown from '@/components/sidebar/CommentMarkdown'
import { cn } from '@/lib/utils'
import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event'
import {
getCommentBodySubmitState,
hasBoundedCommentBodyText
@@ -637,6 +638,9 @@ export function LinearIssueEditSection({
value={estimateInput}
onChange={(event) => setEstimateInput(event.target.value)}
onKeyDown={(event) => {
if (isImeCompositionKeyDown(event)) {
return
}
if (event.key === 'Enter') {
event.preventDefault()
handleEstimateSubmit()
@@ -883,6 +887,9 @@ export function LinearIssueEditSection({
value={estimateInput}
onChange={(event) => setEstimateInput(event.target.value)}
onKeyDown={(event) => {
if (isImeCompositionKeyDown(event)) {
return
}
if (event.key === 'Enter') {
event.preventDefault()
handleEstimateSubmit()
@@ -1134,6 +1141,9 @@ export function LinearIssueCommentFooter({
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (isImeCompositionKeyDown(e)) {
return
}
if (isScreenSubmitShortcut(e)) {
e.preventDefault()
handleSubmit()
@@ -19,11 +19,11 @@ function createSearchAddon(): SearchAddon {
} as unknown as SearchAddon
}
function renderSearch(searchAddon: SearchAddon): ReturnType<typeof render> {
function renderSearch(searchAddon: SearchAddon, onClose = vi.fn()): ReturnType<typeof render> {
return render(
<TerminalSearch
isOpen
onClose={vi.fn()}
onClose={onClose}
searchAddon={searchAddon}
searchStateRef={{ current: { query: '', caseSensitive: false, regex: false } }}
/>
@@ -84,3 +84,32 @@ describe('TerminalSearch cleanup', () => {
expect(addon.findNext).toHaveBeenCalledWith('')
})
})
describe('TerminalSearch IME ownership', () => {
it('leaves the search open for a marked Escape and closes it for an ordinary Escape', () => {
const onClose = vi.fn()
const view = renderSearch(createSearchAddon(), onClose)
const input = view.getByPlaceholderText('Search...')
fireEvent.keyDown(input, { key: 'Escape', code: 'Escape', keyCode: 27, isComposing: true })
expect(onClose).not.toHaveBeenCalled()
fireEvent.keyDown(input, { key: 'Escape', code: 'Escape', keyCode: 27 })
expect(onClose).toHaveBeenCalledTimes(1)
})
it('does not navigate on a marked Enter and preserves ordinary Enter navigation', async () => {
const addon = createSearchAddon()
const view = renderSearch(addon)
const input = view.getByPlaceholderText('Search...')
fireEvent.change(input, { target: { value: 'needle' } })
await waitFor(() => expect(addon.findNext).toHaveBeenCalled())
vi.mocked(addon.findNext).mockClear()
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter', keyCode: 13, isComposing: true })
expect(addon.findNext).not.toHaveBeenCalled()
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter', keyCode: 13 })
expect(addon.findNext).toHaveBeenCalledTimes(1)
})
})
@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button'
import type { SearchState } from '@/components/terminal-pane/keyboard-handlers'
import { translate } from '@/i18n/i18n'
import { getFindRequestQuery } from '@/lib/find-query-bounds'
import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event'
import { safeFind } from './terminal-search-safe-find'
type TerminalSearchProps = {
@@ -113,6 +114,9 @@ export default function TerminalSearch({
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
e.stopPropagation()
if (isImeCompositionKeyDown(e)) {
return
}
if (e.key === 'Escape') {
onClose()
@@ -0,0 +1,53 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import BrowserFind from './BrowserFind'
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string) => fallback
}))
afterEach(cleanup)
function renderFind() {
const onClose = vi.fn()
const webview = {
addEventListener: vi.fn(),
findInPage: vi.fn(),
removeEventListener: vi.fn(),
stopFindInPage: vi.fn()
}
const view = render(
<BrowserFind
isOpen
onClose={onClose}
webviewRef={{ current: webview as unknown as Electron.WebviewTag }}
/>
)
return { input: view.getByPlaceholderText('Find in page...'), onClose, view, webview }
}
describe('BrowserFind IME ownership', () => {
it('leaves find open for a marked Escape and closes it for an ordinary Escape', () => {
const { input, onClose } = renderFind()
fireEvent.keyDown(input, { key: 'Escape', code: 'Escape', keyCode: 27, isComposing: true })
expect(onClose).not.toHaveBeenCalled()
fireEvent.keyDown(input, { key: 'Escape', code: 'Escape', keyCode: 27 })
expect(onClose).toHaveBeenCalledTimes(1)
})
it('does not navigate on a marked Enter and preserves ordinary Enter navigation', () => {
const { input, webview } = renderFind()
fireEvent.change(input, { target: { value: 'needle' } })
webview.findInPage.mockClear()
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter', keyCode: 13, isComposing: true })
expect(webview.findInPage).not.toHaveBeenCalled()
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter', keyCode: 13 })
expect(webview.findInPage).toHaveBeenCalledTimes(1)
})
})
@@ -3,6 +3,7 @@ import { ChevronUp, ChevronDown, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { translate } from '@/i18n/i18n'
import { getFindRequestQuery } from '@/lib/find-query-bounds'
import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event'
type BrowserFindProps = {
isOpen: boolean
@@ -127,6 +128,9 @@ export default function BrowserFind({
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
e.stopPropagation()
if (isImeCompositionKeyDown(e)) {
return
}
if (e.key === 'Escape') {
onClose()
@@ -11,6 +11,7 @@ import {
} from 'react'
import { createPortal } from 'react-dom'
import { cn } from '@/lib/utils'
import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event'
import { createBrowserUuid } from '@/lib/browser-uuid'
import { getConnectionId } from '@/lib/connection-context'
import { detectLanguage } from '@/lib/language-detect'
@@ -454,6 +455,9 @@ function PendingBrowserAnnotationCard({
className="h-24 w-full resize-none rounded-md border border-input bg-background px-3 py-2 text-sm outline-none ring-offset-background placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring"
autoFocus
onKeyDown={(event) => {
if (isImeCompositionKeyDown(event)) {
return
}
if (event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
@@ -1298,6 +1302,9 @@ function RemoteBrowserPagePane({
return
}
const handleKeyDown = (event: KeyboardEvent): void => {
if (isImeCompositionKeyDown(event)) {
return
}
if (event.key === 'Escape') {
event.preventDefault()
setContextMenu(null)
@@ -2236,6 +2243,9 @@ function RemoteBrowserPagePane({
if (isEditableKeyboardTarget(event.target)) {
return
}
if (isImeCompositionKeyDown(event)) {
return
}
const target = runtimeTarget()
const pageId = remotePageIdRef.current
const operationToken = pageId ? createRemoteOperationToken(pageId) : null
@@ -3231,6 +3241,9 @@ function BrowserPagePane({
return
}
const handleKeyDown = (e: KeyboardEvent): void => {
if (isImeCompositionKeyDown(e)) {
return
}
if (e.key === 'Escape') {
e.preventDefault()
setContextMenu(null)
@@ -0,0 +1,49 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render } from '@testing-library/react'
import type { EventBus } from 'pdfjs-dist/web/pdf_viewer.mjs'
import { afterEach, describe, expect, it, vi } from 'vitest'
import PdfFind from './PdfFind'
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string) => fallback
}))
afterEach(cleanup)
function renderFind() {
const onClose = vi.fn()
const eventBus = { dispatch: vi.fn(), off: vi.fn(), on: vi.fn() }
const view = render(
<PdfFind
isOpen
onClose={onClose}
eventBusRef={{ current: eventBus as unknown as InstanceType<typeof EventBus> }}
/>
)
return { eventBus, input: view.getByPlaceholderText('Find in page...'), onClose }
}
describe('PdfFind IME ownership', () => {
it('leaves find open for a marked Escape and closes it for an ordinary Escape', () => {
const { input, onClose } = renderFind()
fireEvent.keyDown(input, { key: 'Escape', code: 'Escape', keyCode: 27, isComposing: true })
expect(onClose).not.toHaveBeenCalled()
fireEvent.keyDown(input, { key: 'Escape', code: 'Escape', keyCode: 27 })
expect(onClose).toHaveBeenCalledTimes(1)
})
it('does not navigate on a marked Enter and preserves ordinary Enter navigation', () => {
const { eventBus, input } = renderFind()
fireEvent.change(input, { target: { value: 'needle' } })
eventBus.dispatch.mockClear()
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter', keyCode: 13, isComposing: true })
expect(eventBus.dispatch).not.toHaveBeenCalled()
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter', keyCode: 13 })
expect(eventBus.dispatch).toHaveBeenCalledTimes(1)
})
})
@@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button'
import type { EventBus } from 'pdfjs-dist/web/pdf_viewer.mjs'
import { translate } from '@/i18n/i18n'
import { getFindRequestQuery } from '@/lib/find-query-bounds'
import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event'
type PdfFindProps = {
isOpen: boolean
@@ -94,6 +95,9 @@ export default function PdfFind({
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
e.stopPropagation()
if (isImeCompositionKeyDown(e)) {
return
}
if (e.key === 'Escape') {
onClose()
} else if (e.key === 'Enter' && e.shiftKey) {
@@ -0,0 +1,74 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { RichMarkdownSearchBar } from './RichMarkdownSearchBar'
vi.mock('@/hooks/useShortcutLabel', () => ({ useOptionalShortcutLabel: () => null }))
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string) => fallback
}))
afterEach(cleanup)
function renderBar() {
const actions = {
onClose: vi.fn(),
onMoveToMatch: vi.fn(),
onQueryChange: vi.fn(),
onReplaceAll: vi.fn(),
onReplaceCurrent: vi.fn(),
onReplaceQueryChange: vi.fn(),
onToggleMatchCase: vi.fn(),
onToggleReplaceMode: vi.fn(),
onToggleWholeWord: vi.fn()
}
const view = render(
<RichMarkdownSearchBar
activeMatchIndex={0}
isOpen
isReplaceMode
matchCase={false}
matchCount={2}
query="needle"
replaceQuery="replacement"
replaceDisabled={false}
searchInputRef={{ current: null }}
wholeWord={false}
{...actions}
/>
)
return { actions, view }
}
describe('RichMarkdownSearchBar IME ownership', () => {
it('withholds marked search-field keys and preserves ordinary navigation and close', () => {
const { actions, view } = renderBar()
const input = view.getByLabelText('Find in rich markdown editor')
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter', keyCode: 13, isComposing: true })
fireEvent.keyDown(input, { key: 'Escape', code: 'Escape', keyCode: 27, isComposing: true })
expect(actions.onMoveToMatch).not.toHaveBeenCalled()
expect(actions.onClose).not.toHaveBeenCalled()
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter', keyCode: 13 })
fireEvent.keyDown(input, { key: 'Escape', code: 'Escape', keyCode: 27 })
expect(actions.onMoveToMatch).toHaveBeenCalledWith(1)
expect(actions.onClose).toHaveBeenCalledTimes(1)
})
it('withholds marked replace-field keys and preserves ordinary replace and close', () => {
const { actions, view } = renderBar()
const input = view.getByLabelText('Replace in rich markdown editor')
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter', keyCode: 13, isComposing: true })
fireEvent.keyDown(input, { key: 'Escape', code: 'Escape', keyCode: 27, isComposing: true })
expect(actions.onReplaceCurrent).not.toHaveBeenCalled()
expect(actions.onClose).not.toHaveBeenCalled()
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter', keyCode: 13 })
fireEvent.keyDown(input, { key: 'Escape', code: 'Escape', keyCode: 27 })
expect(actions.onReplaceCurrent).toHaveBeenCalledTimes(1)
expect(actions.onClose).toHaveBeenCalledTimes(1)
})
})
@@ -13,6 +13,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { translate } from '@/i18n/i18n'
import { useOptionalShortcutLabel } from '@/hooks/useShortcutLabel'
import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event'
type RichMarkdownSearchBarProps = {
activeMatchIndex: number
@@ -111,6 +112,9 @@ export function RichMarkdownSearchBar({
value={query}
onChange={(event) => onQueryChange(event.target.value)}
onKeyDown={(event) => {
if (isImeCompositionKeyDown(event)) {
return
}
if (event.key === 'Enter' && event.shiftKey) {
event.preventDefault()
onMoveToMatch(-1)
@@ -247,6 +251,9 @@ export function RichMarkdownSearchBar({
value={replaceQuery}
onChange={(event) => onReplaceQueryChange(event.target.value)}
onKeyDown={(event) => {
if (isImeCompositionKeyDown(event)) {
return
}
if (event.key === 'Enter') {
event.preventDefault()
onReplaceCurrent()
@@ -11,6 +11,7 @@ import {
DialogTitle
} from '@/components/ui/dialog'
import { getRelativePathInsideRoot } from '@/lib/path'
import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event'
import { useMountedRef } from '@/hooks/useMountedRef'
import { translate } from '@/i18n/i18n'
@@ -157,6 +158,9 @@ export function UntitledFileRenameDialog({
setError(null)
}}
onKeyDown={(e) => {
if (isImeCompositionKeyDown(e)) {
return
}
if (e.key === 'Enter') {
e.preventDefault()
handleSubmit()
@@ -186,6 +190,9 @@ export function UntitledFileRenameDialog({
setError(null)
}}
onKeyDown={(e) => {
if (isImeCompositionKeyDown(e)) {
return
}
if (e.key === 'Enter') {
e.preventDefault()
handleSubmit()
@@ -0,0 +1,50 @@
// @vitest-environment happy-dom
import { act, cleanup, renderHook } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { useRichMarkdownSearch } from './useRichMarkdownSearch'
afterEach(() => {
cleanup()
document.body.replaceChildren()
})
describe('useRichMarkdownSearch IME ownership', () => {
it('keeps search open for a marked Escape and closes it for an ordinary Escape', () => {
const root = document.createElement('div')
const input = document.createElement('input')
root.appendChild(input)
document.body.appendChild(root)
const { result } = renderHook(() =>
useRichMarkdownSearch({
editor: null,
rootRef: { current: root },
scrollContainerRef: { current: null }
})
)
act(() => {
result.current.openSearch()
;(result.current.searchState.searchInputRef as { current: HTMLInputElement | null }).current =
input
})
expect(result.current.searchState.isSearchOpen).toBe(true)
act(() => {
input.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Escape',
code: 'Escape',
keyCode: 27,
isComposing: true,
bubbles: true
})
)
})
expect(result.current.searchState.isSearchOpen).toBe(true)
act(() => {
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
})
expect(result.current.searchState.isSearchOpen).toBe(false)
})
})
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { RefObject } from 'react'
import type { Editor } from '@tiptap/react'
import { TextSelection } from '@tiptap/pm/state'
import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event'
import { getShortcutPlatform } from '@/lib/shortcut-platform'
import { useAppStore } from '@/store'
import {
@@ -301,6 +302,9 @@ export function useRichMarkdownSearch({
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent): void => {
if (isImeCompositionKeyDown(event)) {
return
}
const root = rootRef.current
if (!root) {
return
@@ -11,6 +11,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { cn } from '@/lib/utils'
import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event'
import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
import { useAppStore } from '@/store'
import { useMountedRef } from '@/hooks/useMountedRef'
@@ -621,6 +622,9 @@ export default function ProjectPicker({ activeProject, onSelect }: Props): React
)
}}
onKeyDown={(e) => {
if (isImeCompositionKeyDown(e)) {
return
}
if (e.key === 'Enter') {
void handlePaste()
}
@@ -13,6 +13,7 @@ import { LabelsEditor } from './LabelsEditor'
import { AssigneesEditor } from './AssigneesEditor'
import { CommentsList, NewCommentForm } from './Comments'
import { translate } from '@/i18n/i18n'
import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event'
export function SlugDialogBody({
projectOrigin,
@@ -159,6 +160,9 @@ export function SlugDialogBody({
onChange={(e) => setTitleDraft(e.target.value)}
onBlur={() => void commitTitle()}
onKeyDown={(e) => {
if (isImeCompositionKeyDown(e)) {
return
}
if (e.key === 'Enter') {
e.preventDefault()
void commitTitle()
@@ -137,4 +137,33 @@ describe('NativeChatQuestionCard', () => {
expect(onAnswer).toHaveBeenCalledWith([{ indices: [], other: 'four spaces' }])
})
it('does not submit a composing free-text answer on Enter', () => {
const onAnswer = vi.fn()
render(tabsOrSpaces, onAnswer)
const input = container.querySelector('input')!
act(() => {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!
setter.call(input, '네')
input.dispatchEvent(new Event('input', { bubbles: true }))
input.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Enter',
code: 'Enter',
keyCode: 13,
isComposing: true,
bubbles: true
})
)
})
expect(onAnswer).not.toHaveBeenCalled()
act(() => {
input.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true })
)
})
expect(onAnswer).toHaveBeenCalledWith([{ indices: [], other: '네' }])
})
})
@@ -1,6 +1,7 @@
import { useState, type RefObject } from 'react'
import { Check, Pencil, X } from 'lucide-react'
import { cn } from '@/lib/utils'
import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event'
import { translate } from '@/i18n/i18n'
import type { AskAnswerSelection, AskPrompt } from './native-chat-interactive-prompt'
@@ -193,6 +194,9 @@ export function NativeChatQuestionCard({
value={otherText[index]}
onChange={(e) => setOther(index, e.target.value)}
onKeyDown={(e) => {
if (isImeCompositionKeyDown(e)) {
return
}
if (e.key === 'Enter') {
e.preventDefault()
confirm(true)
@@ -18,6 +18,34 @@ const copy = {
}
describe('CustomAddressDialog', () => {
it('does not submit a composing address on Enter', async () => {
const onConfirm = vi.fn().mockResolvedValue(true)
render(
<CustomAddressDialog
open
onOpenChange={vi.fn()}
validate={(input) => (input ? { ok: true, value: input } : { ok: false })}
copy={copy}
inputId="custom-address"
onConfirm={onConfirm}
/>
)
const input = screen.getByLabelText('Address')
fireEvent.change(input, { target: { value: '서버.example' } })
fireEvent.keyDown(input, {
key: 'Enter',
code: 'Enter',
keyCode: 13,
isComposing: true
})
expect(onConfirm).not.toHaveBeenCalled()
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter', keyCode: 13 })
await waitFor(() => expect(onConfirm).toHaveBeenCalledWith('서버.example'))
})
it('keeps a failed asynchronous confirmation open and does not commit it', async () => {
const onOpenChange = vi.fn()
const onConfirm = vi.fn().mockResolvedValue(false)
@@ -11,6 +11,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Loader2 } from 'lucide-react'
import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event'
export type CustomAddressValidator = (input: string) => { ok: true; value: string } | { ok: false }
@@ -119,6 +120,9 @@ export function CustomAddressDialog({
setConfirmationFailed(false)
}}
onKeyDown={(e) => {
if (isImeCompositionKeyDown(e)) {
return
}
if (e.key === 'Enter') {
e.preventDefault()
void submit()
@@ -313,6 +313,36 @@ describe('ProjectCombobox', () => {
expect(onValueSelected).toHaveBeenCalledWith('github:stablyai/noqa')
})
it('leaves candidate navigation to the IME and preserves ordinary Enter', () => {
const onValueChange = vi.fn()
act(() => {
root.render(<ProjectCombobox options={projects} value={null} onValueChange={onValueChange} />)
})
openList()
type('noq')
act(() => {
field().dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Enter',
code: 'Enter',
keyCode: 13,
isComposing: true,
bubbles: true
})
)
})
expect(onValueChange).not.toHaveBeenCalled()
act(() => {
field().dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true })
)
})
expect(onValueChange).toHaveBeenCalledWith('github:stablyai/noqa')
})
it('arms "Add a new project" when a query matches nothing, so Enter is never a wrong guess', () => {
const onAddProject = vi.fn()
const onValueChange = vi.fn()
@@ -2,6 +2,7 @@ import React, { useCallback, useMemo } from 'react'
import { ChevronDown, FolderPlus } from 'lucide-react'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
import { cn } from '@/lib/utils'
import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event'
import type { NewWorkspaceProjectOption } from '@/lib/new-workspace-project-options'
import { translate } from '@/i18n/i18n'
import {
@@ -110,6 +111,9 @@ export default function ProjectCombobox({
const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLInputElement>): void => {
if (isImeCompositionKeyDown(event)) {
return
}
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
setOpen(true)
@@ -24,6 +24,7 @@ import { getGitHubPRCacheKey, getGitHubRepoCacheKey } from '@/store/slices/githu
import { useActiveWorktree, useRepoById } from '@/store/selectors'
import { useChecksPanelTerminalWorktree } from './use-checks-panel-terminal-worktree'
import { cn } from '@/lib/utils'
import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event'
import { openHttpLink } from '@/lib/http-link-routing'
import { Button } from '@/components/ui/button'
import { DetachedHeadBadge } from '@/components/DetachedHeadBadge'
@@ -2638,6 +2639,9 @@ export default function ChecksPanel(): React.JSX.Element {
const handleTitleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (isImeCompositionKeyDown(e)) {
return
}
if (e.key === 'Enter') {
e.preventDefault()
void handleSaveTitle()
@@ -33,6 +33,7 @@ import {
ContextMenuTrigger
} from '@/components/ui/context-menu'
import { cn } from '@/lib/utils'
import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event'
import { useAppStore } from '@/store'
import { useShortcutLabel } from '@/hooks/useShortcutLabel'
import { detectLanguage } from '@/lib/language-detect'
@@ -219,6 +220,9 @@ export function InlineInputRow({
className="flex-1 min-w-0 bg-transparent text-xs text-foreground outline-none border border-ring rounded-sm px-1"
defaultValue={inlineInput.type === 'rename' ? inlineInput.existingName : ''}
onKeyDown={(e) => {
if (isImeCompositionKeyDown(e)) {
return
}
if (e.key === 'Enter') {
e.preventDefault()
submit(e.currentTarget.value)
@@ -10,6 +10,7 @@ import type { PrimaryAction } from './source-control-primary-action'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event'
import { translate } from '@/i18n/i18n'
import type { WorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-display'
import { HostedReviewHeaderLink, HostedReviewIcon } from './hosted-review-header-chrome'
@@ -248,6 +249,9 @@ export function SourceControlHeaderToolbar({
value={filterQuery}
onChange={(event) => onFilterQueryChange(event.target.value)}
onKeyDown={(event) => {
if (isImeCompositionKeyDown(event)) {
return
}
if (event.key === 'Escape') {
event.preventDefault()
collapseFilter()
@@ -18,6 +18,7 @@ import { useAppStore } from '@/store'
import { Button } from '../ui/button'
import { Input } from '../ui/input'
import { cn } from '@/lib/utils'
import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event'
import { AgentAwakeSetting } from './AgentAwakeSetting'
import { AgentCacheTimerSection } from './AgentCacheTimerSection'
import { AgentRuntimeSetting } from './AgentRuntimeSetting'
@@ -302,6 +303,9 @@ function AgentCommandOverrideInput({
onChange={(e) => setCmdDraft(e.target.value)}
onBlur={commitCmd}
onKeyDown={(e) => {
if (isImeCompositionKeyDown(e)) {
return
}
if (e.key === 'Enter') {
commitCmd()
e.currentTarget.blur()
@@ -357,6 +361,9 @@ function AgentDefaultArgsInput({
onChange={(e) => setArgsDraft(e.target.value)}
onBlur={commitArgs}
onKeyDown={(e) => {
if (isImeCompositionKeyDown(e)) {
return
}
if (e.key === 'Enter') {
commitArgs()
e.currentTarget.blur()
@@ -428,6 +435,9 @@ function AgentDefaultEnvInput({
}}
onBlur={commitEnv}
onKeyDown={(e) => {
if (isImeCompositionKeyDown(e)) {
return
}
if (e.key === 'Enter') {
commitEnv()
e.currentTarget.blur()
@@ -106,6 +106,58 @@ describe('FontAutocomplete', () => {
expectOptionsToBePortaled()
})
it('leaves marked candidate navigation to the IME', async () => {
function Harness(): ReactNode {
const [value, setValue] = useState('Geist')
return (
<FontAutocomplete
value={value}
suggestions={['Arial', 'Courier New', 'Geist', 'JetBrains Mono', 'SF Mono']}
onChange={setValue}
/>
)
}
await act(async () => {
root.render(<Harness />)
})
const input = getInput()
await act(async () => input.focus())
await act(async () => {
input.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'ArrowDown',
code: 'ArrowDown',
keyCode: 40,
isComposing: true,
bubbles: true
})
)
})
expect(
document
.querySelector<HTMLButtonElement>('[role="option"][aria-selected="true"]')
?.textContent?.trim()
).toBe('Geist')
await act(async () => {
input.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'ArrowDown',
code: 'ArrowDown',
keyCode: 40,
bubbles: true
})
)
})
expect(
document
.querySelector<HTMLButtonElement>('[role="option"][aria-selected="true"]')
?.textContent?.trim()
).toBe('JetBrains Mono')
})
it('shows the full list on focus when the committed font has multiple matching suggestions', async () => {
function Harness(): ReactNode {
const [value, setValue] = useState('Cascadia Mono')
@@ -18,6 +18,7 @@ import {
isSettingsFormOptionQueryTooLarge
} from './settings-form-option-filter'
import { cn } from '@/lib/utils'
import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event'
import { translate } from '@/i18n/i18n'
type SettingsSwitchProps = {
@@ -593,6 +594,9 @@ export function NumberField({
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (isImeCompositionKeyDown(e)) {
return
}
if (e.key === 'Enter') {
commit()
}
@@ -746,6 +750,9 @@ export function FontAutocomplete({
setOpen(true)
}}
onKeyDown={(e) => {
if (isImeCompositionKeyDown(e)) {
return
}
if (e.key === 'Escape') {
if (open) {
e.preventDefault()
@@ -6,6 +6,7 @@ import { Button } from '../ui/button'
import { Input } from '../ui/input'
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
import { translate } from '@/i18n/i18n'
import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event'
export type AgentSessionSourceHomeControl = {
runtimeLabel: string
@@ -136,6 +137,9 @@ export function AgentSessionSourceHomeInput({
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (isImeCompositionKeyDown(e)) {
return
}
if (e.key === 'Enter') {
commit()
e.currentTarget.blur()
@@ -12,6 +12,7 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useAppStore } from '@/store'
import { translate } from '@/i18n/i18n'
import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event'
import type { ExecutionHostId } from '../../../../shared/execution-host'
import { applyHostRename, getHostDisplayLabelOverride } from './host-rename-remove'
@@ -78,6 +79,9 @@ export function HostRenameDialog({
placeholder={derivedLabel}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (isImeCompositionKeyDown(e)) {
return
}
if (e.key === 'Enter') {
e.preventDefault()
submit()
@@ -162,4 +162,73 @@ describe('RemoteFileBrowser paste-sized input', () => {
root.unmount()
})
})
it('withholds marked Escape while preserving ordinary Escape', async () => {
const onCancel = vi.fn()
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
await act(async () => {
root.render(<RemoteFileBrowser targetId="target-1" onSelect={vi.fn()} onCancel={onCancel} />)
await flushPromises()
})
const input = container.querySelector('input')
if (!input) {
throw new Error('Remote file browser input was not rendered')
}
browseDir.mockClear()
await act(async () => {
input.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Escape',
code: 'Escape',
keyCode: 27,
isComposing: true,
bubbles: true
})
)
await flushPromises()
})
expect(onCancel).not.toHaveBeenCalled()
await act(async () => {
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
})
expect(onCancel).toHaveBeenCalledTimes(1)
await act(async () => {
root.unmount()
})
})
it('withholds marked Enter while preserving ordinary Enter navigation', async () => {
const { input, root } = await renderRemoteFileBrowser()
await changeInput(input, 'src')
browseDir.mockClear()
await act(async () => {
input.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Enter',
code: 'Enter',
keyCode: 13,
isComposing: true,
bubbles: true
})
)
await flushPromises()
})
expect(browseDir).not.toHaveBeenCalled()
await act(async () => {
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
await flushPromises()
})
expect(browseDir).toHaveBeenCalledTimes(1)
await act(async () => {
root.unmount()
})
})
})
@@ -3,6 +3,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ChevronRight, Folder, ArrowUp, LoaderCircle, Home, Search } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event'
import { getFileTypeIcon } from '@/lib/file-type-icons'
import {
decideEnterAction,
@@ -478,6 +479,9 @@ export function RemoteFileBrowser({
const handleFilterKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (isImeCompositionKeyDown(e)) {
return
}
if (e.key === 'Enter') {
if (preview) {
// Path mode Enter.
@@ -18,6 +18,7 @@ import {
} from './worktree-meta-updates'
import { useWorktreeIssueLink } from './use-worktree-issue-link'
import { getScreenSubmitShortcutLabel, isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut'
import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event'
import { ExternalLink, LoaderCircle } from 'lucide-react'
import { useMountedRef } from '@/hooks/useMountedRef'
import { translate } from '@/i18n/i18n'
@@ -158,6 +159,9 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() {
const handleCommentKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (isImeCompositionKeyDown(e)) {
return
}
const isPlainEnter = e.key === 'Enter' && !e.shiftKey && !e.altKey && !e.metaKey && !e.ctrlKey
if (isPlainEnter || isScreenSubmitShortcut(e)) {
e.preventDefault()
@@ -170,6 +174,9 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() {
const handleIssueKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (isImeCompositionKeyDown(e)) {
return
}
if (e.key === 'Enter') {
e.preventDefault()
handleSave()
@@ -3,9 +3,8 @@ import { join, sep } from 'node:path'
import { describe, expect, it } from 'vitest'
// The marker sweep that found the dropped-`isComposing` adapters is blind to this defect: the
// markers are present and the guard is called, just one branch too late. An Escape that dismisses
// an IME candidate window is marked, so a handler that runs its Escape branch first discards the
// draft, resets the search, or cancels recording on a keystroke the user aimed at the IME.
// markers are present and the guard is called, just one branch too late. A marked Enter or Escape
// can commit a candidate, so application dispatch must wait until composition finishes.
//
// Ordering is a property of the source, not of any one component's behaviour, so it is pinned
// here once rather than re-tested in every composer that happens to handle Escape.
@@ -15,22 +14,49 @@ import { describe, expect, it } from 'vitest'
const ROOTS = ['src/renderer/src', 'src/main', 'src/shared']
const GUARD = 'isImeCompositionKeyDown'
const ESCAPE_COMPARISON = /[!=]==\s*['"]Escape['"]/
// Ordering alone cannot see a second, wholly unguarded Escape handler added below a guarded one,
const COMPOSITION_DISPATCH_COMPARISON =
/[!=]==\s*['"](?:Enter|Escape)['"]|\[[^\]]*['"](?:Enter|Escape)['"][^\]]*\]\.includes\([^)]*\.key\)/
// Ordering alone cannot see a second, wholly unguarded handler added below a guarded one,
// so files this work reviewed are additionally checked for absence — a guard in one handler no
// longer vouches for the rest of the file.
const MUST_GUARD_EVERY_ESCAPE_HANDLER = new Set([
const MUST_GUARD_EVERY_DISPATCH_HANDLER = new Set([
'src/renderer/src/components/LinearIssueTextEditor.tsx',
'src/renderer/src/components/LinearIssueWorkspace.tsx',
'src/renderer/src/components/LinearItemDrawer.tsx',
'src/renderer/src/components/TerminalSearch.tsx',
'src/renderer/src/components/NewWorkspaceComposerModal.tsx',
'src/renderer/src/components/browser-pane/BrowserAddressBar.tsx',
'src/renderer/src/components/browser-pane/BrowserFind.tsx',
'src/renderer/src/components/browser-pane/BrowserPane.tsx',
'src/renderer/src/components/diff-comments/DiffCommentPopover.tsx',
'src/renderer/src/components/editor/EditorPanelHeaderPath.tsx',
'src/renderer/src/components/editor/MarkdownPreview.tsx',
'src/renderer/src/components/editor/PdfFind.tsx',
'src/renderer/src/components/editor/RichMarkdownLinkBubble.tsx',
'src/renderer/src/components/editor/RichMarkdownSearchBar.tsx',
'src/renderer/src/components/editor/UntitledFileRenameDialog.tsx',
'src/renderer/src/components/editor/useRichMarkdownSearch.ts',
'src/renderer/src/components/github/GitHubMarkdownComposer.tsx',
'src/renderer/src/components/github-project/ProjectPicker.tsx',
'src/renderer/src/components/github-project/ProjectCell.tsx',
'src/renderer/src/components/mobile/use-mobile-page-escape.ts'
'src/renderer/src/components/github-project/slug-dialog/SlugDialogBody.tsx',
'src/renderer/src/components/mobile/use-mobile-page-escape.ts',
'src/renderer/src/components/native-chat/NativeChatQuestionCard.tsx',
'src/renderer/src/components/network/CustomAddressDialog.tsx',
'src/renderer/src/components/new-workspace/ProjectCombobox.tsx',
'src/renderer/src/components/right-sidebar/ChecksPanel.tsx',
'src/renderer/src/components/right-sidebar/FileExplorerRow.tsx',
'src/renderer/src/components/right-sidebar/source-control-header-toolbar.tsx',
'src/renderer/src/components/right-sidebar/useFileSearchPanel.ts',
'src/renderer/src/components/settings/AgentsPane.tsx',
'src/renderer/src/components/settings/SettingsFormControls.tsx',
'src/renderer/src/components/settings/codex-session-source-home-control.tsx',
'src/renderer/src/components/sidebar/HostRenameDialog.tsx',
'src/renderer/src/components/sidebar/RemoteFileBrowser.tsx',
'src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx'
])
const MUST_SCAN = [
...MUST_GUARD_EVERY_ESCAPE_HANDLER,
...MUST_GUARD_EVERY_DISPATCH_HANDLER,
'src/renderer/src/components/native-chat/use-native-chat-composer-keydown.ts',
'src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx',
'src/renderer/src/components/settings/ShortcutRecorderButton.tsx',
@@ -87,7 +113,8 @@ function bodyBrace(source: string, from: number): number {
// Returns every function body in `source`, including inline JSX keydown handlers.
function functionBodies(source: string): string[] {
const bodies: string[] = []
const starts = /(?:on|handle)KeyDown(?:Capture)?\s*[:=]|(?:function|const)\s+\w+\s*[=(]/g
const starts =
/(?:on|handle)KeyDown(?:Capture)?\s*[:=]|(?:function|const)\s+\w+\s*[=(]|addEventListener\(\s*['"]keydown['"]\s*,\s*(?:async\s*)?(?:\([^)]*\)|\w+)\s*=>/g
for (let match = starts.exec(source); match !== null; match = starts.exec(source)) {
const open = bodyBrace(source, match.index + match[0].length)
if (open === -1) {
@@ -109,33 +136,58 @@ function functionBodies(source: string): string[] {
return bodies
}
describe('composition guards sit above the Escape branch', () => {
function bodyDispatchesBeforeGuard(body: string, requiresGuard: boolean): boolean {
const guardAt = body.indexOf(GUARD)
const dispatchAt = body.search(
requiresGuard ? COMPOSITION_DISPATCH_COMPARISON : ESCAPE_COMPARISON
)
return (
dispatchAt !== -1 &&
((requiresGuard && guardAt === -1) || (guardAt !== -1 && dispatchAt < guardAt))
)
}
describe('composition guards sit above Enter and Escape dispatch', () => {
const offenders = new Set<string>()
for (const root of ROOTS) {
for (const file of sourceFiles(root)) {
const source = readFileSync(file, 'utf8')
if (!source.includes(GUARD) || !ESCAPE_COMPARISON.test(source)) {
if (!source.includes(GUARD) || !COMPOSITION_DISPATCH_COMPARISON.test(source)) {
continue
}
for (const body of functionBodies(source)) {
const guardAt = body.indexOf(GUARD)
const escapeAt = body.search(ESCAPE_COMPARISON)
const relativeFile = file.split(sep).join('/')
const requiresGuard = MUST_GUARD_EVERY_ESCAPE_HANDLER.has(relativeFile)
if (
escapeAt !== -1 &&
((requiresGuard && guardAt === -1) || (guardAt !== -1 && escapeAt < guardAt))
) {
const requiresGuard = MUST_GUARD_EVERY_DISPATCH_HANDLER.has(relativeFile)
if (bodyDispatchesBeforeGuard(body, requiresGuard)) {
offenders.add(file)
}
}
}
}
it('finds no keydown handler that dispatches Escape before consulting the guard', () => {
it('finds no reviewed keydown handler that dispatches before consulting the guard', () => {
expect([...offenders]).toEqual([])
})
it('catches a later unguarded handler and anonymous listener in a guarded file', () => {
const source = `
const handleKeyDown = (event) => {
if (isImeCompositionKeyDown(event)) return
if (event.key === 'Escape') close()
}
const handleOtherKeyDown = (event) => {
if (event.key === 'Enter') submit()
}
window.addEventListener('keydown', (event) => {
if (['Escape'].includes(event.key)) close()
})
`
expect(
functionBodies(source).filter((body) => bodyDispatchesBeforeGuard(body, true))
).toHaveLength(2)
})
// A scanner that parses nothing reports zero offenders forever, so the coverage it claims is
// asserted by name. MUST_SCAN holds the handler shapes the brace matcher has actually got wrong:
// a hook whose destructured parameter list opens before its body, an inline JSX arrow, and a