Reset stuck send phase after turns settle#1542
Conversation
- Keep the send timer alive while a send is active - Fallback-reset sendPhase when a turn settles after a missed transition
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Fallback effect immediately resets sendPhase on follow-up messages
- Added a turnIdAtSendRef that records the latest turnId when beginSendPhase is called, and the fallback effect now only resets sendPhase when the latest turn has changed since the send began, preventing premature resets from the previous turn's settled state.
Or push these changes by commenting:
@cursor push ff3437122d
Preview (ff3437122d)
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -2161,14 +2161,21 @@
};
}, [phase, sendPhase]);
- const beginSendPhase = useCallback((nextPhase: Exclude<SendPhase, "idle">) => {
- setSendStartedAt((current) => current ?? new Date().toISOString());
- setSendPhase(nextPhase);
- }, []);
+ const turnIdAtSendRef = useRef<string | null>(null);
+ const beginSendPhase = useCallback(
+ (nextPhase: Exclude<SendPhase, "idle">) => {
+ setSendStartedAt((current) => current ?? new Date().toISOString());
+ turnIdAtSendRef.current = activeLatestTurn?.turnId ?? null;
+ setSendPhase(nextPhase);
+ },
+ [activeLatestTurn?.turnId],
+ );
+
const resetSendPhase = useCallback(() => {
setSendPhase("idle");
setSendStartedAt(null);
+ turnIdAtSendRef.current = null;
}, []);
useEffect(() => {
@@ -2198,12 +2205,18 @@
// this by checking whether the turn has settled while sendPhase is still
// non-idle — the turn completing is a definitive signal that "sending" is
// over.
+ //
+ // Guard: only reset when the latest turn differs from the one recorded at
+ // send time. Without this, a follow-up message on a thread whose previous
+ // turn already settled would be immediately reset before the server creates
+ // the new turn.
useEffect(() => {
if (sendPhase === "idle") return;
- if (phase !== "running" && phase !== "connecting" && latestTurnSettled) {
+ const turnChanged = activeLatestTurn?.turnId !== turnIdAtSendRef.current;
+ if (phase !== "running" && phase !== "connecting" && latestTurnSettled && turnChanged) {
resetSendPhase();
}
- }, [latestTurnSettled, phase, resetSendPhase, sendPhase]);
+ }, [activeLatestTurn?.turnId, latestTurnSettled, phase, resetSendPhase, sendPhase]);
useEffect(() => {
if (!activeThreadId) return;…follow-up messages The fallback effect that catches missed 'running' phase transitions was incorrectly firing on follow-up messages. When beginSendPhase was called, latestTurnSettled was still true from the previous completed turn, causing an immediate resetSendPhase before the server could create the new turn. Fix: record the turnId at the time beginSendPhase is called (via a ref), and only allow the fallback reset when the latest turn differs from the one recorded — meaning a new turn was actually created and has settled. Applied via @cursor push command
- Replace local send phase state with React transition/optimistic state - Clear send timing reliably after send failures and thread creation
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Stale send timestamp never cleared after successful send
- Added a useEffect that clears sendStartedAtRef.current when phase transitions to "running", restoring the cleanup that was lost when the old sendPhase effect was removed, so subsequent sends get a fresh timestamp.
Or push these changes by commenting:
@cursor push 2a283aeced
Preview (2a283aeced)
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -2038,6 +2038,12 @@
}, [threadId]);
useEffect(() => {
+ if (phase === "running") {
+ sendStartedAtRef.current = null;
+ }
+ }, [phase]);
+
+ useEffect(() => {
let cancelled = false;
void (async () => {
if (composerImages.length === 0) {- Treat `connecting` as working in ChatView - Sync orchestration snapshot before send transitions end
- Clear `sendStartedAtRef` unconditionally when send work finishes - Prevent stale send-phase state after success or failure
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Interrupt button shown during non-send "connecting" phase
- Replaced
optimisticPhase === "connecting"withisSendBusyin theisWorkingformula so the connecting session phase only triggers working state during an active send transition, not during steady-state connection.
- Replaced
Or push these changes by commenting:
@cursor push 9480055156
Preview (9480055156)
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -674,8 +674,7 @@
const [optimisticPhase, setOptimisticPhase] = useOptimistic(phase);
const isSendBusy = isSendPending;
const isPreparingWorktree = createWorktreeMutation.isPending;
- const isWorking =
- optimisticPhase === "running" || optimisticPhase === "connecting" || isRevertingCheckpoint;
+ const isWorking = optimisticPhase === "running" || isSendBusy || isRevertingCheckpoint;
const nowIso = new Date(nowTick).toISOString();
const activeWorkStartedAt = deriveActiveWorkStartedAt(
activeLatestTurn,- Treat sub-second elapsed time as no timer - Keep send-phase status text as "Working..." until time has accrued
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 2 total unresolved issues (including 1 from previous review).
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Optimistic phase leaks to wrong thread on switch
- Added sendThreadIdRef to track which thread owns the pending send transition, then scoped isSendBusy and the optimistic phase override to only apply when the current threadId matches, preventing the working state from leaking across thread boundaries.
Or push these changes by commenting:
@cursor push 26e7c556ae
Preview (26e7c556ae)
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -340,6 +340,7 @@
>({});
const [isSendPending, startSendTransition] = useTransition();
const sendStartedAtRef = useRef<string | null>(null);
+ const sendThreadIdRef = useRef<ThreadId | null>(null);
const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false);
const [respondingRequestIds, setRespondingRequestIds] = useState<ApprovalRequestId[]>([]);
const [respondingUserInputRequestIds, setRespondingUserInputRequestIds] = useState<
@@ -672,10 +673,12 @@
const selectedModelForPicker = selectedModel;
const phase = derivePhase(activeThread?.session ?? null);
const [optimisticPhase, setOptimisticPhase] = useOptimistic(phase);
- const isSendBusy = isSendPending;
+ const isSendForCurrentThread = sendThreadIdRef.current === threadId;
+ const isSendBusy = isSendPending && isSendForCurrentThread;
const isPreparingWorktree = createWorktreeMutation.isPending;
+ const effectivePhase = isSendForCurrentThread ? optimisticPhase : phase;
const isWorking =
- optimisticPhase === "running" || optimisticPhase === "connecting" || isRevertingCheckpoint;
+ effectivePhase === "running" || effectivePhase === "connecting" || isRevertingCheckpoint;
const nowIso = new Date(nowTick).toISOString();
const activeWorkStartedAt = deriveActiveWorkStartedAt(
activeLatestTurn,
@@ -2030,6 +2033,7 @@
return [];
});
sendStartedAtRef.current = null;
+ sendInFlightRef.current = false;
setComposerHighlightedItemId(null);
setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length));
setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length));
@@ -2160,14 +2164,14 @@
: "local";
useEffect(() => {
- if (optimisticPhase !== "running" && optimisticPhase !== "connecting") return;
+ if (effectivePhase !== "running" && effectivePhase !== "connecting") return;
const timer = window.setInterval(() => {
setNowTick(Date.now());
}, 1000);
return () => {
window.clearInterval(timer);
};
- }, [optimisticPhase]);
+ }, [effectivePhase]);
useEffect(() => {
if (!activeThreadId) return;
@@ -2387,7 +2391,7 @@
const api = readNativeApi();
if (!api || !activeThread || isRevertingCheckpoint) return;
- if (optimisticPhase === "running" || isSendBusy) {
+ if (effectivePhase === "running" || isSendBusy) {
setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints.");
return;
}
@@ -2420,7 +2424,7 @@
}
setIsRevertingCheckpoint(false);
},
- [activeThread, isRevertingCheckpoint, isSendBusy, optimisticPhase, setThreadError],
+ [activeThread, effectivePhase, isRevertingCheckpoint, isSendBusy, setThreadError],
);
const onSend = async (e?: { preventDefault: () => void }) => {
@@ -2507,6 +2511,7 @@
sendInFlightRef.current = true;
sendStartedAtRef.current ??= new Date().toISOString();
+ sendThreadIdRef.current = threadIdForSend;
const composerImagesSnapshot = [...composerImages];
const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts];
@@ -2758,6 +2763,7 @@
}
sendInFlightRef.current = false;
sendStartedAtRef.current = null;
+ sendThreadIdRef.current = null;
});
};
@@ -2950,6 +2956,7 @@
sendInFlightRef.current = true;
sendStartedAtRef.current ??= new Date().toISOString();
+ sendThreadIdRef.current = threadIdForSend;
setThreadError(threadIdForSend, null);
setOptimisticUserMessages((existing) => [
...existing,
@@ -3028,6 +3035,7 @@
}
sendInFlightRef.current = false;
sendStartedAtRef.current = null;
+ sendThreadIdRef.current = null;
});
},
[
@@ -3081,6 +3089,7 @@
sendInFlightRef.current = true;
sendStartedAtRef.current ??= new Date().toISOString();
+ sendThreadIdRef.current = nextThreadId;
startSendTransition(async () => {
setOptimisticPhase("running");
@@ -3147,6 +3156,7 @@
}
sendInFlightRef.current = false;
sendStartedAtRef.current = null;
+ sendThreadIdRef.current = null;
});
}, [
activeProject,- Track send phase state per active thread - Clear stale optimistic sending state after failures and settles - Keep work indicators aligned with the authoritative session phase
- Keep the optimistic send phase stable during turn start - Remove redundant snapshot syncs that caused phase flicker
- Track the send-phase bridge phase alongside thread state - Keep active thread phase stable during optimistic send resets
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 2 total unresolved issues (including 1 from previous review).
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Stuck send state when server errors without creating turn
- Added
threadErrorinput touseOptimisticSendPhaseand includedinput.threadError !== nullas a clearing condition in the bridge cleanup effect, restoring the safety net that the oldactiveThread?.errorwatch provided.
- Added
Or push these changes by commenting:
@cursor push 47e507f331
Preview (47e507f331)
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -280,6 +280,7 @@
activeThreadId: ThreadId | null;
phase: SessionPhase;
latestTurnRequestedAt: string | null;
+ threadError: string | null;
}): {
activeSendStartedAt: string | null;
clearOptimisticSendPhase: (threadId?: ThreadId | null) => void;
@@ -303,16 +304,24 @@
if (!sendPhaseBridge || !isSendForActiveThread || !sendPhaseBridge.requestSettled) {
return;
}
- // Keep the optimistic phase visible until the async send has settled and
- // the authoritative thread state has caught up to that send.
if (
input.phase === "connecting" ||
input.phase === "running" ||
- hasLatestTurnCaughtUpToOptimisticSend(input.latestTurnRequestedAt, sendPhaseBridge.startedAt)
+ hasLatestTurnCaughtUpToOptimisticSend(
+ input.latestTurnRequestedAt,
+ sendPhaseBridge.startedAt,
+ ) ||
+ input.threadError !== null
) {
setSendPhaseBridge(null);
}
- }, [input.latestTurnRequestedAt, input.phase, isSendForActiveThread, sendPhaseBridge]);
+ }, [
+ input.latestTurnRequestedAt,
+ input.phase,
+ input.threadError,
+ isSendForActiveThread,
+ sendPhaseBridge,
+ ]);
const startOptimisticSendPhase = useCallback(
(threadId: ThreadId, startedAt: string, phase: SessionPhase) => {
@@ -792,6 +801,7 @@
activeThreadId,
phase,
latestTurnRequestedAt: activeLatestTurn?.requestedAt ?? null,
+ threadError: activeThread?.error ?? null,
});
const isSendForActiveThread = isSendBusy;
const isPreparingWorktree = createWorktreeMutation.isPending && isSendForActiveThread;- Move optimistic send state into shared store - Keep connecting composer disabled without showing Stop generation - Add coverage for the restored composer state
- add `requestSettled` state to pending sends - clear sends only after the request has settled and state has caught up - update ChatView to mark sends settled at the relevant points


Fixes stale UI state in ChatView:
What Changed
Why
UI Changes
Checklist
Note
Medium Risk
Changes core send/turn-start UI state management and reconciliation with server snapshots, which can affect composer enable/disable and interrupt behavior across threads. Risk is mitigated by added store/unit and browser-level regression tests.
Overview
Prevents stuck “Sending/Preparing” UI by moving send-phase tracking out of local
ChatViewstate into a persisted per-thread store map (pendingThreadSendById) with new actions (beginThreadSend,setThreadSendPhase,markThreadSendSettled,clearThreadSend,moveThreadSend).On snapshot sync, pending sends are reconciled/cleared automatically once the server session starts running/errors, or once a settled request is “caught up” (via
latestTurn.requestedAtvs sendstartedAt), keeping the working timer ticking during active sends.Tweaks composer UX to separate “connecting” from “sending” (disabled
Connectingsend button without showing the stop button), and updates the working timer label to avoidWorking for 0s. Adds targeted tests covering pending-send reconciliation and the connecting composer state.Written by Cursor Bugbot for commit a948198. This will update automatically on new commits. Configure here.
Note
Reset stuck send phase in ChatView after turns settle using global store
PendingThreadSend) from localChatViewcomponent state into the global Zustand store, tracked inpendingThreadSendById.reconcilePendingThreadSendslogic in store.ts to auto-clear pending sends when the server session is running, an error occurs, or the latest turn'srequestedAtcatches up to the send'sstartedAt.ChatViewnow shows a disabled 'Connecting...' button (without a stop button) when the session is connecting and no send is in progress; revert-to-checkpoint is no longer blocked during connecting.MessagesTimelinenow shows 'Working...' until at least 1 second has elapsed instead of 'Working for 0s'.Macroscope summarized a948198.