agent-orchestrator/packages/web/src/hooks/useSessionEvents.ts

329 lines
11 KiB
TypeScript

"use client";
import { useEffect, useReducer, useRef, useCallback } from "react";
import type {
AttentionLevel,
DashboardSession,
GlobalPauseState,
SSESnapshotEvent,
} from "@/lib/types";
/** Debounce before fetching full session list after membership change. */
const MEMBERSHIP_REFRESH_DELAY_MS = 120;
/** Re-fetch full session list if no refresh has happened in this interval. */
const STALE_REFRESH_INTERVAL_MS = 15000;
/** Grace period before declaring "disconnected" (allows for transient reconnects). */
const DISCONNECTED_GRACE_PERIOD_MS = 4000;
type ConnectionStatus = "connected" | "reconnecting" | "disconnected";
/** Server-computed attention levels from the latest SSE snapshot. */
export type SSEAttentionMap = Readonly<Record<string, AttentionLevel>>;
interface State {
sessions: DashboardSession[];
globalPause: GlobalPauseState | null;
connectionStatus: ConnectionStatus;
/** Attention levels from the latest SSE snapshot (server-computed, includes PR state). */
sseAttentionLevels: SSEAttentionMap;
}
type Action =
| { type: "reset"; sessions: DashboardSession[]; globalPause: GlobalPauseState | null; sseAttentionLevels?: SSEAttentionMap }
| { type: "snapshot"; patches: SSESnapshotEvent["sessions"] }
| { type: "setConnection"; status: ConnectionStatus };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "reset":
return {
...state,
sessions: action.sessions,
globalPause: action.globalPause,
...(action.sseAttentionLevels !== undefined
? { sseAttentionLevels: action.sseAttentionLevels }
: {}),
};
case "setConnection":
return { ...state, connectionStatus: action.status };
case "snapshot": {
const patchMap = new Map(action.patches.map((p) => [p.id, p]));
let changed = false;
const next = state.sessions.map((s) => {
const patch = patchMap.get(s.id);
if (!patch) return s;
if (
s.status === patch.status &&
s.activity === patch.activity &&
s.lastActivityAt === patch.lastActivityAt
) {
return s;
}
changed = true;
return {
...s,
status: patch.status,
activity: patch.activity,
lastActivityAt: patch.lastActivityAt,
};
});
// Build attention level map from server-computed values
const levels: Record<string, AttentionLevel> = {};
for (const p of action.patches) {
levels[p.id] = p.attentionLevel;
}
const sessionsChanged = changed;
const levelsChanged =
Object.keys(levels).length !== Object.keys(state.sseAttentionLevels).length ||
action.patches.some((p) => state.sseAttentionLevels[p.id] !== p.attentionLevel);
if (!sessionsChanged && !levelsChanged) return state;
return {
...state,
sessions: sessionsChanged ? next : state.sessions,
sseAttentionLevels: levelsChanged ? levels : state.sseAttentionLevels,
};
}
}
}
function createMembershipKey(
sessions: Array<Pick<DashboardSession, "id">> | SSESnapshotEvent["sessions"],
): string {
return sessions
.map((session) => session.id)
.sort()
.join("\u0000");
}
export function useSessionEvents(
initialSessions: DashboardSession[],
initialGlobalPause?: GlobalPauseState | null,
project?: string,
muxSessions?: Array<{ id: string; status: string; activity: string | null; attentionLevel: string; lastActivityAt: string }>,
initialAttentionLevels?: SSEAttentionMap,
): State {
const [state, dispatch] = useReducer(reducer, {
sessions: initialSessions,
globalPause: initialGlobalPause ?? null,
connectionStatus: "connected" as ConnectionStatus,
sseAttentionLevels: initialAttentionLevels ?? ({} as SSEAttentionMap),
});
const sessionsRef = useRef(state.sessions);
const initialAttentionLevelsRef = useRef(initialAttentionLevels);
initialAttentionLevelsRef.current = initialAttentionLevels;
const refreshingRef = useRef(false);
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingMembershipKeyRef = useRef<string | null>(null);
const lastRefreshAtRef = useRef(Date.now());
const disconnectedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const activeRefreshControllerRef = useRef<AbortController | null>(null);
useEffect(() => {
sessionsRef.current = state.sessions;
}, [state.sessions]);
useEffect(() => {
dispatch({
type: "reset",
sessions: initialSessions,
globalPause: initialGlobalPause ?? null,
sseAttentionLevels: initialAttentionLevelsRef.current ?? ({} as SSEAttentionMap),
});
}, [initialSessions, initialGlobalPause]);
// Stable boolean — only changes when mux transitions between present/absent,
// not on every new snapshot array reference. Used in the SSE effect deps so
// SSE setup/teardown runs only on that transition, not every mux update.
const muxActive = muxSessions !== undefined;
// Define scheduleRefresh with useCallback so both effects can use it
const scheduleRefresh = useCallback(() => {
if (refreshingRef.current || refreshTimerRef.current) return;
refreshTimerRef.current = setTimeout(() => {
refreshTimerRef.current = null;
refreshingRef.current = true;
const requestedMembershipKey = pendingMembershipKeyRef.current;
const refreshController = new AbortController();
activeRefreshControllerRef.current = refreshController;
const sessionsUrl = project
? `/api/sessions?project=${encodeURIComponent(project)}`
: "/api/sessions";
void fetch(sessionsUrl, { signal: refreshController.signal })
.then((res) => (res.ok ? res.json() : null))
.then(
(updated: { sessions?: DashboardSession[]; globalPause?: GlobalPauseState } | null) => {
if (refreshController.signal.aborted || !updated?.sessions) return;
lastRefreshAtRef.current = Date.now();
dispatch({
type: "reset",
sessions: updated.sessions,
globalPause: updated.globalPause ?? null,
});
},
)
.catch((err: unknown) => {
if (err instanceof DOMException && err.name === "AbortError") return;
console.warn("[useSessionEvents] refresh failed:", err);
})
.finally(() => {
if (activeRefreshControllerRef.current === refreshController) {
activeRefreshControllerRef.current = null;
}
if (refreshController.signal.aborted) {
refreshingRef.current = false;
// If there's still a pending membership change, reschedule so it isn't lost
if (pendingMembershipKeyRef.current !== null) {
scheduleRefresh();
}
return;
}
refreshingRef.current = false;
if (
pendingMembershipKeyRef.current !== null &&
pendingMembershipKeyRef.current !== requestedMembershipKey
) {
scheduleRefresh();
return;
}
pendingMembershipKeyRef.current = null;
});
}, MEMBERSHIP_REFRESH_DELAY_MS);
}, [project]);
// Mux-based session updates (replaces SSE when available)
useEffect(() => {
if (!muxSessions) return;
// Note: empty array is intentional — it means all sessions were removed and we
// must still run the membership-key comparison to trigger scheduleRefresh().
dispatch({ type: "snapshot", patches: muxSessions as SSESnapshotEvent["sessions"] });
const currentMembershipKey = createMembershipKey(sessionsRef.current);
const snapshotMembershipKey = createMembershipKey(muxSessions);
if (currentMembershipKey !== snapshotMembershipKey) {
pendingMembershipKeyRef.current = snapshotMembershipKey;
scheduleRefresh();
} else if (Date.now() - lastRefreshAtRef.current >= STALE_REFRESH_INTERVAL_MS) {
scheduleRefresh();
}
return () => {
// Only abort in-flight requests — do NOT clear the debounce timer.
// Cancelling the timer here would prevent the membership-change refresh
// from completing when muxSessions updates arrive in rapid succession.
activeRefreshControllerRef.current?.abort();
activeRefreshControllerRef.current = null;
};
}, [muxSessions, scheduleRefresh]);
useEffect(() => {
// Skip SSE if mux sessions are available
if (muxActive) {
dispatch({ type: "setConnection", status: "connected" });
return () => {
// Clear any pending refresh timer so it doesn't fire after unmount
if (refreshTimerRef.current) {
clearTimeout(refreshTimerRef.current);
refreshTimerRef.current = null;
}
activeRefreshControllerRef.current?.abort();
activeRefreshControllerRef.current = null;
};
}
const url = project ? `/api/events?project=${encodeURIComponent(project)}` : "/api/events";
const es = new EventSource(url);
let disposed = false;
const clearRefreshTimer = () => {
if (refreshTimerRef.current) {
clearTimeout(refreshTimerRef.current);
refreshTimerRef.current = null;
}
};
const clearDisconnectedTimer = () => {
if (disconnectedTimerRef.current) {
clearTimeout(disconnectedTimerRef.current);
disconnectedTimerRef.current = null;
}
};
es.onmessage = (event: MessageEvent) => {
try {
const data = JSON.parse(event.data as string) as { type: string };
if (data.type === "snapshot") {
const snapshot = data as SSESnapshotEvent;
dispatch({ type: "snapshot", patches: snapshot.sessions });
const currentMembershipKey = createMembershipKey(sessionsRef.current);
const snapshotMembershipKey = createMembershipKey(snapshot.sessions);
if (currentMembershipKey !== snapshotMembershipKey) {
pendingMembershipKeyRef.current = snapshotMembershipKey;
scheduleRefresh();
return;
}
if (Date.now() - lastRefreshAtRef.current >= STALE_REFRESH_INTERVAL_MS) {
scheduleRefresh();
}
}
} catch {
return;
}
};
es.onopen = () => {
clearDisconnectedTimer();
if (!disposed) dispatch({ type: "setConnection", status: "connected" });
};
es.onerror = () => {
if (disposed) return;
if (es.readyState === EventSource.CLOSED) {
clearDisconnectedTimer();
dispatch({ type: "setConnection", status: "disconnected" });
return;
}
dispatch({ type: "setConnection", status: "reconnecting" });
if (disconnectedTimerRef.current === null) {
disconnectedTimerRef.current = setTimeout(() => {
disconnectedTimerRef.current = null;
if (!disposed && es.readyState !== EventSource.OPEN) {
dispatch({ type: "setConnection", status: "disconnected" });
}
}, DISCONNECTED_GRACE_PERIOD_MS);
}
};
return () => {
disposed = true;
activeRefreshControllerRef.current?.abort();
activeRefreshControllerRef.current = null;
refreshingRef.current = false;
pendingMembershipKeyRef.current = null;
clearRefreshTimer();
clearDisconnectedTimer();
es.close();
};
}, [project, muxActive, scheduleRefresh]);
return state;
}