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
+2
View File
@@ -11,6 +11,7 @@ import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
import { useMenuActions } from '@/hooks/useMenuActions';
import { useSessionStatusBootstrap } from '@/hooks/useSessionStatusBootstrap';
import { useTraySync } from '@/hooks/useTraySync';
import { useGlobalSessionsPolling } from '@/hooks/useGlobalSessionsPolling';
import { useRouter } from '@/hooks/useRouter';
import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon';
import { useWebNotificationStream } from '@/hooks/useWebNotificationStream';
@@ -719,6 +720,7 @@ function App({ apis }: AppProps) {
useMenuActions(handleToggleMemoryDebug);
useTraySync();
useGlobalSessionsPolling(!embeddedSessionChat);
useSessionStatusBootstrap({ enabled: embeddedBackgroundWorkEnabled });
+2
View File
@@ -10,6 +10,7 @@ import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
import { OpenCodeUpdateToast } from '@/components/update/OpenCodeUpdateToast';
import { VSCodeLayout } from '@/components/layout/VSCodeLayout';
import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon';
import { useGlobalSessionsPolling } from '@/hooks/useGlobalSessionsPolling';
import { useRouter } from '@/hooks/useRouter';
import { useWindowTitle } from '@/hooks/useWindowTitle';
import { opencodeClient } from '@/lib/opencode/client';
@@ -56,6 +57,7 @@ export function VSCodeApp({ apis }: VSCodeAppProps) {
usePushVisibilityBeacon({ enabled: true });
useWindowTitle();
useRouter();
useGlobalSessionsPolling(panelType !== 'agentManager');
React.useEffect(() => {
document.documentElement.classList.toggle('wide-chat-layout', wideChatLayoutEnabled);
@@ -543,22 +543,6 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
const isWorktreeTopologyLoading = !isVSCode && resolvedWorktreeTopologyKey !== projectWorktreeDiscoveryKey;
const [unresolvedWorktreeProjectPaths, setUnresolvedWorktreeProjectPaths] = React.useState<ReadonlySet<string>>(new Set());
const initialGlobalSessionsRefreshStartedRef = React.useRef(false);
React.useEffect(() => {
if (initialGlobalSessionsRefreshStartedRef.current) {
return;
}
initialGlobalSessionsRefreshStartedRef.current = true;
void refreshGlobalSessions(syncSessionsSnapshotRef.current);
}, []);
React.useEffect(() => {
const interval = window.setInterval(() => {
void refreshGlobalSessions();
}, 45_000);
return () => window.clearInterval(interval);
}, []);
React.useEffect(() => {
let cancelled = false;
@@ -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();
+10
View File
@@ -113,6 +113,16 @@ Session materialization recency is keyed by runtime and directory. Foreground lo
Use `useGlobalSessionsStore` when the UI needs a **shared global session cache**.
Each full app root owns one global polling lifecycle through
`useGlobalSessionsPolling`. The web/desktop root and VS Code chat root load once
when mounted and refresh every 45 seconds so sessions created by another
OpenCode process are discovered without relying on the sidebar or native tray
being visible. Embedded chats and the VS Code agent-manager panel do not poll.
The sidebar and tray consume the same store and must not start their own
full-list timers. Surface-specific refreshes, such as opening the mobile session
sheet or returning from suspension, may still request freshness at their
explicit lifecycle edge; the store coalesces an overlapping in-flight load.
Current consumers:
- `useSessionAutoCleanup.ts`