Skip to content

Close the active thread when it is deleted from another client#4091

Open
alfredmouelle wants to merge 1 commit into
pingdotgg:mainfrom
alfredmouelle:fix/close-deleted-active-thread
Open

Close the active thread when it is deleted from another client#4091
alfredmouelle wants to merge 1 commit into
pingdotgg:mainfrom
alfredmouelle:fix/close-deleted-active-thread

Conversation

@alfredmouelle

@alfredmouelle alfredmouelle commented Jul 17, 2026

Copy link
Copy Markdown

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:
1-before-delete

After deleting it from client B — sidebar is empty ("No threads yet"), but the thread is still fully rendered:
2-bug-thread-still-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:
3-bug-control-two-threads

Root cause

isThreadDetailEvent in apps/server/src/ws.ts is an allowlist of the event types forwarded to subscribeThread subscribers. thread.deleted was missing from it, so the server never told detail subscribers the thread was gone.

The client has always been ready for this event: threadReducer.ts already has a case "thread.deleted" returning { kind: "deleted" }, and threads.ts already calls setDeleted() on it. That branch was simply unreachable in practice.

The consequence cascades into the route. _chat.$environmentId.$threadId.tsx derives:

const threadExists = serverThreadShell !== null || serverThreadDetail !== null;

The shell clears (hence the sidebar updating), but the detail atom stays populated, so threadExists remains true via the ||, routeThreadExists never flips to false, and ChatView keeps rendering a thread that no longer exists.

Deleting from the same client always worked, which is why this went unnoticed: useThreadActions.deleteThread navigates on its own once the RPC resolves, without depending on the event stream.

Changes

apps/server/src/ws.ts — add thread.deleted to isThreadDetailEvent, so detail subscribers learn the thread is gone and their state clears.

apps/web/src/routes/_chat.$environmentId.$threadId.tsx — drop the environmentHasAnyThreads condition from the redirect guard:

-if (!routeThreadExists && environmentHasAnyThreads) {
+if (!routeThreadExists) {
   void navigate({ to: "/", replace: true });
 }

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.tsx already redirects unconditionally in the same situation, so this also removes an inconsistency between the two routes. environmentThreadRefs, environmentHasServerThreads and environmentHasDraftThreads become unused and are removed with it.

Testing

Added "delivers thread.deleted to thread detail subscribers" to apps/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:

Scenario Before After
Delete viewed thread from another client, others remain Deleted thread stays fully rendered Closes, returns to empty state
Delete viewed thread from another client, it was the last one Deleted thread stays fully rendered Closes, returns to empty state
Delete from the same client (regression check) Works Works
Create a thread (regression check for the route change) Works Works

After the fix — client A returns to the empty state on its own:
4-after-fix-empty-state

The creation check matters: the route now redirects as soon as routeThreadExists is false, and I confirmed bootstrapComplete and draftThreadExists cover 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/web currently has no component testing setup (no jsdom, no testing-library, no .tsx tests), 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: useThreadListActions has no notion of an active thread and ThreadRouteScreen shows "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

  • Adds 'thread.deleted' to the isThreadDetailEvent type guard in ws.ts so deletion events are forwarded to thread detail subscribers over WebSocket.
  • Simplifies the redirect logic in ChatThreadRouteView: when a thread no longer exists after bootstrap, the route now always navigates to '/', removing the previous check for whether the environment has any other threads.
  • Adds a server router test covering delivery of thread.deleted events 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.deleted is included in isThreadDetailEvent, so subscribeThread WebSocket 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.deleted event after snapshot.

Reviewed by Cursor Bugbot for commit 47b4ed3. Bugbot is set up for automated code reviews on this repo. Configure here.

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.
@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown

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: cdbf169d-880c-46ae-9962-89744a910f5d

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

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.

❤️ Share

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

@github-actions github-actions Bot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:S 10-29 changed lines (additions + deletions). labels Jul 17, 2026
@alfredmouelle
alfredmouelle marked this pull request as ready for review July 17, 2026 14:54

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

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 using high effort and found 1 potential issue.

Fix All in Cursor

❌ 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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 47b4ed3. Configure here.

@alfredmouelle alfredmouelle Jul 17, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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:

  1. Ack before event: React can't re-render mid-synchronous-JS, so the thread.deleted handler (a separate task) can't fire the route guard before navigate(fallback) is issued. Fallback wins.
  2. Event before ack: the guard issues navigate({ to: "/" }), then deleteThread issues navigate(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.

@macroscopeapp

macroscopeapp Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

You can customize Macroscope's approvability policy. Learn more.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:S 10-29 changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant