Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions desktop/frontend/src/__tests__/ask-card-layout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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);
Original file line number Diff line number Diff line change
Expand Up @@ -134,5 +134,39 @@ console.log("\ncommand palette interactions");
dom.window.close();
}

{
const dom = installDom();
const { root, calls } = await renderPalette();
const input = document.querySelector<HTMLInputElement>(".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);
2 changes: 2 additions & 0 deletions desktop/frontend/src/components/ApprovalModal.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -632,6 +633,7 @@ export function ApprovalModal({
};

const onRevisionKeyDown = (event: ReactKeyboardEvent<HTMLTextAreaElement>) => {
if (isImeEvent(event)) return;
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
submitRevision();
event.stopPropagation();
Expand Down
5 changes: 5 additions & 0 deletions desktop/frontend/src/components/AskCard.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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();
Expand Down
2 changes: 2 additions & 0 deletions desktop/frontend/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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") {
Expand Down
2 changes: 2 additions & 0 deletions desktop/frontend/src/components/HistoryPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
}}
Expand Down
3 changes: 3 additions & 0 deletions desktop/frontend/src/components/MemoryPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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();
}}
/>
Expand Down Expand Up @@ -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();
}}
/>
Expand Down
2 changes: 2 additions & 0 deletions desktop/frontend/src/components/ModelSwitcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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]);
}}
Expand Down
2 changes: 2 additions & 0 deletions desktop/frontend/src/components/OnboardingOverlay.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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();
Expand Down
2 changes: 2 additions & 0 deletions desktop/frontend/src/components/SettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
}}
/>
Expand Down
27 changes: 27 additions & 0 deletions desktop/frontend/src/lib/imeComposition.ts
Original file line number Diff line number Diff line change
@@ -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);
}
6 changes: 3 additions & 3 deletions tools/repolint/baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down