fix(control-ui): queue distinct repeated submissions instead of dropping user actions (#118884)

* fix(control-ui): preserve distinct identical chat submissions

Co-authored-by: ZYV5ge <39863830+ZYV5ge@users.noreply.github.com>

* fix(control-ui): keep submission guard key immutable

Co-authored-by: ZYV5ge <39863830+ZYV5ge@users.noreply.github.com>

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
ZYV5ge
2026-08-20 12:53:08 -07:00
committed by GitHub
parent cd04d3f82d
commit adc608401e
12 changed files with 79 additions and 24 deletions
@@ -294,10 +294,10 @@ describe("renderChatComposer controls", () => {
textarea.value = liveDraft;
}
pressComposerEnter(container, modifiers);
const action = pressComposerEnter(container, modifiers);
expect(onSend).toHaveBeenCalledOnce();
expect(onSend).toHaveBeenCalledWith("steer");
expect(onSend).toHaveBeenCalledWith("steer", action);
},
);
@@ -319,9 +319,27 @@ describe("renderChatComposer controls", () => {
sendShortcut,
});
pressComposerEnter(container, { altKey, ctrlKey: true });
const action = pressComposerEnter(container, { altKey, ctrlKey: true });
expect(onSend.mock.calls).toEqual([[]]);
expect(onSend.mock.calls).toEqual([[undefined, action]]);
},
);
it.each(["keyboard", "pointer"] as const)(
"passes the original %s submission event through the composer",
(kind) => {
const onSend = vi.fn();
const { container } = renderComposer({ draft: "Repeat this message", onSend });
const action =
kind === "keyboard"
? pressComposerEnter(container)
: new MouseEvent("click", { bubbles: true, cancelable: true });
if (kind === "pointer") {
primaryButton(container).dispatchEvent(action);
}
expect(onSend).toHaveBeenCalledWith(undefined, action);
},
);
+2 -1
View File
@@ -529,7 +529,7 @@ export class ChatPane extends ChatPaneLayoutRender {
state.requestUpdate?.();
},
onRemoveAttachment: this.removeBrowserAnnotation,
onSend: (followUpModeOverride) =>
onSend: (followUpModeOverride, submissionAction) =>
catalogKey
? void this.continueCatalogSession(catalogKey)
: suggestionViewer
@@ -537,6 +537,7 @@ export class ChatPane extends ChatPaneLayoutRender {
: void state.handleSendChat(
undefined,
followUpModeOverride ? { followUpMode: followUpModeOverride } : undefined,
submissionAction,
),
onCompact: sessionActionCallbacks.onCompact,
// Checkpoint deep-link carries the archived filter so the row stays findable.
+4 -2
View File
@@ -195,6 +195,7 @@ export async function handleSendChat(
host: ChatHost,
messageOverride?: string,
opts?: ChatSendSubmitOptions,
submissionAction?: Event,
) {
const previousDraft = host.chatMessage;
const userMessage = (messageOverride ?? host.chatMessage).trim();
@@ -470,7 +471,7 @@ export async function handleSendChat(
// Keep their guards independent so submitting one cannot suppress the other.
const submitKind = requestedEditId ? "queued-edit" : "message";
const submitKey = chatSubmitKey(host, submitKind, effectiveMessage, attachmentsToSend);
await withChatSubmitGuard(host, submitKey, async () => {
const submitMessage = async () => {
if (host.chatLoading) {
// A terminal event can render before its authoritative leaf arrives.
// Reuse the in-flight history request before fencing the follow-up send.
@@ -593,7 +594,8 @@ export async function handleSendChat(
// The reconnect queue owns the quote; later offline turns must not reuse it.
host.chatReplyTarget = null;
}
});
};
await withChatSubmitGuard(host, submitKey, submitMessage, submissionAction);
}
function prependReplyQuote(
+20
View File
@@ -5821,6 +5821,26 @@ describe("handleSendChat", () => {
expect(host.chatMessages).toStrictEqual([]);
});
it("queues identical messages from distinct user actions while coalescing re-entry", async () => {
const sent = createDeferred<unknown>();
const host = makeChatHost({
requestHandlers: { "chat.send": () => sent.promise },
});
const firstAction = new Event("submit");
const secondAction = new Event("submit");
const first = handleSendChat(host, "same prompt", undefined, firstAction);
const reentry = handleSendChat(host, "same prompt", undefined, firstAction);
const second = handleSendChat(host, "same prompt", undefined, secondAction);
expect(host.request.mock.calls.filter(([method]) => method === "chat.send")).toHaveLength(1);
expect(host.chatQueue).toHaveLength(2);
expect(host.chatQueue.map((item) => item.text)).toEqual(["same prompt", "same prompt"]);
sent.resolve({ runId: host.chatQueue[0]?.sendRunId, status: "started" });
await Promise.all([first, reentry, second]);
});
it("keeps an acknowledged live send pending while durable history is briefly stale", async () => {
let historyRequests = 0;
let runId: string | undefined;
+2 -2
View File
@@ -89,8 +89,8 @@ export class ChatStateController<TState extends ChatPageHost> implements Reactiv
state.requestUpdate = () => renderLifecycle.invalidate();
this.cleanups.push(subscribeChatOutboxProjection(state));
const sendChat = state.handleSendChat;
state.handleSendChat = async (messageOverride, options) => {
const pending = sendChat(messageOverride, options);
state.handleSendChat = async (messageOverride, options, submissionAction) => {
const pending = sendChat(messageOverride, options, submissionAction);
renderLifecycle.invalidate();
try {
await pending;
+5 -1
View File
@@ -135,7 +135,11 @@ export type ChatPageHost = ChatHost &
handleChatScroll: (event: Event) => void;
handleChatDraftChange: (next: string) => void;
handleChatInputHistoryKey: (input: ChatInputHistoryKeyInput) => ChatInputHistoryKeyResult;
handleSendChat: (messageOverride?: string, options?: unknown) => Promise<void>;
handleSendChat: (
messageOverride?: string,
options?: unknown,
submissionAction?: Event,
) => Promise<void>;
handleAbortChat: (options?: unknown) => Promise<void>;
removeQueuedMessage: (id: string) => void;
retryQueuedChatMessage: (id: string) => Promise<void>;
+2 -2
View File
@@ -293,7 +293,7 @@ export function createPageState(
};
attachChatRealtimeActions(state);
state.loadAssistantIdentity = () => loadPageAssistantIdentity(state);
state.handleSendChat = (messageOverride, options) => {
state.handleSendChat = (messageOverride, options, submissionAction) => {
const message = messageOverride ?? state.chatMessage;
const isCommand =
parseSlashCommand(message) !== null ||
@@ -312,7 +312,7 @@ export function createPageState(
) {
autoPromptNotificationsOnSend(context);
}
return handleSendChat(state, messageOverride, options as never);
return handleSendChat(state, messageOverride, options as never, submissionAction);
};
state.handleAbortChat = async (options) => {
await handleAbortChat(state, options as never);
+14 -4
View File
@@ -1,25 +1,35 @@
import { generateUUID } from "../../lib/uuid.ts";
import type { ChatHost } from "./chat-send-contract.ts";
const submissionActionIds = new WeakMap<Event, string>();
export async function withChatSubmitGuard<T>(
host: ChatHost,
key: string,
run: () => Promise<T>,
action?: Event,
): Promise<T | undefined> {
let guardKey = key;
if (action) {
const actionId = submissionActionIds.get(action) ?? generateUUID();
submissionActionIds.set(action, actionId);
guardKey = `${key}\0${actionId}`;
}
const guards = (host.chatSubmitGuards ??= new Map<string, Promise<void>>());
if (guards.has(key)) {
if (guards.has(guardKey)) {
return undefined;
}
let releaseGuard!: () => void;
const guard = new Promise<void>((resolve) => {
releaseGuard = resolve;
});
guards.set(key, guard);
guards.set(guardKey, guard);
try {
return await run();
} finally {
releaseGuard();
if (guards.get(key) === guard) {
guards.delete(key);
if (guards.get(guardKey) === guard) {
guards.delete(guardKey);
}
}
}
@@ -36,7 +36,7 @@ export type ChatRunControlsProps = {
onDictationPointerDown?: (event: PointerEvent) => void;
onPrimaryActionPointerDown?: (event: PointerEvent) => void;
onAbort?: () => void;
onSend: () => void;
onSend: (submissionAction?: Event) => void;
onToggleVoice?: () => void;
onToggleCamera?: () => void;
microphonePicker?: TemplateResult | typeof nothing;
@@ -222,8 +222,8 @@ export function renderChatPrimaryActions(props: ChatRunControlsProps) {
const activeRunActionTooltip = queueSteerShortcutAvailable
? `${activeRunActionLabel} ⏎ · ${t("chat.queue.steer")} ${t("chat.sendShortcutModifierEnter")}`
: activeRunActionLabel;
// Lit passes the click event to handlers; keep it out of the scalar send override.
const send = () => props.onSend();
// Preserve the click identity without mistaking it for a follow-up mode.
const send = (event: Event) => props.onSend(event);
const abortAction = props.canAbort
? html`
<openclaw-tooltip .content=${t("chat.runControls.stop")}>
@@ -207,9 +207,9 @@ export function createComposerKeyDownHandler({
commitDraft(target.value);
const steerImmediately = steerNowEnabled && (event.metaKey || event.ctrlKey) && !event.altKey;
if (steerImmediately) {
props.onSend("steer");
props.onSend("steer", event);
} else {
props.onSend();
props.onSend(undefined, event);
}
syncDraftAfterSend(target);
}
@@ -126,7 +126,7 @@ export type ChatComposerProps = ChatAttachmentControlsProps & {
onDraftChange: (next: string) => void;
onHistoryKeydown?: (input: ChatInputHistoryKeyInput) => ChatInputHistoryKeyResult;
onSlashIntent?: () => void | Promise<void>;
onSend: (followUpModeOverride?: "steer") => void;
onSend: (followUpModeOverride?: "steer", submissionAction?: Event) => void;
onCompact?: () => void | Promise<void>;
onToggleRealtimeTalk?: () => void;
onToggleRealtimeCamera?: () => void;
@@ -327,7 +327,7 @@ export function renderChatComposer(props: ChatComposerProps) {
commitComposerDraft(props, target.value);
props.onTypingChange?.(false);
};
const handleSend = () => {
const handleSend = (submissionAction?: Event) => {
const draft = state.composerTextarea?.value ?? props.draft;
if (!canSubmitDraft(draft)) {
return;
@@ -336,7 +336,7 @@ export function renderChatComposer(props: ChatComposerProps) {
state.composingDraft = null;
commitComposerDraft(props, draft);
props.onTypingChange?.(false);
props.onSend();
props.onSend(undefined, submissionAction);
syncComposerDraftAfterSend(state.composerTextarea);
};
const handleVoicePrimaryAction = () => {