From 3dc3813266a23c7780e76f4e25b3971a1782c076 Mon Sep 17 00:00:00 2001 From: Forgotten Date: Tue, 17 Feb 2026 12:24:48 -0600 Subject: [PATCH] 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 --- ui/package.json | 1 + ui/src/api/agents.ts | 10 ++ ui/src/api/heartbeats.ts | 10 +- ui/src/components/CommandPalette.tsx | 40 +++-- ui/src/components/IssueProperties.tsx | 10 +- ui/src/components/NewIssueDialog.tsx | 72 ++++----- ui/src/components/NewProjectDialog.tsx | 62 ++++---- ui/src/context/CompanyContext.tsx | 82 +++++------ ui/src/context/LiveUpdatesProvider.tsx | 193 +++++++++++++++++++++++++ ui/src/hooks/useAgents.ts | 11 -- ui/src/hooks/useApi.ts | 21 --- ui/src/lib/queryKeys.ts | 33 +++++ ui/src/main.tsx | 41 ++++-- ui/src/pages/Activity.tsx | 19 ++- ui/src/pages/AgentDetail.tsx | 82 ++++++----- ui/src/pages/Agents.tsx | 16 +- ui/src/pages/Approvals.tsx | 55 +++---- ui/src/pages/Companies.tsx | 28 ++-- ui/src/pages/Costs.tsx | 30 ++-- ui/src/pages/Dashboard.tsx | 47 +++--- ui/src/pages/GoalDetail.tsx | 38 ++--- ui/src/pages/Goals.tsx | 18 +-- ui/src/pages/Inbox.tsx | 87 +++++------ ui/src/pages/IssueDetail.tsx | 73 ++++++---- ui/src/pages/Issues.tsx | 41 ++++-- ui/src/pages/MyIssues.tsx | 20 +-- ui/src/pages/Org.tsx | 18 +-- ui/src/pages/ProjectDetail.tsx | 28 ++-- ui/src/pages/Projects.tsx | 18 +-- ui/vite.config.ts | 5 +- 30 files changed, 744 insertions(+), 465 deletions(-) create mode 100644 ui/src/context/LiveUpdatesProvider.tsx delete mode 100644 ui/src/hooks/useAgents.ts delete mode 100644 ui/src/hooks/useApi.ts create mode 100644 ui/src/lib/queryKeys.ts diff --git a/ui/package.json b/ui/package.json index 3db92cba..c35dd406 100644 --- a/ui/package.json +++ b/ui/package.json @@ -12,6 +12,7 @@ "dependencies": { "@paperclip/shared": "workspace:*", "@radix-ui/react-slot": "^1.2.4", + "@tanstack/react-query": "^5.90.21", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts index ef427469..02517dd9 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -21,4 +21,14 @@ export const agentsApi = { terminate: (id: string) => api.post(`/agents/${id}/terminate`, {}), createKey: (id: string, name: string) => api.post(`/agents/${id}/keys`, { name }), invoke: (id: string) => api.post(`/agents/${id}/heartbeat/invoke`, {}), + wakeup: ( + id: string, + data: { + source?: "timer" | "assignment" | "on_demand" | "automation"; + triggerDetail?: "manual" | "ping" | "callback" | "system"; + reason?: string | null; + payload?: Record | null; + idempotencyKey?: string | null; + }, + ) => api.post(`/agents/${id}/wakeup`, data), }; diff --git a/ui/src/api/heartbeats.ts b/ui/src/api/heartbeats.ts index 19d0a70e..7183c6e6 100644 --- a/ui/src/api/heartbeats.ts +++ b/ui/src/api/heartbeats.ts @@ -1,4 +1,4 @@ -import type { HeartbeatRun } from "@paperclip/shared"; +import type { HeartbeatRun, HeartbeatRunEvent } from "@paperclip/shared"; import { api } from "./client"; export const heartbeatsApi = { @@ -6,4 +6,12 @@ export const heartbeatsApi = { const params = agentId ? `?agentId=${agentId}` : ""; return api.get(`/companies/${companyId}/heartbeat-runs${params}`); }, + events: (runId: string, afterSeq = 0, limit = 200) => + api.get( + `/heartbeat-runs/${runId}/events?afterSeq=${encodeURIComponent(String(afterSeq))}&limit=${encodeURIComponent(String(limit))}`, + ), + log: (runId: string, offset = 0, limitBytes = 256000) => + api.get<{ runId: string; store: string; logRef: string; content: string; nextOffset?: number }>( + `/heartbeat-runs/${runId}/log?offset=${encodeURIComponent(String(offset))}&limitBytes=${encodeURIComponent(String(limitBytes))}`, + ), }; diff --git a/ui/src/components/CommandPalette.tsx b/ui/src/components/CommandPalette.tsx index a26da594..0d1e5c44 100644 --- a/ui/src/components/CommandPalette.tsx +++ b/ui/src/components/CommandPalette.tsx @@ -1,10 +1,12 @@ -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect } from "react"; import { useNavigate } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; import { useCompany } from "../context/CompanyContext"; import { useDialog } from "../context/DialogContext"; import { issuesApi } from "../api/issues"; import { agentsApi } from "../api/agents"; import { projectsApi } from "../api/projects"; +import { queryKeys } from "../lib/queryKeys"; import { CommandDialog, CommandEmpty, @@ -27,13 +29,9 @@ import { SquarePen, Plus, } from "lucide-react"; -import type { Issue, Agent, Project } from "@paperclip/shared"; export function CommandPalette() { const [open, setOpen] = useState(false); - const [issues, setIssues] = useState([]); - const [agents, setAgents] = useState([]); - const [projects, setProjects] = useState([]); const navigate = useNavigate(); const { selectedCompanyId } = useCompany(); const { openNewIssue } = useDialog(); @@ -49,23 +47,23 @@ export function CommandPalette() { return () => document.removeEventListener("keydown", handleKeyDown); }, []); - const loadData = useCallback(async () => { - if (!selectedCompanyId) return; - const [i, a, p] = await Promise.all([ - issuesApi.list(selectedCompanyId).catch(() => []), - agentsApi.list(selectedCompanyId).catch(() => []), - projectsApi.list(selectedCompanyId).catch(() => []), - ]); - setIssues(i); - setAgents(a); - setProjects(p); - }, [selectedCompanyId]); + const { data: issues = [] } = useQuery({ + queryKey: queryKeys.issues.list(selectedCompanyId!), + queryFn: () => issuesApi.list(selectedCompanyId!), + enabled: !!selectedCompanyId && open, + }); - useEffect(() => { - if (open) { - void loadData(); - } - }, [open, loadData]); + const { data: agents = [] } = useQuery({ + queryKey: queryKeys.agents.list(selectedCompanyId!), + queryFn: () => agentsApi.list(selectedCompanyId!), + enabled: !!selectedCompanyId && open, + }); + + const { data: projects = [] } = useQuery({ + queryKey: queryKeys.projects.list(selectedCompanyId!), + queryFn: () => projectsApi.list(selectedCompanyId!), + enabled: !!selectedCompanyId && open, + }); function go(path: string) { setOpen(false); diff --git a/ui/src/components/IssueProperties.tsx b/ui/src/components/IssueProperties.tsx index 69f37ec9..60fd54f6 100644 --- a/ui/src/components/IssueProperties.tsx +++ b/ui/src/components/IssueProperties.tsx @@ -1,6 +1,8 @@ import type { Issue } from "@paperclip/shared"; +import { useQuery } from "@tanstack/react-query"; +import { agentsApi } from "../api/agents"; import { useCompany } from "../context/CompanyContext"; -import { useAgents } from "../hooks/useAgents"; +import { queryKeys } from "../lib/queryKeys"; import { StatusIcon } from "./StatusIcon"; import { PriorityIcon } from "./PriorityIcon"; import { formatDate } from "../lib/utils"; @@ -31,7 +33,11 @@ function priorityLabel(priority: string): string { export function IssueProperties({ issue, onUpdate }: IssuePropertiesProps) { const { selectedCompanyId } = useCompany(); - const { data: agents } = useAgents(selectedCompanyId); + const { data: agents } = useQuery({ + queryKey: queryKeys.agents.list(selectedCompanyId!), + queryFn: () => agentsApi.list(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); const agentName = (id: string | null) => { if (!id || !agents) return null; diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index c7e8bbcf..7a37626a 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -1,10 +1,11 @@ -import { useState, useCallback, useEffect } from "react"; +import { useState, useEffect } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useDialog } from "../context/DialogContext"; import { useCompany } from "../context/CompanyContext"; import { issuesApi } from "../api/issues"; import { projectsApi } from "../api/projects"; -import { useAgents } from "../hooks/useAgents"; -import { useApi } from "../hooks/useApi"; +import { agentsApi } from "../api/agents"; +import { queryKeys } from "../lib/queryKeys"; import { Dialog, DialogContent, @@ -47,13 +48,10 @@ const priorities = [ { value: "low", label: "Low", icon: ArrowDown, color: "text-blue-400" }, ]; -interface NewIssueDialogProps { - onCreated?: () => void; -} - -export function NewIssueDialog({ onCreated }: NewIssueDialogProps) { +export function NewIssueDialog() { const { newIssueOpen, newIssueDefaults, closeNewIssue } = useDialog(); const { selectedCompanyId, selectedCompany } = useCompany(); + const queryClient = useQueryClient(); const [title, setTitle] = useState(""); const [description, setDescription] = useState(""); const [status, setStatus] = useState("todo"); @@ -61,7 +59,6 @@ export function NewIssueDialog({ onCreated }: NewIssueDialogProps) { const [assigneeId, setAssigneeId] = useState(""); const [projectId, setProjectId] = useState(""); const [expanded, setExpanded] = useState(false); - const [submitting, setSubmitting] = useState(false); // Popover states const [statusOpen, setStatusOpen] = useState(false); @@ -70,13 +67,27 @@ export function NewIssueDialog({ onCreated }: NewIssueDialogProps) { const [projectOpen, setProjectOpen] = useState(false); const [moreOpen, setMoreOpen] = useState(false); - const { data: agents } = useAgents(selectedCompanyId); + const { data: agents } = useQuery({ + queryKey: queryKeys.agents.list(selectedCompanyId!), + queryFn: () => agentsApi.list(selectedCompanyId!), + enabled: !!selectedCompanyId && newIssueOpen, + }); - const projectsFetcher = useCallback(() => { - if (!selectedCompanyId) return Promise.resolve([] as Project[]); - return projectsApi.list(selectedCompanyId); - }, [selectedCompanyId]); - const { data: projects } = useApi(projectsFetcher); + const { data: projects } = useQuery({ + queryKey: queryKeys.projects.list(selectedCompanyId!), + queryFn: () => projectsApi.list(selectedCompanyId!), + enabled: !!selectedCompanyId && newIssueOpen, + }); + + const createIssue = useMutation({ + mutationFn: (data: Record) => + issuesApi.create(selectedCompanyId!, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId!) }); + reset(); + closeNewIssue(); + }, + }); useEffect(() => { if (newIssueOpen) { @@ -96,25 +107,16 @@ export function NewIssueDialog({ onCreated }: NewIssueDialogProps) { setExpanded(false); } - async function handleSubmit() { + function handleSubmit() { if (!selectedCompanyId || !title.trim()) return; - - setSubmitting(true); - try { - await issuesApi.create(selectedCompanyId, { - title: title.trim(), - description: description.trim() || undefined, - status, - priority: priority || "medium", - ...(assigneeId ? { assigneeAgentId: assigneeId } : {}), - ...(projectId ? { projectId } : {}), - }); - reset(); - closeNewIssue(); - onCreated?.(); - } finally { - setSubmitting(false); - } + createIssue.mutate({ + title: title.trim(), + description: description.trim() || undefined, + status, + priority: priority || "medium", + ...(assigneeId ? { assigneeAgentId: assigneeId } : {}), + ...(projectId ? { projectId } : {}), + }); } function handleKeyDown(e: React.KeyboardEvent) { @@ -359,10 +361,10 @@ export function NewIssueDialog({ onCreated }: NewIssueDialogProps) {
diff --git a/ui/src/components/NewProjectDialog.tsx b/ui/src/components/NewProjectDialog.tsx index 1c13656e..cea4124b 100644 --- a/ui/src/components/NewProjectDialog.tsx +++ b/ui/src/components/NewProjectDialog.tsx @@ -1,9 +1,10 @@ -import { useState, useCallback } from "react"; +import { useState } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useDialog } from "../context/DialogContext"; import { useCompany } from "../context/CompanyContext"; import { projectsApi } from "../api/projects"; import { goalsApi } from "../api/goals"; -import { useApi } from "../hooks/useApi"; +import { queryKeys } from "../lib/queryKeys"; import { Dialog, DialogContent, @@ -32,29 +33,35 @@ const projectStatuses = [ { value: "cancelled", label: "Cancelled" }, ]; -interface NewProjectDialogProps { - onCreated?: () => void; -} - -export function NewProjectDialog({ onCreated }: NewProjectDialogProps) { +export function NewProjectDialog() { const { newProjectOpen, closeNewProject } = useDialog(); const { selectedCompanyId, selectedCompany } = useCompany(); + const queryClient = useQueryClient(); const [name, setName] = useState(""); const [description, setDescription] = useState(""); const [status, setStatus] = useState("planned"); const [goalId, setGoalId] = useState(""); const [targetDate, setTargetDate] = useState(""); const [expanded, setExpanded] = useState(false); - const [submitting, setSubmitting] = useState(false); const [statusOpen, setStatusOpen] = useState(false); const [goalOpen, setGoalOpen] = useState(false); - const goalsFetcher = useCallback(() => { - if (!selectedCompanyId) return Promise.resolve([] as Goal[]); - return goalsApi.list(selectedCompanyId); - }, [selectedCompanyId]); - const { data: goals } = useApi(goalsFetcher); + const { data: goals } = useQuery({ + queryKey: queryKeys.goals.list(selectedCompanyId!), + queryFn: () => goalsApi.list(selectedCompanyId!), + enabled: !!selectedCompanyId && newProjectOpen, + }); + + const createProject = useMutation({ + mutationFn: (data: Record) => + projectsApi.create(selectedCompanyId!, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.projects.list(selectedCompanyId!) }); + reset(); + closeNewProject(); + }, + }); function reset() { setName(""); @@ -65,24 +72,15 @@ export function NewProjectDialog({ onCreated }: NewProjectDialogProps) { setExpanded(false); } - async function handleSubmit() { + function handleSubmit() { if (!selectedCompanyId || !name.trim()) return; - - setSubmitting(true); - try { - await projectsApi.create(selectedCompanyId, { - name: name.trim(), - description: description.trim() || undefined, - status, - ...(goalId ? { goalId } : {}), - ...(targetDate ? { targetDate } : {}), - }); - reset(); - closeNewProject(); - onCreated?.(); - } finally { - setSubmitting(false); - } + createProject.mutate({ + name: name.trim(), + description: description.trim() || undefined, + status, + ...(goalId ? { goalId } : {}), + ...(targetDate ? { targetDate } : {}), + }); } function handleKeyDown(e: React.KeyboardEvent) { @@ -239,10 +237,10 @@ export function NewProjectDialog({ onCreated }: NewProjectDialogProps) {
diff --git a/ui/src/context/CompanyContext.tsx b/ui/src/context/CompanyContext.tsx index a33894a6..061a2db0 100644 --- a/ui/src/context/CompanyContext.tsx +++ b/ui/src/context/CompanyContext.tsx @@ -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(null); export function CompanyProvider({ children }: { children: ReactNode }) { - const [companies, setCompanies] = useState([]); - const [selectedCompanyId, setSelectedCompanyIdState] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const queryClient = useQueryClient(); + const [selectedCompanyId, setSelectedCompanyIdState] = useState( + () => 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, diff --git a/ui/src/context/LiveUpdatesProvider.tsx b/ui/src/context/LiveUpdatesProvider.tsx new file mode 100644 index 00000000..233254c1 --- /dev/null +++ b/ui/src/context/LiveUpdatesProvider.tsx @@ -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, + companyId: string, + payload: Record, +) { + 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, + companyId: string, + payload: Record, +) { + 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, + 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}; +} diff --git a/ui/src/hooks/useAgents.ts b/ui/src/hooks/useAgents.ts deleted file mode 100644 index e719c5a0..00000000 --- a/ui/src/hooks/useAgents.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { useCallback } from "react"; -import { agentsApi } from "../api/agents"; -import { useApi } from "./useApi"; - -export function useAgents(companyId: string | null) { - const fetcher = useCallback(() => { - if (!companyId) return Promise.resolve([]); - return agentsApi.list(companyId); - }, [companyId]); - return useApi(fetcher); -} diff --git a/ui/src/hooks/useApi.ts b/ui/src/hooks/useApi.ts deleted file mode 100644 index bcc5f89c..00000000 --- a/ui/src/hooks/useApi.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { useState, useEffect, useCallback } from "react"; - -export function useApi(fetcher: () => Promise) { - const [data, setData] = useState(null); - const [error, setError] = useState(null); - const [loading, setLoading] = useState(true); - - const load = useCallback(() => { - setLoading(true); - fetcher() - .then(setData) - .catch(setError) - .finally(() => setLoading(false)); - }, [fetcher]); - - useEffect(() => { - load(); - }, [load]); - - return { data, error, loading, reload: load }; -} diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts new file mode 100644 index 00000000..1c8a391c --- /dev/null +++ b/ui/src/lib/queryKeys.ts @@ -0,0 +1,33 @@ +export const queryKeys = { + companies: { + all: ["companies"] as const, + detail: (id: string) => ["companies", id] as const, + }, + agents: { + list: (companyId: string) => ["agents", companyId] as const, + detail: (id: string) => ["agents", "detail", id] as const, + }, + issues: { + list: (companyId: string) => ["issues", companyId] as const, + detail: (id: string) => ["issues", "detail", id] as const, + comments: (issueId: string) => ["issues", "comments", issueId] as const, + }, + projects: { + list: (companyId: string) => ["projects", companyId] as const, + detail: (id: string) => ["projects", "detail", id] as const, + }, + goals: { + list: (companyId: string) => ["goals", companyId] as const, + detail: (id: string) => ["goals", "detail", id] as const, + }, + approvals: { + list: (companyId: string, status?: string) => + ["approvals", companyId, status] as const, + }, + dashboard: (companyId: string) => ["dashboard", companyId] as const, + activity: (companyId: string) => ["activity", companyId] as const, + costs: (companyId: string) => ["costs", companyId] as const, + heartbeats: (companyId: string, agentId?: string) => + ["heartbeats", companyId, agentId] as const, + org: (companyId: string) => ["org", companyId] as const, +}; diff --git a/ui/src/main.tsx b/ui/src/main.tsx index f056297d..7e64d040 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -1,28 +1,43 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { BrowserRouter } from "react-router-dom"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { App } from "./App"; import { CompanyProvider } from "./context/CompanyContext"; +import { LiveUpdatesProvider } from "./context/LiveUpdatesProvider"; import { BreadcrumbProvider } from "./context/BreadcrumbContext"; import { PanelProvider } from "./context/PanelContext"; import { DialogProvider } from "./context/DialogContext"; import { TooltipProvider } from "@/components/ui/tooltip"; import "./index.css"; +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, + refetchOnWindowFocus: true, + }, + }, +}); + createRoot(document.getElementById("root")!).render( - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + ); diff --git a/ui/src/pages/Activity.tsx b/ui/src/pages/Activity.tsx index 8bb657fd..4d7394ef 100644 --- a/ui/src/pages/Activity.tsx +++ b/ui/src/pages/Activity.tsx @@ -1,9 +1,10 @@ -import { useCallback, useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; import { activityApi } from "../api/activity"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; -import { useApi } from "../hooks/useApi"; +import { queryKeys } from "../lib/queryKeys"; import { EmptyState } from "../components/EmptyState"; import { timeAgo } from "../lib/timeAgo"; import { Badge } from "@/components/ui/badge"; @@ -17,7 +18,6 @@ import { import { History, Bot, User, Settings } from "lucide-react"; function formatAction(action: string, entityType: string, entityId: string): string { - const shortId = entityId.slice(0, 8); const actionMap: Record = { "company.created": "Company created", "agent.created": `Agent created`, @@ -80,12 +80,11 @@ export function Activity() { setBreadcrumbs([{ label: "Activity" }]); }, [setBreadcrumbs]); - const fetcher = useCallback(() => { - if (!selectedCompanyId) return Promise.resolve([]); - return activityApi.list(selectedCompanyId); - }, [selectedCompanyId]); - - const { data, loading, error } = useApi(fetcher); + const { data, isLoading, error } = useQuery({ + queryKey: queryKeys.activity(selectedCompanyId!), + queryFn: () => activityApi.list(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); if (!selectedCompanyId) { return ; @@ -119,7 +118,7 @@ export function Activity() { - {loading &&

Loading...

} + {isLoading &&

Loading...

} {error &&

{error.message}

} {filtered && filtered.length === 0 && ( diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index a6803bfa..6249ce99 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -1,12 +1,13 @@ -import { useCallback, useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import { useParams } from "react-router-dom"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { agentsApi } from "../api/agents"; import { heartbeatsApi } from "../api/heartbeats"; import { issuesApi } from "../api/issues"; -import { useApi } from "../hooks/useApi"; import { usePanel } from "../context/PanelContext"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; +import { queryKeys } from "../lib/queryKeys"; import { AgentProperties } from "../components/AgentProperties"; import { StatusBadge } from "../components/StatusBadge"; import { EntityRow } from "../components/EntityRow"; @@ -20,29 +21,53 @@ export function AgentDetail() { const { selectedCompanyId } = useCompany(); const { openPanel, closePanel } = usePanel(); const { setBreadcrumbs } = useBreadcrumbs(); + const queryClient = useQueryClient(); const [actionError, setActionError] = useState(null); - const agentFetcher = useCallback(() => { - if (!agentId) return Promise.reject(new Error("No agent ID")); - return agentsApi.get(agentId); - }, [agentId]); + const { data: agent, isLoading, error } = useQuery({ + queryKey: queryKeys.agents.detail(agentId!), + queryFn: () => agentsApi.get(agentId!), + enabled: !!agentId, + }); - const heartbeatsFetcher = useCallback(() => { - if (!selectedCompanyId || !agentId) return Promise.resolve([] as HeartbeatRun[]); - return heartbeatsApi.list(selectedCompanyId, agentId); - }, [selectedCompanyId, agentId]); + const { data: heartbeats } = useQuery({ + queryKey: queryKeys.heartbeats(selectedCompanyId!, agentId), + queryFn: () => heartbeatsApi.list(selectedCompanyId!, agentId), + enabled: !!selectedCompanyId && !!agentId, + }); - const issuesFetcher = useCallback(() => { - if (!selectedCompanyId) return Promise.resolve([] as Issue[]); - return issuesApi.list(selectedCompanyId); - }, [selectedCompanyId]); - - const { data: agent, loading, error, reload: reloadAgent } = useApi(agentFetcher); - const { data: heartbeats } = useApi(heartbeatsFetcher); - const { data: allIssues } = useApi(issuesFetcher); + const { data: allIssues } = useQuery({ + queryKey: queryKeys.issues.list(selectedCompanyId!), + queryFn: () => issuesApi.list(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); const assignedIssues = (allIssues ?? []).filter((i) => i.assigneeAgentId === agentId); + const agentAction = useMutation({ + mutationFn: async (action: "invoke" | "pause" | "resume") => { + if (!agentId) return Promise.reject(new Error("No agent ID")); + if (action === "invoke") { + await agentsApi.invoke(agentId); + return; + } + if (action === "pause") { + await agentsApi.pause(agentId); + return; + } + await agentsApi.resume(agentId); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentId!) }); + if (selectedCompanyId) { + queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(selectedCompanyId) }); + } + }, + onError: (err) => { + setActionError(err instanceof Error ? err.message : "Action failed"); + }, + }); + useEffect(() => { setBreadcrumbs([ { label: "Agents", href: "/agents" }, @@ -57,20 +82,7 @@ export function AgentDetail() { return () => closePanel(); }, [agent]); // eslint-disable-line react-hooks/exhaustive-deps - async function handleAction(action: "invoke" | "pause" | "resume") { - if (!agentId) return; - setActionError(null); - try { - if (action === "invoke") await agentsApi.invoke(agentId); - else if (action === "pause") await agentsApi.pause(agentId); - else await agentsApi.resume(agentId); - reloadAgent(); - } catch (err) { - setActionError(err instanceof Error ? err.message : `Failed to ${action} agent`); - } - } - - if (loading) return

Loading...

; + if (isLoading) return

Loading...

; if (error) return

{error.message}

; if (!agent) return null; @@ -89,15 +101,15 @@ export function AgentDetail() {

- {agent.status === "active" ? ( - ) : ( - )} diff --git a/ui/src/pages/Agents.tsx b/ui/src/pages/Agents.tsx index 3f288677..52a5d1c5 100644 --- a/ui/src/pages/Agents.tsx +++ b/ui/src/pages/Agents.tsx @@ -1,9 +1,10 @@ import { useState, useEffect } from "react"; import { useNavigate } from "react-router-dom"; -import { useAgents } from "../hooks/useAgents"; +import { useQuery } from "@tanstack/react-query"; +import { agentsApi } from "../api/agents"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; -import { agentsApi } from "../api/agents"; +import { queryKeys } from "../lib/queryKeys"; import { StatusBadge } from "../components/StatusBadge"; import { EntityRow } from "../components/EntityRow"; import { EmptyState } from "../components/EmptyState"; @@ -12,10 +13,14 @@ import { Bot } from "lucide-react"; export function Agents() { const { selectedCompanyId } = useCompany(); - const { data: agents, loading, error, reload } = useAgents(selectedCompanyId); const { setBreadcrumbs } = useBreadcrumbs(); const navigate = useNavigate(); - const [actionError, setActionError] = useState(null); + + const { data: agents, isLoading, error } = useQuery({ + queryKey: queryKeys.agents.list(selectedCompanyId!), + queryFn: () => agentsApi.list(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); useEffect(() => { setBreadcrumbs([{ label: "Agents" }]); @@ -29,9 +34,8 @@ export function Agents() {

Agents

- {loading &&

Loading...

} + {isLoading &&

Loading...

} {error &&

{error.message}

} - {actionError &&

{actionError}

} {agents && agents.length === 0 && ( (null); - const fetcher = useCallback(() => { - if (!selectedCompanyId) return Promise.resolve([]); - return approvalsApi.list(selectedCompanyId); - }, [selectedCompanyId]); + const { data, isLoading, error } = useQuery({ + queryKey: queryKeys.approvals.list(selectedCompanyId!), + queryFn: () => approvalsApi.list(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); - const { data, loading, error, reload } = useApi(fetcher); - - async function approve(id: string) { - setActionError(null); - try { - await approvalsApi.approve(id); - reload(); - } catch (err) { + const approveMutation = useMutation({ + mutationFn: (id: string) => approvalsApi.approve(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(selectedCompanyId!) }); + }, + onError: (err) => { setActionError(err instanceof Error ? err.message : "Failed to approve"); - } - } + }, + }); - async function reject(id: string) { - setActionError(null); - try { - await approvalsApi.reject(id); - reload(); - } catch (err) { + const rejectMutation = useMutation({ + mutationFn: (id: string) => approvalsApi.reject(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(selectedCompanyId!) }); + }, + onError: (err) => { setActionError(err instanceof Error ? err.message : "Failed to reject"); - } - } + }, + }); if (!selectedCompanyId) { return

Select a company first.

; @@ -44,7 +45,7 @@ export function Approvals() { return (

Approvals

- {loading &&

Loading...

} + {isLoading &&

Loading...

} {error &&

{error.message}

} {actionError &&

{actionError}

} @@ -68,14 +69,14 @@ export function Approvals() { variant="outline" size="sm" className="border-green-700 text-green-400 hover:bg-green-900/50" - onClick={() => approve(approval.id)} + onClick={() => approveMutation.mutate(approval.id)} > Approve diff --git a/ui/src/pages/Companies.tsx b/ui/src/pages/Companies.tsx index db8fabc0..c026113a 100644 --- a/ui/src/pages/Companies.tsx +++ b/ui/src/pages/Companies.tsx @@ -1,7 +1,9 @@ import { useState, useEffect } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { companiesApi } from "../api/companies"; +import { queryKeys } from "../lib/queryKeys"; import { formatCents } from "../lib/utils"; import { Card, CardContent } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; @@ -16,9 +18,9 @@ export function Companies() { createCompany, loading, error, - reloadCompanies, } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); + const queryClient = useQueryClient(); const [name, setName] = useState(""); const [description, setDescription] = useState(""); const [budget, setBudget] = useState("0"); @@ -28,7 +30,15 @@ export function Companies() { // Inline edit state const [editingId, setEditingId] = useState(null); const [editName, setEditName] = useState(""); - const [editSaving, setEditSaving] = useState(false); + + const editMutation = useMutation({ + mutationFn: ({ id, newName }: { id: string; newName: string }) => + companiesApi.update(id, { name: newName }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.companies.all }); + setEditingId(null); + }, + }); useEffect(() => { setBreadcrumbs([{ label: "Companies" }]); @@ -49,7 +59,6 @@ export function Companies() { setName(""); setDescription(""); setBudget("0"); - await reloadCompanies(); } catch (err) { setSubmitError(err instanceof Error ? err.message : "Failed to create company"); } finally { @@ -62,16 +71,9 @@ export function Companies() { setEditName(currentName); } - async function saveEdit() { + function saveEdit() { if (!editingId || !editName.trim()) return; - setEditSaving(true); - try { - await companiesApi.update(editingId, { name: editName.trim() }); - await reloadCompanies(); - setEditingId(null); - } finally { - setEditSaving(false); - } + editMutation.mutate({ id: editingId, newName: editName.trim() }); } function cancelEdit() { @@ -151,7 +153,7 @@ export function Companies() { variant="ghost" size="icon-xs" onClick={saveEdit} - disabled={editSaving} + disabled={editMutation.isPending} > diff --git a/ui/src/pages/Costs.tsx b/ui/src/pages/Costs.tsx index 4d88fc4e..a55e5414 100644 --- a/ui/src/pages/Costs.tsx +++ b/ui/src/pages/Costs.tsx @@ -1,8 +1,9 @@ -import { useCallback, useEffect } from "react"; +import { useEffect } from "react"; +import { useQuery } from "@tanstack/react-query"; import { costsApi } from "../api/costs"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; -import { useApi } from "../hooks/useApi"; +import { queryKeys } from "../lib/queryKeys"; import { EmptyState } from "../components/EmptyState"; import { formatCents } from "../lib/utils"; import { Card, CardContent } from "@/components/ui/card"; @@ -16,17 +17,18 @@ export function Costs() { setBreadcrumbs([{ label: "Costs" }]); }, [setBreadcrumbs]); - const fetcher = useCallback(async () => { - if (!selectedCompanyId) return null; - const [summary, byAgent, byProject] = await Promise.all([ - costsApi.summary(selectedCompanyId), - costsApi.byAgent(selectedCompanyId), - costsApi.byProject(selectedCompanyId), - ]); - return { summary, byAgent, byProject }; - }, [selectedCompanyId]); - - const { data, loading, error } = useApi(fetcher); + const { data, isLoading, error } = useQuery({ + queryKey: queryKeys.costs(selectedCompanyId!), + queryFn: async () => { + const [summary, byAgent, byProject] = await Promise.all([ + costsApi.summary(selectedCompanyId!), + costsApi.byAgent(selectedCompanyId!), + costsApi.byProject(selectedCompanyId!), + ]); + return { summary, byAgent, byProject }; + }, + enabled: !!selectedCompanyId, + }); if (!selectedCompanyId) { return ; @@ -36,7 +38,7 @@ export function Costs() {

Costs

- {loading &&

Loading...

} + {isLoading &&

Loading...

} {error &&

{error.message}

} {data && ( diff --git a/ui/src/pages/Dashboard.tsx b/ui/src/pages/Dashboard.tsx index 2fd0f43d..2819cd96 100644 --- a/ui/src/pages/Dashboard.tsx +++ b/ui/src/pages/Dashboard.tsx @@ -1,12 +1,13 @@ -import { useCallback, useEffect } from "react"; +import { useEffect } from "react"; import { useNavigate } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; import { dashboardApi } from "../api/dashboard"; import { activityApi } from "../api/activity"; import { issuesApi } from "../api/issues"; +import { agentsApi } from "../api/agents"; import { useCompany } from "../context/CompanyContext"; -import { useAgents } from "../hooks/useAgents"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; -import { useApi } from "../hooks/useApi"; +import { queryKeys } from "../lib/queryKeys"; import { MetricCard } from "../components/MetricCard"; import { EmptyState } from "../components/EmptyState"; import { StatusIcon } from "../components/StatusIcon"; @@ -56,30 +57,34 @@ export function Dashboard() { const { selectedCompanyId, selectedCompany } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); const navigate = useNavigate(); - const { data: agents } = useAgents(selectedCompanyId); + + const { data: agents } = useQuery({ + queryKey: queryKeys.agents.list(selectedCompanyId!), + queryFn: () => agentsApi.list(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); useEffect(() => { setBreadcrumbs([{ label: "Dashboard" }]); }, [setBreadcrumbs]); - const dashFetcher = useCallback(() => { - if (!selectedCompanyId) return Promise.resolve(null); - return dashboardApi.summary(selectedCompanyId); - }, [selectedCompanyId]); + const { data, isLoading, error } = useQuery({ + queryKey: queryKeys.dashboard(selectedCompanyId!), + queryFn: () => dashboardApi.summary(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); - const activityFetcher = useCallback(() => { - if (!selectedCompanyId) return Promise.resolve([]); - return activityApi.list(selectedCompanyId); - }, [selectedCompanyId]); + const { data: activity } = useQuery({ + queryKey: queryKeys.activity(selectedCompanyId!), + queryFn: () => activityApi.list(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); - const issuesFetcher = useCallback(() => { - if (!selectedCompanyId) return Promise.resolve([]); - return issuesApi.list(selectedCompanyId); - }, [selectedCompanyId]); - - const { data, loading, error } = useApi(dashFetcher); - const { data: activity } = useApi(activityFetcher); - const { data: issues } = useApi(issuesFetcher); + const { data: issues } = useQuery({ + queryKey: queryKeys.issues.list(selectedCompanyId!), + queryFn: () => issuesApi.list(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); const staleIssues = issues ? getStaleIssues(issues) : []; @@ -103,7 +108,7 @@ export function Dashboard() { )}
- {loading &&

Loading...

} + {isLoading &&

Loading...

} {error &&

{error.message}

} {data && ( diff --git a/ui/src/pages/GoalDetail.tsx b/ui/src/pages/GoalDetail.tsx index 2ec56ff3..5e78377d 100644 --- a/ui/src/pages/GoalDetail.tsx +++ b/ui/src/pages/GoalDetail.tsx @@ -1,11 +1,12 @@ -import { useCallback, useEffect } from "react"; +import { useEffect } from "react"; import { useNavigate, useParams } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; import { goalsApi } from "../api/goals"; import { projectsApi } from "../api/projects"; -import { useApi } from "../hooks/useApi"; import { usePanel } from "../context/PanelContext"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; +import { queryKeys } from "../lib/queryKeys"; import { GoalProperties } from "../components/GoalProperties"; import { GoalTree } from "../components/GoalTree"; import { StatusBadge } from "../components/StatusBadge"; @@ -20,24 +21,23 @@ export function GoalDetail() { const { setBreadcrumbs } = useBreadcrumbs(); const navigate = useNavigate(); - const goalFetcher = useCallback(() => { - if (!goalId) return Promise.reject(new Error("No goal ID")); - return goalsApi.get(goalId); - }, [goalId]); + const { data: goal, isLoading, error } = useQuery({ + queryKey: queryKeys.goals.detail(goalId!), + queryFn: () => goalsApi.get(goalId!), + enabled: !!goalId, + }); - const allGoalsFetcher = useCallback(() => { - if (!selectedCompanyId) return Promise.resolve([] as Goal[]); - return goalsApi.list(selectedCompanyId); - }, [selectedCompanyId]); + const { data: allGoals } = useQuery({ + queryKey: queryKeys.goals.list(selectedCompanyId!), + queryFn: () => goalsApi.list(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); - const projectsFetcher = useCallback(() => { - if (!selectedCompanyId) return Promise.resolve([] as Project[]); - return projectsApi.list(selectedCompanyId); - }, [selectedCompanyId]); - - const { data: goal, loading, error } = useApi(goalFetcher); - const { data: allGoals } = useApi(allGoalsFetcher); - const { data: allProjects } = useApi(projectsFetcher); + const { data: allProjects } = useQuery({ + queryKey: queryKeys.projects.list(selectedCompanyId!), + queryFn: () => projectsApi.list(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); const childGoals = (allGoals ?? []).filter((g) => g.parentId === goalId); const linkedProjects = (allProjects ?? []).filter((p) => p.goalId === goalId); @@ -56,7 +56,7 @@ export function GoalDetail() { return () => closePanel(); }, [goal]); // eslint-disable-line react-hooks/exhaustive-deps - if (loading) return

Loading...

; + if (isLoading) return

Loading...

; if (error) return

{error.message}

; if (!goal) return null; diff --git a/ui/src/pages/Goals.tsx b/ui/src/pages/Goals.tsx index 6c9e3a03..e77d734d 100644 --- a/ui/src/pages/Goals.tsx +++ b/ui/src/pages/Goals.tsx @@ -1,9 +1,10 @@ -import { useCallback, useEffect } from "react"; +import { useEffect } from "react"; import { useNavigate } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; import { goalsApi } from "../api/goals"; -import { useApi } from "../hooks/useApi"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; +import { queryKeys } from "../lib/queryKeys"; import { GoalTree } from "../components/GoalTree"; import { EmptyState } from "../components/EmptyState"; import { Target } from "lucide-react"; @@ -17,12 +18,11 @@ export function Goals() { setBreadcrumbs([{ label: "Goals" }]); }, [setBreadcrumbs]); - const fetcher = useCallback(() => { - if (!selectedCompanyId) return Promise.resolve([]); - return goalsApi.list(selectedCompanyId); - }, [selectedCompanyId]); - - const { data: goals, loading, error } = useApi(fetcher); + const { data: goals, isLoading, error } = useQuery({ + queryKey: queryKeys.goals.list(selectedCompanyId!), + queryFn: () => goalsApi.list(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); if (!selectedCompanyId) { return ; @@ -32,7 +32,7 @@ export function Goals() {

Goals

- {loading &&

Loading...

} + {isLoading &&

Loading...

} {error &&

{error.message}

} {goals && goals.length === 0 && ( diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index 45aeb236..2f4de147 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -1,13 +1,13 @@ -import { useCallback, useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { approvalsApi } from "../api/approvals"; import { dashboardApi } from "../api/dashboard"; import { issuesApi } from "../api/issues"; +import { agentsApi } from "../api/agents"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; -import { useAgents } from "../hooks/useAgents"; -import { useApi } from "../hooks/useApi"; -import { StatusBadge } from "../components/StatusBadge"; +import { queryKeys } from "../lib/queryKeys"; import { StatusIcon } from "../components/StatusIcon"; import { PriorityIcon } from "../components/PriorityIcon"; import { EmptyState } from "../components/EmptyState"; @@ -43,31 +43,36 @@ export function Inbox() { const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); const navigate = useNavigate(); + const queryClient = useQueryClient(); const [actionError, setActionError] = useState(null); - const { data: agents } = useAgents(selectedCompanyId); + + const { data: agents } = useQuery({ + queryKey: queryKeys.agents.list(selectedCompanyId!), + queryFn: () => agentsApi.list(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); useEffect(() => { setBreadcrumbs([{ label: "Inbox" }]); }, [setBreadcrumbs]); - const approvalsFetcher = useCallback(() => { - if (!selectedCompanyId) return Promise.resolve([]); - return approvalsApi.list(selectedCompanyId, "pending"); - }, [selectedCompanyId]); + const { data: approvals, isLoading, error } = useQuery({ + queryKey: queryKeys.approvals.list(selectedCompanyId!, "pending"), + queryFn: () => approvalsApi.list(selectedCompanyId!, "pending"), + enabled: !!selectedCompanyId, + }); - const dashboardFetcher = useCallback(() => { - if (!selectedCompanyId) return Promise.resolve(null); - return dashboardApi.summary(selectedCompanyId); - }, [selectedCompanyId]); + const { data: dashboard } = useQuery({ + queryKey: queryKeys.dashboard(selectedCompanyId!), + queryFn: () => dashboardApi.summary(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); - const issuesFetcher = useCallback(() => { - if (!selectedCompanyId) return Promise.resolve([]); - return issuesApi.list(selectedCompanyId); - }, [selectedCompanyId]); - - const { data: approvals, loading, error, reload } = useApi(approvalsFetcher); - const { data: dashboard } = useApi(dashboardFetcher); - const { data: issues } = useApi(issuesFetcher); + const { data: issues } = useQuery({ + queryKey: queryKeys.issues.list(selectedCompanyId!), + queryFn: () => issuesApi.list(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); const staleIssues = issues ? getStaleIssues(issues) : []; @@ -77,25 +82,25 @@ export function Inbox() { return agent?.name ?? null; }; - async function approve(id: string) { - setActionError(null); - try { - await approvalsApi.approve(id); - reload(); - } catch (err) { + const approveMutation = useMutation({ + mutationFn: (id: string) => approvalsApi.approve(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(selectedCompanyId!, "pending") }); + }, + onError: (err) => { setActionError(err instanceof Error ? err.message : "Failed to approve"); - } - } + }, + }); - async function reject(id: string) { - setActionError(null); - try { - await approvalsApi.reject(id); - reload(); - } catch (err) { + const rejectMutation = useMutation({ + mutationFn: (id: string) => approvalsApi.reject(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(selectedCompanyId!, "pending") }); + }, + onError: (err) => { setActionError(err instanceof Error ? err.message : "Failed to reject"); - } - } + }, + }); if (!selectedCompanyId) { return ; @@ -113,11 +118,11 @@ export function Inbox() {

Inbox

- {loading &&

Loading...

} + {isLoading &&

Loading...

} {error &&

{error.message}

} {actionError &&

{actionError}

} - {!loading && !hasContent && ( + {!isLoading && !hasContent && ( )} @@ -152,14 +157,14 @@ export function Inbox() { size="sm" variant="outline" className="border-green-700 text-green-500 hover:bg-green-900/20" - onClick={() => approve(approval.id)} + onClick={() => approveMutation.mutate(approval.id)} > Approve diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 4d9c4dcc..ce4be2dd 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -1,34 +1,53 @@ -import { useCallback, useEffect } from "react"; +import { useEffect } from "react"; import { useParams } from "react-router-dom"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { issuesApi } from "../api/issues"; -import { useApi } from "../hooks/useApi"; +import { useCompany } from "../context/CompanyContext"; import { usePanel } from "../context/PanelContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; +import { queryKeys } from "../lib/queryKeys"; import { InlineEditor } from "../components/InlineEditor"; import { CommentThread } from "../components/CommentThread"; import { IssueProperties } from "../components/IssueProperties"; import { StatusIcon } from "../components/StatusIcon"; import { PriorityIcon } from "../components/PriorityIcon"; -import type { IssueComment } from "@paperclip/shared"; import { Separator } from "@/components/ui/separator"; export function IssueDetail() { const { issueId } = useParams<{ issueId: string }>(); + const { selectedCompanyId } = useCompany(); const { openPanel, closePanel } = usePanel(); const { setBreadcrumbs } = useBreadcrumbs(); + const queryClient = useQueryClient(); - const issueFetcher = useCallback(() => { - if (!issueId) return Promise.reject(new Error("No issue ID")); - return issuesApi.get(issueId); - }, [issueId]); + const { data: issue, isLoading, error } = useQuery({ + queryKey: queryKeys.issues.detail(issueId!), + queryFn: () => issuesApi.get(issueId!), + enabled: !!issueId, + }); - const commentsFetcher = useCallback(() => { - if (!issueId) return Promise.resolve([] as IssueComment[]); - return issuesApi.listComments(issueId); - }, [issueId]); + const { data: comments } = useQuery({ + queryKey: queryKeys.issues.comments(issueId!), + queryFn: () => issuesApi.listComments(issueId!), + enabled: !!issueId, + }); - const { data: issue, loading, error, reload: reloadIssue } = useApi(issueFetcher); - const { data: comments, reload: reloadComments } = useApi(commentsFetcher); + const updateIssue = useMutation({ + mutationFn: (data: Record) => issuesApi.update(issueId!, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!) }); + if (selectedCompanyId) { + queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId) }); + } + }, + }); + + const addComment = useMutation({ + mutationFn: (body: string) => issuesApi.addComment(issueId!, body), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueId!) }); + }, + }); useEffect(() => { setBreadcrumbs([ @@ -37,28 +56,16 @@ export function IssueDetail() { ]); }, [setBreadcrumbs, issue, issueId]); - async function handleUpdate(data: Record) { - if (!issueId) return; - await issuesApi.update(issueId, data); - reloadIssue(); - } - - async function handleAddComment(body: string) { - if (!issueId) return; - await issuesApi.addComment(issueId, body); - reloadComments(); - } - useEffect(() => { if (issue) { openPanel( - + updateIssue.mutate(data)} /> ); } return () => closePanel(); }, [issue]); // eslint-disable-line react-hooks/exhaustive-deps - if (loading) return

Loading...

; + if (isLoading) return

Loading...

; if (error) return

{error.message}

; if (!issue) return null; @@ -68,25 +75,25 @@ export function IssueDetail() {
handleUpdate({ status })} + onChange={(status) => updateIssue.mutate({ status })} /> handleUpdate({ priority })} + onChange={(priority) => updateIssue.mutate({ priority })} /> {issue.id.slice(0, 8)}
handleUpdate({ title })} + onSave={(title) => updateIssue.mutate({ title })} as="h2" className="text-xl font-bold" /> handleUpdate({ description })} + onSave={(description) => updateIssue.mutate({ description })} as="p" className="text-sm text-muted-foreground" placeholder="Add a description..." @@ -98,7 +105,9 @@ export function IssueDetail() { { + await addComment.mutateAsync(body); + }} />
); diff --git a/ui/src/pages/Issues.tsx b/ui/src/pages/Issues.tsx index 4f6596be..d2b728fb 100644 --- a/ui/src/pages/Issues.tsx +++ b/ui/src/pages/Issues.tsx @@ -1,11 +1,12 @@ -import { useCallback, useEffect, useState } from "react"; +import { useState, useEffect } from "react"; import { useNavigate } from "react-router-dom"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { issuesApi } from "../api/issues"; -import { useApi } from "../hooks/useApi"; -import { useAgents } from "../hooks/useAgents"; +import { agentsApi } from "../api/agents"; import { useCompany } from "../context/CompanyContext"; import { useDialog } from "../context/DialogContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; +import { queryKeys } from "../lib/queryKeys"; import { groupBy } from "../lib/groupBy"; import { StatusIcon } from "../components/StatusIcon"; import { PriorityIcon } from "../components/PriorityIcon"; @@ -43,30 +44,38 @@ export function Issues() { const { openNewIssue } = useDialog(); const { setBreadcrumbs } = useBreadcrumbs(); const navigate = useNavigate(); + const queryClient = useQueryClient(); const [tab, setTab] = useState("all"); - const { data: agents } = useAgents(selectedCompanyId); + + const { data: agents } = useQuery({ + queryKey: queryKeys.agents.list(selectedCompanyId!), + queryFn: () => agentsApi.list(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); useEffect(() => { setBreadcrumbs([{ label: "Issues" }]); }, [setBreadcrumbs]); - const fetcher = useCallback(() => { - if (!selectedCompanyId) return Promise.resolve([]); - return issuesApi.list(selectedCompanyId); - }, [selectedCompanyId]); + const { data: issues, isLoading, error } = useQuery({ + queryKey: queryKeys.issues.list(selectedCompanyId!), + queryFn: () => issuesApi.list(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); - const { data: issues, loading, error, reload } = useApi(fetcher); + const updateStatus = useMutation({ + mutationFn: ({ id, status }: { id: string; status: string }) => + issuesApi.update(id, { status }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId!) }); + }, + }); const agentName = (id: string | null) => { if (!id || !agents) return null; return agents.find((a) => a.id === id)?.name ?? null; }; - async function handleStatusChange(issue: Issue, status: string) { - await issuesApi.update(issue.id, { status }); - reload(); - } - if (!selectedCompanyId) { return ; } @@ -96,7 +105,7 @@ export function Issues() { - {loading &&

Loading...

} + {isLoading &&

Loading...

} {error &&

{error.message}

} {issues && filtered.length === 0 && ( @@ -137,7 +146,7 @@ export function Issues() { handleStatusChange(issue, s)} + onChange={(s) => updateStatus.mutate({ id: issue.id, status: s })} /> } diff --git a/ui/src/pages/MyIssues.tsx b/ui/src/pages/MyIssues.tsx index 5434c969..a53fdabb 100644 --- a/ui/src/pages/MyIssues.tsx +++ b/ui/src/pages/MyIssues.tsx @@ -1,9 +1,10 @@ -import { useCallback, useEffect } from "react"; +import { useEffect } from "react"; import { useNavigate } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; import { issuesApi } from "../api/issues"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; -import { useApi } from "../hooks/useApi"; +import { queryKeys } from "../lib/queryKeys"; import { StatusIcon } from "../components/StatusIcon"; import { PriorityIcon } from "../components/PriorityIcon"; import { EntityRow } from "../components/EntityRow"; @@ -20,12 +21,11 @@ export function MyIssues() { setBreadcrumbs([{ label: "My Issues" }]); }, [setBreadcrumbs]); - const fetcher = useCallback(() => { - if (!selectedCompanyId) return Promise.resolve([]); - return issuesApi.list(selectedCompanyId); - }, [selectedCompanyId]); - - const { data: issues, loading, error } = useApi(fetcher); + const { data: issues, isLoading, error } = useQuery({ + queryKey: queryKeys.issues.list(selectedCompanyId!), + queryFn: () => issuesApi.list(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); if (!selectedCompanyId) { return ; @@ -40,10 +40,10 @@ export function MyIssues() {

My Issues

- {loading &&

Loading...

} + {isLoading &&

Loading...

} {error &&

{error.message}

} - {!loading && myIssues.length === 0 && ( + {!isLoading && myIssues.length === 0 && ( )} diff --git a/ui/src/pages/Org.tsx b/ui/src/pages/Org.tsx index ea946a2e..a6d78aa7 100644 --- a/ui/src/pages/Org.tsx +++ b/ui/src/pages/Org.tsx @@ -1,9 +1,10 @@ -import { useCallback, useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; import { agentsApi, type OrgNode } from "../api/agents"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; -import { useApi } from "../hooks/useApi"; +import { queryKeys } from "../lib/queryKeys"; import { StatusBadge } from "../components/StatusBadge"; import { EmptyState } from "../components/EmptyState"; import { ChevronRight, GitBranch } from "lucide-react"; @@ -93,12 +94,11 @@ export function Org() { setBreadcrumbs([{ label: "Org Chart" }]); }, [setBreadcrumbs]); - const fetcher = useCallback(() => { - if (!selectedCompanyId) return Promise.resolve([] as OrgNode[]); - return agentsApi.org(selectedCompanyId); - }, [selectedCompanyId]); - - const { data, loading, error } = useApi(fetcher); + const { data, isLoading, error } = useQuery({ + queryKey: queryKeys.org(selectedCompanyId!), + queryFn: () => agentsApi.org(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); if (!selectedCompanyId) { return ; @@ -108,7 +108,7 @@ export function Org() {

Org Chart

- {loading &&

Loading...

} + {isLoading &&

Loading...

} {error &&

{error.message}

} {data && data.length === 0 && ( diff --git a/ui/src/pages/ProjectDetail.tsx b/ui/src/pages/ProjectDetail.tsx index 90b77dd0..878eebac 100644 --- a/ui/src/pages/ProjectDetail.tsx +++ b/ui/src/pages/ProjectDetail.tsx @@ -1,11 +1,12 @@ -import { useCallback, useEffect } from "react"; +import { useEffect } from "react"; import { useParams } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; import { projectsApi } from "../api/projects"; import { issuesApi } from "../api/issues"; -import { useApi } from "../hooks/useApi"; import { usePanel } from "../context/PanelContext"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; +import { queryKeys } from "../lib/queryKeys"; import { ProjectProperties } from "../components/ProjectProperties"; import { StatusBadge } from "../components/StatusBadge"; import { EntityRow } from "../components/EntityRow"; @@ -18,18 +19,17 @@ export function ProjectDetail() { const { openPanel, closePanel } = usePanel(); const { setBreadcrumbs } = useBreadcrumbs(); - const projectFetcher = useCallback(() => { - if (!projectId) return Promise.reject(new Error("No project ID")); - return projectsApi.get(projectId); - }, [projectId]); + const { data: project, isLoading, error } = useQuery({ + queryKey: queryKeys.projects.detail(projectId!), + queryFn: () => projectsApi.get(projectId!), + enabled: !!projectId, + }); - const issuesFetcher = useCallback(() => { - if (!selectedCompanyId) return Promise.resolve([] as Issue[]); - return issuesApi.list(selectedCompanyId); - }, [selectedCompanyId]); - - const { data: project, loading, error } = useApi(projectFetcher); - const { data: allIssues } = useApi(issuesFetcher); + const { data: allIssues } = useQuery({ + queryKey: queryKeys.issues.list(selectedCompanyId!), + queryFn: () => issuesApi.list(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); const projectIssues = (allIssues ?? []).filter((i) => i.projectId === projectId); @@ -47,7 +47,7 @@ export function ProjectDetail() { return () => closePanel(); }, [project]); // eslint-disable-line react-hooks/exhaustive-deps - if (loading) return

Loading...

; + if (isLoading) return

Loading...

; if (error) return

{error.message}

; if (!project) return null; diff --git a/ui/src/pages/Projects.tsx b/ui/src/pages/Projects.tsx index 274cd043..b73aac75 100644 --- a/ui/src/pages/Projects.tsx +++ b/ui/src/pages/Projects.tsx @@ -1,10 +1,11 @@ -import { useCallback, useEffect } from "react"; +import { useEffect } from "react"; import { useNavigate } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; import { projectsApi } from "../api/projects"; -import { useApi } from "../hooks/useApi"; import { useCompany } from "../context/CompanyContext"; import { useDialog } from "../context/DialogContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; +import { queryKeys } from "../lib/queryKeys"; import { EntityRow } from "../components/EntityRow"; import { StatusBadge } from "../components/StatusBadge"; import { EmptyState } from "../components/EmptyState"; @@ -22,12 +23,11 @@ export function Projects() { setBreadcrumbs([{ label: "Projects" }]); }, [setBreadcrumbs]); - const fetcher = useCallback(() => { - if (!selectedCompanyId) return Promise.resolve([]); - return projectsApi.list(selectedCompanyId); - }, [selectedCompanyId]); - - const { data: projects, loading, error } = useApi(fetcher); + const { data: projects, isLoading, error } = useQuery({ + queryKey: queryKeys.projects.list(selectedCompanyId!), + queryFn: () => projectsApi.list(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); if (!selectedCompanyId) { return ; @@ -43,7 +43,7 @@ export function Projects() {
- {loading &&

Loading...

} + {isLoading &&

Loading...

} {error &&

{error.message}

} {projects && projects.length === 0 && ( diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 5a20451e..22d0b012 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -13,7 +13,10 @@ export default defineConfig({ server: { port: 5173, proxy: { - "/api": "http://localhost:3100", + "/api": { + target: "http://localhost:3100", + ws: true, + }, }, }, });