Adopt React Query and live updates across all UI pages
Replace custom useApi/useAgents hooks with @tanstack/react-query. Add LiveUpdatesProvider for WebSocket-driven cache invalidation. Add queryKeys module for centralized cache key management. Rework all pages and dialogs to use React Query mutations and queries. Improve CompanyContext with query-based data fetching. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
193
ui/src/context/LiveUpdatesProvider.tsx
Normal file
193
ui/src/context/LiveUpdatesProvider.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
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) });
|
||||
|
||||
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) });
|
||||
|
||||
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) });
|
||||
}
|
||||
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}</>;
|
||||
}
|
||||
Reference in New Issue
Block a user