From 688ccd2c6ba2a48316635bc44adfbc78945fca64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8D=92=E5=B7=9D=E9=9D=96=E7=AB=A0?= Date: Sat, 8 Aug 2026 09:22:51 +0900 Subject: [PATCH] Don't treat IME composition keys as Enter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing Japanese (or any other IME-composed text) sent the prompt half-written: the Enter that commits a kana-kanji conversion reached our keydown handlers as a plain Enter, so the chat input submitted mid-word. The same bug hit every rename and inline-edit field, and in the command palette the arrow keys that pick a conversion candidate moved the result selection instead. Add an isComposingKeyEvent() helper (nativeEvent.isComposing, plus the legacy keyCode 229 that Safari still reports on the commit key) and bail out of the affected handlers while composition is in progress. The Enter/Space handlers on role="button" divs are left alone — they aren't text inputs, so no IME is ever composing over them. Co-Authored-By: Claude Opus 5 (1M context) --- packages/workshop-frontend/src/ChatInterface.tsx | 6 ++++++ packages/workshop-frontend/src/Connections.tsx | 2 ++ packages/workshop-frontend/src/FileSidebar.tsx | 3 +++ packages/workshop-frontend/src/GadgetEditor.tsx | 2 ++ packages/workshop-frontend/src/SettingsPage.tsx | 2 ++ packages/workshop-frontend/src/ShareModal.tsx | 6 ++++-- packages/workshop-frontend/src/WorkpiecePicker.tsx | 2 ++ .../src/components/AppShell/CommandPalette.tsx | 3 +++ .../src/components/AppShell/SidebarGadgetRow.tsx | 2 ++ .../src/components/GadgetList.tsx | 2 ++ .../src/components/chat/ConnectionConfigModal.tsx | 2 ++ .../src/components/format/AdminFormatsPanel.tsx | 2 ++ .../workshop-frontend/src/utils/imeComposition.ts | 14 ++++++++++++++ 13 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 packages/workshop-frontend/src/utils/imeComposition.ts diff --git a/packages/workshop-frontend/src/ChatInterface.tsx b/packages/workshop-frontend/src/ChatInterface.tsx index d5dac25a..4224a249 100644 --- a/packages/workshop-frontend/src/ChatInterface.tsx +++ b/packages/workshop-frontend/src/ChatInterface.tsx @@ -115,6 +115,7 @@ import { useActionEntries } from "./useActions"; import { useAlwaysApproveTag } from "./useAlwaysApproveTag"; import { useResolveAction } from "./useResolveAction"; import { safeExternalUrl } from "./utils/safeExternalUrl"; +import { isComposingKeyEvent } from "./utils/imeComposition"; import { useAuthenticatedApi } from "./AuthContext"; import { useVendorBranding } from "./useVendorBranding"; import OutOfCreditsModal from "./components/billing/OutOfCreditsModal"; @@ -3146,6 +3147,9 @@ export const ChatInput = ({ } }} onKeyDown={(e) => { + // While an IME is composing (e.g. Japanese kana-kanji conversion), Enter and the + // arrow keys belong to the input method, not to us. Let them through untouched. + if (isComposingKeyEvent(e)) return; if (slashCommandPicker.open && e.key === "Escape") { e.preventDefault(); slashCommandPicker.dismiss(); @@ -6511,6 +6515,7 @@ function ChatInterface({ onChange={(e) => setRenamingInput(e.target.value)} onClick={(e) => e.stopPropagation()} onKeyDown={(e) => { + if (isComposingKeyEvent(e)) return; if (e.key === "Enter") { e.preventDefault(); handleSaveListRename(chat.id); @@ -6730,6 +6735,7 @@ function ChatInterface({ value={titleInput} onChange={(e) => setTitleInput(e.target.value)} onKeyDown={(e) => { + if (isComposingKeyEvent(e)) return; if (e.key === "Enter") handleSaveChatTitle(); if (e.key === "Escape") handleCancelTitleEdit(); }} diff --git a/packages/workshop-frontend/src/Connections.tsx b/packages/workshop-frontend/src/Connections.tsx index 1a687067..f479ba75 100644 --- a/packages/workshop-frontend/src/Connections.tsx +++ b/packages/workshop-frontend/src/Connections.tsx @@ -21,6 +21,7 @@ import { loadBindingCardData, } from './components/BlueprintBindingCard' import { reportIssue } from './errorReporting' +import { isComposingKeyEvent } from './utils/imeComposition' interface ConnectionsProps { overseer: RpcStub @@ -266,6 +267,7 @@ export default function Connections({ overseer, gadget, chatId, authenticatedApi value={editValue} onChange={(e) => setEditValue(e.target.value)} onKeyDown={(e) => { + if (isComposingKeyEvent(e)) return if (e.key === 'Enter') handleEditSave(gk.name) if (e.key === 'Escape') handleEditCancel() }} diff --git a/packages/workshop-frontend/src/FileSidebar.tsx b/packages/workshop-frontend/src/FileSidebar.tsx index 3c1633ef..ada6d380 100644 --- a/packages/workshop-frontend/src/FileSidebar.tsx +++ b/packages/workshop-frontend/src/FileSidebar.tsx @@ -3,6 +3,7 @@ import { Dialog, DropdownMenu, useKumoToastManager } from '@cloudflare/kumo' import { DotsThree, DownloadSimple, Pencil, Plus, Trash, X } from '@phosphor-icons/react' import DeleteConfirmationDialog from './components/DeleteConfirmationDialog' import { WorkshopButton, WorkshopIconButton, WorkshopInput } from './components/WorkshopControls' +import { isComposingKeyEvent } from './utils/imeComposition' interface FileSidebarProps { files: string[] @@ -213,6 +214,7 @@ export default function FileSidebar({ value={newFileName} onChange={(e) => setNewFileName(e.target.value)} onKeyDown={(e) => { + if (isComposingKeyEvent(e)) return if (e.key === 'Enter') { e.preventDefault() handleCreateFile() @@ -347,6 +349,7 @@ function FileRow({ onChange={(event) => setRenameValue(event.target.value)} onClick={(event) => event.stopPropagation()} onKeyDown={(event) => { + if (isComposingKeyEvent(event)) return if (event.key === 'Enter') { event.preventDefault() onRenameSubmit(renameValue) diff --git a/packages/workshop-frontend/src/GadgetEditor.tsx b/packages/workshop-frontend/src/GadgetEditor.tsx index 70915c88..ba30b71e 100644 --- a/packages/workshop-frontend/src/GadgetEditor.tsx +++ b/packages/workshop-frontend/src/GadgetEditor.tsx @@ -55,6 +55,7 @@ import WorkspaceOpenErrorPage from './components/WorkspaceOpenErrorPage' import { useWorkspaceOpen } from './useWorkspaceOpen' import { reportIssue } from './errorReporting' import GadgetExportMenu from './GadgetExportMenu' +import { isComposingKeyEvent } from './utils/imeComposition' const NO_GADGETS: ReadonlySet = new Set() @@ -1348,6 +1349,7 @@ export default function GadgetEditor() { value={titleInput} onChange={e => setTitleInput(e.target.value)} onKeyDown={e => { + if (isComposingKeyEvent(e)) return if (e.key === 'Enter') handleSaveTitle() if (e.key === 'Escape') handleCancelEdit() }} diff --git a/packages/workshop-frontend/src/SettingsPage.tsx b/packages/workshop-frontend/src/SettingsPage.tsx index 1caa3749..d86a0e04 100644 --- a/packages/workshop-frontend/src/SettingsPage.tsx +++ b/packages/workshop-frontend/src/SettingsPage.tsx @@ -9,6 +9,7 @@ import { useAvatar, invalidateAvatarCache } from './useAvatar' import { compressAvatar, avatarBlobUrl } from './avatarUtils' import UsageSettings from './components/billing/UsageSettings' import { useDocumentTitle } from './useDocumentTitle' +import { isComposingKeyEvent } from './utils/imeComposition' // Shared, on-language control classes (match the rest of the app: Workspaces/Blueprints headers, // the gatekeepers toolbar, the command palette). Kept here so the profile page reads as part of the @@ -311,6 +312,7 @@ export default function SettingsPage() { value={nameInput} onChange={(e) => setNameInput(e.target.value)} onKeyDown={(e) => { + if (isComposingKeyEvent(e)) return if (e.key === 'Enter') handleSaveName() if (e.key === 'Escape') handleCancelEdit() }} diff --git a/packages/workshop-frontend/src/ShareModal.tsx b/packages/workshop-frontend/src/ShareModal.tsx index d843ea04..658f1909 100644 --- a/packages/workshop-frontend/src/ShareModal.tsx +++ b/packages/workshop-frontend/src/ShareModal.tsx @@ -17,6 +17,7 @@ import { import { WorkshopButton, WorkshopIconButton } from './components/WorkshopControls' import { PersonAvatar } from './components/PersonAvatar' import { copyToClipboard } from './clipboard' +import { isComposingKeyEvent } from './utils/imeComposition' type CollaboratorRow = | { kind: 'owner'; profile: AiChatAuthorInfo } @@ -808,7 +809,7 @@ export default function ShareModal({ open, onClose, overseer, metadata, currentU aria-label="Username or email" value={addUsername} onChange={(e) => setAddUsername(e.target.value)} - onKeyDown={(e) => { if (e.key === 'Enter') handleAddCollaborator() }} + onKeyDown={(e) => { if (!isComposingKeyEvent(e) && e.key === 'Enter') handleAddCollaborator() }} name="gadget-share-people-search" autoComplete="off" autoCorrect="off" @@ -906,7 +907,7 @@ export default function ShareModal({ open, onClose, overseer, metadata, currentU ref={linkNameRef} value={newLinkNote} onChange={(e) => setNewLinkNote(e.target.value)} - onKeyDown={(e) => { if (e.key === 'Enter') handleCreateShareLink() }} + onKeyDown={(e) => { if (!isComposingKeyEvent(e) && e.key === 'Enter') handleCreateShareLink() }} placeholder="Name this link (optional)…" aria-label="Share link name (optional)" className="h-9 min-w-0 flex-1 border-0 bg-transparent p-0 text-[14px] leading-5 tracking-[-0.25px] text-kumo-default outline-none placeholder:text-kumo-inactive" @@ -1040,6 +1041,7 @@ export default function ShareModal({ open, onClose, overseer, metadata, currentU value={editingShareLinkNote} onChange={(e) => setEditingShareLinkNote(e.target.value)} onKeyDown={(e) => { + if (isComposingKeyEvent(e)) return if (e.key === 'Enter') handleSaveShareLinkNote() if (e.key === 'Escape') cancelRenameShareLink() }} diff --git a/packages/workshop-frontend/src/WorkpiecePicker.tsx b/packages/workshop-frontend/src/WorkpiecePicker.tsx index 34601f1c..e98fcb9a 100644 --- a/packages/workshop-frontend/src/WorkpiecePicker.tsx +++ b/packages/workshop-frontend/src/WorkpiecePicker.tsx @@ -5,6 +5,7 @@ import { Tooltip } from '@cloudflare/kumo' import type { WorkpieceId, WorkpieceSummary } from '@gadgets/workshop-shared/api' import { CountBadge } from './components/CountBadge' import { WorkshopIconButton, WorkshopInput } from './components/WorkshopControls' +import { isComposingKeyEvent } from './utils/imeComposition' export const WORKPIECE_RAIL_COLLAPSED_WIDTH = 48 export const WORKPIECE_RAIL_EXPANDED_WIDTH = 220 @@ -88,6 +89,7 @@ export default function WorkpiecePicker({ value={editing.value} onChange={e => setEditing({ id: gadget.id, value: e.target.value })} onKeyDown={e => { + if (isComposingKeyEvent(e)) return if (e.key === 'Enter') commitRename() if (e.key === 'Escape') setEditing(null) }} diff --git a/packages/workshop-frontend/src/components/AppShell/CommandPalette.tsx b/packages/workshop-frontend/src/components/AppShell/CommandPalette.tsx index bdd678d8..b7950d5e 100644 --- a/packages/workshop-frontend/src/components/AppShell/CommandPalette.tsx +++ b/packages/workshop-frontend/src/components/AppShell/CommandPalette.tsx @@ -11,6 +11,7 @@ import { useAuthenticatedApi } from '../../AuthContext' import type { GadgetMetadataWithTimestamps, OutputFormatOffer } from '@gadgets/workshop-shared/api' import { FormatGlyph } from '../format/FormatVisuals' import { createFromFormat } from '../format/useOutputFormats' +import { isComposingKeyEvent } from '../../utils/imeComposition' // A ⌘K command palette: jump to a workspace or a primary destination. Because it's keyboard-driven // and opened many times a day, it deliberately has *no* open/close animation (instant feels faster @@ -311,6 +312,8 @@ export default function CommandPalette({ const onKeyDown = useCallback( (e: React.KeyboardEvent) => { + // Arrow keys pick IME candidates and Enter commits them; neither is ours mid-composition. + if (isComposingKeyEvent(e)) return if (e.key === 'ArrowDown') { e.preventDefault() setActiveIndex((i) => (flat.length ? (i + 1) % flat.length : 0)) diff --git a/packages/workshop-frontend/src/components/AppShell/SidebarGadgetRow.tsx b/packages/workshop-frontend/src/components/AppShell/SidebarGadgetRow.tsx index 7fe34a79..3bbbbc45 100644 --- a/packages/workshop-frontend/src/components/AppShell/SidebarGadgetRow.tsx +++ b/packages/workshop-frontend/src/components/AppShell/SidebarGadgetRow.tsx @@ -4,6 +4,7 @@ import { DropdownMenu } from '@cloudflare/kumo' import { MENU_CONTENT, MENU_ITEM, MENU_ITEM_DANGER, MENU_POSITIONER_STYLE } from '../menuStyles' import { useState, useEffect, useRef } from 'react' import type { GadgetMetadataWithTimestamps } from '@gadgets/workshop-shared/api' +import { isComposingKeyEvent } from '../../utils/imeComposition' function initials(title: string | undefined): string { const t = (title || 'Untitled').trim() @@ -76,6 +77,7 @@ export default function SidebarGadgetRow({ onChange={(e) => setRenameValue(e.target.value)} onBlur={commit} onKeyDown={(e) => { + if (isComposingKeyEvent(e)) return if (e.key === 'Enter') commit() if (e.key === 'Escape') setRenaming(false) }} diff --git a/packages/workshop-frontend/src/components/GadgetList.tsx b/packages/workshop-frontend/src/components/GadgetList.tsx index 2b8eae1c..882b3ddc 100644 --- a/packages/workshop-frontend/src/components/GadgetList.tsx +++ b/packages/workshop-frontend/src/components/GadgetList.tsx @@ -10,6 +10,7 @@ import { BindingBadge, getGradient as getBlueprintGradient, uniqueBindingBadges import { MENU_CONTENT, MENU_ITEM, MENU_ITEM_DANGER } from './menuStyles' import { BlueprintPreviewImage } from './BlueprintPreviewImage' import DeleteConfirmationDialog from './DeleteConfirmationDialog' +import { isComposingKeyEvent } from '../utils/imeComposition' // Neutral monogram for a workspace — matches the sidebar treatment (no per-item color noise). function initials(title: string | undefined): string { @@ -96,6 +97,7 @@ function AppRow({ onChange={(e) => setRenameValue(e.target.value)} onBlur={commitRename} onKeyDown={(e) => { + if (isComposingKeyEvent(e)) return if (e.key === 'Enter') commitRename() if (e.key === 'Escape') setIsRenaming(false) }} diff --git a/packages/workshop-frontend/src/components/chat/ConnectionConfigModal.tsx b/packages/workshop-frontend/src/components/chat/ConnectionConfigModal.tsx index 5c496a67..cb239c2b 100644 --- a/packages/workshop-frontend/src/components/chat/ConnectionConfigModal.tsx +++ b/packages/workshop-frontend/src/components/chat/ConnectionConfigModal.tsx @@ -3,6 +3,7 @@ import { Dialog, Button, Input } from '@cloudflare/kumo' import { X } from '@phosphor-icons/react' import type { Connection, ConnectionResource } from '../../data/sample' import { logoComponents } from '../ConnectionLogos' +import { isComposingKeyEvent } from '../../utils/imeComposition' export default function ConnectionConfigModal({ connection, @@ -80,6 +81,7 @@ export default function ConnectionConfigModal({ value={inputValue} onChange={(e) => setInputValue(e.currentTarget.value)} onKeyDown={(e) => { + if (isComposingKeyEvent(e)) return if (e.key === 'Enter') handleAdd() }} placeholder={config.placeholder} diff --git a/packages/workshop-frontend/src/components/format/AdminFormatsPanel.tsx b/packages/workshop-frontend/src/components/format/AdminFormatsPanel.tsx index 0ba996e4..0be017a8 100644 --- a/packages/workshop-frontend/src/components/format/AdminFormatsPanel.tsx +++ b/packages/workshop-frontend/src/components/format/AdminFormatsPanel.tsx @@ -22,6 +22,7 @@ import { useAuthenticatedApi } from '../../AuthContext' import { MENU_CONTENT } from '../menuStyles' import { FORMAT_ICONS, GENERIC_OUTPUT } from './formats' import { FormatGlyph, FormatPreview } from './FormatVisuals' +import { isComposingKeyEvent } from '../../utils/imeComposition' // A blueprint the admin could promote. `declared` is what it says it produces, when we know -- // known for the deployment's featured blueprints, unknown for the admin's own published ones. @@ -501,6 +502,7 @@ function OverrideField({ onChange={(e) => setDraft(e.target.value)} onBlur={commit} onKeyDown={(e) => { + if (isComposingKeyEvent(e)) return if (e.key === 'Enter') e.currentTarget.blur() if (e.key === 'Escape') setDraft(value) }} diff --git a/packages/workshop-frontend/src/utils/imeComposition.ts b/packages/workshop-frontend/src/utils/imeComposition.ts new file mode 100644 index 00000000..edb8d480 --- /dev/null +++ b/packages/workshop-frontend/src/utils/imeComposition.ts @@ -0,0 +1,14 @@ +import type {KeyboardEvent as ReactKeyboardEvent} from 'react' + +// True while an input method editor is composing text — e.g. the Enter that commits a Japanese +// kana-kanji conversion, or the arrow keys that pick a candidate. Those keystrokes belong to the +// IME, so handlers that treat Enter as "submit" must ignore them, or the first conversion in a +// message sends it half-written. +// +// `isComposing` covers modern browsers; keyCode 229 is the legacy signal some IMEs still emit for +// keydown during composition (notably Safari, where `isComposing` can be false on the commit key). +export function isComposingKeyEvent( + event: ReactKeyboardEvent | KeyboardEvent): boolean { + const native = 'nativeEvent' in event ? event.nativeEvent : event + return native.isComposing || native.keyCode === 229 +}