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:
@@ -7,8 +7,10 @@ import {
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { Company } from "@paperclip/shared";
|
||||
import { companiesApi } from "../api/companies";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
|
||||
interface CompanyContextValue {
|
||||
companies: Company[];
|
||||
@@ -30,10 +32,28 @@ const STORAGE_KEY = "paperclip.selectedCompanyId";
|
||||
const CompanyContext = createContext<CompanyContextValue | null>(null);
|
||||
|
||||
export function CompanyProvider({ children }: { children: ReactNode }) {
|
||||
const [companies, setCompanies] = useState<Company[]>([]);
|
||||
const [selectedCompanyId, setSelectedCompanyIdState] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedCompanyId, setSelectedCompanyIdState] = useState<string | null>(
|
||||
() => localStorage.getItem(STORAGE_KEY)
|
||||
);
|
||||
|
||||
const { data: companies = [], isLoading, error } = useQuery({
|
||||
queryKey: queryKeys.companies.all,
|
||||
queryFn: () => companiesApi.list(),
|
||||
});
|
||||
|
||||
// Auto-select first company when list loads
|
||||
useEffect(() => {
|
||||
if (companies.length === 0) return;
|
||||
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored && companies.some((c) => c.id === stored)) return;
|
||||
if (selectedCompanyId && companies.some((c) => c.id === selectedCompanyId)) return;
|
||||
|
||||
const next = companies[0]!.id;
|
||||
setSelectedCompanyIdState(next);
|
||||
localStorage.setItem(STORAGE_KEY, next);
|
||||
}, [companies, selectedCompanyId]);
|
||||
|
||||
const setSelectedCompanyId = useCallback((companyId: string) => {
|
||||
setSelectedCompanyIdState(companyId);
|
||||
@@ -41,47 +61,23 @@ export function CompanyProvider({ children }: { children: ReactNode }) {
|
||||
}, []);
|
||||
|
||||
const reloadCompanies = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const rows = await companiesApi.list();
|
||||
setCompanies(rows);
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
|
||||
}, [queryClient]);
|
||||
|
||||
if (rows.length === 0) {
|
||||
setSelectedCompanyIdState(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
const next = rows.some((company) => company.id === stored)
|
||||
? stored
|
||||
: selectedCompanyId && rows.some((company) => company.id === selectedCompanyId)
|
||||
? selectedCompanyId
|
||||
: rows[0]!.id;
|
||||
|
||||
if (next) {
|
||||
setSelectedCompanyIdState(next);
|
||||
localStorage.setItem(STORAGE_KEY, next);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error("Failed to load companies"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [selectedCompanyId]);
|
||||
|
||||
useEffect(() => {
|
||||
void reloadCompanies();
|
||||
}, [reloadCompanies]);
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: { name: string; description?: string | null; budgetMonthlyCents?: number }) =>
|
||||
companiesApi.create(data),
|
||||
onSuccess: (company) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
|
||||
setSelectedCompanyId(company.id);
|
||||
},
|
||||
});
|
||||
|
||||
const createCompany = useCallback(
|
||||
async (data: { name: string; description?: string | null; budgetMonthlyCents?: number }) => {
|
||||
const company = await companiesApi.create(data);
|
||||
await reloadCompanies();
|
||||
setSelectedCompanyId(company.id);
|
||||
return company;
|
||||
return createMutation.mutateAsync(data);
|
||||
},
|
||||
[reloadCompanies, setSelectedCompanyId],
|
||||
[createMutation],
|
||||
);
|
||||
|
||||
const selectedCompany = useMemo(
|
||||
@@ -94,8 +90,8 @@ export function CompanyProvider({ children }: { children: ReactNode }) {
|
||||
companies,
|
||||
selectedCompanyId,
|
||||
selectedCompany,
|
||||
loading,
|
||||
error,
|
||||
loading: isLoading,
|
||||
error: error as Error | null,
|
||||
setSelectedCompanyId,
|
||||
reloadCompanies,
|
||||
createCompany,
|
||||
@@ -104,7 +100,7 @@ export function CompanyProvider({ children }: { children: ReactNode }) {
|
||||
companies,
|
||||
selectedCompanyId,
|
||||
selectedCompany,
|
||||
loading,
|
||||
isLoading,
|
||||
error,
|
||||
setSelectedCompanyId,
|
||||
reloadCompanies,
|
||||
|
||||
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