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() {
-