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