From ffd365e3e597227e48a024949fecc3768aa2beff Mon Sep 17 00:00:00 2001 From: haowei Date: Fri, 17 Jul 2026 11:45:09 +0800 Subject: [PATCH] fix(frontend): don't send on Enter while an IME is composing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pressing Enter to pick a candidate from a Chinese (or Japanese/Korean) IME sent the message instead, committing the raw pinyin. While an IME is composing, the browser still fires keydown for Enter and marks it isComposing — the keystroke belongs to the candidate popup, not to us. Nothing checked that flag, so the composer read "confirm this word" as "send". Add a shared isComposing() helper (nativeEvent.isComposing, plus the legacy keyCode 229 some browsers still emit) and guard on it. In the composer the guard sits at the top of handleKeyDown rather than on the send branch alone: the @// picker also claims Enter/Arrow/Escape, and those keys navigate the IME popup while it is open. Apply the same guard to the other Enter-to-submit text inputs that had the identical bug: new channel/workspace/session dialogs, the workbench file-name input, the kanban task input, and friend lookup. Password fields are left alone — browsers disable IME there. Verified against the in-cluster gateway by driving the real composer with a keydown carrying isComposing: true, A/B'd by stashing the fix. Before: draft cleared (sent). After: draft kept; a normal Enter still sends, and exactly one message was persisted across both dispatches. Co-Authored-By: Claude Opus 4.8 --- frontend/src/features/chat/MessageComposer.tsx | 4 ++++ frontend/src/features/chat/NewChannelDialog.tsx | 3 ++- frontend/src/features/chat/NewSessionDialog.tsx | 3 ++- frontend/src/features/chat/NewWorkspaceDialog.tsx | 3 ++- .../src/features/chat/workbench/lens/builtins.tsx | 3 ++- .../src/features/chat/workbench/panels/FilePanel.tsx | 2 ++ frontend/src/features/friends/FriendsPage.tsx | 3 ++- frontend/src/lib/ime.ts | 12 ++++++++++++ 8 files changed, 28 insertions(+), 5 deletions(-) create mode 100644 frontend/src/lib/ime.ts diff --git a/frontend/src/features/chat/MessageComposer.tsx b/frontend/src/features/chat/MessageComposer.tsx index aa1bac2b..dbb29aaa 100644 --- a/frontend/src/features/chat/MessageComposer.tsx +++ b/frontend/src/features/chat/MessageComposer.tsx @@ -25,6 +25,7 @@ import { } from "lucide-react"; import toast from "react-hot-toast"; import { cn } from "@/lib/cn"; +import { isComposing } from "@/lib/ime"; import { uploadFile, transcribeFile, getFileStatus } from "@/api/files"; import type { FileInfo } from "@/types"; import { isAudioFile } from "./fileUtils"; @@ -505,6 +506,9 @@ function MessageComposerImpl({ } function handleKeyDown(e: KeyboardEvent) { + // Every branch below claims Enter/Arrow/Escape, all of which belong to the + // IME candidate popup while it is up. + if (isComposing(e)) return; if (picker && activeCount) { if (e.key === "ArrowDown") { e.preventDefault(); diff --git a/frontend/src/features/chat/NewChannelDialog.tsx b/frontend/src/features/chat/NewChannelDialog.tsx index ee0534ab..ac155a60 100644 --- a/frontend/src/features/chat/NewChannelDialog.tsx +++ b/frontend/src/features/chat/NewChannelDialog.tsx @@ -6,6 +6,7 @@ import { useChatStore } from "@/stores/chatStore"; import { Dialog } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/cn"; +import { isComposing } from "@/lib/ime"; // Create a channel in the given workspace, then add it to the store and select it // (it opens in the normal chat view). Mirrors the NewDmDialog pattern. @@ -64,7 +65,7 @@ export function NewChannelDialog({ autoFocus value={name} onChange={(e) => setName(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && void submit()} + onKeyDown={(e) => e.key === "Enter" && !isComposing(e) && void submit()} placeholder="Channel name…" className="flex-1 bg-transparent py-2 text-sm text-zinc-200 outline-none" /> diff --git a/frontend/src/features/chat/NewSessionDialog.tsx b/frontend/src/features/chat/NewSessionDialog.tsx index 1bc5a2fd..94fd3463 100644 --- a/frontend/src/features/chat/NewSessionDialog.tsx +++ b/frontend/src/features/chat/NewSessionDialog.tsx @@ -12,6 +12,7 @@ import { getWorkspaceMeta, type WorkspaceMeta } from "@/api/workspace"; import { Button } from "@/components/ui/button"; import { Dialog } from "@/components/ui/dialog"; import { bustBotControls } from "./sessionControlsCache"; +import { isComposing } from "@/lib/ime"; export function NewSessionDialog({ channelId, @@ -102,7 +103,7 @@ export function NewSessionDialog({ placeholder={meta?.default_cwd ?? "/abs/workdir"} list="ws-allowed-roots" onChange={(e) => setCwd(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && void create()} + onKeyDown={(e) => e.key === "Enter" && !isComposing(e) && void create()} className="w-full rounded-lg bg-zinc-800 px-2 py-1.5 font-mono text-xs text-zinc-200 focus:outline-none focus:ring-2 focus:ring-indigo-500" /> {/* Datalist = suggestions, not a constraint: any path under an allowed root works. */} diff --git a/frontend/src/features/chat/NewWorkspaceDialog.tsx b/frontend/src/features/chat/NewWorkspaceDialog.tsx index 92771ebd..6713c048 100644 --- a/frontend/src/features/chat/NewWorkspaceDialog.tsx +++ b/frontend/src/features/chat/NewWorkspaceDialog.tsx @@ -3,6 +3,7 @@ import { createWorkspace } from "@/api/workspaces"; import { useChatStore } from "@/stores/chatStore"; import { Dialog } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; +import { isComposing } from "@/lib/ime"; // Create a team workspace, add it to the rail, and switch to it. export function NewWorkspaceDialog({ onClose }: { onClose: () => void }) { @@ -33,7 +34,7 @@ export function NewWorkspaceDialog({ onClose }: { onClose: () => void }) { autoFocus value={name} onChange={(e) => setName(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && void submit()} + onKeyDown={(e) => e.key === "Enter" && !isComposing(e) && void submit()} placeholder="Workspace name…" className="w-full rounded-lg bg-zinc-800 px-3 py-2 text-sm text-zinc-200 focus:outline-none focus:ring-2 focus:ring-indigo-500" /> diff --git a/frontend/src/features/chat/workbench/lens/builtins.tsx b/frontend/src/features/chat/workbench/lens/builtins.tsx index ef913b72..cdf1ad56 100644 --- a/frontend/src/features/chat/workbench/lens/builtins.tsx +++ b/frontend/src/features/chat/workbench/lens/builtins.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; import { ChevronLeft, ChevronRight, Plus, Trash2, X } from "lucide-react"; import { registerLens, type LensProps } from "./registry"; +import { isComposing } from "@/lib/ime"; // ── table: array of row objects; columns from config, else inferred ────────── interface TableConfig { @@ -158,7 +159,7 @@ function KanbanLens({ data, onChange }: LensProps) { setDrafts({ ...drafts, [ci]: e.target.value })} - onKeyDown={(e) => e.key === "Enter" && addItem(ci)} + onKeyDown={(e) => e.key === "Enter" && !isComposing(e) && addItem(ci)} placeholder="+ Task" className="bg-transparent flex-1 text-zinc-300 outline-none placeholder:text-zinc-400" /> diff --git a/frontend/src/features/chat/workbench/panels/FilePanel.tsx b/frontend/src/features/chat/workbench/panels/FilePanel.tsx index 9b40386e..d8c4ec79 100644 --- a/frontend/src/features/chat/workbench/panels/FilePanel.tsx +++ b/frontend/src/features/chat/workbench/panels/FilePanel.tsx @@ -29,6 +29,7 @@ import { import { TextQuote } from "lucide-react"; import { candidatesFor, getRenderer, type RendererDesc } from "../renderers/registry"; import { RendererHost } from "../renderers/RendererHost"; +import { isComposing } from "@/lib/ime"; // Click-gated: the CodeMirror editor (its own chunk, incl. md/json language packs) only // downloads when a user actually opens Raw mode — keeps it off the chat critical path, like @@ -248,6 +249,7 @@ export function FilePanel({ ctx }: { ctx: WorkbenchContext }) { value={newName} onChange={(e) => setNewName(e.target.value)} onKeyDown={(e) => { + if (isComposing(e)) return; if (e.key === "Enter") { e.preventDefault(); void submitCreate(); diff --git a/frontend/src/features/friends/FriendsPage.tsx b/frontend/src/features/friends/FriendsPage.tsx index d01c6b4a..967efbfa 100644 --- a/frontend/src/features/friends/FriendsPage.tsx +++ b/frontend/src/features/friends/FriendsPage.tsx @@ -15,6 +15,7 @@ import { cn } from "@/lib/cn"; import { Avatar } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; import { SurfaceSpinner } from "@/components/ui/spinner"; +import { isComposing } from "@/lib/ime"; import { listFriends, removeFriend, @@ -342,7 +343,7 @@ function AddTab() { setId(e.target.value); setResult(null); }} - onKeyDown={(e) => e.key === "Enter" && lookup()} + onKeyDown={(e) => e.key === "Enter" && !isComposing(e) && lookup()} placeholder="Paste a user ID (e.g. b3dbce7e-1f94-…)" // text-base (16px) below md prevents iOS Safari's auto-zoom on focus. className="w-full pl-9 pr-3 py-2 rounded-lg bg-zinc-900 text-base md:text-sm text-zinc-100 placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-colors font-mono" diff --git a/frontend/src/lib/ime.ts b/frontend/src/lib/ime.ts new file mode 100644 index 00000000..4d7aed5f --- /dev/null +++ b/frontend/src/lib/ime.ts @@ -0,0 +1,12 @@ +import type { KeyboardEvent } from "react"; + +/** + * True while an IME (pinyin, kana, hangul…) is mid-composition, i.e. the + * candidate popup owns the keystroke. Enter there means "pick this word" and + * Arrow/Escape navigate the popup — a handler that acts on them instead + * commits the raw pinyin. `keyCode === 229` is the pre-`isComposing` signal + * some browsers still emit. + */ +export function isComposing(e: KeyboardEvent): boolean { + return e.nativeEvent.isComposing || e.keyCode === 229; +}