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 +}