@@ -445,15 +445,20 @@ export const useSessionStore = create<SessionStore>((set, get) => ({ |
| 445 | 445 | async subscribeToChanges() { |
| 446 | 446 | if (watcherAttached) return; |
| 447 | 447 | watcherAttached = true; |
| 448 | | - await onSessionsChanged(() => { |
| 449 | | - // 2s debounce is a compromise between feeling responsive when |
| 450 | | - // the user finishes a claude turn in the real CLI outside |
| 451 | | - // claudex (so we eventually pick up the new session on disk) |
| 452 | | - // and avoiding a rescan storm while PTYs are actively |
| 453 | | - // writing. The Rust side also suppresses file-watcher events |
| 454 | | - // for sessions owned by a live PTY, so this mostly fires for |
| 455 | | - // out-of-band changes (external claude runs, git operations, |
| 456 | | - // archive pruning). |
| 448 | + await onSessionsChanged((ev) => { |
| 449 | + // ONLY rescan on structural changes (new session file appears, |
| 450 | + // session file disappears). Message-count ticks from active |
| 451 | + // writes fire as `modified` events and previously caused a |
| 452 | + // periodic ~300ms main-thread stall every few seconds — |
| 453 | + // Rust walks every session on disk, serializes a ~1-2 MB |
| 454 | + // Project[] payload, the frontend deserializes on the main |
| 455 | + // thread, and React reconciles the whole sidebar. That |
| 456 | + // full storm ran every ~2s any time an external claude |
| 457 | + // process (observer sessions, background runs) was writing |
| 458 | + // to disk, producing the "periodic mouse-move beachball" |
| 459 | + // signature. Added/Removed still trigger a rescan because |
| 460 | + // the sidebar needs to show new sessions as they appear. |
| 461 | + if (ev.kind === "modified") return; |
| 457 | 462 | if (pendingRescan) clearTimeout(pendingRescan); |
| 458 | 463 | pendingRescan = setTimeout(() => { |
| 459 | 464 | pendingRescan = null; |