From 783d5c19dd18516eacb33bbc9b449d19e9e84969 Mon Sep 17 00:00:00 2001 From: zhang-guiping Date: Mon, 22 Jun 2026 15:34:01 +0800 Subject: [PATCH] fix #89466: [Bug]: Control UI chat input text not cleared after sending (#95503) Merged via squash. Prepared head SHA: 32e5fd9cc3bd3b28012b0ba0897fe456edd266f7 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 --- ui/src/ui/e2e/chat-flow.e2e.test.ts | 51 +++++++ ui/src/ui/views/chat.test.ts | 206 ++++++++++++++++++++++++++++ ui/src/ui/views/chat.ts | 75 +++++++++- 3 files changed, 331 insertions(+), 1 deletion(-) diff --git a/ui/src/ui/e2e/chat-flow.e2e.test.ts b/ui/src/ui/e2e/chat-flow.e2e.test.ts index 5c25e114e4f..66920fab1a6 100644 --- a/ui/src/ui/e2e/chat-flow.e2e.test.ts +++ b/ui/src/ui/e2e/chat-flow.e2e.test.ts @@ -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", diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index e79f4892f91..f0da355f499 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -1973,6 +1973,212 @@ describe("chat slash menu accessibility", () => { expect(container.querySelector("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(".chat-send-btn")!.click(); + + const textarea = container.querySelector("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(".chat-send-btn")!.click(); + + const textarea = container.querySelector("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 = { + "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(".chat-send-btn")!.click(); + expect(container.querySelector("textarea")?.value).toBe(""); + + renderSession("stale-replay-b"); + const textarea = container.querySelector("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 = { + "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(".chat-send-btn")!.click(); + expect(container.querySelector("textarea")?.value).toBe(""); + + renderSession("delayed-replay-b"); + const textarea = container.querySelector("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(); diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index 61b815ddd34..83fc59708ef 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -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;