import { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { fetchAiSessions, deleteAiSession, type AiSessionSummary } from "../api"; import { useAiSessionSync } from "./useAiSessionSync"; interface UseBackgroundSessionsResult { sessions: AiSessionSummary[]; generating: number; needsInput: number; /** Active sessions filtered to type === "planning" only */ planningSessions: AiSessionSummary[]; dismissSession: (id: string) => void; refresh: () => void; } function parseTimestamp(updatedAt: string | undefined): number { if (!updatedAt) return 0; const parsed = Date.parse(updatedAt); return Number.isFinite(parsed) ? parsed : 0; } function shouldIncludeSession(session: AiSessionSummary): boolean { return ( session.status === "generating" || session.status === "awaiting_input" || session.status === "complete" || session.status === "error" ); } export function useBackgroundSessions(projectId?: string): UseBackgroundSessionsResult { const [sessions, setSessions] = useState([]); const eventSourceRef = useRef(null); const sessionTimestampsRef = useRef>(new Map()); const { sessions: syncedSessions, broadcastUpdate, broadcastCompleted, requestSync, } = useAiSessionSync(); const refresh = useCallback(() => { fetchAiSessions(projectId) .then((fetched) => { const nextTimestampMap = new Map(); for (const session of fetched) { nextTimestampMap.set(session.id, parseTimestamp(session.updatedAt)); } sessionTimestampsRef.current = nextTimestampMap; setSessions(fetched); }) .catch(() => {}); }, [projectId]); // Initial load: request state from sibling tabs first, then fetch authoritative API state. useEffect(() => { requestSync(); refresh(); }, [refresh, requestSync]); // Merge cross-tab state updates as a low-latency supplement to SSE/API. useEffect(() => { setSessions((prev) => { if (syncedSessions.size === 0) { return prev; } let changed = false; const nextById = new Map(prev.map((session) => [session.id, session])); for (const syncState of syncedSessions.values()) { if (projectId && syncState.projectId && syncState.projectId !== projectId) { continue; } const incomingTimestamp = syncState.lastEventTimestamp; const knownTimestamp = sessionTimestampsRef.current.get(syncState.sessionId) ?? 0; if (incomingTimestamp < knownTimestamp) { continue; } const existing = nextById.get(syncState.sessionId); const type = syncState.type ?? existing?.type; const title = syncState.title ?? existing?.title; // Without type/title metadata we cannot safely materialize a new list item yet. if (!existing && (!type || !title)) { continue; } const nextSession: AiSessionSummary = { id: syncState.sessionId, type: type ?? "planning", status: syncState.status, title: title ?? "AI Session", projectId: syncState.projectId ?? existing?.projectId ?? projectId ?? null, lockedByTab: syncState.owningTabId ?? existing?.lockedByTab ?? null, updatedAt: syncState.updatedAt ?? existing?.updatedAt ?? new Date(incomingTimestamp).toISOString(), }; const previous = nextById.get(syncState.sessionId); const hasChanged = !previous || previous.status !== nextSession.status || previous.title !== nextSession.title || previous.type !== nextSession.type || previous.projectId !== nextSession.projectId || previous.lockedByTab !== nextSession.lockedByTab || previous.updatedAt !== nextSession.updatedAt; if (hasChanged) { nextById.set(syncState.sessionId, nextSession); sessionTimestampsRef.current.set(syncState.sessionId, incomingTimestamp); changed = true; } } if (!changed) { return prev; } return [...nextById.values()].sort( (a, b) => parseTimestamp(b.updatedAt) - parseTimestamp(a.updatedAt), ); }); }, [projectId, syncedSessions]); // Listen for server-side SSE events (authoritative source of truth). useEffect(() => { const params = projectId ? `?projectId=${encodeURIComponent(projectId)}` : ""; const es = new EventSource(`/api/events${params}`); eventSourceRef.current = es; const handleUpdated = (e: MessageEvent) => { try { const updated = JSON.parse(e.data) as AiSessionSummary; const eventTimestamp = parseTimestamp(updated.updatedAt) || Date.now(); setSessions((prev) => { const knownTimestamp = sessionTimestampsRef.current.get(updated.id) ?? 0; if (eventTimestamp < knownTimestamp) { return prev; } sessionTimestampsRef.current.set(updated.id, eventTimestamp); const idx = prev.findIndex((s) => s.id === updated.id); if (idx >= 0) { const next = [...prev]; next[idx] = updated; return next; } if (shouldIncludeSession(updated)) { return [updated, ...prev]; } return prev; }); broadcastUpdate({ sessionId: updated.id, status: updated.status, needsInput: updated.status === "awaiting_input", type: updated.type, title: updated.title, projectId: updated.projectId, owningTabId: updated.lockedByTab, updatedAt: updated.updatedAt, timestamp: eventTimestamp, }); if (updated.status === "complete" || updated.status === "error") { broadcastCompleted({ sessionId: updated.id, status: updated.status, timestamp: eventTimestamp, }); } } catch { // ignore malformed payload } }; const handleDeleted = (e: MessageEvent) => { try { const id = JSON.parse(e.data) as string; setSessions((prev) => prev.filter((s) => s.id !== id)); sessionTimestampsRef.current.delete(id); } catch { // ignore malformed payload } }; es.addEventListener("ai_session:updated", handleUpdated); es.addEventListener("ai_session:deleted", handleDeleted); return () => { es.removeEventListener("ai_session:updated", handleUpdated); es.removeEventListener("ai_session:deleted", handleDeleted); es.close(); }; }, [broadcastCompleted, broadcastUpdate, projectId]); const dismissSession = useCallback((id: string) => { deleteAiSession(id).catch(() => {}); setSessions((prev) => prev.filter((s) => s.id !== id)); sessionTimestampsRef.current.delete(id); }, []); const active = useMemo( () => sessions.filter((session) => shouldIncludeSession(session)), [sessions], ); const planningSessions = useMemo( () => active.filter((session) => session.type === "planning"), [active], ); return { sessions: active, generating: active.filter((session) => session.status === "generating").length, needsInput: active.filter((session) => session.status === "awaiting_input").length, planningSessions, dismissSession, refresh, }; }