fix(frontend): surface a toast when send is suppressed during reconnect

shouldSuppressDuplicateSend returned a boolean for two very different
cases (reconnect-in-flight vs duplicate text echo), so the caller had
to silently drop both. Flagged by Sentry (MEDIUM): the React state
driving the disabled-input UI lags the synchronous ref by one render,
so a user can click send against a still-enabled input and have the
message vanish with no feedback.

Split into getSendSuppressionReason() returning "reconnecting" |
"duplicate" | null. useCopilotStream shows a toast for reconnect
("Wait for the connection to resume before sending") and keeps the
silent drop for duplicate-text echoes from the session. Boolean wrapper
retained as deprecated for back-compat.
This commit is contained in:
Zamil Majdy
2026-04-05 13:12:14 +02:00
parent fe660d9aaf
commit 7d061a0de0
2 changed files with 49 additions and 14 deletions

View File

@@ -82,17 +82,30 @@ interface SuppressDuplicateArgs {
messages: UIMessage[];
}
/**
* Reason a sendMessage was suppressed, or ``null`` to pass through.
*
* - ``"reconnecting"``: the stream is reconnecting; the caller should
* notify the user (the UI may not yet reflect the disabled state).
* - ``"duplicate"``: the same text was just submitted and echoed back
* by the session — safe to silently drop (user double-clicked).
*/
export type SuppressReason = "reconnecting" | "duplicate" | null;
/**
* Determine whether a sendMessage call should be suppressed to prevent
* duplicate POSTs during reconnect cycles or re-submits of the same text.
*
* Returns the reason so callers can surface user-visible feedback when
* the suppression isn't just a silent duplicate.
*/
export function shouldSuppressDuplicateSend({
export function getSendSuppressionReason({
text,
isReconnectScheduled,
lastSubmittedText,
messages,
}: SuppressDuplicateArgs): boolean {
if (isReconnectScheduled) return true;
}: SuppressDuplicateArgs): SuppressReason {
if (isReconnectScheduled) return "reconnecting";
if (text && lastSubmittedText === text) {
const lastUserMsg = messages.filter((m) => m.role === "user").pop();
@@ -100,10 +113,22 @@ export function shouldSuppressDuplicateSend({
?.map((p) => ("text" in p ? p.text : ""))
.join("")
.trim();
if (lastUserText === text) return true;
if (lastUserText === text) return "duplicate";
}
return false;
return null;
}
/**
* Backwards-compatible boolean wrapper for ``getSendSuppressionReason``.
*
* @deprecated Call ``getSendSuppressionReason`` directly so callers can
* distinguish between reconnect and duplicate suppression.
*/
export function shouldSuppressDuplicateSend(
args: SuppressDuplicateArgs,
): boolean {
return getSendSuppressionReason(args) !== null;
}
/**

View File

@@ -16,7 +16,7 @@ import {
extractSendMessageText,
hasActiveBackendStream,
resolveInProgressTools,
shouldSuppressDuplicateSend,
getSendSuppressionReason,
} from "./helpers";
const RECONNECT_BASE_DELAY_MS = 1_000;
@@ -260,15 +260,25 @@ export function useCopilotStream({
const sendMessage: typeof sdkSendMessage = async (...args) => {
const text = extractSendMessageText(args[0]);
if (
shouldSuppressDuplicateSend({
text,
isReconnectScheduled: isReconnectScheduledRef.current,
lastSubmittedText: lastSubmittedMsgRef.current,
messages: rawMessages,
})
)
const suppressReason = getSendSuppressionReason({
text,
isReconnectScheduled: isReconnectScheduledRef.current,
lastSubmittedText: lastSubmittedMsgRef.current,
messages: rawMessages,
});
if (suppressReason === "reconnecting") {
// The ref flips to ``true`` synchronously while the React state that
// drives the UI's disabled state only updates on the next render, so
// the user may have clicked send against a still-enabled input. Tell
// them their message wasn't dropped silently.
toast({
title: "Reconnecting",
description: "Wait for the connection to resume before sending.",
});
return;
}
if (suppressReason === "duplicate") return;
lastSubmittedMsgRef.current = text;
return sdkSendMessage(...args);