From 538bfde9c25259ef6495ac33e75d2de9cfa74c9e Mon Sep 17 00:00:00 2001 From: Formatted Date: Tue, 28 Apr 2026 04:19:00 +0000 Subject: [PATCH 1/2] fix(session): poll sidebar session list via query cache to surface externally created sessions Automated runs, messaging bot sessions (Slack/Telegram), and other externally created sessions were invisible in the sidebar until the user triggered an indirect refresh (e.g. renaming a session). Replaces the closed useEffect+setInterval approach (PR #1551) with a visibility-aware polling hook backed by TanStack Query caching: - New session-list-cache.ts module with query key factory and useSessionListPolling hook - Session lists written to TanStack Query cache alongside imperative state - Lightweight refreshSessionLists callback avoids heavy refreshRouteState work - Existing refreshInFlightRef dedup guard prevents overlapping calls - Only fires when tab is visible; also fires immediately on visibility change Fixes #1262 --- .../src/react-app/shell/session-list-cache.ts | 68 +++++++++++++++++++ .../app/src/react-app/shell/session-route.tsx | 40 +++++++++++ 2 files changed, 108 insertions(+) create mode 100644 apps/app/src/react-app/shell/session-list-cache.ts diff --git a/apps/app/src/react-app/shell/session-list-cache.ts b/apps/app/src/react-app/shell/session-list-cache.ts new file mode 100644 index 000000000..2778bc1da --- /dev/null +++ b/apps/app/src/react-app/shell/session-list-cache.ts @@ -0,0 +1,68 @@ +import { useEffect } from "react"; +import { getReactQueryClient } from "../infra/query-client"; + +const SESSION_LIST_KEY = ["workspace-session-list"] as const; + +export function sessionListQueryKey(workspaceId: string) { + return [...SESSION_LIST_KEY, workspaceId] as const; +} + +export function sessionListQueryKeyPattern() { + return { queryKey: SESSION_LIST_KEY, exact: false }; +} + +export function cacheSessionList(workspaceId: string, sessions: unknown[]) { + getReactQueryClient().setQueryData(sessionListQueryKey(workspaceId), sessions); +} + +export function invalidateSessionLists() { + getReactQueryClient().invalidateQueries(sessionListQueryKeyPattern()); +} + +export function isTabVisible(): boolean { + return typeof document === "undefined" || document.visibilityState === "visible"; +} + +/** + * Sets up a visibility-aware polling loop that invalidates workspace session + * lists at a fixed interval so that sessions created externally (by automations, + * messaging bots like Slack/Telegram, or other clients) appear in the sidebar + * without requiring manual refresh. Only fires when the browser tab is visible + * to avoid unnecessary network requests while the user is working elsewhere. + * + * The `refetch` callback is responsible for performing the actual fetch and + * writing results into both the TanStack Query cache and the imperative state + * used by the sidebar. The existing `refreshInFlightRef` guard in the caller + * prevents overlapping fetches. + * + * Fixes [#1262]{@link https://github.com/different-ai/openwork/issues/1262} + */ +export function useSessionListPolling( + refetch: () => void | Promise, + intervalMs: number = 30_000, +) { + useEffect(() => { + if (typeof window === "undefined") return; + + const tick = () => { + if (!isTabVisible()) return; + void refetch(); + }; + + const id = window.setInterval(tick, intervalMs); + + // Listen for visibility changes so we can fire an immediate fetch when + // the tab comes back into view, instead of waiting for the next interval. + const onVisibility = () => { + if (isTabVisible()) { + void refetch(); + } + }; + document.addEventListener("visibilitychange", onVisibility); + + return () => { + window.clearInterval(id); + document.removeEventListener("visibilitychange", onVisibility); + }; + }, [refetch, intervalMs]); +} diff --git a/apps/app/src/react-app/shell/session-route.tsx b/apps/app/src/react-app/shell/session-route.tsx index 20542ce1c..4241238ed 100644 --- a/apps/app/src/react-app/shell/session-route.tsx +++ b/apps/app/src/react-app/shell/session-route.tsx @@ -84,6 +84,7 @@ import { filterProviderList, mapConfigProvidersToList } from "../../app/utils/pr import { ensureDesktopLocalOpenworkConnection } from "./desktop-local-openwork"; import { resolveOpenworkConnection } from "./openwork-connection"; import { useReloadCoordinator } from "./reload-coordinator"; +import { useSessionListPolling, cacheSessionList } from "./session-list-cache"; type RouteWorkspace = OpenworkWorkspaceInfo & { displayNameResolved: string; @@ -299,6 +300,7 @@ export function SessionRoute() { const reloadEventCursorByWorkspaceRef = useRef>({}); const workspacesRef = useRef([]); const sessionsByWorkspaceIdRef = useRef>({}); + const clientRef = useRef(null); const startupRetryTimerRef = useRef(null); const [retryingWorkspaceIds, setRetryingWorkspaceIds] = useState([]); const [createWorkspaceOpen, setCreateWorkspaceOpen] = useState(false); @@ -385,6 +387,7 @@ export function SessionRoute() { ) : (response.items ?? []); setSessionsByWorkspaceId((current) => ({ ...current, [workspace.id]: items })); + cacheSessionList(workspace.id, items); setErrorsByWorkspaceId((current) => ({ ...current, [workspace.id]: null })); setRetryingWorkspaceIds((current) => current.includes(workspace.id) ? current.filter((id) => id !== workspace.id) : current, @@ -555,6 +558,39 @@ export function SessionRoute() { } }, [loadWorkspaceSessionsInBackground, markBootRouteReady, selectedSessionId]); + // Lightweight session-list-only refresh used by the periodic polling hook. + // This avoids the heavy work of `refreshRouteState` (re-fetching workspaces, + // re-resolving connections, etc.) and only re-fetches the session lists so + // that externally created sessions appear in the sidebar. + const refreshSessionLists = useCallback(async () => { + if (refreshInFlightRef.current) return; + const owClient = clientRef.current; + const wsList = workspacesRef.current; + if (!owClient || wsList.length === 0) return; + + for (const workspace of wsList) { + try { + const response = await owClient.listSessions(workspace.id, { limit: 200 }); + const workspaceRoot = normalizeDirectoryPath(workspace.path ?? ""); + const items = workspaceRoot + ? (response.items ?? []).filter((session: any) => + normalizeDirectoryPath(session?.directory ?? "") === workspaceRoot, + ) + : (response.items ?? []); + setSessionsByWorkspaceId((current) => ({ ...current, [workspace.id]: items })); + cacheSessionList(workspace.id, items); + setErrorsByWorkspaceId((current) => ({ ...current, [workspace.id]: null })); + } catch { + // Best-effort; transient failures are retried on the next tick. + } + } + }, []); + + // Periodic polling to surface externally created sessions (automations, + // messaging bots, other clients) without requiring manual refresh. + // Fixes #1262 + useSessionListPolling(refreshSessionLists, 30_000); + const remoteAccessRestart = useRemoteAccessRestart({ isEnabled: () => openworkServerSettings.remoteAccessEnabled === true, onHostInfo: setOpenworkServerHostInfoState, @@ -661,6 +697,10 @@ export function SessionRoute() { sessionsByWorkspaceIdRef.current = sessionsByWorkspaceId; }, [sessionsByWorkspaceId]); + useEffect(() => { + clientRef.current = client; + }, [client]); + useEffect(() => { let cancelled = false; From b075e21d62e729874c3fa0055c15e6b21c04bf5a Mon Sep 17 00:00:00 2001 From: Formatted Date: Mon, 4 May 2026 20:02:15 -0600 Subject: [PATCH 2/2] fix(session): add dedicated in-flight guard for session-list poller and stop double refresh on visibility restore Addresses review feedback on #1584: - refreshSessionLists previously only checked refreshInFlightRef but never set its own guard, so a slow listSessions call (or a visibility-triggered refresh during an active poll) could overlap with itself. Add pollSessionsInFlightRef and toggle it in try/finally. - useSessionListPolling's visibilitychange handler raced with session-route's existing handleVisibility, which already runs the heavier refreshRouteState (and therefore re-fetches sessions). Drop the polling hook's visibility handler so we don't fire two concurrent session-list refreshes on every tab focus. --- .../src/react-app/shell/session-list-cache.ts | 20 ++++------ .../app/src/react-app/shell/session-route.tsx | 39 ++++++++++++------- 2 files changed, 32 insertions(+), 27 deletions(-) diff --git a/apps/app/src/react-app/shell/session-list-cache.ts b/apps/app/src/react-app/shell/session-list-cache.ts index 2778bc1da..0736f40af 100644 --- a/apps/app/src/react-app/shell/session-list-cache.ts +++ b/apps/app/src/react-app/shell/session-list-cache.ts @@ -32,8 +32,14 @@ export function isTabVisible(): boolean { * * The `refetch` callback is responsible for performing the actual fetch and * writing results into both the TanStack Query cache and the imperative state - * used by the sidebar. The existing `refreshInFlightRef` guard in the caller - * prevents overlapping fetches. + * used by the sidebar. The caller is responsible for providing its own + * in-flight guard so two ticks (or a tick that races with another refresh + * path) cannot overlap. + * + * Visibility restore is intentionally NOT handled here: callers in this + * codebase already wire up a `visibilitychange` listener that runs a heavier + * route-level refresh, and firing `refetch` from here as well caused two + * concurrent session-list refreshes on every tab focus. * * Fixes [#1262]{@link https://github.com/different-ai/openwork/issues/1262} */ @@ -51,18 +57,8 @@ export function useSessionListPolling( const id = window.setInterval(tick, intervalMs); - // Listen for visibility changes so we can fire an immediate fetch when - // the tab comes back into view, instead of waiting for the next interval. - const onVisibility = () => { - if (isTabVisible()) { - void refetch(); - } - }; - document.addEventListener("visibilitychange", onVisibility); - return () => { window.clearInterval(id); - document.removeEventListener("visibilitychange", onVisibility); }; }, [refetch, intervalMs]); } diff --git a/apps/app/src/react-app/shell/session-route.tsx b/apps/app/src/react-app/shell/session-route.tsx index 4241238ed..e5309f2fd 100644 --- a/apps/app/src/react-app/shell/session-route.tsx +++ b/apps/app/src/react-app/shell/session-route.tsx @@ -297,6 +297,10 @@ export function SessionRoute() { // One-way latch for "a refreshRouteState is currently running"; prevents // overlapping route refreshes from queueing up when the user clicks fast. const refreshInFlightRef = useRef(false); + // Dedicated guard for the lightweight session-list poller so a slow + // listSessions call (or a visibility-triggered refresh during an active + // poll) cannot overlap with itself. + const pollSessionsInFlightRef = useRef(false); const reloadEventCursorByWorkspaceRef = useRef>({}); const workspacesRef = useRef([]); const sessionsByWorkspaceIdRef = useRef>({}); @@ -563,26 +567,31 @@ export function SessionRoute() { // re-resolving connections, etc.) and only re-fetches the session lists so // that externally created sessions appear in the sidebar. const refreshSessionLists = useCallback(async () => { - if (refreshInFlightRef.current) return; + if (refreshInFlightRef.current || pollSessionsInFlightRef.current) return; const owClient = clientRef.current; const wsList = workspacesRef.current; if (!owClient || wsList.length === 0) return; - for (const workspace of wsList) { - try { - const response = await owClient.listSessions(workspace.id, { limit: 200 }); - const workspaceRoot = normalizeDirectoryPath(workspace.path ?? ""); - const items = workspaceRoot - ? (response.items ?? []).filter((session: any) => - normalizeDirectoryPath(session?.directory ?? "") === workspaceRoot, - ) - : (response.items ?? []); - setSessionsByWorkspaceId((current) => ({ ...current, [workspace.id]: items })); - cacheSessionList(workspace.id, items); - setErrorsByWorkspaceId((current) => ({ ...current, [workspace.id]: null })); - } catch { - // Best-effort; transient failures are retried on the next tick. + pollSessionsInFlightRef.current = true; + try { + for (const workspace of wsList) { + try { + const response = await owClient.listSessions(workspace.id, { limit: 200 }); + const workspaceRoot = normalizeDirectoryPath(workspace.path ?? ""); + const items = workspaceRoot + ? (response.items ?? []).filter((session: any) => + normalizeDirectoryPath(session?.directory ?? "") === workspaceRoot, + ) + : (response.items ?? []); + setSessionsByWorkspaceId((current) => ({ ...current, [workspace.id]: items })); + cacheSessionList(workspace.id, items); + setErrorsByWorkspaceId((current) => ({ ...current, [workspace.id]: null })); + } catch { + // Best-effort; transient failures are retried on the next tick. + } } + } finally { + pollSessionsInFlightRef.current = false; } }, []);