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:
@@ -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}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user