diff --git a/apps/desktop/src/app/chat/right-rail/preview-browser-bar.tsx b/apps/desktop/src/app/chat/right-rail/preview-browser-bar.tsx
index 662ca8464d..2ecf13f7e1 100644
--- a/apps/desktop/src/app/chat/right-rail/preview-browser-bar.tsx
+++ b/apps/desktop/src/app/chat/right-rail/preview-browser-bar.tsx
@@ -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()
}
diff --git a/apps/desktop/src/app/chat/sidebar/profile-remote-override-dialog.tsx b/apps/desktop/src/app/chat/sidebar/profile-remote-override-dialog.tsx
index cfd9bd00df..c294b89da5 100644
--- a/apps/desktop/src/app/chat/sidebar/profile-remote-override-dialog.tsx
+++ b/apps/desktop/src/app/chat/sidebar/profile-remote-override-dialog.tsx
@@ -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
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
setToken(event.target.value)}
- onKeyDown={event => event.key === 'Enter' && submit()}
+ onKeyDown={event => isSubmitEnter(event) && submit()}
placeholder={p.tokenPlaceholder}
type="password"
value={token}
diff --git a/apps/desktop/src/app/chat/sidebar/project-dialog.tsx b/apps/desktop/src/app/chat/sidebar/project-dialog.tsx
index fff39d52c8..b29b6bc8ed 100644
--- a/apps/desktop/src/app/chat/sidebar/project-dialog.tsx
+++ b/apps/desktop/src/app/chat/sidebar/project-dialog.tsx
@@ -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') {
diff --git a/apps/desktop/src/app/chat/sidebar/projects/worktree-dialog.tsx b/apps/desktop/src/app/chat/sidebar/projects/worktree-dialog.tsx
index 7f808f6390..22ef952ab4 100644
--- a/apps/desktop/src/app/chat/sidebar/projects/worktree-dialog.tsx
+++ b/apps/desktop/src/app/chat/sidebar/projects/worktree-dialog.tsx
@@ -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') {
diff --git a/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx b/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx
index e8bcfc67c3..4be69f8c15 100644
--- a/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx
+++ b/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx
@@ -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') {
diff --git a/apps/desktop/src/app/pet-generate/components/hatch-preview.tsx b/apps/desktop/src/app/pet-generate/components/hatch-preview.tsx
index 46ef28343f..a3ca8abac0 100644
--- a/apps/desktop/src/app/pet-generate/components/hatch-preview.tsx
+++ b/apps/desktop/src/app/pet-generate/components/hatch-preview.tsx
@@ -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)
}
diff --git a/apps/desktop/src/app/pet-generate/pet-generate-content.tsx b/apps/desktop/src/app/pet-generate/pet-generate-content.tsx
index aead2dd147..c1452e8f18 100644
--- a/apps/desktop/src/app/pet-generate/pet-generate-content.tsx
+++ b/apps/desktop/src/app/pet-generate/pet-generate-content.tsx
@@ -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()
}
diff --git a/apps/desktop/src/app/pet-overlay/pet-overlay-app.tsx b/apps/desktop/src/app/pet-overlay/pet-overlay-app.tsx
index 928294c482..a8637d1814 100644
--- a/apps/desktop/src/app/pet-overlay/pet-overlay-app.tsx
+++ b/apps/desktop/src/app/pet-overlay/pet-overlay-app.tsx
@@ -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() {
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') {
diff --git a/apps/desktop/src/app/quick-entry/quick-entry-app.tsx b/apps/desktop/src/app/quick-entry/quick-entry-app.tsx
index 3f5d54835d..62442840c8 100644
--- a/apps/desktop/src/app/quick-entry/quick-entry-app.tsx
+++ b/apps/desktop/src/app/quick-entry/quick-entry-app.tsx
@@ -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') {
diff --git a/apps/desktop/src/app/right-sidebar/file-actions.tsx b/apps/desktop/src/app/right-sidebar/file-actions.tsx
index e5e9443f32..9a0853634d 100644
--- a/apps/desktop/src/app/right-sidebar/file-actions.tsx
+++ b/apps/desktop/src/app/right-sidebar/file-actions.tsx
@@ -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') {
diff --git a/apps/desktop/src/app/right-sidebar/review/ship-bar.tsx b/apps/desktop/src/app/right-sidebar/review/ship-bar.tsx
index 3d0e03325f..22fb94d9bb 100644
--- a/apps/desktop/src/app/right-sidebar/review/ship-bar.tsx
+++ b/apps/desktop/src/app/right-sidebar/review/ship-bar.tsx
@@ -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)
}
diff --git a/apps/desktop/src/app/settings/combobox-input.tsx b/apps/desktop/src/app/settings/combobox-input.tsx
index 0f4dbd9964..984766764b 100644
--- a/apps/desktop/src/app/settings/combobox-input.tsx
+++ b/apps/desktop/src/app/settings/combobox-input.tsx
@@ -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)
}
}}
diff --git a/apps/desktop/src/app/settings/config-settings.tsx b/apps/desktop/src/app/settings/config-settings.tsx
index 73cb0d205b..ae095564b2 100644
--- a/apps/desktop/src/app/settings/config-settings.tsx
+++ b/apps/desktop/src/app/settings/config-settings.tsx
@@ -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()
}
}}
diff --git a/apps/desktop/src/app/settings/credential-key-ui.tsx b/apps/desktop/src/app/settings/credential-key-ui.tsx
index e963c3c0f8..6b6d57650a 100644
--- a/apps/desktop/src/app/settings/credential-key-ui.tsx
+++ b/apps/desktop/src/app/settings/credential-key-ui.tsx
@@ -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) => setEdits(c => ({ ...c, [varKey]: e.target.value }))
const keydown = (e: KeyboardEvent) => {
- if (e.key === 'Enter' && dirty) {
+ if (isSubmitEnter(e) && dirty) {
void onSave(varKey)
} else if (e.key === 'Escape' && editing) {
e.preventDefault()
diff --git a/apps/desktop/src/app/settings/model-settings.tsx b/apps/desktop/src/app/settings/model-settings.tsx
index 4d51c5ab03..66fea2017a 100644
--- a/apps/desktop/src/app/settings/model-settings.tsx
+++ b/apps/desktop/src/app/settings/model-settings.tsx
@@ -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()
}
}}
diff --git a/apps/desktop/src/app/settings/pet-settings.tsx b/apps/desktop/src/app/settings/pet-settings.tsx
index c02b661937..d73127b05c 100644
--- a/apps/desktop/src/app/settings/pet-settings.tsx
+++ b/apps/desktop/src/app/settings/pet-settings.tsx
@@ -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()
}
diff --git a/apps/desktop/src/app/settings/quick-entry-settings.tsx b/apps/desktop/src/app/settings/quick-entry-settings.tsx
index 4e38531d11..3daf56f8f2 100644
--- a/apps/desktop/src/app/settings/quick-entry-settings.tsx
+++ b/apps/desktop/src/app/settings/quick-entry-settings.tsx
@@ -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()
}
diff --git a/apps/desktop/src/app/shell/model-catalog-menu.tsx b/apps/desktop/src/app/shell/model-catalog-menu.tsx
index d99ff8a421..bffbaa0265 100644
--- a/apps/desktop/src/app/shell/model-catalog-menu.tsx
+++ b/apps/desktop/src/app/shell/model-catalog-menu.tsx
@@ -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()
diff --git a/apps/desktop/src/components/assistant-ui/mcp-setup-tool.tsx b/apps/desktop/src/components/assistant-ui/mcp-setup-tool.tsx
index 25ec46366c..2ab75ed8b8 100644
--- a/apps/desktop/src/components/assistant-ui/mcp-setup-tool.tsx
+++ b/apps/desktop/src/components/assistant-ui/mcp-setup-tool.tsx
@@ -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()
diff --git a/apps/desktop/src/components/assistant-ui/tool/approval.tsx b/apps/desktop/src/components/assistant-ui/tool/approval.tsx
index 0dbdfcda6d..df75fcf456 100644
--- a/apps/desktop/src/components/assistant-ui/tool/approval.tsx
+++ b/apps/desktop/src/components/assistant-ui/tool/approval.tsx
@@ -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') {
diff --git a/apps/desktop/src/components/find-bar.test.tsx b/apps/desktop/src/components/find-bar.test.tsx
index eece4c7354..ff6a205e6b 100644
--- a/apps/desktop/src/components/find-bar.test.tsx
+++ b/apps/desktop/src/components/find-bar.test.tsx
@@ -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()
diff --git a/apps/desktop/src/components/onboarding/index.tsx b/apps/desktop/src/components/onboarding/index.tsx
index d8549be89a..b40b504ef5 100644
--- a/apps/desktop/src/components/onboarding/index.tsx
+++ b/apps/desktop/src/components/onboarding/index.tsx
@@ -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}
diff --git a/apps/desktop/src/lib/find-in-page.ts b/apps/desktop/src/lib/find-in-page.ts
index 1890484cb1..5ea056ae60 100644
--- a/apps/desktop/src/lib/find-in-page.ts
+++ b/apps/desktop/src/lib/find-in-page.ts
@@ -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'
}
diff --git a/apps/desktop/src/lib/ime.test.ts b/apps/desktop/src/lib/ime.test.ts
new file mode 100644
index 0000000000..cbf15f5bf6
--- /dev/null
+++ b/apps/desktop/src/lib/ime.test.ts
@@ -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)
+ })
+})
diff --git a/apps/desktop/src/lib/ime.ts b/apps/desktop/src/lib/ime.ts
new file mode 100644
index 0000000000..26dde13f65
--- /dev/null
+++ b/apps/desktop/src/lib/ime.ts
@@ -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)
+}
diff --git a/apps/desktop/src/plugins/kanban/drawer.tsx b/apps/desktop/src/plugins/kanban/drawer.tsx
index c5491bea3c..e56151f64c 100644
--- a/apps/desktop/src/plugins/kanban/drawer.tsx
+++ b/apps/desktop/src/plugins/kanban/drawer.tsx
@@ -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()
}