diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 80085329..3470f692 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -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 }); diff --git a/packages/ui/src/apps/VSCodeApp.tsx b/packages/ui/src/apps/VSCodeApp.tsx index 47b8086c..9090cd1d 100644 --- a/packages/ui/src/apps/VSCodeApp.tsx +++ b/packages/ui/src/apps/VSCodeApp.tsx @@ -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); diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx index 078ef2e4..a35a8697 100644 --- a/packages/ui/src/components/session/SessionSidebar.tsx +++ b/packages/ui/src/components/session/SessionSidebar.tsx @@ -543,22 +543,6 @@ const SessionSidebarComponent: React.FC = ({ const isWorktreeTopologyLoading = !isVSCode && resolvedWorktreeTopologyKey !== projectWorktreeDiscoveryKey; const [unresolvedWorktreeProjectPaths, setUnresolvedWorktreeProjectPaths] = React.useState>(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; diff --git a/packages/ui/src/hooks/useGlobalSessionsPolling.test.ts b/packages/ui/src/hooks/useGlobalSessionsPolling.test.ts new file mode 100644 index 00000000..accec862 --- /dev/null +++ b/packages/ui/src/hooks/useGlobalSessionsPolling.test.ts @@ -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); + }); +}); diff --git a/packages/ui/src/hooks/useGlobalSessionsPolling.ts b/packages/ui/src/hooks/useGlobalSessionsPolling.ts new file mode 100644 index 00000000..ccf17d12 --- /dev/null +++ b/packages/ui/src/hooks/useGlobalSessionsPolling.ts @@ -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]); +}; diff --git a/packages/ui/src/hooks/useTraySync.ts b/packages/ui/src/hooks/useTraySync.ts index 5a9dc9b0..a27cfd7c 100644 --- a/packages/ui/src/hooks/useTraySync.ts +++ b/packages/ui/src/hooks/useTraySync.ts @@ -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(); diff --git a/packages/ui/src/sync/DOCUMENTATION.md b/packages/ui/src/sync/DOCUMENTATION.md index c88021a3..e0d61d26 100644 --- a/packages/ui/src/sync/DOCUMENTATION.md +++ b/packages/ui/src/sync/DOCUMENTATION.md @@ -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`