fix(sessions): centralize global polling

This commit is contained in:
Bohdan Triapitsyn
2026-08-22 02:49:45 +03:00
parent c232d358da
commit 80068c634d
7 changed files with 88 additions and 30 deletions
@@ -0,0 +1,39 @@
import { describe, expect, test } from 'bun:test';
import {
GLOBAL_SESSIONS_REFRESH_INTERVAL_MS,
startGlobalSessionsPolling,
} from './useGlobalSessionsPolling';
describe('global sessions polling lifecycle', () => {
test('loads immediately, owns one interval, and clears it on disposal', () => {
let initialLoads = 0;
let refreshes = 0;
let scheduledCallback = () => {};
let scheduledIntervals = 0;
let scheduledDelay = 0;
let clearedIntervalId: number | null = null;
const dispose = startGlobalSessionsPolling(
() => { initialLoads += 1; },
() => { refreshes += 1; },
(callback, delay) => {
scheduledIntervals += 1;
scheduledCallback = callback;
scheduledDelay = delay;
return 42;
},
(intervalId) => { clearedIntervalId = intervalId; },
);
expect(initialLoads).toBe(1);
expect(refreshes).toBe(0);
expect(scheduledIntervals).toBe(1);
expect(scheduledDelay).toBe(GLOBAL_SESSIONS_REFRESH_INTERVAL_MS);
scheduledCallback();
expect(refreshes).toBe(1);
dispose();
expect(clearedIntervalId).toBe(42);
});
});
@@ -0,0 +1,34 @@
import React from 'react';
import { getAllSyncSessions } from '@/sync/sync-refs';
import {
ensureGlobalSessionsLoaded,
refreshGlobalSessions,
} from '@/stores/useGlobalSessionsStore';
export const GLOBAL_SESSIONS_REFRESH_INTERVAL_MS = 45_000;
type ScheduleInterval = (callback: () => void, delay: number) => number;
type ClearInterval = (intervalId: number) => void;
export const startGlobalSessionsPolling = (
initialLoad: () => void,
refresh: () => void,
scheduleInterval: ScheduleInterval = window.setInterval.bind(window),
clearScheduledInterval: ClearInterval = window.clearInterval.bind(window),
): (() => void) => {
initialLoad();
const intervalId = scheduleInterval(refresh, GLOBAL_SESSIONS_REFRESH_INTERVAL_MS);
return () => clearScheduledInterval(intervalId);
};
/** Owns the one global-session polling lifecycle for the main app runtime. */
export const useGlobalSessionsPolling = (enabled: boolean): void => {
React.useEffect(() => {
if (!enabled) return;
return startGlobalSessionsPolling(
() => { void ensureGlobalSessionsLoaded(getAllSyncSessions()); },
() => { void refreshGlobalSessions(); },
);
}, [enabled]);
};
+1 -14
View File
@@ -3,7 +3,7 @@ import type { Session } from '@opencode-ai/sdk/v2';
import { canUseElectronDesktopIPC, invokeDesktop, isDesktopLocalOriginActive } from '@/lib/desktop';
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
import { desktopHostsGet, getDesktopHostApiUrl, locationMatchesHost, redactSensitiveUrl } from '@/lib/desktopHosts';
import { getSyncChildStores, getAllSyncSessions } from '@/sync/sync-refs';
import { getSyncChildStores } from '@/sync/sync-refs';
import { opencodeClient } from '@/lib/opencode/client';
import { useGlobalSessionStatusStore, applyGlobalSessionStatusSnapshot } from '@/sync/global-session-status';
import { compareSessionsByLifecycleOrder, useSessionOrderingStore } from '@/sync/session-ordering';
@@ -12,8 +12,6 @@ import { useSessionPinnedStore } from '@/stores/useSessionPinnedStore';
import { respondToPermission } from '@/sync/session-actions';
import {
useGlobalSessionsStore,
ensureGlobalSessionsLoaded,
refreshGlobalSessions,
resolveGlobalSessionDirectory,
} from '@/stores/useGlobalSessionsStore';
import { useQuotaStore } from '@/stores/useQuotaStore';
@@ -40,10 +38,6 @@ const TRAY_ACTION_EVENT = 'openchamber:tray-action';
// Event-driven updates do the real work; this is just a slow safety net.
const POLL_INTERVAL_MS = 5000;
const FLUSH_DEBOUNCE_MS = 500;
// Pull the full cross-project session list periodically. SSE keeps the active
// directory instant; this catches sessions created in directories this client
// never opened (other worktrees, other projects, the TUI, …).
const GLOBAL_REFRESH_MS = 45000;
const MAX_SESSIONS = 20;
type TraySessionStatus = 'idle' | 'busy' | 'retry';
@@ -535,12 +529,6 @@ export const useTraySync = (): void => {
const unsubscribeSessionOrder = useSessionOrderingStore.subscribe(() => scheduleFlush());
const unsubscribePinnedSessions = useSessionPinnedStore.subscribe(() => scheduleFlush());
// Make the tray self-sufficient: load the full cross-project list now
// (independent of the sidebar) and refresh it periodically so sessions from
// directories this client never opened still show up and stay current.
void ensureGlobalSessionsLoaded(getAllSyncSessions());
const refreshInterval = window.setInterval(() => { void refreshGlobalSessions(); }, GLOBAL_REFRESH_MS);
// Global busy/retry status: fetch now and poll, so unsynced sessions don't
// sit looking idle. Synced directories stay instant via their SSE stores.
void refreshGlobalStatus();
@@ -567,7 +555,6 @@ export const useTraySync = (): void => {
disposed = true;
if (flushTimer !== null) window.clearTimeout(flushTimer);
window.clearInterval(interval);
window.clearInterval(refreshInterval);
window.clearInterval(globalStatusInterval);
unsubscribeNotif();
unsubscribeGlobal();