From 49adb0edec110eaa7a963ce2e9b6b45697b2ce25 Mon Sep 17 00:00:00 2001 From: LingYi Date: Mon, 19 Oct 2026 07:11:53 +0800 Subject: [PATCH] fix(frontend): ignore message edit shortcuts during IME composition (#6768) Co-authored-by: LingYi-Liang <270316896+LingYi-Liang@users.noreply.github.com> Co-authored-by: Willem Jiang --- .../workspace/messages/message-list-item.tsx | 2 + frontend/tests/e2e/message-edit-ime.spec.ts | 69 +++++++++++++++++++ .../messages/message-list-item.dom.test.tsx | 60 ++++++++++++++++ 3 files changed, 131 insertions(+) create mode 100644 frontend/tests/e2e/message-edit-ime.spec.ts diff --git a/frontend/src/components/workspace/messages/message-list-item.tsx b/frontend/src/components/workspace/messages/message-list-item.tsx index 8f1e0651ba..0ac5033c2d 100644 --- a/frontend/src/components/workspace/messages/message-list-item.tsx +++ b/frontend/src/components/workspace/messages/message-list-item.tsx @@ -57,6 +57,7 @@ import { } from "@/core/skills"; import { useSkills } from "@/core/skills/hooks"; import { pathOfThread } from "@/core/threads/utils"; +import { isIMEComposing } from "@/lib/ime"; import { cn } from "@/lib/utils"; import { WorkspaceChangeBadge } from "../changes"; @@ -589,6 +590,7 @@ function MessageContent_({ editState.onDraftChange(event.currentTarget.value) } onKeyDown={(event) => { + if (isIMEComposing(event)) return; if (event.key === "Escape") { event.preventDefault(); editState.onCancel(); diff --git a/frontend/tests/e2e/message-edit-ime.spec.ts b/frontend/tests/e2e/message-edit-ime.spec.ts new file mode 100644 index 0000000000..2122be30fc --- /dev/null +++ b/frontend/tests/e2e/message-edit-ime.spec.ts @@ -0,0 +1,69 @@ +import { expect, test } from "@playwright/test"; + +import { MOCK_THREAD_ID, mockLangGraphAPI } from "./utils/mock-api"; + +for (const agentName of [undefined, "test-agent"]) { + test(`message editing keeps composing shortcuts in ${agentName ?? "default"} chat`, async ({ + page, + }) => { + mockLangGraphAPI(page, { + agents: agentName + ? [ + { + name: agentName, + description: "Test agent", + system_prompt: "Test", + }, + ] + : [], + threads: [ + { + thread_id: MOCK_THREAD_ID, + title: "Editing with an IME", + agent_name: agentName, + messages: [ + { type: "human", id: "human-1", content: "Original question" }, + { type: "ai", id: "ai-1", content: "Original answer" }, + ], + }, + ], + }); + let rerunRequests = 0; + await page.route("**/runs/edit-regenerate/prepare", async (route) => { + rerunRequests++; + await route.fulfill({ + status: 409, + json: { detail: "Test rerun blocked" }, + }); + }); + await page.goto( + agentName + ? `/workspace/agents/${agentName}/chats/${MOCK_THREAD_ID}` + : `/workspace/chats/${MOCK_THREAD_ID}`, + ); + await expect( + page.getByText("Original answer", { exact: true }), + ).toBeVisible(); + await page.getByText("Original question", { exact: true }).hover(); + await page + .getByRole("button", { name: "Edit and rerun", exact: true }) + .click(); + const editor = page.locator(".is-user textarea"); + await editor.fill("Unfinished draft"); + for (const event of [ + { key: "Escape", isComposing: true }, + { key: "Escape", keyCode: 229 }, + { key: "Enter", ctrlKey: true, isComposing: true }, + { key: "Enter", ctrlKey: true, keyCode: 229 }, + { key: "Enter", metaKey: true, isComposing: true }, + { key: "Enter", metaKey: true, keyCode: 229 }, + ]) { + await editor.dispatchEvent("keydown", event); + await expect(editor).toHaveValue("Unfinished draft"); + expect(rerunRequests).toBe(0); + } + await editor.press("Escape"); + await expect(editor).toHaveCount(0); + expect(rerunRequests).toBe(0); + }); +} diff --git a/frontend/tests/unit/components/workspace/messages/message-list-item.dom.test.tsx b/frontend/tests/unit/components/workspace/messages/message-list-item.dom.test.tsx index e2f8bd9223..2f8437f0ba 100644 --- a/frontend/tests/unit/components/workspace/messages/message-list-item.dom.test.tsx +++ b/frontend/tests/unit/components/workspace/messages/message-list-item.dom.test.tsx @@ -71,6 +71,66 @@ afterEach(() => { copyDataCalls.mockClear(); }); +describe("MessageListItem edit shortcuts during IME composition", () => { + function openEditor() { + const onEditAndRegenerate = rs.fn(async () => true); + const view = render( + withI18n( + , + ), + ); + fireEvent.click( + view.getByRole("button", { name: enUS.common.editAndRerun }), + ); + const editor = view.getByRole("textbox"); + fireEvent.change(editor, { target: { value: "unfinished draft" } }); + return { view, editor, onEditAndRegenerate }; + } + + it.each([ + { key: "Escape", isComposing: true }, + { key: "Escape", keyCode: 229 }, + { key: "Enter", ctrlKey: true, isComposing: true }, + { key: "Enter", ctrlKey: true, keyCode: 229 }, + { key: "Enter", metaKey: true, isComposing: true }, + { key: "Enter", metaKey: true, keyCode: 229 }, + ])("leaves the draft and composing key untouched: %j", async (event) => { + const { view, editor, onEditAndRegenerate } = openEditor(); + await act(async () => { + expect(fireEvent.keyDown(editor, event)).toBe(true); + }); + expect(view.getByRole("textbox")).toBe(editor); + expect(view.getByDisplayValue("unfinished draft")).toBe(editor); + expect(onEditAndRegenerate).not.toHaveBeenCalled(); + }); + + it("still cancels with Escape outside composition", () => { + const { view, editor, onEditAndRegenerate } = openEditor(); + fireEvent.keyDown(editor, { key: "Escape" }); + expect(view.queryByRole("textbox")).toBeNull(); + expect(onEditAndRegenerate).not.toHaveBeenCalled(); + }); + + it.each([{ ctrlKey: true }, { metaKey: true }])( + "still submits with modified Enter outside composition: %j", + async (modifier) => { + const { view, editor, onEditAndRegenerate } = openEditor(); + await act(async () => { + fireEvent.keyDown(editor, { key: "Enter", ...modifier }); + }); + expect(onEditAndRegenerate).toHaveBeenCalledExactlyOnceWith( + "unfinished draft", + ); + expect(view.queryByRole("textbox")).toBeNull(); + }, + ); +}); + describe("MessageListItem copy-data derivation guard", () => { it("derives no copy data for an assistant row that never renders the toolbar", () => { const message = makeMessage("ai");