mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
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:
@@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
Reference in New Issue
Block a user