perf(ui): keep chat draft local while typing (#88998)

This commit is contained in:
Vincent Koc
2026-06-01 09:19:53 +01:00
committed by GitHub
parent e680604577
commit 61574eb50b
2 changed files with 137 additions and 17 deletions

View File

@@ -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<HTMLButtonElement>(".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<HTMLTextAreaElement>("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<HTMLTextAreaElement>("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<HTMLTextAreaElement>("textarea")?.value).toBe("history recall");
});
it("wires command suggestions to the composer with stable active option ids", () => {

View File

@@ -493,7 +493,43 @@ type CachedChatItems = {
items: ReturnType<typeof buildChatItems>;
};
type ComposerDraftMirror = {
hostDraft: string;
value: string;
};
const chatItemsBySession = new Map<string, CachedChatItems>();
const composerDraftMirrors = new Map<string, ComposerDraftMirror>();
function composerDraftMirrorKey(props: Pick<ChatProps, "currentAgentId" | "sessionKey">): 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, boolean>): 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)}
<div class="agent-chat__composer-status-stack">
${renderFallbackIndicator(props.fallbackStatus)}
${renderCompactionIndicator(props.compactionStatus)}
@@ -1845,8 +1900,8 @@ export function renderChat(props: ChatProps) {
<div class="agent-chat__composer-combobox">
<textarea
${ref((el) => el && adjustTextareaHeight(el as HTMLTextAreaElement))}
.value=${props.draft}
dir=${detectTextDirection(props.draft)}
.value=${visibleDraft}
dir=${detectTextDirection(visibleDraft)}
?disabled=${!props.connected}
aria-autocomplete="list"
aria-controls=${ifDefined(slashMenuVisible ? SLASH_MENU_LISTBOX_ID : undefined)}
@@ -1854,6 +1909,7 @@ export function renderChat(props: ChatProps) {
aria-describedby=${SLASH_MENU_ACTIVE_ANNOUNCEMENT_ID}
@keydown=${handleKeyDown}
@input=${handleInput}
@blur=${handleBlur}
@paste=${(e: ClipboardEvent) => handlePaste(e, props)}
placeholder=${placeholder}
rows="1"
@@ -1933,14 +1989,14 @@ export function renderChat(props: ChatProps) {
${renderChatRunControls({
canAbort: showAbortableUi,
connected: props.connected,
draft: props.draft,
draft: visibleDraft,
hasMessages: props.messages.length > 0,
isBusy,
sending: props.sending,
onAbort: props.onAbort,
onExport: () => exportMarkdown(props),
onNewSession: props.onNewSession,
onSend: props.onSend,
onSend: handleSend,
onStoreDraft: () => {},
showSecondary: false,
})}