Port from cloudflare/cloudflare-os#291: IME-aware Enter guards across every desktop text field

Widens the salvaged Kanban fix (PR #94611 by @huklaa) to the whole bug
class, following cloudflare-os PR #291 which centralized one
isImeComposing predicate and swept every Enter-submit site.

- New shared helper src/lib/ime.ts (isImeComposing / isSubmitEnter):
  handles nativeEvent.isComposing (React), event.isComposing (DOM), and
  the legacy Chromium/Safari keyCode 229 commit-Enter that arrives after
  compositionend.
- Sweeps 21 previously unguarded Enter-submit sites: dialogs (project,
  worktree, profile-remote-override, pet rename), settings fields
  (credential keys, model API key, quick-entry shortcut, attachment
  size, combobox), quick entry, pet overlay composer, pet generate +
  hatch, review ship bar, file rename, preview browser address bar,
  model catalog menu, MCP setup + approval strips, Kanban drawer.
- Upgrades two partial guards (onboarding, session-actions-menu) that
  checked isComposing but missed keyCode 229.
- find-in-page: Enter step no longer fires mid-composition (typed CJK
  search queries jumped the viewport on every conversion commit).

Validation: tsc clean, eslint clean, 135 tests green across ime/find-bar/
composer/kanban suites; sabotage run (guard removed) fails the new test.
This commit is contained in:
Teknium
2026-08-25 21:51:40 -07:00
parent 19729c6ea7
commit 5cbd994575
26 changed files with 149 additions and 25 deletions
@@ -21,6 +21,7 @@ import { CopyButton } from '@/components/ui/copy-button'
import { Input } from '@/components/ui/input'
import { PaneStripGlyph } from '@/components/ui/pane-tab'
import { useI18n } from '@/i18n'
import { isSubmitEnter } from '@/lib/ime'
import { ANNOTATE_BLUE } from '@/lib/preview-annotate'
import { cn } from '@/lib/utils'
@@ -191,7 +192,7 @@ export function PreviewBrowserBar({
event.currentTarget.select()
}}
onKeyDown={event => {
if (event.key === 'Enter') {
if (isSubmitEnter(event)) {
commit(event.currentTarget.value)
event.currentTarget.blur()
}
@@ -13,6 +13,7 @@ import {
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { useI18n } from '@/i18n'
import { isSubmitEnter } from '@/lib/ime'
import { notify, notifyError } from '@/store/notifications'
import {
$remoteOverrideDialogProfile,
@@ -242,7 +243,7 @@ export function ProfileRemoteOverrideDialog({ profileNames }: { profileNames: st
<Input
autoCorrect="off"
onChange={event => setUrl(event.target.value)}
onKeyDown={event => event.key === 'Enter' && submit()}
onKeyDown={event => isSubmitEnter(event) && submit()}
placeholder={p.urlPlaceholder}
ref={urlRef}
spellCheck={false}
@@ -257,7 +258,7 @@ export function ProfileRemoteOverrideDialog({ profileNames }: { profileNames: st
<Input
autoComplete="off"
onChange={event => setToken(event.target.value)}
onKeyDown={event => event.key === 'Enter' && submit()}
onKeyDown={event => isSubmitEnter(event) && submit()}
placeholder={p.tokenPlaceholder}
type="password"
value={token}
@@ -17,6 +17,7 @@ import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { isSubmitEnter } from '@/lib/ime'
import { type ProjectIdeaTemplate, randomIdeaTemplates } from '@/lib/project-idea-templates'
import { cn } from '@/lib/utils'
import { notifyError } from '@/store/notifications'
@@ -191,7 +192,7 @@ export function ProjectDialog() {
disabled={submitting}
onChange={event => setName(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter') {
if (isSubmitEnter(event)) {
event.preventDefault()
void submit()
} else if (event.key === 'Escape') {
@@ -16,6 +16,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
import { SanitizedInput } from '@/components/ui/sanitized-input'
import type { HermesGitBranch } from '@/global'
import { useI18n } from '@/i18n'
import { isSubmitEnter } from '@/lib/ime'
import { gitRef } from '@/lib/sanitize'
import { notifyError } from '@/store/notifications'
import {
@@ -310,7 +311,7 @@ export function WorktreeDialog() {
autoFocus
disabled={pending}
onKeyDown={event => {
if (event.key === 'Enter') {
if (isSubmitEnter(event)) {
event.preventDefault()
void submit()
} else if (event.key === 'Escape') {
@@ -27,6 +27,7 @@ import { Input } from '@/components/ui/input'
import { renameSession } from '@/hermes'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { isSubmitEnter } from '@/lib/ime'
import { PROFILE_SWATCHES } from '@/lib/profile-color'
import { exportSession } from '@/lib/session-export'
import { activeGateway } from '@/store/gateway'
@@ -695,7 +696,7 @@ function RenameSessionDialog({ open, onOpenChange, sessionId, currentTitle, prof
disabled={submitting}
onChange={event => setValue(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter' && !event.nativeEvent.isComposing) {
if (isSubmitEnter(event)) {
event.preventDefault()
void submit()
} else if (event.key === 'Escape') {
@@ -9,6 +9,7 @@ import { Input } from '@/components/ui/input'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Loader2, PawPrint, RefreshCw } from '@/lib/icons'
import { isSubmitEnter } from '@/lib/ime'
import { type PetInfo } from '@/store/pet'
import { frameCountForRow } from '../lib/frame-count'
@@ -124,7 +125,7 @@ export function HatchPreview({ pet, adopting, error, onAdopt, onDiscard }: Hatch
className="w-full"
onChange={event => setName(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter') {
if (isSubmitEnter(event)) {
event.preventDefault()
onAdopt(name)
}
@@ -12,6 +12,7 @@ import { Input } from '@/components/ui/input'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Egg, ImageIcon } from '@/lib/icons'
import { isSubmitEnter } from '@/lib/ime'
import { cn } from '@/lib/utils'
import {
$petGenAvailable,
@@ -223,7 +224,7 @@ export function PetGenerateContent() {
className="pr-9"
onChange={event => $petGenInput.set(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter') {
if (isSubmitEnter(event)) {
event.preventDefault()
generate()
}
@@ -6,6 +6,7 @@ import { PetBubble } from '@/components/pet/pet-bubble'
import { PetSprite } from '@/components/pet/pet-sprite'
import { type PetZoomAnchor, usePetZoomGesture } from '@/components/pet/use-pet-zoom-gesture'
import { Mail } from '@/lib/icons'
import { isSubmitEnter } from '@/lib/ime'
import { $petActivity, $petInfo, setPetInfo } from '@/store/pet'
import { overlayWindowSize } from '@/store/pet-overlay'
import { setAwaitingResponse, setBusy } from '@/store/session'
@@ -390,7 +391,7 @@ export function PetOverlayApp() {
<input
onChange={e => setDraft(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && !e.shiftKey) {
if (isSubmitEnter(e) && !e.shiftKey) {
e.preventDefault()
send()
} else if (e.key === 'Escape') {
@@ -1,5 +1,6 @@
import { useEffect, useReducer, useRef } from 'react'
import { isSubmitEnter } from '@/lib/ime'
import {
initialQuickComposerState,
QUICK_TARGET_CURRENT,
@@ -123,7 +124,7 @@ export function QuickEntryApp() {
}}
onChange={event => dispatch({ draft: event.target.value, type: 'edit' })}
onKeyDown={event => {
if (event.key === 'Enter' && !event.shiftKey) {
if (isSubmitEnter(event) && !event.shiftKey) {
event.preventDefault()
dispatch({ type: 'submit' })
} else if (event.key === 'Escape') {
@@ -11,6 +11,7 @@ import {
} from '@/components/ui/context-menu'
import { translateNow, useI18n } from '@/i18n'
import { isDesktopFsRemoteMode } from '@/lib/desktop-fs'
import { isSubmitEnter } from '@/lib/ime'
import { IS_MAC } from '@/lib/keybinds/combo'
import { cn } from '@/lib/utils'
import {
@@ -197,7 +198,7 @@ export function InlineRenameInput({ className, name, path }: InlineRenameInputPr
onKeyDown={event => {
event.stopPropagation()
if (event.key === 'Enter') {
if (isSubmitEnter(event)) {
event.preventDefault()
void finish(true)
} else if (event.key === 'Escape') {
@@ -9,6 +9,7 @@ import { SplitButton } from '@/components/ui/split-button'
import { Textarea } from '@/components/ui/textarea'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { isSubmitEnter } from '@/lib/ime'
import { formatCombo } from '@/lib/keybinds/combo'
import { notifyError } from '@/store/notifications'
import {
@@ -85,7 +86,7 @@ export function ReviewShipBar() {
disabled={generating}
onChange={event => setMessage(event.target.value)}
onKeyDown={event => {
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
if ((event.metaKey || event.ctrlKey) && isSubmitEnter(event)) {
event.preventDefault()
runCommit(commitDefault)
}
@@ -5,6 +5,7 @@ import { Command, CommandGroup, CommandItem, CommandList } from '@/components/ui
import { Input } from '@/components/ui/input'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
import { useI18n } from '@/i18n'
import { isSubmitEnter } from '@/lib/ime'
import { cn } from '@/lib/utils'
/**
@@ -59,7 +60,7 @@ export function ComboboxInput({
}}
onFocus={() => setOpen(true)}
onKeyDown={e => {
if (e.key === 'Escape' || e.key === 'Enter' || e.key === 'Tab') {
if (e.key === 'Escape' || e.key === 'Tab' || isSubmitEnter(e)) {
setOpen(false)
}
}}
@@ -9,6 +9,7 @@ import { Input } from '@/components/ui/input'
import { getElevenLabsVoices, getHermesConfigSchema, saveHermesConfig } from '@/hermes'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { isSubmitEnter } from '@/lib/ime'
import { confirm } from '@/store/confirm'
import {
$dataUrlReadMaxMb,
@@ -511,7 +512,7 @@ function AttachmentSizeSetting() {
onBlur={commit}
onChange={event => setDraft(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter') {
if (isSubmitEnter(event)) {
event.currentTarget.blur()
}
}}
@@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { translateNow, useI18n } from '@/i18n'
import { ChevronDown, ExternalLink, Loader2, Save, Trash2 } from '@/lib/icons'
import { isSubmitEnter } from '@/lib/ime'
import { cn } from '@/lib/utils'
import type { EnvVarInfo } from '@/types/hermes'
@@ -71,7 +72,7 @@ export function KeyField({
const update = (e: ChangeEvent<HTMLInputElement>) => setEdits(c => ({ ...c, [varKey]: e.target.value }))
const keydown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && dirty) {
if (isSubmitEnter(e) && dirty) {
void onSave(varKey)
} else if (e.key === 'Escape' && editing) {
e.preventDefault()
@@ -28,6 +28,7 @@ import type {
import { useI18n } from '@/i18n'
import { isCodeSkewRestartRequired } from '@/lib/code-skew-error'
import { AlertTriangle, Cpu, Loader2 } from '@/lib/icons'
import { isSubmitEnter } from '@/lib/ime'
import { cn } from '@/lib/utils'
import { setMainModelAssignment } from '@/store/cron-model-impact'
import { notifyError, readableError } from '@/store/notifications'
@@ -869,7 +870,7 @@ export function ModelSettings({ onMainModelChanged, scopeProfile }: ModelSetting
className={cn('min-w-60 flex-1', CONTROL_TEXT)}
onChange={event => setApiKeyDraft(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter') {
if (isSubmitEnter(event)) {
void activateApiKeyProvider()
}
}}
@@ -13,6 +13,7 @@ import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Download, Loader2, PawPrint, Pencil, Trash2 } from '@/lib/icons'
import { isSubmitEnter } from '@/lib/ime'
import { selectableCardClass } from '@/lib/selectable-card'
import { cn } from '@/lib/utils'
import { $petInfo, $petRoam, setPetRoam } from '@/store/pet'
@@ -351,7 +352,7 @@ export function PetSettings() {
autoFocus
onChange={event => setRenameValue(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter') {
if (isSubmitEnter(event)) {
event.preventDefault()
saveRename()
}
@@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'
import { Input } from '@/components/ui/input'
import { useI18n } from '@/i18n'
import { isSubmitEnter } from '@/lib/ime'
import {
$quickEntry,
canUseQuickEntry,
@@ -73,7 +74,7 @@ export function QuickEntrySettings() {
onBlur={commit}
onChange={event => setDraft(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter') {
if (isSubmitEnter(event)) {
event.preventDefault()
commit()
}
@@ -23,6 +23,7 @@ import { Skeleton } from '@/components/ui/skeleton'
import type { HermesGateway } from '@/hermes'
import { getLocalModelsStatus } from '@/hermes'
import { useI18n } from '@/i18n'
import { isSubmitEnter } from '@/lib/ime'
import { catalogProviderMatches, modelOptionsQueryKey, requestModelOptions } from '@/lib/model-options'
import { displayModelName, modelDisplayParts } from '@/lib/model-status-label'
import { reasoningEffortLabel } from '@/lib/reasoning-effort'
@@ -414,7 +415,7 @@ export function ModelCatalogMenu({
event.preventDefault()
event.stopPropagation()
stepKb(event.key === 'ArrowDown' ? 1 : -1)
} else if (event.key === 'Enter') {
} else if (isSubmitEnter(event)) {
event.preventDefault()
event.stopPropagation()
commitKbRow()
@@ -21,6 +21,7 @@ import {
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Loader2 } from '@/lib/icons'
import { isSubmitEnter } from '@/lib/ime'
import { completeMcpDesktopOAuth, McpOAuthCancelled } from '@/lib/mcp-dashboard-oauth'
import { directoryEntry } from '@/lib/mcp-directory'
import { prettyName } from '@/lib/text'
@@ -419,7 +420,7 @@ function McpSetupPending({ args }: ToolCallMessagePartProps) {
return
}
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
if (isSubmitEnter(event) && (event.metaKey || event.ctrlKey)) {
if (!working) {
event.preventDefault()
void approve()
@@ -17,6 +17,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { AlertCircle, ChevronDown } from '@/lib/icons'
import { isSubmitEnter } from '@/lib/ime'
import { cn } from '@/lib/utils'
import { $gateway } from '@/store/gateway'
import { notifyError } from '@/store/notifications'
@@ -180,7 +181,7 @@ const ApprovalBar: FC<{ request: ApprovalRequest; surface: 'floating' | 'inline'
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
if (isSubmitEnter(event) && (event.metaKey || event.ctrlKey)) {
event.preventDefault()
void respond('once')
} else if (event.key === 'Escape') {
@@ -183,6 +183,12 @@ describe('findBarKeyAction', () => {
expect(findBarKeyAction({ key: 'Enter' })).toBeNull()
})
it('ignores Enter while an IME composition is active', () => {
expect(findBarKeyAction({ isComposing: true, key: 'Enter' }, { inInput: true })).toBeNull()
expect(findBarKeyAction({ key: 'Enter', keyCode: 229 }, { inInput: true })).toBeNull()
expect(findBarKeyAction({ key: 'Enter', nativeEvent: { isComposing: true } }, { inInput: true })).toBeNull()
})
it('ignores a bare g so typing never triggers a step', () => {
expect(findBarKeyAction({ key: 'g' })).toBeNull()
expect(findBarKeyAction({ key: 'g' }, { inInput: true })).toBeNull()
@@ -10,6 +10,7 @@ import { Progress } from '@/components/ui/progress'
import { getGlobalModelOptions } from '@/hermes'
import { useI18n } from '@/i18n'
import { Check, ChevronDown, ChevronLeft, KeyRound, Loader2 } from '@/lib/icons'
import { isSubmitEnter } from '@/lib/ime'
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
import { cn } from '@/lib/utils'
import { $desktopBoot, type DesktopBootState } from '@/store/boot'
@@ -813,7 +814,7 @@ export function ApiKeyForm({
autoFocus
className="font-mono"
onChange={e => setValue(e.target.value)}
onKeyDown={e => e.key === 'Enter' && !e.nativeEvent.isComposing && void submit()}
onKeyDown={e => isSubmitEnter(e) && void submit()}
placeholder={
currentRedacted ??
(alreadySet ? t.onboarding.replaceCurrent : option.placeholder || t.onboarding.pasteApiKey)
@@ -826,7 +827,7 @@ export function ApiKeyForm({
autoComplete="off"
className="font-mono"
onChange={e => setLocalKey(e.target.value)}
onKeyDown={e => e.key === 'Enter' && !e.nativeEvent.isComposing && void submit()}
onKeyDown={e => isSubmitEnter(e) && void submit()}
placeholder={t.onboarding.localApiKeyPlaceholder}
type="password"
value={localKey}
+10 -1
View File
@@ -1,3 +1,5 @@
import { isImeComposing } from './ime'
// Pure logic for the find-in-page bar (⌘F). Kept out of the component so the
// match-counter projection and the in-bar key routing can be unit-tested
// without jsdom, a BrowserWindow, or the preload bridge.
@@ -45,6 +47,10 @@ export interface FindBarKeyEvent {
metaKey?: boolean
ctrlKey?: boolean
altKey?: boolean
/** IME composition state — bare Enter steps must not fire mid-composition. */
isComposing?: boolean
keyCode?: number
nativeEvent?: { isComposing?: boolean; keyCode?: number }
}
/**
@@ -79,7 +85,10 @@ export function findBarKeyAction(event: FindBarKeyEvent, options: { inInput?: bo
return event.shiftKey ? 'previous' : 'next'
}
if (event.key === 'Enter' && !mod && options.inInput) {
// A bare Enter inside the input can also be an IME composition commit
// (CJK input); stepping matches on that keystroke jumps the viewport while
// the user is still composing their query.
if (event.key === 'Enter' && !mod && options.inInput && !isImeComposing(event)) {
return event.shiftKey ? 'previous' : 'next'
}
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import { isImeComposing, isSubmitEnter } from './ime'
describe('isImeComposing', () => {
it('detects composition via nativeEvent.isComposing (React events)', () => {
expect(isImeComposing({ key: 'Enter', nativeEvent: { isComposing: true } })).toBe(true)
})
it('detects composition via isComposing on the event itself (DOM events)', () => {
expect(isImeComposing({ isComposing: true, key: 'Enter' })).toBe(true)
})
it('detects the legacy 229 keyCode on nativeEvent', () => {
expect(isImeComposing({ key: 'Enter', nativeEvent: { keyCode: 229 } })).toBe(true)
})
it('detects the legacy 229 keyCode on a DOM event', () => {
expect(isImeComposing({ key: 'Enter', keyCode: 229 })).toBe(true)
})
it('passes ordinary events', () => {
expect(isImeComposing({ key: 'Enter', nativeEvent: { isComposing: false, keyCode: 13 } })).toBe(false)
expect(isImeComposing({ key: 'a', keyCode: 65 })).toBe(false)
})
})
describe('isSubmitEnter', () => {
it('accepts a plain Enter', () => {
expect(isSubmitEnter({ key: 'Enter', nativeEvent: {} })).toBe(true)
})
it('rejects Enter during composition', () => {
expect(isSubmitEnter({ key: 'Enter', nativeEvent: { isComposing: true } })).toBe(false)
})
it('rejects the post-compositionend commit Enter still carrying 229', () => {
expect(isSubmitEnter({ key: 'Enter', nativeEvent: { isComposing: false, keyCode: 229 } })).toBe(false)
})
it('rejects non-Enter keys', () => {
expect(isSubmitEnter({ key: 'Escape', nativeEvent: {} })).toBe(false)
})
})
+43
View File
@@ -0,0 +1,43 @@
/**
* IME-aware Enter handling, shared by every text field whose bare Enter
* performs an action (submit, rename, commit, adopt, …).
*
* CJK/IME users press Enter to *commit a composition* — the candidate text
* they are still assembling — and that keystroke must never double as the
* field's submit shortcut. Browsers signal an in-flight composition two ways:
*
* - `isComposing` on the (native) keyboard event — the standard signal.
* - legacy `keyCode === 229` (VK_PROCESSKEY) — Chromium and Safari still
* stamp it on keydowns at composition boundaries, including the commit
* Enter that can arrive *after* `compositionend` with `isComposing`
* already false.
*
* One predicate owns that policy so call sites can't drift apart
* (the main chat composer keeps its own richer stale-flag handling in
* `app/chat/composer/index.tsx`; everything simpler belongs here).
*
* Accepts both React synthetic events (composition state lives on
* `nativeEvent`) and plain DOM `KeyboardEvent`s (state lives on the event
* itself), so window-level listeners can share the policy too.
*/
export interface ImeAwareKeyEvent {
key: string
isComposing?: boolean
keyCode?: number
nativeEvent?: {
isComposing?: boolean
keyCode?: number
}
}
/** Whether this keyboard event belongs to an active IME composition. */
export function isImeComposing(event: ImeAwareKeyEvent): boolean {
const native = event.nativeEvent ?? event
return Boolean(native.isComposing || event.isComposing) || native.keyCode === 229 || event.keyCode === 229
}
/** Enter pressed as a real submit — not an IME composition commit. */
export function isSubmitEnter(event: ImeAwareKeyEvent): boolean {
return event.key === 'Enter' && !isImeComposing(event)
}
+2 -1
View File
@@ -45,6 +45,7 @@ import {
taskKey,
uploadAttachment
} from './api'
import { shouldSubmitOnEnter } from './ime-enter'
import { ModelOverrideField, overridePatch } from './model-override'
import {
type Diagnostic,
@@ -322,7 +323,7 @@ function CommentComposer({
className={cn('field-sizing-content max-h-40 min-h-0 resize-none', running ? 'pr-[3.5rem]' : 'pr-[5rem]')}
onChange={event => setBody(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter' && !event.shiftKey) {
if (shouldSubmitOnEnter(event) && !event.shiftKey) {
event.preventDefault()
submit()
}