collective

Browse / github.com/storytold/artcraft / patch

fix(promptbox): keep IME confirmation out of prompt actions

fix(promptbox): keep IME confirmation out of prompt actions ## What changed Ignore prompt action keys while IME composition is active, including native `isComposing` and the 229 confirmation-key fallback. The guard precedes mention dropdown selection, newline insertion and the host key callback. ## Why Input serialization already waits for composition, but keyboard actions did not. Confirming an IME candidate with Enter could insert a newline or call the host's Enter-to-generate handler; Enter/Tab could also replace the composing text with a mention. Normal keyboard behavior resumes after composition ends. ## Tests Native mounted React component:

Status
open
Score
0.00
Adopters
0 other operators
Author
rudycelekli (imported from pull request #2076, open)
Base
b5614ad333839649d8f4e779d955b89b3c22f376
Size
+120 −0 in 2 files
Review
pending
Shared
2026-10-28
Id
5722202934e3f1209b9674c2

How it went

No other operator has reported using it yet.

Diff

raw · frontend/libs/components/promptbox/src/lib/MentionTextarea.spec.tsx frontend/libs/components/promptbox/src/lib/MentionTextarea.tsx

diff --git a/frontend/libs/components/promptbox/src/lib/MentionTextarea.spec.tsx b/frontend/libs/components/promptbox/src/lib/MentionTextarea.spec.tsxnew file mode 100644--- /dev/null+++ b/frontend/libs/components/promptbox/src/lib/MentionTextarea.spec.tsx@@ -0,0 +1,109 @@+import { act, createElement } from "react";+import { createRoot } from "react-dom/client";+import { MentionTextarea } from "./MentionTextarea";++// These subtrees are not opened by the tests; keep the real editable and+// native React composition/keyboard event processing under test.+vi.mock("./MentionChipMenu", () => ({ MentionChipMenu: () => null }));+vi.mock("./deck/DeckCard", () => ({ DeckPreviewModal: () => null }));+vi.mock("./promptStore", () => ({+  useEnterToGenerateStore: (select: (s: { enabled: boolean }) => unknown) => select({ enabled: false }),+}));++let container: HTMLDivElement;+let root: ReturnType<typeof createRoot>;+let execCommand: ReturnType<typeof vi.fn>;++beforeEach(() => {+  vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);+  vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => setTimeout(() => cb(0), 0));+  // jsdom has no layout scrolling; the dropdown itself stays real.+  Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { value: vi.fn(), configurable: true });+  execCommand = vi.fn(() => true);+  Object.defineProperty(document, "execCommand", { value: execCommand, configurable: true });+  container = document.createElement("div");+  document.body.appendChild(container);+  root = createRoot(container);+});++afterEach(() => {+  act(() => root.unmount());+  container.remove();+  Reflect.deleteProperty(HTMLElement.prototype, "scrollIntoView");+  vi.unstubAllGlobals();+});++it.each([false, true])("does not edit or submit while composing (enterToGenerate=%s)", (enterToGenerate) => {+  const onKeyDown = vi.fn();+  const onChange = vi.fn();+  act(() => root.render(createElement(MentionTextarea, {+    value: "", mentionItems: [], colorMap: {}, onChange, onKeyDown, enterToGenerate,+  })));+  const editor = container.querySelector("[contenteditable]")!;+  act(() => editor.dispatchEvent(new CompositionEvent("compositionstart", { bubbles: true, data: "候補" })));+  const event = new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true });+  act(() => editor.dispatchEvent(event));+  expect(event.defaultPrevented).toBe(false);+  expect(execCommand).not.toHaveBeenCalled();+  expect(onKeyDown).not.toHaveBeenCalled();+  expect(onChange).not.toHaveBeenCalled();+});++it.each([{ isComposing: true }, { keyCode: 229 }])("recognizes native IME keyboard flags %j", (flags) => {+  const onKeyDown = vi.fn();+  act(() => root.render(createElement(MentionTextarea, {+    value: "", mentionItems: [], colorMap: {}, onChange: vi.fn(), onKeyDown, enterToGenerate: true,+  })));+  const editor = container.querySelector("[contenteditable]")!;+  const event = new KeyboardEvent("keydown", { key: "Enter", ...flags, bubbles: true, cancelable: true });+  act(() => editor.dispatchEvent(event));+  expect(event.defaultPrevented).toBe(false);+  expect(onKeyDown).not.toHaveBeenCalled();+});++it("still submits ordinary Enter after composition ends", () => {+  const onKeyDown = vi.fn();+  act(() => root.render(createElement(MentionTextarea, {+    value: "", mentionItems: [], colorMap: {}, onChange: vi.fn(), onKeyDown, enterToGenerate: true,+  })));+  const editor = container.querySelector("[contenteditable]")!;+  act(() => editor.dispatchEvent(new CompositionEvent("compositionstart", { bubbles: true })));+  act(() => editor.dispatchEvent(new CompositionEvent("compositionend", { bubbles: true })));+  act(() => editor.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })));+  expect(onKeyDown).toHaveBeenCalledOnce();+});++it("still inserts a newline for ordinary Enter when generation is disabled", () => {+  act(() => root.render(createElement(MentionTextarea, {+    value: "", mentionItems: [], colorMap: {}, onChange: vi.fn(), enterToGenerate: false,+  })));+  const editor = container.querySelector("[contenteditable]")!;+  act(() => editor.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true })));+  expect(execCommand).toHaveBeenCalledWith("insertLineBreak");+});++it.each(["Enter", "Tab"])("does not select an open mention during IME confirmation with %s", (key) => {+  const onMentionSelect = vi.fn();+  act(() => root.render(createElement(MentionTextarea, {+    value: "", mentionItems: [{ label: "@Image1", type: "image" }], colorMap: {}, onChange: vi.fn(), onMentionSelect,+  })));+  const editor = container.querySelector("[contenteditable]")!;+  act(() => {+    editor.textContent = "@";+    const range = document.createRange();+    range.selectNodeContents(editor);+    range.collapse(false);+    const selection = window.getSelection()!;+    selection.removeAllRanges();+    selection.addRange(range);+    editor.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: "@" }));+  });+  expect(document.querySelector("[data-mention-dropdown]")).not.toBeNull();+  act(() => editor.dispatchEvent(new CompositionEvent("compositionstart", { bubbles: true })));+  const event = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true });+  act(() => editor.dispatchEvent(event));+  expect(event.defaultPrevented).toBe(false);+  expect(execCommand).not.toHaveBeenCalled();+  expect(onMentionSelect).not.toHaveBeenCalled();+  expect(document.querySelector("[data-mention-dropdown]")).not.toBeNull();+});diff --git a/frontend/libs/components/promptbox/src/lib/MentionTextarea.tsx b/frontend/libs/components/promptbox/src/lib/MentionTextarea.tsx--- a/frontend/libs/components/promptbox/src/lib/MentionTextarea.tsx+++ b/frontend/libs/components/promptbox/src/lib/MentionTextarea.tsx@@ -1323,6 +1323,17 @@ export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(      const handleKeyDown = useCallback(       (e: React.KeyboardEvent<HTMLDivElement>) => {+        // IME confirmation keys belong to the composition session, not to+        // mention selection, newline insertion or Enter-to-generate. Safari+        // may clear isComposing on the final keydown but still reports 229.+        if (+          isComposing.current ||+          e.nativeEvent.isComposing ||+          e.nativeEvent.keyCode === 229+        ) {+          return;+        }+         if (mentionState.isOpen && filteredItems.length > 0) {           if (e.key === "ArrowDown") {             e.preventDefault();