Files
paperclip/ui/src/context/LiveUpdatesProvider.tsx
T
Forgotten adca44849a feat(ui): active agents panel, sidebar context, and page enhancements
Add live ActiveAgentsPanel with real-time transcript feed, SidebarContext
for responsive sidebar state, agent config form with reasoning effort,
improved inbox with failed run alerts, enriched issue detail with project
picker, and various component refinements across pages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 10:32:32 -06:00

200 lines
6.6 KiB
TypeScript

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<typeof useQueryClient>,
companyId: string,
payload: Record<string, unknown>,
) {
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<typeof useQueryClient>,
companyId: string,
payload: Record<string, unknown>,
) {
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<typeof useQueryClient>,
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" || event.type === "heartbeat.run.event") {
invalidateHeartbeatQueries(queryClient, expectedCompanyId, payload);
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}</>;
}