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 <willem.jiang@gmail.com>
This commit is contained in:
LingYi 2026-10-19 07:11:53 +08:00 committed by GitHub
parent e33024446a
commit 49adb0edec
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 131 additions and 0 deletions

View File

@ -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();

View File

@ -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);
});
}

View File

@ -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(
<MessageListItem
message={makeMessage("human")}
threadId="thread-1"
canEdit
onEditAndRegenerate={onEditAndRegenerate}
/>,
),
);
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");