From 1b3593defe76fac1ceac386c14cfc9bf43f26614 Mon Sep 17 00:00:00 2001 From: Compl Yue Date: Mon, 10 Aug 2026 22:26:42 +0800 Subject: [PATCH 1/2] fix(desktop): guard IME confirm Enter in ask inputs and small inputs Composer had the guard; the other Enter-submitting inputs (ask card, command palette, rule/note/rename/add inputs) still sent on IME confirm Enter. Add a shared compositionend listener in lib/imeComposition.ts and one isImeEvent() check per keydown. --- .../src/__tests__/ask-card-layout.test.ts | 78 +++++++++++++++++++ .../command-palette-interactions.test.tsx | 34 ++++++++ .../frontend/src/components/ApprovalModal.tsx | 2 + desktop/frontend/src/components/AskCard.tsx | 5 ++ .../src/components/CommandPalette.tsx | 2 + .../frontend/src/components/HistoryPanel.tsx | 2 + .../frontend/src/components/MemoryPanel.tsx | 3 + .../frontend/src/components/ModelSwitcher.tsx | 2 + .../src/components/OnboardingOverlay.tsx | 2 + .../frontend/src/components/SettingsPanel.tsx | 2 + desktop/frontend/src/lib/imeComposition.ts | 27 +++++++ 11 files changed, 159 insertions(+) create mode 100644 desktop/frontend/src/lib/imeComposition.ts diff --git a/desktop/frontend/src/__tests__/ask-card-layout.test.ts b/desktop/frontend/src/__tests__/ask-card-layout.test.ts index 5b564eeec2..5c7aa034c1 100644 --- a/desktop/frontend/src/__tests__/ask-card-layout.test.ts +++ b/desktop/frontend/src/__tests__/ask-card-layout.test.ts @@ -54,6 +54,10 @@ function installDom() { globalThis.localStorage = dom.window.localStorage; globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window); globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window); + // React 19 falls back to the IE input-event polyfill unless attachEvent is + // present; without this, synthetic keydown/input handlers never run in jsdom. + Object.defineProperty(dom.window.HTMLElement.prototype, "attachEvent", { configurable: true, value: () => {} }); + Object.defineProperty(dom.window.HTMLElement.prototype, "detachEvent", { configurable: true, value: () => {} }); Object.defineProperty(dom.window.HTMLElement.prototype, "clientHeight", { configurable: true, get() { @@ -595,5 +599,79 @@ console.log("\nask card layout"); dom.window.close(); } +{ + // IME confirm Enter must not confirm the custom answer; a deliberate Enter + // after the confirm grace still reaches the handler. + const dom = installDom(); + const rootEl = document.getElementById("root"); + if (!rootEl) throw new Error("missing root"); + const root = createRoot(rootEl); + const answers: QuestionAnswer[][] = []; + const ask: WireAsk = { + id: "ask-ime-custom", + questions: [ + { + id: "decision", + header: "Review", + prompt: "What should the archive logic do?", + options: [{ label: "Full alignment", description: "Keep behavior consistent." }], + }, + ], + }; + await act(async () => { + root.render( + React.createElement(LocaleProvider, null, + React.createElement(AskCard, { + ask, + onAnswer: (_id: string, next: QuestionAnswer[]) => answers.push(next), + onDismiss: () => undefined, + onStop: () => undefined, + }), + ), + ); + await flushTimers(); + }); + const customOption = [...document.querySelectorAll(".prompt-shelf__actions .prompt-action")].at(-1) as HTMLElement | undefined; + if (!customOption) throw new Error("custom option did not render"); + await act(async () => { + customOption.click(); + await flushTimers(); + }); + const customInput = document.querySelector(".ask-shelf__custom") as HTMLInputElement | null; + if (!customInput) throw new Error("custom input did not render"); + // A focusin activates React's jsdom input polyfill so keydown dispatches run. + const dispatchEnter = (): KeyboardEvent => { + const event = new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }); + customInput.dispatchEvent(event); + return event; + }; + let imeEnter: KeyboardEvent | null = null; + await act(async () => { + customInput.dispatchEvent(new window.FocusEvent("focusin", { bubbles: true })); + // Typing a pinyin letter primes the shared compositionend listener before + // the confirming Enter arrives (a real IME session always types first). + customInput.dispatchEvent(new window.KeyboardEvent("keydown", { key: "n", bubbles: true, cancelable: true })); + customInput.dispatchEvent(new window.Event("compositionstart", { bubbles: true })); + customInput.dispatchEvent(new window.Event("compositionend", { bubbles: true })); + imeEnter = dispatchEnter(); + await flushTimers(); + }); + eq(imeEnter?.defaultPrevented, true, "IME confirm Enter is swallowed in the ask custom input"); + eq(answers.length, 0, "IME confirm Enter does not confirm the custom answer"); + + let plainEnter: KeyboardEvent | null = null; + await act(async () => { + await flushTimers(150); + plainEnter = dispatchEnter(); + await flushTimers(); + }); + eq(plainEnter?.defaultPrevented, false, "a deliberate Enter after the confirm grace is not swallowed"); + + await act(async () => { + root.unmount(); + }); + dom.window.close(); +} + console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`); if (failed > 0) process.exit(1); diff --git a/desktop/frontend/src/__tests__/command-palette-interactions.test.tsx b/desktop/frontend/src/__tests__/command-palette-interactions.test.tsx index 9270aee4f8..6ac6ee9e01 100644 --- a/desktop/frontend/src/__tests__/command-palette-interactions.test.tsx +++ b/desktop/frontend/src/__tests__/command-palette-interactions.test.tsx @@ -134,5 +134,39 @@ console.log("\ncommand palette interactions"); dom.window.close(); } +{ + const dom = installDom(); + const { root, calls } = await renderPalette(); + const input = document.querySelector(".palette__input"); + if (!input) throw new Error("missing palette input"); + const dispatchKey = (key: string): KeyboardEvent => { + const event = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }); + input.dispatchEvent(event); + return event; + }; + + await act(async () => { + input.dispatchEvent(new dom.window.FocusEvent("focusin", { bubbles: true })); + dispatchKey("n"); + input.dispatchEvent(new Event("compositionstart", { bubbles: true })); + input.dispatchEvent(new Event("compositionend", { bubbles: true })); + dispatchKey("Enter"); + await flush(); + }); + ok(calls.run === 0, "IME confirm Enter does not run the highlighted command"); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 150)); + dispatchKey("Enter"); + await flush(); + }); + ok(calls.run === 1, "a deliberate Enter after the confirm grace runs the highlighted command"); + + await act(async () => { + root.unmount(); + }); + dom.window.close(); +} + console.log(`\n${passed} passed, ${failed} failed`); if (failed > 0) process.exit(1); diff --git a/desktop/frontend/src/components/ApprovalModal.tsx b/desktop/frontend/src/components/ApprovalModal.tsx index bc0a7b9e05..7af69b562f 100644 --- a/desktop/frontend/src/components/ApprovalModal.tsx +++ b/desktop/frontend/src/components/ApprovalModal.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useId, useRef, useState } from "react"; import type { KeyboardEvent as ReactKeyboardEvent } from "react"; import { useT, type Translator } from "../lib/i18n"; +import { isImeEvent } from "../lib/imeComposition"; import type { ComposerInsertRequest, DirEntry, ToolApprovalMode, WireApproval } from "../lib/types"; import { DecisionConfirmBar, @@ -632,6 +633,7 @@ export function ApprovalModal({ }; const onRevisionKeyDown = (event: ReactKeyboardEvent) => { + if (isImeEvent(event)) return; if ((event.metaKey || event.ctrlKey) && event.key === "Enter") { submitRevision(); event.stopPropagation(); diff --git a/desktop/frontend/src/components/AskCard.tsx b/desktop/frontend/src/components/AskCard.tsx index eb3b9c7fa4..aed2f51855 100644 --- a/desktop/frontend/src/components/AskCard.tsx +++ b/desktop/frontend/src/components/AskCard.tsx @@ -1,5 +1,6 @@ import { useEffect, useId, useMemo, useRef, useState } from "react"; import { useT } from "../lib/i18n"; +import { isImeEvent } from "../lib/imeComposition"; import type { QuestionAnswer, WireAsk, WireAskQuestion } from "../lib/types"; import { DecisionConfirmBar, @@ -341,6 +342,10 @@ export function AskCard({ disabled={submitting} onChange={(e) => setTyped(q, e.target.value)} onKeyDown={(e) => { + if (isImeEvent(e)) { + e.preventDefault(); + return; + } if (e.key === "Enter" && canConfirm()) { e.preventDefault(); confirmSelected(); diff --git a/desktop/frontend/src/components/CommandPalette.tsx b/desktop/frontend/src/components/CommandPalette.tsx index 78e8cee138..d50283415b 100644 --- a/desktop/frontend/src/components/CommandPalette.tsx +++ b/desktop/frontend/src/components/CommandPalette.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr import type { ReactNode } from "react"; import { Command, Search } from "lucide-react"; import { useT } from "../lib/i18n"; +import { isImeEvent } from "../lib/imeComposition"; import { useMountTransition } from "../lib/useMountTransition"; // CommandPalette is a ⌘K / Ctrl+K modal that surfaces the desktop app's @@ -163,6 +164,7 @@ export function CommandPalette({ useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => { + if (isImeEvent({ nativeEvent: e })) return; const closeButtonHasFocus = e.target instanceof HTMLElement && Boolean(e.target.closest("[data-palette-close]")); if (closeButtonHasFocus && (e.key === "Enter" || e.key === " ")) return; if (e.key === "Escape") { diff --git a/desktop/frontend/src/components/HistoryPanel.tsx b/desktop/frontend/src/components/HistoryPanel.tsx index f825a905f7..5c5a55403a 100644 --- a/desktop/frontend/src/components/HistoryPanel.tsx +++ b/desktop/frontend/src/components/HistoryPanel.tsx @@ -3,6 +3,7 @@ import type { MouseEvent as ReactMouseEvent } from "react"; import { Archive, Pencil, Search, Trash2, RotateCcw } from "lucide-react"; import { t, useT } from "../lib/i18n"; import { historySessionDisplayTitle, sessionActivityTime } from "../lib/session"; +import { isImeEvent } from "../lib/imeComposition"; import type { HistoryMessage, SessionMeta } from "../lib/types"; import { historyMessagesToItems, type Item } from "../lib/useController"; import { Transcript } from "./Transcript"; @@ -515,6 +516,7 @@ export function HistoryPanel({ value={draft} onChange={(e) => setDraft(e.target.value)} onKeyDown={(e) => { + if (isImeEvent(e)) return; if (e.key === "Enter") commitRename(s.path); if (e.key === "Escape") setEditing(null); }} diff --git a/desktop/frontend/src/components/MemoryPanel.tsx b/desktop/frontend/src/components/MemoryPanel.tsx index 43b015bc44..f8e00738ea 100644 --- a/desktop/frontend/src/components/MemoryPanel.tsx +++ b/desktop/frontend/src/components/MemoryPanel.tsx @@ -2,6 +2,7 @@ import { Activity, AlertTriangle, ArchiveRestore, Check, ChevronDown, ChevronRig import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { app } from "../lib/bridge"; import { useT } from "../lib/i18n"; +import { isImeEvent } from "../lib/imeComposition"; import type { MemoryArchive, MemoryFact, MemorySuggestion, MemorySuggestionsView, MemoryView, SkillSuggestion, TabMeta } from "../lib/types"; import { AnchoredPopover } from "./AnchoredPopover"; import { ResizableDrawer } from "./ResizableDrawer"; @@ -658,6 +659,7 @@ export function MemoryPanel({ value={note} onChange={(e) => setNote(e.target.value)} onKeyDown={(e) => { + if (isImeEvent(e)) return; if (e.key === "Enter") void submitNote(); }} /> @@ -1785,6 +1787,7 @@ export function MemorySettingsPage() { value={note} onChange={(e) => setNote(e.target.value)} onKeyDown={(e) => { + if (isImeEvent(e)) return; if (e.key === "Enter") void submitNote(); }} /> diff --git a/desktop/frontend/src/components/ModelSwitcher.tsx b/desktop/frontend/src/components/ModelSwitcher.tsx index 1a1fffd807..0fdc847e7d 100644 --- a/desktop/frontend/src/components/ModelSwitcher.tsx +++ b/desktop/frontend/src/components/ModelSwitcher.tsx @@ -3,6 +3,7 @@ import { Brain, Check, ChevronsUpDown, Search } from "lucide-react"; import { asArray } from "../lib/array"; import { app } from "../lib/bridge"; import { useT } from "../lib/i18n"; +import { isImeEvent } from "../lib/imeComposition"; import type { ModelInfo } from "../lib/types"; import { AnchoredPopover } from "./AnchoredPopover"; import { Tooltip } from "./Tooltip"; @@ -194,6 +195,7 @@ export function ModelSwitcher({ value={query} onChange={(e) => setQuery(e.target.value)} onKeyDown={(e) => { + if (isImeEvent(e)) return; if (e.key === "Escape") setOpen(false); if (e.key === "Enter" && filtered.length === 1) pick(filtered[0]); }} diff --git a/desktop/frontend/src/components/OnboardingOverlay.tsx b/desktop/frontend/src/components/OnboardingOverlay.tsx index ccdd16b7a1..13db4c8b6d 100644 --- a/desktop/frontend/src/components/OnboardingOverlay.tsx +++ b/desktop/frontend/src/components/OnboardingOverlay.tsx @@ -1,6 +1,7 @@ import { useCallback, useRef, useState } from "react"; import logo from "../assets/logo.svg"; import { useT } from "../lib/i18n"; +import { isImeEvent } from "../lib/imeComposition"; import { app, openExternal } from "../lib/bridge"; // Full-window first-run guide: DeepSeek stays the fastest path, while users can @@ -73,6 +74,7 @@ export function OnboardingOverlay({ if (state === "error") setState("idle"); }} onKeyDown={(e) => { + if (isImeEvent(e)) return; if (e.key === "Enter" && state !== "validating") { e.preventDefault(); void submit(); diff --git a/desktop/frontend/src/components/SettingsPanel.tsx b/desktop/frontend/src/components/SettingsPanel.tsx index c016096a46..cf5d150413 100644 --- a/desktop/frontend/src/components/SettingsPanel.tsx +++ b/desktop/frontend/src/components/SettingsPanel.tsx @@ -8,6 +8,7 @@ import { apiKeyEnvFromProviderName, createLatestRequestGate, inferredVisionModel import { cachedFetchProviderModels, invalidateProviderCacheByAPIKeyEnv, shouldSkipAutoRefresh } from "../lib/providerModelCache"; import { opencodeGoPresetDescriptionKeys } from "../lib/providerPresetDescriptions"; import { useUpdater } from "../lib/useUpdater"; +import { isImeEvent } from "../lib/imeComposition"; import { applyTheme, getTheme, @@ -6863,6 +6864,7 @@ function RuleList({ value={draft} onChange={(e) => setDraft(e.target.value)} onKeyDown={(e) => { + if (isImeEvent(e)) return; if (e.key === "Enter") add(); }} /> diff --git a/desktop/frontend/src/lib/imeComposition.ts b/desktop/frontend/src/lib/imeComposition.ts new file mode 100644 index 0000000000..acadb5b668 --- /dev/null +++ b/desktop/frontend/src/lib/imeComposition.ts @@ -0,0 +1,27 @@ +// IME guard for the many small Enter-submitting inputs (ask card, command +// palette, rule/note/rename/add). WebKit fires compositionend before the +// confirming Enter keydown, so a shared compositionend timestamp plus the +// 229-keyCode check covers every input without per-input listeners. +import { isImeKeyEvent } from "./composerKeyboard"; + +let lastGlobalCompositionEndAt = 0; +let globalListenerTarget: Document | null = null; + +function onGlobalCompositionEnd(): void { + lastGlobalCompositionEndAt = Date.now(); +} + +function ensureGlobalCompositionListener(): void { + if (typeof document === "undefined") return; + if (globalListenerTarget === document) return; + // Tests swap the jsdom document between cases; never leave the listener on + // a closed document. + globalListenerTarget?.removeEventListener("compositionend", onGlobalCompositionEnd); + globalListenerTarget = document; + document.addEventListener("compositionend", onGlobalCompositionEnd); +} + +export function isImeEvent(e: { nativeEvent: { isComposing?: boolean; keyCode?: number } }): boolean { + ensureGlobalCompositionListener(); + return isImeKeyEvent(e.nativeEvent, false, lastGlobalCompositionEndAt); +} From 74be66f8954e3d43ec948e4dd9d771627d4cefaa Mon Sep 17 00:00:00 2001 From: Compl Yue Date: Tue, 11 Aug 2026 00:12:21 +0800 Subject: [PATCH 2/2] build(lint): bump file-size budgets for the IME guard additions The IME guard adds 2-3 lines to three legacy files that already sit at their ratchet file-size budget (ApprovalModal 1040, MemoryPanel 1891, SettingsPanel 7570 lines). Raise their budgets 238->240, 1088->1091, 6768->6770. Full -update was rejected to keep the PR diff focused. --- tools/repolint/baseline.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tools/repolint/baseline.json b/tools/repolint/baseline.json index b30d6db3c0..f0dca35a22 100644 --- a/tools/repolint/baseline.json +++ b/tools/repolint/baseline.json @@ -117,7 +117,7 @@ "test-file-size": 106 }, "desktop/frontend/src/components/ApprovalModal.tsx": { - "file-size": 238 + "file-size": 240 }, "desktop/frontend/src/components/CapabilitiesPanel.tsx": { "file-size": 2658 @@ -126,7 +126,7 @@ "file-size": 4003 }, "desktop/frontend/src/components/MemoryPanel.tsx": { - "file-size": 1088 + "file-size": 1091 }, "desktop/frontend/src/components/Message.tsx": { "file-size": 91 @@ -138,7 +138,7 @@ "file-size": 141 }, "desktop/frontend/src/components/SettingsPanel.tsx": { - "file-size": 6768 + "file-size": 6770 }, "desktop/frontend/src/components/StatusBar.tsx": { "file-size": 20