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 { useMenuActions } from '@/hooks/useMenuActions';
|
||||||
import { useSessionStatusBootstrap } from '@/hooks/useSessionStatusBootstrap';
|
import { useSessionStatusBootstrap } from '@/hooks/useSessionStatusBootstrap';
|
||||||
import { useTraySync } from '@/hooks/useTraySync';
|
import { useTraySync } from '@/hooks/useTraySync';
|
||||||
|
import { useGlobalSessionsPolling } from '@/hooks/useGlobalSessionsPolling';
|
||||||
import { useRouter } from '@/hooks/useRouter';
|
import { useRouter } from '@/hooks/useRouter';
|
||||||
import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon';
|
import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon';
|
||||||
import { useWebNotificationStream } from '@/hooks/useWebNotificationStream';
|
import { useWebNotificationStream } from '@/hooks/useWebNotificationStream';
|
||||||
@@ -719,6 +720,7 @@ function App({ apis }: AppProps) {
|
|||||||
useMenuActions(handleToggleMemoryDebug);
|
useMenuActions(handleToggleMemoryDebug);
|
||||||
|
|
||||||
useTraySync();
|
useTraySync();
|
||||||
|
useGlobalSessionsPolling(!embeddedSessionChat);
|
||||||
|
|
||||||
useSessionStatusBootstrap({ enabled: embeddedBackgroundWorkEnabled });
|
useSessionStatusBootstrap({ enabled: embeddedBackgroundWorkEnabled });
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
|
|||||||
import { OpenCodeUpdateToast } from '@/components/update/OpenCodeUpdateToast';
|
import { OpenCodeUpdateToast } from '@/components/update/OpenCodeUpdateToast';
|
||||||
import { VSCodeLayout } from '@/components/layout/VSCodeLayout';
|
import { VSCodeLayout } from '@/components/layout/VSCodeLayout';
|
||||||
import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon';
|
import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon';
|
||||||
|
import { useGlobalSessionsPolling } from '@/hooks/useGlobalSessionsPolling';
|
||||||
import { useRouter } from '@/hooks/useRouter';
|
import { useRouter } from '@/hooks/useRouter';
|
||||||
import { useWindowTitle } from '@/hooks/useWindowTitle';
|
import { useWindowTitle } from '@/hooks/useWindowTitle';
|
||||||
import { opencodeClient } from '@/lib/opencode/client';
|
import { opencodeClient } from '@/lib/opencode/client';
|
||||||
@@ -56,6 +57,7 @@ export function VSCodeApp({ apis }: VSCodeAppProps) {
|
|||||||
usePushVisibilityBeacon({ enabled: true });
|
usePushVisibilityBeacon({ enabled: true });
|
||||||
useWindowTitle();
|
useWindowTitle();
|
||||||
useRouter();
|
useRouter();
|
||||||
|
useGlobalSessionsPolling(panelType !== 'agentManager');
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
document.documentElement.classList.toggle('wide-chat-layout', wideChatLayoutEnabled);
|
document.documentElement.classList.toggle('wide-chat-layout', wideChatLayoutEnabled);
|
||||||
|
|||||||
@@ -543,22 +543,6 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
|||||||
const isWorktreeTopologyLoading = !isVSCode && resolvedWorktreeTopologyKey !== projectWorktreeDiscoveryKey;
|
const isWorktreeTopologyLoading = !isVSCode && resolvedWorktreeTopologyKey !== projectWorktreeDiscoveryKey;
|
||||||
const [unresolvedWorktreeProjectPaths, setUnresolvedWorktreeProjectPaths] = React.useState<ReadonlySet<string>>(new Set());
|
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(() => {
|
React.useEffect(() => {
|
||||||
let cancelled = false;
|
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 { canUseElectronDesktopIPC, invokeDesktop, isDesktopLocalOriginActive } from '@/lib/desktop';
|
||||||
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
|
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
|
||||||
import { desktopHostsGet, getDesktopHostApiUrl, locationMatchesHost, redactSensitiveUrl } from '@/lib/desktopHosts';
|
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 { opencodeClient } from '@/lib/opencode/client';
|
||||||
import { useGlobalSessionStatusStore, applyGlobalSessionStatusSnapshot } from '@/sync/global-session-status';
|
import { useGlobalSessionStatusStore, applyGlobalSessionStatusSnapshot } from '@/sync/global-session-status';
|
||||||
import { compareSessionsByLifecycleOrder, useSessionOrderingStore } from '@/sync/session-ordering';
|
import { compareSessionsByLifecycleOrder, useSessionOrderingStore } from '@/sync/session-ordering';
|
||||||
@@ -12,8 +12,6 @@ import { useSessionPinnedStore } from '@/stores/useSessionPinnedStore';
|
|||||||
import { respondToPermission } from '@/sync/session-actions';
|
import { respondToPermission } from '@/sync/session-actions';
|
||||||
import {
|
import {
|
||||||
useGlobalSessionsStore,
|
useGlobalSessionsStore,
|
||||||
ensureGlobalSessionsLoaded,
|
|
||||||
refreshGlobalSessions,
|
|
||||||
resolveGlobalSessionDirectory,
|
resolveGlobalSessionDirectory,
|
||||||
} from '@/stores/useGlobalSessionsStore';
|
} from '@/stores/useGlobalSessionsStore';
|
||||||
import { useQuotaStore } from '@/stores/useQuotaStore';
|
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.
|
// Event-driven updates do the real work; this is just a slow safety net.
|
||||||
const POLL_INTERVAL_MS = 5000;
|
const POLL_INTERVAL_MS = 5000;
|
||||||
const FLUSH_DEBOUNCE_MS = 500;
|
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;
|
const MAX_SESSIONS = 20;
|
||||||
|
|
||||||
type TraySessionStatus = 'idle' | 'busy' | 'retry';
|
type TraySessionStatus = 'idle' | 'busy' | 'retry';
|
||||||
@@ -535,12 +529,6 @@ export const useTraySync = (): void => {
|
|||||||
const unsubscribeSessionOrder = useSessionOrderingStore.subscribe(() => scheduleFlush());
|
const unsubscribeSessionOrder = useSessionOrderingStore.subscribe(() => scheduleFlush());
|
||||||
const unsubscribePinnedSessions = useSessionPinnedStore.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
|
// Global busy/retry status: fetch now and poll, so unsynced sessions don't
|
||||||
// sit looking idle. Synced directories stay instant via their SSE stores.
|
// sit looking idle. Synced directories stay instant via their SSE stores.
|
||||||
void refreshGlobalStatus();
|
void refreshGlobalStatus();
|
||||||
@@ -567,7 +555,6 @@ export const useTraySync = (): void => {
|
|||||||
disposed = true;
|
disposed = true;
|
||||||
if (flushTimer !== null) window.clearTimeout(flushTimer);
|
if (flushTimer !== null) window.clearTimeout(flushTimer);
|
||||||
window.clearInterval(interval);
|
window.clearInterval(interval);
|
||||||
window.clearInterval(refreshInterval);
|
|
||||||
window.clearInterval(globalStatusInterval);
|
window.clearInterval(globalStatusInterval);
|
||||||
unsubscribeNotif();
|
unsubscribeNotif();
|
||||||
unsubscribeGlobal();
|
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**.
|
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:
|
Current consumers:
|
||||||
|
|
||||||
- `useSessionAutoCleanup.ts`
|
- `useSessionAutoCleanup.ts`
|
||||||
|
|||||||
Reference in New Issue
Block a user