Skip to content

Reset stuck send phase after turns settle#1542

Closed
juliusmarminge wants to merge 12 commits intomainfrom
feature/fix-send-phase-reset
Closed

Reset stuck send phase after turns settle#1542
juliusmarminge wants to merge 12 commits intomainfrom
feature/fix-send-phase-reset

Conversation

@juliusmarminge
Copy link
Copy Markdown
Member

@juliusmarminge juliusmarminge commented Mar 29, 2026

  • Keep the send timer alive while a send is active
  • Fallback-reset sendPhase when a turn settles after a missed transition

Fixes stale UI state in ChatView:

image

What Changed

Why

UI Changes

Checklist

  • This PR is small and focused
  • I explained what changed and why
  • I included before/after screenshots for any UI changes
  • I included a video for animation/interaction changes

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 ChatView state 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.requestedAt vs send startedAt), keeping the working timer ticking during active sends.

Tweaks composer UX to separate “connecting” from “sending” (disabled Connecting send button without showing the stop button), and updates the working timer label to avoid Working 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

  • Moves per-thread send state (PendingThreadSend) from local ChatView component state into the global Zustand store, tracked in pendingThreadSendById.
  • Adds reconcilePendingThreadSends logic in store.ts to auto-clear pending sends when the server session is running, an error occurs, or the latest turn's requestedAt catches up to the send's startedAt.
  • ChatView now 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.
  • The working timer in MessagesTimeline now shows 'Working...' until at least 1 second has elapsed instead of 'Working for 0s'.
  • Behavioral Change: send busy state persists across re-renders and thread navigation until the server confirms the turn, rather than being reset on local state teardown.

Macroscope summarized a948198.

- Keep the send timer alive while a send is active
- Fallback-reset sendPhase when a turn settles after a missed transition
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 29, 2026

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: cee18028-41de-4bbd-980d-0e61da93dc10

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/fix-send-phase-reset

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions bot added size:S 10-29 changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. labels Mar 29, 2026
Copy link
Copy Markdown
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Create PR

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;

@juliusmarminge
Copy link
Copy Markdown
Member Author

@cursor push ff34371

…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
@github-actions github-actions bot added size:M 30-99 changed lines (additions + deletions). and removed size:S 10-29 changed lines (additions + deletions). labels Mar 29, 2026
- Replace local send phase state with React transition/optimistic state
- Clear send timing reliably after send failures and thread creation
@github-actions github-actions bot added size:L 100-499 changed lines (additions + deletions). and removed size:M 30-99 changed lines (additions + deletions). labels Mar 29, 2026
Copy link
Copy Markdown
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Create PR

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
Copy link
Copy Markdown
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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" with isSendBusy in the isWorking formula so the connecting session phase only triggers working state during an active send transition, not during steady-state connection.

Create PR

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
Copy link
Copy Markdown
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Create PR

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
Copy link
Copy Markdown
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Stuck send state when server errors without creating turn
    • Added threadError input to useOptimisticSendPhase and included input.threadError !== null as a clearing condition in the bridge cleanup effect, restoring the safety net that the old activeThread?.error watch provided.

Create PR

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L 100-499 changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants