fix #89466: [Bug]: Control UI chat input text not cleared after sending (#95503)

Merged via squash.

Prepared head SHA: 32e5fd9cc3
Co-authored-by: zhangguiping-xydt <275915537+zhangguiping-xydt@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
This commit is contained in:
zhang-guiping
2026-06-22 15:34:01 +08:00
committed by GitHub
parent 15a0609a6b
commit 783d5c19dd
3 changed files with 331 additions and 1 deletions

View File

@@ -252,6 +252,57 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
}
});
it("keeps the composer clear when a stale native input replay arrives after send", async () => {
const context = await newBrowserContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const gateway = await installMockGateway(page, {
historyMessages: [
{
content: [{ text: "Ready for stale replay check.", type: "text" }],
role: "assistant",
timestamp: Date.now(),
},
],
});
try {
await page.goto(`${server.baseUrl}chat`);
await page.getByText("Ready for stale replay check.").waitFor({ timeout: 10_000 });
const prompt = "submitted message";
const composer = page.locator(".agent-chat__composer-combobox textarea");
await composer.fill(prompt);
await page.getByRole("button", { name: "Send message" }).click();
await gateway.waitForRequest("chat.send");
expect(await composer.inputValue()).toBe("");
const afterReplay = await composer.evaluate((element, submitted) => {
const textarea = element as HTMLTextAreaElement;
textarea.value = submitted;
textarea.dispatchEvent(
new InputEvent("input", {
bubbles: true,
data: submitted,
inputType: "insertText",
}),
);
return textarea.value;
}, prompt);
expect(afterReplay).toBe("");
expect(await composer.inputValue()).toBe("");
await composer.pressSequentially(prompt);
expect(await composer.inputValue()).toBe(prompt);
} finally {
await closeBrowserContext(context);
}
});
it("copies a code block over a non-secure context via the execCommand fallback", async () => {
const context = await newBrowserContext({
locale: "en-US",

View File

@@ -1973,6 +1973,212 @@ describe("chat slash menu accessibility", () => {
expect(container.querySelector<HTMLTextAreaElement>("textarea")?.value).toBe("");
});
it("ignores a stale native InputEvent replay after send clears the host draft", () => {
let draft = "";
const container = document.createElement("div");
const onDraftChange = vi.fn((next: string) => {
draft = next;
});
const onSend = vi.fn(() => {
draft = "";
});
const renderWithDraft = () => {
render(
renderChat(createChatProps({ draft, getDraft: () => draft, onDraftChange, onSend })),
container,
);
};
renderWithDraft();
inputDraft(container, "submitted message");
container.querySelector<HTMLButtonElement>(".chat-send-btn")!.click();
const textarea = container.querySelector<HTMLTextAreaElement>("textarea");
expect(textarea?.value).toBe("");
textarea!.value = "submitted message";
textarea!.dispatchEvent(
new InputEvent("input", {
bubbles: true,
data: "submitted message",
inputType: "insertText",
}),
);
expect(textarea?.value).toBe("");
expect(onDraftChange).toHaveBeenCalledTimes(1);
});
it("keeps a new same-session draft when a delayed stale replay arrives", () => {
let draft = "";
const container = document.createElement("div");
const onDraftChange = vi.fn((next: string) => {
draft = next;
});
const onSend = vi.fn(() => {
draft = "";
});
const renderWithDraft = () => {
render(
renderChat(createChatProps({ draft, getDraft: () => draft, onDraftChange, onSend })),
container,
);
};
renderWithDraft();
inputDraft(container, "submitted message");
container.querySelector<HTMLButtonElement>(".chat-send-btn")!.click();
const textarea = container.querySelector<HTMLTextAreaElement>("textarea");
expect(textarea?.value).toBe("");
textarea!.dispatchEvent(
new InputEvent("beforeinput", {
bubbles: true,
data: "new draft",
inputType: "insertText",
}),
);
textarea!.value = "new draft";
textarea!.dispatchEvent(
new InputEvent("input", {
bubbles: true,
data: "new draft",
inputType: "insertText",
}),
);
expect(textarea?.value).toBe("new draft");
textarea!.value = "submitted message";
textarea!.dispatchEvent(
new InputEvent("input", {
bubbles: true,
data: "submitted message",
inputType: "insertText",
}),
);
expect(textarea?.value).toBe("new draft");
expect(onDraftChange).toHaveBeenCalledTimes(1);
});
it("does not apply a stale submitted draft replay to another session", () => {
const drafts: Record<string, string> = {
"stale-replay-a": "",
"stale-replay-b": "",
};
const onDraftChange = vi.fn((sessionKey: string, next: string) => {
drafts[sessionKey] = next;
});
const container = document.createElement("div");
const renderSession = (sessionKey: string) => {
render(
renderChat(
createChatProps({
currentAgentId: "stale-replay-agent",
draft: drafts[sessionKey],
getDraft: () => drafts[sessionKey],
onDraftChange: (next) => onDraftChange(sessionKey, next),
onSend: () => {
drafts[sessionKey] = "";
},
sessionKey,
}),
),
container,
);
};
renderSession("stale-replay-a");
inputDraft(container, "submitted message");
container.querySelector<HTMLButtonElement>(".chat-send-btn")!.click();
expect(container.querySelector<HTMLTextAreaElement>("textarea")?.value).toBe("");
renderSession("stale-replay-b");
const textarea = container.querySelector<HTMLTextAreaElement>("textarea");
expect(textarea?.value).toBe("");
textarea!.value = "submitted message";
textarea!.dispatchEvent(
new InputEvent("input", {
bubbles: true,
data: "submitted message",
inputType: "insertText",
}),
);
expect(textarea?.value).toBe("");
expect(drafts["stale-replay-b"]).toBe("");
expect(onDraftChange).toHaveBeenCalledTimes(1);
});
it("keeps an intervening session draft when a delayed stale replay arrives", () => {
const drafts: Record<string, string> = {
"delayed-replay-a": "",
"delayed-replay-b": "",
};
const onDraftChange = vi.fn((sessionKey: string, next: string) => {
drafts[sessionKey] = next;
});
const container = document.createElement("div");
const renderSession = (sessionKey: string) => {
render(
renderChat(
createChatProps({
currentAgentId: "delayed-replay-agent",
draft: drafts[sessionKey],
getDraft: () => drafts[sessionKey],
onDraftChange: (next) => onDraftChange(sessionKey, next),
onSend: () => {
drafts[sessionKey] = "";
},
sessionKey,
}),
),
container,
);
};
renderSession("delayed-replay-a");
inputDraft(container, "submitted message");
container.querySelector<HTMLButtonElement>(".chat-send-btn")!.click();
expect(container.querySelector<HTMLTextAreaElement>("textarea")?.value).toBe("");
renderSession("delayed-replay-b");
const textarea = container.querySelector<HTMLTextAreaElement>("textarea");
expect(textarea?.value).toBe("");
textarea!.dispatchEvent(
new InputEvent("beforeinput", {
bubbles: true,
data: "session b draft",
inputType: "insertText",
}),
);
textarea!.value = "session b draft";
textarea!.dispatchEvent(
new InputEvent("input", {
bubbles: true,
data: "session b draft",
inputType: "insertText",
}),
);
expect(textarea?.value).toBe("session b draft");
textarea!.value = "submitted message";
textarea!.dispatchEvent(
new InputEvent("input", {
bubbles: true,
data: "submitted message",
inputType: "insertText",
}),
);
expect(textarea?.value).toBe("session b draft");
expect(drafts["delayed-replay-b"]).toBe("");
expect(onDraftChange).toHaveBeenCalledTimes(1);
});
it("commits local draft input before Enter sends", () => {
const onDraftChange = vi.fn();
const onSend = vi.fn();

View File

@@ -33,13 +33,13 @@ import { CHAT_HISTORY_RENDER_LIMIT } from "../chat/history-limits.ts";
import type { ChatInputHistoryKeyInput, ChatInputHistoryKeyResult } from "../chat/input-history.ts";
import { PinnedMessages } from "../chat/pinned-messages.ts";
import { getPinnedMessageSummary } from "../chat/pinned-summary.ts";
import type { RealtimeTalkConversationEntry } from "../chat/realtime-talk-conversation.ts";
import {
REALTIME_TALK_FALLBACK_PROVIDERS,
listSelectableRealtimeTalkProviders,
resolveControlUiRealtimeTalkProviderTransports,
type RealtimeTalkCatalogProvider,
} from "../chat/realtime-talk-catalog.ts";
import type { RealtimeTalkConversationEntry } from "../chat/realtime-talk-conversation.ts";
import type { RealtimeTalkStatus } from "../chat/realtime-talk.ts";
import { renderChatRunControls } from "../chat/run-controls.ts";
import type { ChatRunUiStatus } from "../chat/run-lifecycle.ts";
@@ -506,6 +506,11 @@ function renderRealtimeTalkConversation(props: ChatProps) {
`;
}
type PendingClearedSubmittedDraft = {
key: string;
value: string;
};
interface ChatEphemeralState {
slashMenuOpen: boolean;
slashMenuItems: SlashCommandDef[];
@@ -519,6 +524,8 @@ interface ChatEphemeralState {
searchQuery: string;
pinnedExpanded: boolean;
composerComposing: boolean;
composerInputIntentKey: string | null;
pendingClearedSubmittedDraft: PendingClearedSubmittedDraft | null;
historyRenderSessionKey: string | null;
historyRenderMessagesRef: unknown[] | null;
historyRenderMessageCount: number;
@@ -546,6 +553,8 @@ function createChatEphemeralState(): ChatEphemeralState {
searchQuery: "",
pinnedExpanded: false,
composerComposing: false,
composerInputIntentKey: null,
pendingClearedSubmittedDraft: null,
historyRenderSessionKey: null,
historyRenderMessagesRef: null,
historyRenderMessageCount: 0,
@@ -602,6 +611,47 @@ function commitComposerDraft(props: ChatProps, value: string): void {
props.onDraftChange(value);
}
function markComposerInputIntent(key: string): void {
vs.composerInputIntentKey = key;
}
function consumeComposerInputIntent(key: string): boolean {
if (vs.composerInputIntentKey !== key) {
return false;
}
vs.composerInputIntentKey = null;
return true;
}
function clearPendingClearedSubmittedDraft(key: string): void {
if (vs.pendingClearedSubmittedDraft?.key === key) {
vs.pendingClearedSubmittedDraft = null;
}
}
function isExplicitComposerInsertion(event: InputEvent): boolean {
return event.inputType === "insertFromPaste" || event.inputType === "insertFromDrop";
}
function suppressStaleSubmittedDraftReplay(
target: HTMLTextAreaElement,
event: InputEvent,
draftMirror: ComposerDraftMirror,
hasInputIntent: boolean,
): boolean {
const pending = vs.pendingClearedSubmittedDraft;
if (!pending) {
return false;
}
if (target.value !== pending.value || hasInputIntent || isExplicitComposerInsertion(event)) {
return false;
}
target.value = draftMirror.value;
adjustTextareaHeight(target);
return true;
}
function sameChatItemsInput(previous: BuildChatItemsProps, next: BuildChatItemsProps): boolean {
return (
previous.sessionKey === next.sessionKey &&
@@ -2263,10 +2313,22 @@ export function renderChat(props: ChatProps) {
if (typeof hostDraft !== "string") {
return;
}
const mirrorKey = composerDraftMirrorKey(props);
const submittedDraft = draftMirror.value;
const clearedSubmittedDraft =
hostDraft === "" && submittedDraft !== "" && target?.value === submittedDraft;
// Sends can clear the host draft synchronously before Lit rerenders; keep
// the local mirror aligned so the submitted text does not stay editable.
draftMirror.hostDraft = hostDraft;
draftMirror.value = hostDraft;
if (clearedSubmittedDraft) {
vs.pendingClearedSubmittedDraft = {
key: mirrorKey,
value: submittedDraft,
};
} else {
clearPendingClearedSubmittedDraft(mirrorKey);
}
if (target && target.value !== hostDraft) {
target.value = hostDraft;
adjustTextareaHeight(target);
@@ -2417,8 +2479,15 @@ export function renderChat(props: ChatProps) {
}
updateSlashMenu(target.value, requestUpdate, props, {}, () => target.value);
};
const handleBeforeInput = (e: InputEvent) => {
if (!vs.composerComposing && !e.isComposing) {
markComposerInputIntent(composerDraftMirrorKey(props));
}
};
const handleInput = (e: InputEvent) => {
const target = e.target as HTMLTextAreaElement;
const mirrorKey = composerDraftMirrorKey(props);
const hasInputIntent = consumeComposerInputIntent(mirrorKey);
if (vs.composerComposing || e.isComposing) {
// Skip adjustTextareaHeight during IME composition — each pinyin
// keystroke fires `input` and the height read/write forces a
@@ -2427,6 +2496,9 @@ export function renderChat(props: ChatProps) {
draftMirror.value = target.value;
return;
}
if (suppressStaleSubmittedDraftReplay(target, e, draftMirror, hasInputIntent)) {
return;
}
syncComposerValue(target);
};
const handleCompositionEnd = (e: CompositionEvent) => {
@@ -2538,6 +2610,7 @@ export function renderChat(props: ChatProps) {
aria-activedescendant=${ifDefined(activeSlashMenuOptionId ?? undefined)}
aria-describedby=${SLASH_MENU_ACTIVE_ANNOUNCEMENT_ID}
@keydown=${handleKeyDown}
@beforeinput=${handleBeforeInput}
@input=${handleInput}
@compositionstart=${() => {
vs.composerComposing = true;