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
new file mode 100644@@ -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();+});@@ -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();