import { useEffect, type ReactNode } from "react"; import { useQueryClient } from "@tanstack/react-query"; import type { LiveEvent } from "@paperclip/shared"; import { useCompany } from "./CompanyContext"; import { queryKeys } from "../lib/queryKeys"; function readString(value: unknown): string | null { return typeof value === "string" && value.length > 0 ? value : null; } function invalidateHeartbeatQueries( queryClient: ReturnType, companyId: string, payload: Record, ) { queryClient.invalidateQueries({ queryKey: queryKeys.liveRuns(companyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(companyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(companyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.dashboard(companyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.costs(companyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(companyId) }); const agentId = readString(payload.agentId); if (agentId) { queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentId) }); queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(companyId, agentId) }); } } function invalidateActivityQueries( queryClient: ReturnType, companyId: string, payload: Record, ) { queryClient.invalidateQueries({ queryKey: queryKeys.activity(companyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.dashboard(companyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(companyId) }); const entityType = readString(payload.entityType); const entityId = readString(payload.entityId); if (entityType === "issue") { queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) }); if (entityId) { queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(entityId) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(entityId) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(entityId) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(entityId) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(entityId) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(entityId) }); } return; } if (entityType === "agent") { queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(companyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.org(companyId) }); if (entityId) { queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(entityId) }); queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(companyId, entityId) }); } return; } if (entityType === "project") { queryClient.invalidateQueries({ queryKey: queryKeys.projects.list(companyId) }); if (entityId) queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(entityId) }); return; } if (entityType === "goal") { queryClient.invalidateQueries({ queryKey: queryKeys.goals.list(companyId) }); if (entityId) queryClient.invalidateQueries({ queryKey: queryKeys.goals.detail(entityId) }); return; } if (entityType === "approval") { queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(companyId) }); return; } if (entityType === "cost_event") { queryClient.invalidateQueries({ queryKey: queryKeys.costs(companyId) }); return; } if (entityType === "company") { queryClient.invalidateQueries({ queryKey: queryKeys.companies.all }); } } function handleLiveEvent( queryClient: ReturnType, expectedCompanyId: string, event: LiveEvent, ) { if (event.companyId !== expectedCompanyId) return; const payload = event.payload ?? {}; if (event.type === "heartbeat.run.log") { return; } if (event.type === "heartbeat.run.queued" || event.type === "heartbeat.run.status") { invalidateHeartbeatQueries(queryClient, expectedCompanyId, payload); return; } if (event.type === "heartbeat.run.event") { return; } if (event.type === "agent.status") { queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(expectedCompanyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.dashboard(expectedCompanyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.org(expectedCompanyId) }); const agentId = readString(payload.agentId); if (agentId) queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentId) }); return; } if (event.type === "activity.logged") { invalidateActivityQueries(queryClient, expectedCompanyId, payload); } } export function LiveUpdatesProvider({ children }: { children: ReactNode }) { const { selectedCompanyId } = useCompany(); const queryClient = useQueryClient(); useEffect(() => { if (!selectedCompanyId) return; let closed = false; let reconnectAttempt = 0; let reconnectTimer: number | null = null; let socket: WebSocket | null = null; const clearReconnect = () => { if (reconnectTimer !== null) { window.clearTimeout(reconnectTimer); reconnectTimer = null; } }; const scheduleReconnect = () => { if (closed) return; reconnectAttempt += 1; const delayMs = Math.min(15000, 1000 * 2 ** Math.min(reconnectAttempt - 1, 4)); reconnectTimer = window.setTimeout(() => { reconnectTimer = null; connect(); }, delayMs); }; const connect = () => { if (closed) return; const protocol = window.location.protocol === "https:" ? "wss" : "ws"; const url = `${protocol}://${window.location.host}/api/companies/${encodeURIComponent(selectedCompanyId)}/events/ws`; socket = new WebSocket(url); socket.onopen = () => { reconnectAttempt = 0; }; socket.onmessage = (message) => { const raw = typeof message.data === "string" ? message.data : ""; if (!raw) return; try { const parsed = JSON.parse(raw) as LiveEvent; handleLiveEvent(queryClient, selectedCompanyId, parsed); } catch { // Ignore non-JSON payloads. } }; socket.onerror = () => { socket?.close(); }; socket.onclose = () => { if (closed) return; scheduleReconnect(); }; }; connect(); return () => { closed = true; clearReconnect(); if (socket) { socket.onopen = null; socket.onmessage = null; socket.onerror = null; socket.onclose = null; socket.close(1000, "provider_unmount"); } }; }, [queryClient, selectedCompanyId]); return <>{children}; }