import type { BrowserSession, BrowserSessionMutationResponse, CreateBrowserSessionRequest, } from "@opengeni/sdk/interaction"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { EmbeddedBrowserInteractionClientLike } from "../client"; import { type EmbeddedBrowserInteractionClientOverride, useEmbeddedBrowserInteraction, } from "../session-context"; import { useInteractionInvalidation } from "./use-interaction-invalidation"; export type UseBrowserSessionsOptions = EmbeddedBrowserInteractionClientOverride & { /** Current agent/session relevance. Workspace peers remain in `sessions`. */ sessionId?: string | undefined; enabled?: boolean | undefined; /** Polling fallback used only while the workspace invalidation stream is unavailable. */ pollIntervalMs?: number | undefined; }; export type UseBrowserSessionsResult = { revision: number; sessions: BrowserSession[]; relevantSessions: BrowserSession[]; loading: boolean; refreshing: boolean; error: Error | null; refresh: () => Promise; create: ( request: Omit & { operationId?: string; }, ) => Promise; suspend: ( browserSessionId: string, operationId?: string, ) => Promise; resume: ( browserSessionId: string, operationId?: string, ) => Promise; end: (browserSessionId: string, operationId?: string) => Promise; }; /** * Workspace-wide BrowserSession registry. Associations rank relevance but never * hide peer resources; no explicit share ceremony or per-view ownership exists. */ export function useBrowserSessions( options: UseBrowserSessionsOptions = {}, ): UseBrowserSessionsResult { const { client, workspaceId, registerSessionReconciler, workspaceInteractionEvent, workspaceInteractionConnectionState, } = useEmbeddedBrowserInteraction(options); const enabled = options.enabled ?? true; const [state, setState] = useState<{ workspaceId: string; active: boolean; revision: number; sessions: BrowserSession[]; loading: boolean; refreshing: boolean; error: Error | null; }>(() => emptyState(workspaceId, enabled)); const requestRef = useRef<{ id: number; controller: AbortController | null }>({ id: 0, controller: null, }); const loadQueueRef = useRef<{ pending: boolean; promise: Promise } | null>(null); const mountedRef = useRef(true); const visibleState = state.workspaceId === workspaceId ? state : emptyState(workspaceId, enabled); const cancelLoad = useCallback(() => { loadQueueRef.current = null; const id = requestRef.current.id + 1; requestRef.current.controller?.abort(); requestRef.current = { id, controller: null }; }, []); const loadOnce = useCallback( async (foreground: boolean): Promise => { if (!enabled) return; const id = requestRef.current.id + 1; requestRef.current.controller?.abort(); const controller = new AbortController(); requestRef.current = { id, controller }; setState((current) => ({ ...(current.workspaceId === workspaceId ? current : emptyState(workspaceId, true)), active: true, loading: foreground, refreshing: !foreground, error: foreground || current.workspaceId !== workspaceId ? null : current.error, })); try { const response = await client.listBrowserSessions(workspaceId, { signal: controller.signal, }); if (!mountedRef.current || requestRef.current.id !== id) return; setState({ workspaceId, active: true, revision: response.revision, sessions: sortBrowserSessions(response.sessions), loading: false, refreshing: false, error: null, }); } catch (cause) { if (controller.signal.aborted || !mountedRef.current || requestRef.current.id !== id) return; const error = cause instanceof Error ? cause : new Error(String(cause)); setState((current) => ({ ...(current.workspaceId === workspaceId ? current : emptyState(workspaceId, true)), loading: false, refreshing: false, error, })); } finally { if (requestRef.current.id === id) { requestRef.current = { id, controller: null }; } } }, [client, enabled, workspaceId], ); const load = useCallback( (foreground: boolean): Promise => { const current = loadQueueRef.current; if (current) { current.pending = true; return current.promise; } const queue = { pending: false, promise: Promise.resolve() }; loadQueueRef.current = queue; queue.promise = Promise.resolve() .then(async () => { // A slow read must be allowed to finish. Invalidations and polling // coalesce into one trailing read instead of repeatedly aborting it. while (loadQueueRef.current === queue) { queue.pending = false; await loadOnce(foreground); foreground = false; if (!queue.pending) break; } if (loadQueueRef.current === queue) loadQueueRef.current = null; }) .finally(() => { if (loadQueueRef.current === queue) loadQueueRef.current = null; }); return queue.promise; }, [loadOnce], ); const refresh = useCallback(async () => await load(false), [load]); useEffect(() => { mountedRef.current = true; if (!enabled) { cancelLoad(); setState(emptyState(workspaceId, false)); return; } void load(true); return () => { mountedRef.current = false; cancelLoad(); }; }, [cancelLoad, enabled, load, workspaceId]); useInteractionInvalidation({ workspaceId, key: workspaceId, enabled, event: workspaceInteractionEvent, connectionState: workspaceInteractionConnectionState, knownRevision: visibleState.revision, refresh, fallbackPollIntervalMs: options.pollIntervalMs, }); useEffect(() => { if (!enabled || !options.sessionId) return; return registerSessionReconciler(options.sessionId, `browser-sessions:${workspaceId}`, refresh); }, [enabled, options.sessionId, refresh, registerSessionReconciler, workspaceId]); const create = useCallback( async ( request: Omit & { operationId?: string; }, ): Promise => { const response = await client.createBrowserSession(workspaceId, { ...request, operationId: request.operationId ?? crypto.randomUUID(), }); setState((current) => mergeSession(current, workspaceId, response.session)); return response; }, [client, workspaceId], ); const end = useCallback( async ( browserSessionId: string, operationId: string = crypto.randomUUID(), ): Promise => { const response = await client.endBrowserSession(workspaceId, browserSessionId, { operationId, }); setState((current) => mergeSession(current, workspaceId, response.session)); return response; }, [client, workspaceId], ); const suspend = useCallback( async ( browserSessionId: string, operationId: string = crypto.randomUUID(), ): Promise => { const response = await client.suspendBrowserSession(workspaceId, browserSessionId, { operationId, }); setState((current) => mergeSession(current, workspaceId, response.session)); return response; }, [client, workspaceId], ); const resume = useCallback( async ( browserSessionId: string, operationId: string = crypto.randomUUID(), ): Promise => { const response = await client.resumeBrowserSession(workspaceId, browserSessionId, { operationId, }); setState((current) => mergeSession(current, workspaceId, response.session)); return response; }, [client, workspaceId], ); const relevantSessions = useMemo( () => options.sessionId ? visibleState.sessions.filter((session) => session.associations.some((association) => association.sessionId === options.sessionId), ) : [], [options.sessionId, visibleState.sessions], ); return { revision: visibleState.revision, sessions: visibleState.sessions, relevantSessions, // A disabled registry is deliberately empty. Expose the first re-enabled // render as loading so consumers do not reconcile against that empty gap. loading: enabled ? !visibleState.active || visibleState.loading : false, refreshing: visibleState.refreshing, error: visibleState.error, refresh, create, suspend, resume, end, }; } function emptyState(workspaceId: string, loading: boolean) { return { workspaceId, active: false, revision: 0, sessions: [] as BrowserSession[], loading, refreshing: false, error: null as Error | null, }; } function sortBrowserSessions(sessions: readonly BrowserSession[]): BrowserSession[] { return [...sessions].sort((left, right) => { const live = Number(isLiveBrowser(right)) - Number(isLiveBrowser(left)); return ( live || timestamp(right.lastUsedAt) - timestamp(left.lastUsedAt) || left.id.localeCompare(right.id) ); }); } function isLiveBrowser(session: BrowserSession): boolean { return !["ending", "ended", "failed", "lost"].includes(session.lifecycle); } function timestamp(value: string): number { const parsed = Date.parse(value); return Number.isFinite(parsed) ? parsed : 0; } function mergeSession( current: ReturnType, workspaceId: string, session: BrowserSession, ): ReturnType { if (current.workspaceId !== workspaceId) return current; const sessions = current.sessions.filter((candidate) => candidate.id !== session.id); sessions.push(session); return { ...current, sessions: sortBrowserSessions(sessions), error: null }; } /** Exported for narrow proxy implementations and tests. */ export type BrowserSessionsClient = EmbeddedBrowserInteractionClientLike;