From 61574eb50bd46e07057ffa430a2b8ba7cc699bd2 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 1 Jun 2026 09:19:53 +0100 Subject: [PATCH] perf(ui): keep chat draft local while typing (#88998) --- ui/src/ui/views/chat.test.ts | 70 ++++++++++++++++++++++++++++-- ui/src/ui/views/chat.ts | 84 ++++++++++++++++++++++++++++++------ 2 files changed, 137 insertions(+), 17 deletions(-) diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index 0f41a571760..47c7ad88eea 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -1127,15 +1127,79 @@ describe("chat slash menu accessibility", () => { textarea!.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true })); } - it("does not request a slash-menu rerender for plain draft input when suggestions are closed", () => { + it("keeps plain draft input local until send while suggestions are closed", () => { const onDraftChange = vi.fn(); const onRequestUpdate = vi.fn(); - const container = renderChatView({ onDraftChange, onRequestUpdate }); + const onSend = vi.fn(); + const container = renderChatView({ onDraftChange, onRequestUpdate, onSend }); inputDraft(container, "plain first message"); - expect(onDraftChange).toHaveBeenCalledWith("plain first message"); + expect(onDraftChange).not.toHaveBeenCalled(); expect(onRequestUpdate).not.toHaveBeenCalled(); + + container.querySelector(".chat-send-btn")!.click(); + + expect(onDraftChange).toHaveBeenCalledWith("plain first message"); + expect(onSend).toHaveBeenCalledTimes(1); + }); + + it("commits local draft input before Enter sends", () => { + const onDraftChange = vi.fn(); + const onSend = vi.fn(); + const container = renderChatView({ onDraftChange, onSend }); + + inputDraft(container, "send from enter"); + keydownComposer(container, "Enter"); + + expect(onDraftChange).toHaveBeenCalledWith("send from enter"); + expect(onSend).toHaveBeenCalledTimes(1); + }); + + it("commits local draft input on blur", () => { + const onDraftChange = vi.fn(); + const container = renderChatView({ onDraftChange }); + + inputDraft(container, "persist before leaving composer"); + container + .querySelector("textarea")! + .dispatchEvent(new FocusEvent("blur", { bubbles: false })); + + expect(onDraftChange).toHaveBeenCalledWith("persist before leaving composer"); + }); + + it("commits plain draft input while a send is active", () => { + const onDraftChange = vi.fn(); + const container = renderChatView({ onDraftChange, sending: true }); + + inputDraft(container, "do not let failed send restore over this"); + + expect(onDraftChange).toHaveBeenCalledWith("do not let failed send restore over this"); + }); + + it("preserves local draft input across unrelated rerenders", () => { + const onDraftChange = vi.fn(); + const container = document.createElement("div"); + + render(renderChat(createChatProps({ onDraftChange })), container); + inputDraft(container, "still typing locally"); + render(renderChat(createChatProps({ onDraftChange, loading: true })), container); + + expect(container.querySelector("textarea")?.value).toBe( + "still typing locally", + ); + expect(onDraftChange).not.toHaveBeenCalled(); + }); + + it("replaces local draft input when the host draft changes", () => { + const onDraftChange = vi.fn(); + const container = document.createElement("div"); + + render(renderChat(createChatProps({ onDraftChange, draft: "" })), container); + inputDraft(container, "still typing locally"); + render(renderChat(createChatProps({ onDraftChange, draft: "history recall" })), container); + + expect(container.querySelector("textarea")?.value).toBe("history recall"); }); it("wires command suggestions to the composer with stable active option ids", () => { diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index 2fecbba3dbb..a56b7d27cc7 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -493,7 +493,43 @@ type CachedChatItems = { items: ReturnType; }; +type ComposerDraftMirror = { + hostDraft: string; + value: string; +}; + const chatItemsBySession = new Map(); +const composerDraftMirrors = new Map(); + +function composerDraftMirrorKey(props: Pick): string { + return `${props.currentAgentId}\u0000${props.sessionKey}`; +} + +function getComposerDraftMirror(props: ChatProps): ComposerDraftMirror { + const mirror = getOrCreateSessionCacheValue( + composerDraftMirrors, + composerDraftMirrorKey(props), + () => ({ + hostDraft: props.draft, + value: props.draft, + }), + ); + if (mirror.hostDraft !== props.draft) { + mirror.hostDraft = props.draft; + mirror.value = props.draft; + } + return mirror; +} + +function commitComposerDraft(props: ChatProps, value: string): void { + const mirror = getComposerDraftMirror(props); + mirror.value = value; + if (mirror.hostDraft === value) { + return; + } + mirror.hostDraft = value; + props.onDraftChange(value); +} function sameChatItemsInput(previous: BuildChatItemsProps, next: BuildChatItemsProps): boolean { return ( @@ -552,6 +588,7 @@ function stableBooleanMapSignature(values: ReadonlyMap): string export function resetChatViewState() { Object.assign(vs, createChatEphemeralState()); chatItemsBySession.clear(); + composerDraftMirrors.clear(); } export const cleanupChatModuleState = resetChatViewState; @@ -965,7 +1002,7 @@ function selectSlashCommand( ): void { // Transition to arg picker when the command has fixed options if (cmd.argOptions?.length) { - props.onDraftChange(`/${cmd.name} `); + commitComposerDraft(props, `/${cmd.name} `); vs.slashMenuMode = "args"; vs.slashMenuCommand = cmd; vs.slashMenuArgItems = cmd.argOptions; @@ -980,11 +1017,11 @@ function selectSlashCommand( resetSlashMenuState(); if (cmd.executeLocal && !cmd.args) { - props.onDraftChange(`/${cmd.name}`); + commitComposerDraft(props, `/${cmd.name}`); requestUpdate(); props.onSend(); } else { - props.onDraftChange(`/${cmd.name} `); + commitComposerDraft(props, `/${cmd.name} `); requestUpdate(); } } @@ -996,7 +1033,7 @@ function tabCompleteSlashCommand( ): void { // Tab: fill in the command text without executing if (cmd.argOptions?.length) { - props.onDraftChange(`/${cmd.name} `); + commitComposerDraft(props, `/${cmd.name} `); vs.slashMenuMode = "args"; vs.slashMenuCommand = cmd; vs.slashMenuArgItems = cmd.argOptions; @@ -1009,7 +1046,7 @@ function tabCompleteSlashCommand( vs.slashMenuOpen = false; resetSlashMenuState(); - props.onDraftChange(cmd.args ? `/${cmd.name} ` : `/${cmd.name}`); + commitComposerDraft(props, cmd.args ? `/${cmd.name} ` : `/${cmd.name}`); requestUpdate(); } @@ -1022,7 +1059,7 @@ function selectSlashArg( const cmdName = vs.slashMenuCommand?.name ?? ""; vs.slashMenuOpen = false; resetSlashMenuState(); - props.onDraftChange(`/${cmdName} ${arg}`); + commitComposerDraft(props, `/${cmdName} ${arg}`); requestUpdate(); if (execute) { props.onSend(); @@ -1205,6 +1242,7 @@ function renderPinnedSection( function renderSlashMenu( requestUpdate: () => void, props: ChatProps, + draft: string, ): TemplateResult | typeof nothing { if (!vs.slashMenuOpen) { return nothing; @@ -1320,7 +1358,7 @@ function renderSlashMenu( e.preventDefault(); e.stopPropagation(); vs.slashMenuExpanded = true; - updateSlashMenu(props.draft, requestUpdate); + updateSlashMenu(draft, requestUpdate); }} > Show ${hiddenCount} more command${hiddenCount !== 1 ? "s" : ""} @@ -1348,10 +1386,12 @@ export function renderChat(props: ChatProps) { name: props.assistantName, avatar: resolveAssistantDisplayAvatar(props), }; + const draftMirror = getComposerDraftMirror(props); + const visibleDraft = draftMirror.value; const pinned = getPinnedMessages(props.sessionKey); const deleted = getDeletedMessages(props.sessionKey); const hasAttachments = (props.attachments?.length ?? 0) > 0; - const tokens = tokenEstimate(props.draft); + const tokens = tokenEstimate(visibleDraft); const placeholder = props.connected ? hasAttachments @@ -1655,6 +1695,7 @@ export function renderChat(props: ChatProps) { if ((e.key === "ArrowUp" || e.key === "ArrowDown") && props.onHistoryKeydown) { const target = e.target as HTMLTextAreaElement; + commitComposerDraft(props, target.value); const result = props.onHistoryKeydown({ key: e.key, selectionStart: target.selectionStart, @@ -1699,6 +1740,8 @@ export function renderChat(props: ChatProps) { } e.preventDefault(); if (canCompose) { + const target = e.target as HTMLTextAreaElement; + commitComposerDraft(props, target.value); props.onSend(); } } @@ -1707,8 +1750,20 @@ export function renderChat(props: ChatProps) { const handleInput = (e: Event) => { const target = e.target as HTMLTextAreaElement; adjustTextareaHeight(target); + draftMirror.value = target.value; + const hostDraftNeeded = isBusy || showAbortableUi || props.queue.length > 0; + if (hostDraftNeeded || target.value.startsWith("/") || hasVisibleSlashMenuState()) { + commitComposerDraft(props, target.value); + } updateSlashMenu(target.value, requestUpdate); - props.onDraftChange(target.value); + }; + const handleBlur = (e: FocusEvent) => { + const target = e.target as HTMLTextAreaElement; + commitComposerDraft(props, target.value); + }; + const handleSend = () => { + commitComposerDraft(props, draftMirror.value); + props.onSend(); }; const slashMenuVisible = isSlashMenuVisible(); const activeSlashMenuOptionId = getActiveSlashMenuOptionId(); @@ -1805,7 +1860,7 @@ export function renderChat(props: ChatProps) { class="agent-chat__input" @click=${(event: MouseEvent) => focusComposerFromChrome(event, props.connected)} > - ${renderSlashMenu(requestUpdate, props)} ${renderAttachmentPreview(props)} + ${renderSlashMenu(requestUpdate, props, visibleDraft)} ${renderAttachmentPreview(props)}
${renderFallbackIndicator(props.fallbackStatus)} ${renderCompactionIndicator(props.compactionStatus)} @@ -1845,8 +1900,8 @@ export function renderChat(props: ChatProps) {