fix(sessions): centralize global polling
This commit is contained in:
@@ -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 });
|
||||
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
@@ -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`
|
||||
|
||||
Reference in New Issue
Block a user