From 5cbd994575c1e22fba6d26519aeddcffa340fa7e Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:51:40 -0700 Subject: [PATCH] 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. --- .../chat/right-rail/preview-browser-bar.tsx | 3 +- .../profile-remote-override-dialog.tsx | 5 ++- .../src/app/chat/sidebar/project-dialog.tsx | 3 +- .../chat/sidebar/projects/worktree-dialog.tsx | 3 +- .../app/chat/sidebar/session-actions-menu.tsx | 3 +- .../pet-generate/components/hatch-preview.tsx | 3 +- .../app/pet-generate/pet-generate-content.tsx | 3 +- .../src/app/pet-overlay/pet-overlay-app.tsx | 3 +- .../src/app/quick-entry/quick-entry-app.tsx | 3 +- .../src/app/right-sidebar/file-actions.tsx | 3 +- .../src/app/right-sidebar/review/ship-bar.tsx | 3 +- .../src/app/settings/combobox-input.tsx | 3 +- .../src/app/settings/config-settings.tsx | 3 +- .../src/app/settings/credential-key-ui.tsx | 3 +- .../src/app/settings/model-settings.tsx | 3 +- .../desktop/src/app/settings/pet-settings.tsx | 3 +- .../src/app/settings/quick-entry-settings.tsx | 3 +- .../src/app/shell/model-catalog-menu.tsx | 3 +- .../assistant-ui/mcp-setup-tool.tsx | 3 +- .../components/assistant-ui/tool/approval.tsx | 3 +- apps/desktop/src/components/find-bar.test.tsx | 6 +++ .../src/components/onboarding/index.tsx | 5 ++- apps/desktop/src/lib/find-in-page.ts | 11 ++++- apps/desktop/src/lib/ime.test.ts | 44 +++++++++++++++++++ apps/desktop/src/lib/ime.ts | 43 ++++++++++++++++++ apps/desktop/src/plugins/kanban/drawer.tsx | 3 +- 26 files changed, 149 insertions(+), 25 deletions(-) create mode 100644 apps/desktop/src/lib/ime.test.ts create mode 100644 apps/desktop/src/lib/ime.ts 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() }