Close the active thread when it is deleted from another client#4091
Close the active thread when it is deleted from another client#4091alfredmouelle wants to merge 1 commit into
Conversation
isThreadDetailEvent is an allowlist of the event types forwarded to subscribeThread subscribers, and thread.deleted was missing from it. A client with the thread open was never told it was gone, so its detail state stayed populated. Since the route derives existence from `shell !== null || detail !== null`, the shell clearing was not enough: ChatView kept rendering a deleted thread while the sidebar dropped it. The client already handled the event (threadReducer has a `case "thread.deleted"` calling setDeleted); the branch was simply unreachable. Also drop the environmentHasAnyThreads condition from the route redirect guard. It only redirected when other threads remained, so deleting the last thread of an environment would land on a blank pane instead of the empty state once the server fix lets the detail state clear. _chat.draft.$draftId.tsx already redirects unconditionally here.
|
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)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Want fixes drafted automatically? Bugbot Autofix can create code changes for findings. A team admin can enable Autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 47b4ed3. Configure here.
| } | ||
|
|
||
| if (!routeThreadExists && environmentHasAnyThreads) { | ||
| if (!routeThreadExists) { |
There was a problem hiding this comment.
Delete redirect races fallback navigation
Medium Severity
Forwarding thread.deleted clears detail state so routeThreadExists can become false while useThreadActions.deleteThread is still navigating to a fallback thread. This effect then fires an unawaited navigate to /, which can finish after the fallback navigation and leave same-client deletes on the empty state instead of the next thread.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 47b4ed3. Configure here.
There was a problem hiding this comment.
I looked into this specific scenario (same-client delete of the active thread while other threads remain, expecting to land on the fallback thread). I could not reproduce the bad outcome, and I believe the ordering makes it safe. Details below.
Empirical: with two threads present, deleting the active one from the same client consistently lands on the fallback thread and stays there (verified stable over ~1.5s, no transient flash to /). Local dev has near-zero WS latency, which is the worst case for thread.deleted arriving ahead of the mutation ack, and it still resolved correctly.
Why the ordering holds. In useThreadActions.deleteThread, everything between the mutation resolving and the fallback navigation is synchronous:
await deleteThreadMutation(...) // thread.deleted may arrive around here
// clearComposerDraftForThread / clearProjectDraftThreadById / clearTerminalUiState are all sync, no await
if (shouldNavigateToFallback) { await router.navigate({ to: fallback, replace: true }) }Two possible orderings:
- Ack before event: React can't re-render mid-synchronous-JS, so the
thread.deletedhandler (a separate task) can't fire the route guard beforenavigate(fallback)is issued. Fallback wins. - Event before ack: the guard issues
navigate({ to: "/" }), thendeleteThreadissuesnavigate(fallback)second. TanStack Router supersedes the pending navigation with the latest one, so the fallback still wins.
The only way to land on / would be case 2 plus the two replace navigations applying out of order, which the router's supersede behavior prevents. Production network latency delays the event relative to the ack, pushing things further toward case 1.
Worth noting the two navigations target different destinations by design: the guard is the cross-client safety net (goes to the empty state), while deleteThread picks a same-project fallback for local deletes. Even in the improbable event this raced, the worst case would be landing on the empty state instead of the next thread on a same-client delete, still a strict improvement over the current behavior, where the deleted thread stays fully rendered.
Happy to add a defensive guard (e.g. having deleteThread navigate before dispatch, or skipping the route redirect for in-flight local deletes) if you'd prefer belt-and-suspenders here.
ApprovabilityVerdict: Needs human review You can customize Macroscope's approvability policy. Learn more. |


Problem
When a thread is deleted while another client has it open, that client keeps rendering the deleted thread indefinitely: title, messages and composer all stay visible, and the composer still accepts input. The sidebar correctly drops the thread, so the two panes disagree. A manual reload is the only way out.
This affects any setup with more than one client on the same environment: two windows/tabs, desktop plus web, or two devices.
Client A viewing the thread, before deletion:

After deleting it from client B — sidebar is empty ("No threads yet"), but the thread is still fully rendered:

Same bug with two threads (deleting the viewed one leaves the other in the sidebar) — this is not specific to deleting the last thread:

Root cause
isThreadDetailEventinapps/server/src/ws.tsis an allowlist of the event types forwarded tosubscribeThreadsubscribers.thread.deletedwas missing from it, so the server never told detail subscribers the thread was gone.The client has always been ready for this event:
threadReducer.tsalready has acase "thread.deleted"returning{ kind: "deleted" }, andthreads.tsalready callssetDeleted()on it. That branch was simply unreachable in practice.The consequence cascades into the route.
_chat.$environmentId.$threadId.tsxderives:The shell clears (hence the sidebar updating), but the detail atom stays populated, so
threadExistsremainstruevia the||,routeThreadExistsnever flips tofalse, andChatViewkeeps rendering a thread that no longer exists.Deleting from the same client always worked, which is why this went unnoticed:
useThreadActions.deleteThreadnavigates on its own once the RPC resolves, without depending on the event stream.Changes
apps/server/src/ws.ts— addthread.deletedtoisThreadDetailEvent, so detail subscribers learn the thread is gone and their state clears.apps/web/src/routes/_chat.$environmentId.$threadId.tsx— drop theenvironmentHasAnyThreadscondition from the redirect guard:This is a latent bug that the server fix would otherwise expose. The guard only redirected when other threads remained, so deleting the last thread of an environment would leave the component on
return null(a blank pane) instead of the empty state._chat.draft.$draftId.tsxalready redirects unconditionally in the same situation, so this also removes an inconsistency between the two routes.environmentThreadRefs,environmentHasServerThreadsandenvironmentHasDraftThreadsbecome unused and are removed with it.Testing
Added
"delivers thread.deleted to thread detail subscribers"toapps/server/src/server.test.ts, modelled on the neighbouring"buffers thread events published while the initial snapshot loads"test. Verified it fails without the server fix (times out waiting for an event that never arrives) and passes with it.Manually verified with two clients on one environment, in both directions:
After the fix — client A returns to the empty state on its own:

The creation check matters: the route now redirects as soon as
routeThreadExistsis false, and I confirmedbootstrapCompleteanddraftThreadExistscover the window during thread creation.Full suites pass: 1403 server tests, 1303 web tests, typecheck and lint clean.
Notes
The route change has no automated test.
apps/webcurrently has no component testing setup (no jsdom, no testing-library, no.tsxtests), so the route is not testable as-is. It is covered only by the manual two-client verification above. Happy to add coverage if you'd like that infrastructure introduced here.Mobile has a related but separate gap:
useThreadListActionshas no notion of an active thread andThreadRouteScreenshows "Thread unavailable" in place rather than navigating back. Left out of scope.Note
Close the active thread view when the thread is deleted from another client
'thread.deleted'to theisThreadDetailEventtype guard in ws.ts so deletion events are forwarded to thread detail subscribers over WebSocket.'/', removing the previous check for whether the environment has any other threads.thread.deletedevents to thread detail subscribers.Macroscope summarized 47b4ed3.
Note
Low Risk
Targeted WebSocket allowlist and route redirect tweak; no auth or data-model changes, with a focused server test.
Overview
Fixes multi-client behavior when a thread is deleted elsewhere: the open chat view now clears instead of staying on a ghost thread.
Server:
thread.deletedis included inisThreadDetailEvent, sosubscribeThreadWebSocket clients receive deletion events (client reducers already handled this type but never saw it).Web route: When bootstrap finishes and the routed thread no longer exists, navigation to
/runs unconditionally—removing the old guard that only redirected if the environment still had other threads (which left a blank pane when the last thread was deleted).Tests: New server effect test asserts the second item on a thread subscription is a
thread.deletedevent after snapshot.Reviewed by Cursor Bugbot for commit 47b4ed3. Bugbot is set up for automated code reviews on this repo. Configure here.