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:
Forgotten
2026-02-17 12:24:48 -06:00
parent c9c75bbc0a
commit 3dc3813266
30 changed files with 744 additions and 465 deletions

View File

@@ -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,

View 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}</>;
}