fix(composer): keep IME-composition Enter out of the slash-skill catalog (#5741)

* fix(composer): keep IME-composition Enter out of the slash-skill catalog

`handlePromptTextareaKeyDown` called `handleSkillSuggestionKeyDown` with no
composition guard, unlike its two siblings: the inline-skill editor wraps the
same call in `isIMEComposing(...)` with a comment naming this exact hazard, and
`handlePromptHistoryKeyDown` checks it too. `PromptInputTextarea` calls the
prop's `onKeyDown` *before* its own `isIMEComposing` check, so the suggestion
handler won the event.

With an IME active and the `/` catalog open, pressing Enter to commit the
composition instead ran `event.preventDefault()` and applied the highlighted
suggestion — discarding the text still being composed and selecting a skill the
user never chose. The catalog opens from the live `textInput.value`, and React
`onChange` fires during composition, so a CJK user typing `/res` to reach a
skill hits it directly.

Guard the main composer's call site the same way the inline-skill editor does,
and pin it with a happy-dom regression test: `keyCode: 229` (the keydown a
browser reports for a key an active IME consumed) must leave both the catalog
and the draft untouched, while a plain Enter still selects the highlighted
skill.

Verified:
- new test `tests/unit/components/workspace/input-box-skill-suggestion-ime.dom.test.tsx`
  fails on main (the IME case applies the skill) and passes here; the plain-Enter
  control case passes on both
- `rstest run --pool threads tests/unit/components/workspace` -> 69 files / 448
  tests passed
- `tsc --noEmit`, `eslint`, `prettier --check` clean on the touched files

* fix(composer): ignore the Enter that follows IME compositionend

Safari emits the confirming Enter after compositionend, with neither
isComposing nor keyCode 229. Drop that Enter in PromptInputTextarea for
a short window, before the skill catalog or prompt history can treat it
as a plain key.

---------

Co-authored-by: Shxiao101 <Shxiao101@users.noreply.github.com>
This commit is contained in:
Shxiao 2026-09-24 18:56:48 +09:00 committed by GitHub
parent 2da53ded13
commit 363b0fc1b4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 261 additions and 5 deletions

View File

@ -36,7 +36,7 @@ import {
} from "@/components/ui/select";
import type { PromptInputFilePart } from "@/core/uploads";
import { splitUnsupportedUploadFiles } from "@/core/uploads";
import { isIMEComposing } from "@/lib/ime";
import { isCompositionConfirmEnter, isIMEComposing } from "@/lib/ime";
import { cn } from "@/lib/utils";
import type { ChatStatus } from "ai";
import {
@ -890,8 +890,15 @@ export const PromptInputTextarea = ({
const attachments = usePromptInputAttachments();
const sanitizeIncomingFiles = usePromptInputValidation();
const [isComposing, setIsComposing] = useState(false);
const compositionEndedAtRef = useRef(0);
const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
// Drop the post-compositionend Enter before parent handlers (the skill
// catalog and prompt history) can treat it as a plain Enter.
if (isCompositionConfirmEnter(e, compositionEndedAtRef.current)) {
e.preventDefault();
return;
}
onKeyDown?.(e);
if (e.defaultPrevented) {
return;
@ -963,7 +970,10 @@ export const PromptInputTextarea = ({
<InputGroupTextarea
className={cn("field-sizing-content max-h-48 min-h-16", className)}
name="message"
onCompositionEnd={() => setIsComposing(false)}
onCompositionEnd={() => {
compositionEndedAtRef.current = Date.now();
setIsComposing(false);
}}
onCompositionStart={() => setIsComposing(true)}
onKeyDown={handleKeyDown}
onPaste={handlePaste}

View File

@ -1958,9 +1958,16 @@ export function InputBox({
const handlePromptTextareaKeyDown = useCallback(
(event: KeyboardEvent<HTMLElement>) => {
handleSkillSuggestionKeyDown(event);
if (event.defaultPrevented) {
return;
// Same rule as the inline-skill editor: the catalog's navigation keys must
// win over Enter-to-submit, but not mid-composition, where Enter belongs
// to the IME candidate rather than the list. Safari's confirming Enter
// carries neither composition flag; PromptInputTextarea drops it before
// this handler runs.
if (!isIMEComposing(event)) {
handleSkillSuggestionKeyDown(event);
if (event.defaultPrevented) {
return;
}
}
handleSelectedSlashSkillKeyDown(event);
if (event.defaultPrevented) {

View File

@ -8,3 +8,20 @@ export function isIMEComposing(
): boolean {
return isComposing || event.nativeEvent.isComposing || event.keyCode === 229;
}
// Safari emits the Enter that confirms a composition after compositionend,
// with isComposing false and keyCode 13. React has already flushed the
// composing state by the time that keydown runs.
export const COMPOSITION_CONFIRM_ENTER_MS = 50;
export function isCompositionConfirmEnter(
event: IMEKeyboardEvent,
compositionEndedAt: number,
now = Date.now(),
): boolean {
if (event.key !== "Enter" || event.shiftKey || isIMEComposing(event)) {
return false;
}
const elapsed = now - compositionEndedAt;
return elapsed >= 0 && elapsed < COMPOSITION_CONFIRM_ENTER_MS;
}

View File

@ -0,0 +1,159 @@
import { afterEach, describe, expect, it, rs } from "@rstest/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import { PromptInputProvider } from "@/components/ai-elements/prompt-input";
import { InputBox } from "@/components/workspace/input-box";
import { ThreadContext } from "@/components/workspace/messages/context";
import { AuthProvider } from "@/core/auth/AuthProvider";
import { DEFAULT_LOCALE } from "@/core/i18n";
import { I18nProvider } from "@/core/i18n/context";
import { COMPOSITION_CONFIRM_ENTER_MS } from "@/lib/ime";
rs.mock("next/navigation", () => ({
useRouter: () => ({ push: rs.fn(), replace: rs.fn(), refresh: rs.fn() }),
usePathname: () => "/workspace",
useSearchParams: () => new URLSearchParams(),
}));
rs.mock("@/core/models/hooks", () => ({
useModels: () => ({
models: [],
tokenUsageEnabled: false,
isLoading: false,
isFetching: false,
error: null,
refetch: rs.fn(),
}),
}));
rs.mock("@/core/skills/hooks", () => ({
useSkills: () => ({
skills: [
{
name: "research",
description: "Research a topic",
category: "general",
license: "MIT",
enabled: true,
editable: false,
},
],
isLoading: false,
error: null,
}),
}));
// Each test gets its own thread id: the composer persists the selected skill in
// a debounced, thread-scoped draft, and a shared id would let the first test's
// selection land in the second test's storage key.
function renderComposer(threadId: string) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
const tree: ReactNode = (
<I18nProvider initialLocale={DEFAULT_LOCALE}>
<QueryClientProvider client={queryClient}>
<AuthProvider
initialUser={{
id: "user-1",
email: "user@example.test",
system_role: "user",
needs_setup: false,
oauth_provider: null,
}}
>
<ThreadContext.Provider
value={{ thread: { messages: [] } as never, isMock: true }}
>
<PromptInputProvider>
<InputBox
threadId={threadId}
status="ready"
context={{ mode: "flash" } as never}
/>
</PromptInputProvider>
</ThreadContext.Provider>
</AuthProvider>
</QueryClientProvider>
</I18nProvider>
);
return render(tree);
}
/** Focus the composer and type a leading slash query that opens the catalog. */
function openSkillCatalog(container: HTMLElement): HTMLTextAreaElement {
const textarea = container.querySelector("textarea");
if (!(textarea instanceof HTMLTextAreaElement)) {
throw new Error("composer textarea not rendered");
}
fireEvent.focus(textarea);
fireEvent.change(textarea, { target: { value: "/res" } });
screen.getByRole("listbox", { name: "Skill suggestions" });
return textarea;
}
afterEach(() => {
rs.restoreAllMocks();
window.sessionStorage.clear();
cleanup();
});
describe("InputBox skill suggestion IME handling", () => {
it("leaves Enter to the IME candidate window while the catalog is open", () => {
const { container } = renderComposer("thread-ime-composing");
const textarea = openSkillCatalog(container);
// 229 is the keyCode browsers report for a keydown consumed by an active
// IME composition; the catalog must not claim it.
fireEvent.keyDown(textarea, { key: "Enter", keyCode: 229 });
expect(
screen.queryByRole("button", { name: "Remove /research" }),
).toBeNull();
expect(textarea.value).toBe("/res");
});
it("does not apply a suggestion for the Enter that follows compositionend", () => {
const { container } = renderComposer("thread-ime-composition-end");
const textarea = openSkillCatalog(container);
// Safari reports the confirming Enter after compositionend, with neither
// isComposing nor keyCode 229 set.
fireEvent.compositionEnd(textarea);
fireEvent.keyDown(textarea, { key: "Enter", keyCode: 13 });
expect(
screen.queryByRole("button", { name: "Remove /research" }),
).toBeNull();
expect(textarea.value).toBe("/res");
});
it("still selects the highlighted skill once the confirm window has passed", () => {
const { container } = renderComposer("thread-ime-after-window");
const textarea = openSkillCatalog(container);
const endedAt = Date.now();
const now = rs.spyOn(Date, "now");
now.mockReturnValue(endedAt);
fireEvent.compositionEnd(textarea);
now.mockReturnValue(endedAt + COMPOSITION_CONFIRM_ENTER_MS);
fireEvent.keyDown(textarea, { key: "Enter", keyCode: 13 });
expect(
screen.getByRole("button", { name: "Remove /research" }),
).toBeTruthy();
});
it("still selects the highlighted skill on a plain Enter", () => {
const { container } = renderComposer("thread-plain-enter");
const textarea = openSkillCatalog(container);
fireEvent.keyDown(textarea, { key: "Enter" });
expect(
screen.getByRole("button", { name: "Remove /research" }),
).toBeTruthy();
});
});

View File

@ -0,0 +1,63 @@
import { describe, expect, it } from "@rstest/core";
import type { KeyboardEvent } from "react";
import {
COMPOSITION_CONFIRM_ENTER_MS,
isCompositionConfirmEnter,
} from "@/lib/ime";
function enter(
overrides: Partial<KeyboardEvent<HTMLElement>> = {},
): KeyboardEvent<HTMLElement> {
return {
key: "Enter",
shiftKey: false,
keyCode: 13,
nativeEvent: { isComposing: false },
...overrides,
} as KeyboardEvent<HTMLElement>;
}
describe("isCompositionConfirmEnter", () => {
const endedAt = 1_000;
it("matches the Enter that arrives just after compositionend", () => {
expect(isCompositionConfirmEnter(enter(), endedAt, endedAt)).toBe(true);
expect(
isCompositionConfirmEnter(
enter(),
endedAt,
endedAt + COMPOSITION_CONFIRM_ENTER_MS - 1,
),
).toBe(true);
});
it("lets a later Enter through", () => {
expect(
isCompositionConfirmEnter(
enter(),
endedAt,
endedAt + COMPOSITION_CONFIRM_ENTER_MS,
),
).toBe(false);
expect(isCompositionConfirmEnter(enter(), 0, endedAt)).toBe(false);
});
it("does not claim Shift+Enter or a keydown that is still composing", () => {
expect(
isCompositionConfirmEnter(enter({ shiftKey: true }), endedAt, endedAt),
).toBe(false);
expect(
isCompositionConfirmEnter(enter({ keyCode: 229 }), endedAt, endedAt),
).toBe(false);
expect(
isCompositionConfirmEnter(
enter({
nativeEvent: { isComposing: true } as KeyboardEvent["nativeEvent"],
}),
endedAt,
endedAt,
),
).toBe(false);
});
});