Skip to content

Commit 1bc6a00

Browse files
authored
🤖 perf: event-driven git status updates instead of polling (#1293)
## Summary Replaces 3-second interval polling for git status (dirty/ahead-behind/additions-deletions) with event-driven updates. ## Trigger strategy Git status now refreshes when: - **File-modifying tool completions** (bash, file_edit_*) — 3s trailing-edge debounce, same as ReviewPanel - **Window focus / visibility change** — catches external git operations (user ran git commands outside mux) - **Initial UI subscription** — immediate fetch when workspace indicator first renders - **Explicit invalidation** — branch switches and other actions that already call `invalidateWorkspace()` ## Implementation - **GitStatusStore**: Removed interval polling; added focus/visibility listeners; wires into WorkspaceStore's file-modification events via `subscribeToFileModifications()` - **WorkspaceStore**: Consolidated `subscribeFileModifyingTool` API — single method with optional workspace filter instead of two separate methods - **AppLoader**: Wires the subscription at init time - **Preserved optimization**: Only fetches status for workspaces with active UI subscribers (`hasKeySubscribers`) ## Validation - `make typecheck` ✓ - `bun test src/browser/stores/` ✓ (60 tests) - `make static-check` ✓ --- _Generated with `mux` • Model: `anthropic:claude-opus-4-5` • Thinking: `high`_
1 parent fcecf80 commit 1bc6a00

File tree

7 files changed

+428
-61
lines changed

7 files changed

+428
-61
lines changed

src/browser/components/AppLoader.tsx

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { useState, useEffect } from "react";
22
import App from "../App";
33
import { AuthTokenModal } from "./AuthTokenModal";
44
import { LoadingScreen } from "./LoadingScreen";
5-
import { useWorkspaceStoreRaw } from "../stores/WorkspaceStore";
5+
import { useWorkspaceStoreRaw, workspaceStore } from "../stores/WorkspaceStore";
66
import { useGitStatusStoreRaw } from "../stores/GitStatusStore";
77
import { ProjectProvider, useProjectContext } from "../contexts/ProjectContext";
88
import { APIProvider, useAPI, type APIClient } from "@/browser/contexts/API";
@@ -47,7 +47,7 @@ function AppLoaderInner() {
4747
const api = apiState.api;
4848

4949
// Get store instances
50-
const workspaceStore = useWorkspaceStoreRaw();
50+
const workspaceStoreInstance = useWorkspaceStoreRaw();
5151
const gitStatusStore = useGitStatusStoreRaw();
5252

5353
// Track whether stores have been synced
@@ -56,21 +56,27 @@ function AppLoaderInner() {
5656
// Sync stores when metadata finishes loading
5757
useEffect(() => {
5858
if (api) {
59-
workspaceStore.setClient(api);
59+
workspaceStoreInstance.setClient(api);
6060
gitStatusStore.setClient(api);
6161
}
6262

6363
if (!workspaceContext.loading) {
64-
workspaceStore.syncWorkspaces(workspaceContext.workspaceMetadata);
64+
workspaceStoreInstance.syncWorkspaces(workspaceContext.workspaceMetadata);
6565
gitStatusStore.syncWorkspaces(workspaceContext.workspaceMetadata);
66+
67+
// Wire up file-modification subscription (idempotent - only subscribes once)
68+
gitStatusStore.subscribeToFileModifications((listener) =>
69+
workspaceStore.subscribeFileModifyingTool(listener)
70+
);
71+
6672
setStoresSynced(true);
6773
} else {
6874
setStoresSynced(false);
6975
}
7076
}, [
7177
workspaceContext.loading,
7278
workspaceContext.workspaceMetadata,
73-
workspaceStore,
79+
workspaceStoreInstance,
7480
gitStatusStore,
7581
api,
7682
]);

src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import { applyFrontendFilters } from "@/browser/utils/review/filterHunks";
4545
import { cn } from "@/common/lib/utils";
4646
import { useAPI, type APIClient } from "@/browser/contexts/API";
4747
import { workspaceStore } from "@/browser/stores/WorkspaceStore";
48+
import { invalidateGitStatus } from "@/browser/stores/GitStatusStore";
4849

4950
/** Stats reported to parent for tab display */
5051
interface ReviewPanelStats {
@@ -242,6 +243,7 @@ export const ReviewPanel: React.FC<ReviewPanelProps> = ({
242243
diffBase: filters.diffBase,
243244
onRefresh: () => setRefreshTrigger((prev) => prev + 1),
244245
scrollContainerRef,
246+
onGitStatusRefresh: () => invalidateGitStatus(workspaceId),
245247
});
246248

247249
const handleRefresh = () => {

src/browser/hooks/useReviewRefreshController.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ export interface UseReviewRefreshControllerOptions {
3131
onRefresh: () => void;
3232
/** Ref to scroll container for preserving scroll position */
3333
scrollContainerRef: React.RefObject<HTMLElement | null>;
34+
/** Optional: called after refresh to trigger git status update */
35+
onGitStatusRefresh?: () => void;
3436
}
3537

3638
export interface ReviewRefreshController {
@@ -60,12 +62,17 @@ export interface ReviewRefreshController {
6062
export function useReviewRefreshController(
6163
options: UseReviewRefreshControllerOptions
6264
): ReviewRefreshController {
63-
const { workspaceId, api, isCreating, onRefresh, scrollContainerRef } = options;
65+
const { workspaceId, api, isCreating, onRefresh, scrollContainerRef, onGitStatusRefresh } =
66+
options;
6467

6568
// Store diffBase in a ref so we always read the latest value
6669
const diffBaseRef = useRef(options.diffBase);
6770
diffBaseRef.current = options.diffBase;
6871

72+
// Store onGitStatusRefresh in a ref to avoid stale closures
73+
const onGitStatusRefreshRef = useRef(onGitStatusRefresh);
74+
onGitStatusRefreshRef.current = onGitStatusRefresh;
75+
6976
// State refs (avoid re-renders, just track state for refresh logic)
7077
const isRefreshingRef = useRef(false);
7178
const isInteractingRef = useRef(false);
@@ -111,6 +118,7 @@ export function useReviewRefreshController(
111118
isRefreshingRef.current = false;
112119
isRefreshingForReturn.current = false;
113120
onRefresh();
121+
onGitStatusRefreshRef.current?.();
114122

115123
// If another refresh was requested while we were fetching, do it now
116124
if (pendingBecauseInFlightRef.current) {
@@ -125,6 +133,7 @@ export function useReviewRefreshController(
125133

126134
// Local base - just trigger refresh immediately
127135
onRefresh();
136+
onGitStatusRefreshRef.current?.();
128137
});
129138

130139
// Update executeRefresh closure dependencies
@@ -151,6 +160,7 @@ export function useReviewRefreshController(
151160
isRefreshingRef.current = false;
152161
isRefreshingForReturn.current = false;
153162
onRefresh();
163+
onGitStatusRefreshRef.current?.();
154164

155165
if (pendingBecauseInFlightRef.current) {
156166
pendingBecauseInFlightRef.current = false;
@@ -162,6 +172,7 @@ export function useReviewRefreshController(
162172
}
163173

164174
onRefresh();
175+
onGitStatusRefreshRef.current?.();
165176
};
166177

167178
/**
@@ -225,7 +236,7 @@ export function useReviewRefreshController(
225236
useEffect(() => {
226237
if (!api || isCreating) return;
227238

228-
const unsubscribe = workspaceStore.subscribeFileModifyingTool(workspaceId, scheduleRefresh);
239+
const unsubscribe = workspaceStore.subscribeFileModifyingTool(scheduleRefresh, workspaceId);
229240

230241
return () => {
231242
unsubscribe();

src/browser/stores/GitStatusStore.ts

Lines changed: 58 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -10,25 +10,26 @@ import {
1010
import { useSyncExternalStore } from "react";
1111
import { MapStore } from "./MapStore";
1212
import { isSSHRuntime } from "@/common/types/runtime";
13+
import { RefreshController } from "@/browser/utils/RefreshController";
1314

1415
/**
1516
* External store for git status of all workspaces.
1617
*
1718
* Architecture:
1819
* - Lives outside React lifecycle (stable references)
19-
* - Polls git status every 3 seconds
20+
* - Event-driven updates (no polling):
21+
* - Initial subscription triggers immediate fetch
22+
* - File-modifying tools trigger debounced refresh (3s)
23+
* - Window focus triggers refresh for visible workspaces
24+
* - Explicit invalidation (branch switch, etc.)
2025
* - Manages git fetch with exponential backoff
2126
* - Notifies subscribers when status changes
2227
* - Components only re-render when their specific workspace status changes
2328
*
24-
* Migration from GitStatusContext:
25-
* - Eliminates provider re-renders every 3 seconds
26-
* - useSyncExternalStore enables selective re-renders
27-
* - Store manages polling logic internally (no useEffect cleanup in components)
29+
* Uses RefreshController for debouncing, focus handling, and in-flight guards.
2830
*/
2931

3032
// Configuration
31-
const GIT_STATUS_INTERVAL_MS = 3000; // 3 seconds - interactive updates
3233
const MAX_CONCURRENT_GIT_OPS = 5;
3334

3435
// Fetch configuration - aggressive intervals for fresh data
@@ -45,16 +46,28 @@ export class GitStatusStore {
4546
private statuses = new MapStore<string, GitStatus | null>();
4647
private fetchCache = new Map<string, FetchState>();
4748
private client: RouterClient<AppRouter> | null = null;
48-
private pollInterval: NodeJS.Timeout | null = null;
4949
private immediateUpdateQueued = false;
5050
private workspaceMetadata = new Map<string, FrontendWorkspaceMetadata>();
5151
private isActive = true;
5252

53+
// File modification subscription
54+
private fileModifyUnsubscribe: (() => void) | null = null;
55+
56+
// RefreshController handles debouncing, focus/visibility, and in-flight guards
57+
private readonly refreshController: RefreshController;
58+
5359
setClient(client: RouterClient<AppRouter>) {
5460
this.client = client;
5561
}
62+
5663
constructor() {
57-
// Store is ready for workspace sync
64+
// Create refresh controller with proactive focus refresh (catches external git changes)
65+
this.refreshController = new RefreshController({
66+
onRefresh: () => this.updateGitStatus(),
67+
debounceMs: 3000, // Same as TOOL_REFRESH_DEBOUNCE_MS in ReviewPanel
68+
refreshOnFocus: true, // Proactively refresh on focus to catch external changes
69+
focusDebounceMs: 500, // Prevent spam from rapid alt-tabbing
70+
});
5871
}
5972

6073
/**
@@ -70,16 +83,14 @@ export class GitStatusStore {
7083
subscribeKey = (workspaceId: string, listener: () => void) => {
7184
const unsubscribe = this.statuses.subscribeKey(workspaceId, listener);
7285

73-
// If a component subscribes after we started polling (common on initial load),
74-
// kick an immediate update so the UI doesn't wait for the next interval tick.
75-
//
76-
// We schedule the update as a microtask to avoid doing work in the subscribe
77-
// call itself, and to preserve the batching/concurrency limits of updateGitStatus().
86+
// If a component subscribes after initial load, kick an immediate update
87+
// so the UI doesn't wait. Uses microtask to batch multiple subscriptions.
88+
// Routes through RefreshController to respect in-flight guards.
7889
if (!this.immediateUpdateQueued && this.isActive && this.client) {
7990
this.immediateUpdateQueued = true;
8091
queueMicrotask(() => {
8192
this.immediateUpdateQueued = false;
82-
void this.updateGitStatus();
93+
this.refreshController.requestImmediate();
8394
});
8495
}
8596

@@ -114,8 +125,8 @@ export class GitStatusStore {
114125
this.statusCache.set(workspaceId, null);
115126
// Bump version to notify subscribers of the null state
116127
this.statuses.bump(workspaceId);
117-
// Trigger immediate update to fetch fresh status
118-
void this.updateGitStatus();
128+
// Trigger immediate refresh (routes through RefreshController for in-flight guard)
129+
this.refreshController.requestImmediate();
119130
}
120131

121132
private statusCache = new Map<string, GitStatus | null>();
@@ -146,38 +157,11 @@ export class GitStatusStore {
146157
}
147158
}
148159

149-
// Start polling only once (not on every sync)
150-
if (!this.pollInterval) {
151-
this.startPolling();
152-
}
153-
}
154-
155-
/**
156-
* Start polling git status.
157-
*/
158-
private startPolling(): void {
159-
// Clear existing interval
160-
if (this.pollInterval) {
161-
clearInterval(this.pollInterval);
162-
}
163-
164-
// Run immediately
165-
void this.updateGitStatus();
166-
167-
// Poll at configured interval
168-
this.pollInterval = setInterval(() => {
169-
void this.updateGitStatus();
170-
}, GIT_STATUS_INTERVAL_MS);
171-
}
160+
// Bind focus/visibility listeners once (catches external git changes)
161+
this.refreshController.bindListeners();
172162

173-
/**
174-
* Stop polling git status.
175-
*/
176-
private stopPolling(): void {
177-
if (this.pollInterval) {
178-
clearInterval(this.pollInterval);
179-
this.pollInterval = null;
180-
}
163+
// Initial fetch for all workspaces (routes through RefreshController)
164+
this.refreshController.requestImmediate();
181165
}
182166

183167
/**
@@ -480,9 +464,35 @@ export class GitStatusStore {
480464
*/
481465
dispose(): void {
482466
this.isActive = false;
483-
this.stopPolling();
484467
this.statuses.clear();
485468
this.fetchCache.clear();
469+
this.fileModifyUnsubscribe?.();
470+
this.fileModifyUnsubscribe = null;
471+
this.refreshController.dispose();
472+
}
473+
474+
/**
475+
* Subscribe to file-modifying tool completions from WorkspaceStore.
476+
* Triggers debounced git status refresh when files change.
477+
* Idempotent: only subscribes once, subsequent calls are no-ops.
478+
*/
479+
subscribeToFileModifications(
480+
subscribeAny: (listener: (workspaceId: string) => void) => () => void
481+
): void {
482+
// Only subscribe once - subsequent calls are no-ops
483+
if (this.fileModifyUnsubscribe) {
484+
return;
485+
}
486+
487+
this.fileModifyUnsubscribe = subscribeAny((workspaceId) => {
488+
// Only schedule if workspace has subscribers (same optimization as before)
489+
if (!this.statuses.hasKeySubscribers(workspaceId)) {
490+
return;
491+
}
492+
493+
// RefreshController handles debouncing, focus gating, and in-flight guards
494+
this.refreshController.schedule();
495+
});
486496
}
487497
}
488498

src/browser/stores/WorkspaceStore.ts

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1536,11 +1536,25 @@ export class WorkspaceStore {
15361536
}
15371537

15381538
/**
1539-
* Subscribe to file-modifying tool completions for a workspace.
1540-
* Used by ReviewPanel to trigger diff refresh.
1539+
* Subscribe to file-modifying tool completions.
1540+
* @param listener Called with workspaceId when a file-modifying tool completes
1541+
* @param workspaceId If provided, only notify for this workspace
15411542
*/
1542-
subscribeFileModifyingTool(workspaceId: string, listener: () => void): () => void {
1543-
return this.fileModifyingToolSubs.subscribeKey(workspaceId, listener);
1543+
subscribeFileModifyingTool(
1544+
listener: (workspaceId: string) => void,
1545+
workspaceId?: string
1546+
): () => void {
1547+
if (workspaceId) {
1548+
// Per-workspace: wrap listener to match subscribeKey signature
1549+
return this.fileModifyingToolSubs.subscribeKey(workspaceId, () => listener(workspaceId));
1550+
}
1551+
// All workspaces: subscribe to global notifications
1552+
return this.fileModifyingToolSubs.subscribeAny(() => {
1553+
// Notify for all workspaces that have pending changes
1554+
for (const wsId of this.fileModifyingToolMs.keys()) {
1555+
listener(wsId);
1556+
}
1557+
});
15441558
}
15451559

15461560
/**
@@ -1793,8 +1807,8 @@ function getStoreInstance(): WorkspaceStore {
17931807
export const workspaceStore = {
17941808
onIdleCompactionNeeded: (callback: (workspaceId: string) => void) =>
17951809
getStoreInstance().onIdleCompactionNeeded(callback),
1796-
subscribeFileModifyingTool: (workspaceId: string, listener: () => void) =>
1797-
getStoreInstance().subscribeFileModifyingTool(workspaceId, listener),
1810+
subscribeFileModifyingTool: (listener: (workspaceId: string) => void, workspaceId?: string) =>
1811+
getStoreInstance().subscribeFileModifyingTool(listener, workspaceId),
17981812
getFileModifyingToolMs: (workspaceId: string) =>
17991813
getStoreInstance().getFileModifyingToolMs(workspaceId),
18001814
clearFileModifyingToolMs: (workspaceId: string) =>

0 commit comments

Comments
 (0)