feat(nexus): add useAssistantHomeStatus hook (phase 9)
Aggregates active agents, pending gates, recent completions, and stale projects from the existing agents, approvals, activity, and projects APIs into a single shape consumed by AssistantHomeGreeting. The pure `composeHomeStatus` helper drives the tests; the hook wires it to react-query and degrades each slice to empty on fetch error. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
533490f1a2
commit
397e12a8fd
2 changed files with 620 additions and 0 deletions
296
ui/src/hooks/useAssistantHomeStatus.test.ts
Normal file
296
ui/src/hooks/useAssistantHomeStatus.test.ts
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
// @vitest-environment jsdom
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { Agent, Approval, ActivityEvent, Project } from "@paperclipai/shared";
|
||||
import { composeHomeStatus } from "./useAssistantHomeStatus";
|
||||
|
||||
// ─── Fixture factories ──────────────────────────────────────────────────────
|
||||
|
||||
const NOW_MS = Date.parse("2026-04-11T12:00:00Z");
|
||||
|
||||
function makeAgent(overrides: Partial<Agent> = {}): Agent {
|
||||
return {
|
||||
id: "agent-" + Math.random().toString(36).slice(2, 8),
|
||||
companyId: "co-1",
|
||||
name: "Agent",
|
||||
urlKey: "agent",
|
||||
role: "engineer",
|
||||
title: null,
|
||||
icon: null,
|
||||
status: "idle",
|
||||
reportsTo: null,
|
||||
capabilities: null,
|
||||
adapterType: "process",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
budgetMonthlyCents: 0,
|
||||
spentMonthlyCents: 0,
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
permissions: { canCreateAgents: false },
|
||||
lastHeartbeatAt: null,
|
||||
metadata: null,
|
||||
createdAt: new Date(NOW_MS),
|
||||
updatedAt: new Date(NOW_MS),
|
||||
...overrides,
|
||||
} as Agent;
|
||||
}
|
||||
|
||||
function makeApproval(overrides: Partial<Approval> = {}): Approval {
|
||||
return {
|
||||
id: "gate-" + Math.random().toString(36).slice(2, 8),
|
||||
companyId: "co-1",
|
||||
type: "generic",
|
||||
requestedByAgentId: null,
|
||||
requestedByUserId: null,
|
||||
status: "pending",
|
||||
payload: {},
|
||||
decisionNote: null,
|
||||
decidedByUserId: null,
|
||||
decidedAt: null,
|
||||
createdAt: new Date(NOW_MS),
|
||||
updatedAt: new Date(NOW_MS),
|
||||
...overrides,
|
||||
} as Approval;
|
||||
}
|
||||
|
||||
function makeActivity(overrides: Partial<ActivityEvent> = {}): ActivityEvent {
|
||||
return {
|
||||
id: "act-" + Math.random().toString(36).slice(2, 8),
|
||||
companyId: "co-1",
|
||||
actorType: "agent",
|
||||
actorId: "agent-1",
|
||||
action: "project.completed",
|
||||
entityType: "project",
|
||||
entityId: "proj-a",
|
||||
agentId: null,
|
||||
runId: null,
|
||||
details: null,
|
||||
createdAt: new Date(NOW_MS - 60 * 60 * 1000), // 1h ago
|
||||
...overrides,
|
||||
} as ActivityEvent;
|
||||
}
|
||||
|
||||
function makeProject(overrides: Partial<Project> = {}): Project {
|
||||
return {
|
||||
id: "proj-" + Math.random().toString(36).slice(2, 8),
|
||||
companyId: "co-1",
|
||||
urlKey: "nexus-demo",
|
||||
goalId: null,
|
||||
goalIds: [],
|
||||
goals: [],
|
||||
name: "Demo",
|
||||
description: null,
|
||||
status: "active",
|
||||
leadAgentId: null,
|
||||
targetDate: null,
|
||||
color: null,
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
executionWorkspacePolicy: null,
|
||||
codebase: { kind: "none" } as Project["codebase"],
|
||||
workspaces: [],
|
||||
primaryWorkspace: null,
|
||||
archivedAt: null,
|
||||
createdAt: new Date(NOW_MS),
|
||||
updatedAt: new Date(NOW_MS),
|
||||
...overrides,
|
||||
} as Project;
|
||||
}
|
||||
|
||||
function baseArgs() {
|
||||
return {
|
||||
nowMs: NOW_MS,
|
||||
companyPrefix: "NEX",
|
||||
maxStaleProjects: 3,
|
||||
maxRecentCompletions: 3,
|
||||
staleThresholdMs: 3 * 24 * 60 * 60 * 1000,
|
||||
recentWindowMs: 24 * 60 * 60 * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Tests ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("composeHomeStatus", () => {
|
||||
it("counts agents whose status is 'running' as active", () => {
|
||||
const agents = [
|
||||
makeAgent({ status: "running" }),
|
||||
makeAgent({ status: "running" }),
|
||||
makeAgent({ status: "idle" }),
|
||||
makeAgent({ status: "paused" }),
|
||||
];
|
||||
const result = composeHomeStatus({
|
||||
agents,
|
||||
approvals: [],
|
||||
activity: [],
|
||||
projects: [],
|
||||
...baseArgs(),
|
||||
});
|
||||
expect(result.activeAgents).toBe(2);
|
||||
});
|
||||
|
||||
it("includes only pending approvals as pending gates and resolves project names", () => {
|
||||
const proj = makeProject({ id: "proj-a", name: "nexus-design-migration" });
|
||||
const approved = makeApproval({ status: "approved", payload: { projectId: "proj-a" } });
|
||||
const pending1 = makeApproval({
|
||||
status: "pending",
|
||||
payload: { projectId: "proj-a", title: "Phase 4 audit" },
|
||||
});
|
||||
const pending2 = makeApproval({ status: "pending", payload: {} });
|
||||
|
||||
const result = composeHomeStatus({
|
||||
agents: [],
|
||||
approvals: [approved, pending1, pending2],
|
||||
activity: [],
|
||||
projects: [proj],
|
||||
...baseArgs(),
|
||||
});
|
||||
|
||||
expect(result.pendingGates).toHaveLength(2);
|
||||
const first = result.pendingGates[0]!;
|
||||
expect(first.gateName).toBe("Phase 4 audit");
|
||||
expect(first.projectName).toBe("nexus-design-migration");
|
||||
expect(first.href).toBe(`/NEX/approvals/${pending1.id}`);
|
||||
// Second gate has no projectId → empty projectName, gateName falls back to type.
|
||||
expect(result.pendingGates[1]!.projectName).toBe("");
|
||||
expect(result.pendingGates[1]!.gateName).toBe("generic");
|
||||
});
|
||||
|
||||
it("surfaces recent completions within the recent window, newest first, capped", () => {
|
||||
const proj = makeProject({ id: "proj-a", name: "nexus-demo" });
|
||||
const completedRecent = makeActivity({
|
||||
id: "e1",
|
||||
action: "project.completed",
|
||||
entityId: "proj-a",
|
||||
details: { summary: "Phase 4 shipped" },
|
||||
createdAt: new Date(NOW_MS - 2 * 60 * 60 * 1000), // 2h ago
|
||||
});
|
||||
const completedOlder = makeActivity({
|
||||
id: "e2",
|
||||
action: "task.finished",
|
||||
entityId: "proj-a",
|
||||
details: { summary: "Older one" },
|
||||
createdAt: new Date(NOW_MS - 6 * 60 * 60 * 1000), // 6h ago
|
||||
});
|
||||
const completedAncient = makeActivity({
|
||||
id: "e3",
|
||||
action: "task.done",
|
||||
entityId: "proj-a",
|
||||
details: null,
|
||||
createdAt: new Date(NOW_MS - 48 * 60 * 60 * 1000), // 48h ago, outside window
|
||||
});
|
||||
const notACompletion = makeActivity({
|
||||
id: "e4",
|
||||
action: "project.started",
|
||||
entityId: "proj-a",
|
||||
createdAt: new Date(NOW_MS - 30 * 60 * 1000),
|
||||
});
|
||||
|
||||
const result = composeHomeStatus({
|
||||
agents: [],
|
||||
approvals: [],
|
||||
activity: [completedRecent, completedOlder, completedAncient, notACompletion],
|
||||
projects: [proj],
|
||||
...baseArgs(),
|
||||
});
|
||||
|
||||
expect(result.recentCompletions.map((c) => c.id)).toEqual(["e1", "e2"]);
|
||||
expect(result.recentCompletions[0]!.summary).toBe("Phase 4 shipped");
|
||||
expect(result.recentCompletions[0]!.projectName).toBe("nexus-demo");
|
||||
expect(result.recentCompletions[0]!.when).toBe("2h ago");
|
||||
});
|
||||
|
||||
it("respects maxRecentCompletions cap", () => {
|
||||
const completions = Array.from({ length: 5 }).map((_, i) =>
|
||||
makeActivity({
|
||||
id: `c${i}`,
|
||||
action: "completed",
|
||||
createdAt: new Date(NOW_MS - (i + 1) * 60 * 1000),
|
||||
}),
|
||||
);
|
||||
const result = composeHomeStatus({
|
||||
agents: [],
|
||||
approvals: [],
|
||||
activity: completions,
|
||||
projects: [],
|
||||
...baseArgs(),
|
||||
maxRecentCompletions: 2,
|
||||
});
|
||||
expect(result.recentCompletions).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("marks projects with no activity in staleThresholdMs as stale, oldest first", () => {
|
||||
const fresh = makeProject({
|
||||
id: "fresh",
|
||||
name: "fresh",
|
||||
updatedAt: new Date(NOW_MS - 60 * 60 * 1000), // 1h ago
|
||||
});
|
||||
const stale = makeProject({
|
||||
id: "stale",
|
||||
name: "personal-finance",
|
||||
urlKey: "personal-finance",
|
||||
updatedAt: new Date(NOW_MS - 5 * 24 * 60 * 60 * 1000), // 5d ago
|
||||
});
|
||||
const ancient = makeProject({
|
||||
id: "ancient",
|
||||
name: "ancient",
|
||||
urlKey: "ancient",
|
||||
updatedAt: new Date(NOW_MS - 30 * 24 * 60 * 60 * 1000), // 30d ago
|
||||
});
|
||||
const archived = makeProject({
|
||||
id: "arch",
|
||||
name: "archived-project",
|
||||
status: "archived",
|
||||
updatedAt: new Date(NOW_MS - 60 * 24 * 60 * 60 * 1000),
|
||||
});
|
||||
|
||||
const result = composeHomeStatus({
|
||||
agents: [],
|
||||
approvals: [],
|
||||
activity: [],
|
||||
projects: [fresh, stale, ancient, archived],
|
||||
...baseArgs(),
|
||||
});
|
||||
|
||||
// Ancient is stalest (oldest), so it should come first.
|
||||
expect(result.staleProjects.map((p) => p.id)).toEqual(["ancient", "stale"]);
|
||||
expect(result.staleProjects[0]!.lastActivity).toBe("30d ago");
|
||||
expect(result.staleProjects[1]!.href).toBe("/NEX/projects/personal-finance");
|
||||
});
|
||||
|
||||
it("falls back to absolute paths when companyPrefix is null", () => {
|
||||
const stale = makeProject({
|
||||
id: "stale",
|
||||
name: "stale",
|
||||
urlKey: "stale-url",
|
||||
updatedAt: new Date(NOW_MS - 10 * 24 * 60 * 60 * 1000),
|
||||
});
|
||||
const gate = makeApproval({ status: "pending", payload: {} });
|
||||
|
||||
const result = composeHomeStatus({
|
||||
agents: [],
|
||||
approvals: [gate],
|
||||
activity: [],
|
||||
projects: [stale],
|
||||
...baseArgs(),
|
||||
companyPrefix: null,
|
||||
});
|
||||
|
||||
expect(result.staleProjects[0]!.href).toBe("/projects/stale-url");
|
||||
expect(result.pendingGates[0]!.href).toBe(`/approvals/${gate.id}`);
|
||||
expect(result.companyPrefix).toBeNull();
|
||||
});
|
||||
|
||||
it("returns zero/empty values when no data is present", () => {
|
||||
const result = composeHomeStatus({
|
||||
agents: [],
|
||||
approvals: [],
|
||||
activity: [],
|
||||
projects: [],
|
||||
...baseArgs(),
|
||||
});
|
||||
expect(result.activeAgents).toBe(0);
|
||||
expect(result.pendingGates).toEqual([]);
|
||||
expect(result.recentCompletions).toEqual([]);
|
||||
expect(result.staleProjects).toEqual([]);
|
||||
});
|
||||
});
|
||||
324
ui/src/hooks/useAssistantHomeStatus.ts
Normal file
324
ui/src/hooks/useAssistantHomeStatus.ts
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
// [nexus] Assistant home-state data aggregator (Phase 9).
|
||||
//
|
||||
// Composes existing APIs (agents, approvals, activity, projects) into the
|
||||
// shape the AssistantHomeGreeting needs. Does NOT create new endpoints.
|
||||
// If any source errors out, that slice gracefully degrades to an empty
|
||||
// value and logs a single console.warn in development.
|
||||
import { useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { Agent, Approval, Project, ActivityEvent } from "@paperclipai/shared";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { approvalsApi } from "../api/approvals";
|
||||
import { activityApi } from "../api/activity";
|
||||
import { projectsApi } from "../api/projects";
|
||||
|
||||
export interface PendingGate {
|
||||
id: string;
|
||||
projectName: string;
|
||||
gateName: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export interface RecentCompletion {
|
||||
id: string;
|
||||
projectName: string;
|
||||
summary: string;
|
||||
when: string;
|
||||
}
|
||||
|
||||
export interface StaleProject {
|
||||
id: string;
|
||||
name: string;
|
||||
lastActivity: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export interface AssistantHomeStatus {
|
||||
activeAgents: number;
|
||||
pendingGates: PendingGate[];
|
||||
recentCompletions: RecentCompletion[];
|
||||
staleProjects: StaleProject[];
|
||||
loading: boolean;
|
||||
companyPrefix: string | null;
|
||||
}
|
||||
|
||||
export interface UseAssistantHomeStatusOptions {
|
||||
companyId: string | null;
|
||||
companyPrefix?: string | null;
|
||||
/**
|
||||
* The current time. Exposed for deterministic testing — defaults to `Date.now()`.
|
||||
*/
|
||||
now?: () => number;
|
||||
/**
|
||||
* Maximum number of stale projects to surface. Defaults to 3.
|
||||
*/
|
||||
maxStaleProjects?: number;
|
||||
/**
|
||||
* Maximum number of recent completions to surface. Defaults to 3.
|
||||
*/
|
||||
maxRecentCompletions?: number;
|
||||
/**
|
||||
* Staleness threshold in milliseconds. Defaults to 3 days.
|
||||
*/
|
||||
staleThresholdMs?: number;
|
||||
/**
|
||||
* Window for "recent" completions, in milliseconds. Defaults to 24 hours.
|
||||
*/
|
||||
recentWindowMs?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_STALE_THRESHOLD_MS = 3 * 24 * 60 * 60 * 1000;
|
||||
const DEFAULT_RECENT_WINDOW_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function toDateMs(value: Date | string | null | undefined): number | null {
|
||||
if (!value) return null;
|
||||
const t = value instanceof Date ? value.getTime() : Date.parse(value);
|
||||
return Number.isFinite(t) ? t : null;
|
||||
}
|
||||
|
||||
function formatRelative(fromMs: number, nowMs: number): string {
|
||||
const delta = Math.max(0, nowMs - fromMs);
|
||||
const minutes = Math.floor(delta / 60_000);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
function gateLabel(approval: Approval): string {
|
||||
const payload = approval.payload as Record<string, unknown> | null;
|
||||
if (payload && typeof payload === "object") {
|
||||
const title = payload["title"] ?? payload["name"] ?? payload["summary"];
|
||||
if (typeof title === "string" && title.trim()) return title.trim();
|
||||
}
|
||||
return String(approval.type ?? "Gate");
|
||||
}
|
||||
|
||||
function completionSummary(event: ActivityEvent): string {
|
||||
const details = event.details ?? null;
|
||||
if (details && typeof details === "object") {
|
||||
const summary = (details as Record<string, unknown>)["summary"];
|
||||
if (typeof summary === "string" && summary.trim()) return summary.trim();
|
||||
const title = (details as Record<string, unknown>)["title"];
|
||||
if (typeof title === "string" && title.trim()) return title.trim();
|
||||
}
|
||||
return event.action;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given raw payloads from the four upstream APIs, produce the aggregated
|
||||
* home-status view used by the AssistantHomeGreeting. Pure: easy to unit test.
|
||||
*/
|
||||
export function composeHomeStatus(args: {
|
||||
agents: Agent[];
|
||||
approvals: Approval[];
|
||||
activity: ActivityEvent[];
|
||||
projects: Project[];
|
||||
nowMs: number;
|
||||
companyPrefix: string | null;
|
||||
maxStaleProjects: number;
|
||||
maxRecentCompletions: number;
|
||||
staleThresholdMs: number;
|
||||
recentWindowMs: number;
|
||||
}): Omit<AssistantHomeStatus, "loading"> {
|
||||
const {
|
||||
agents,
|
||||
approvals,
|
||||
activity,
|
||||
projects,
|
||||
nowMs,
|
||||
companyPrefix,
|
||||
maxStaleProjects,
|
||||
maxRecentCompletions,
|
||||
staleThresholdMs,
|
||||
recentWindowMs,
|
||||
} = args;
|
||||
|
||||
const activeAgents = agents.filter((a) => a.status === "running").length;
|
||||
|
||||
const projectNameById = new Map<string, string>();
|
||||
for (const project of projects) {
|
||||
projectNameById.set(project.id, project.name);
|
||||
}
|
||||
|
||||
const pendingGates: PendingGate[] = approvals
|
||||
.filter((a) => a.status === "pending")
|
||||
.map((a) => {
|
||||
const payload = a.payload as Record<string, unknown> | null;
|
||||
const projectId =
|
||||
payload && typeof payload === "object" && typeof payload["projectId"] === "string"
|
||||
? (payload["projectId"] as string)
|
||||
: null;
|
||||
const projectName = projectId ? projectNameById.get(projectId) ?? "" : "";
|
||||
return {
|
||||
id: a.id,
|
||||
projectName,
|
||||
gateName: gateLabel(a),
|
||||
href: companyPrefix ? `/${companyPrefix}/approvals/${a.id}` : `/approvals/${a.id}`,
|
||||
} satisfies PendingGate;
|
||||
});
|
||||
|
||||
const recentCompletions: RecentCompletion[] = activity
|
||||
.filter((e) => {
|
||||
if (!/completed|completion|done|finished/i.test(e.action)) return false;
|
||||
const createdMs = toDateMs(e.createdAt);
|
||||
if (createdMs === null) return false;
|
||||
return nowMs - createdMs <= recentWindowMs;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const aMs = toDateMs(a.createdAt) ?? 0;
|
||||
const bMs = toDateMs(b.createdAt) ?? 0;
|
||||
return bMs - aMs;
|
||||
})
|
||||
.slice(0, maxRecentCompletions)
|
||||
.map((e) => {
|
||||
const projectId = e.entityType === "project" ? e.entityId : null;
|
||||
const projectName = projectId ? projectNameById.get(projectId) ?? "" : "";
|
||||
const createdMs = toDateMs(e.createdAt) ?? nowMs;
|
||||
return {
|
||||
id: e.id,
|
||||
projectName,
|
||||
summary: completionSummary(e),
|
||||
when: formatRelative(createdMs, nowMs),
|
||||
} satisfies RecentCompletion;
|
||||
});
|
||||
|
||||
const staleProjects: StaleProject[] = projects
|
||||
.filter((p) => p.status !== "archived")
|
||||
.map((p) => {
|
||||
const lastMs = toDateMs(p.updatedAt) ?? toDateMs(p.createdAt) ?? 0;
|
||||
return { project: p, lastMs };
|
||||
})
|
||||
.filter(({ lastMs }) => lastMs > 0 && nowMs - lastMs > staleThresholdMs)
|
||||
.sort((a, b) => a.lastMs - b.lastMs)
|
||||
.slice(0, maxStaleProjects)
|
||||
.map(({ project, lastMs }) => ({
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
lastActivity: formatRelative(lastMs, nowMs),
|
||||
href: companyPrefix
|
||||
? `/${companyPrefix}/projects/${project.urlKey || project.id}`
|
||||
: `/projects/${project.urlKey || project.id}`,
|
||||
}));
|
||||
|
||||
return { activeAgents, pendingGates, recentCompletions, staleProjects, companyPrefix };
|
||||
}
|
||||
|
||||
function warnOnce(key: string, err: unknown) {
|
||||
if (typeof window === "undefined") return;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const bag = (window as any).__nexusHomeStatusWarned__ ?? new Set<string>();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(window as any).__nexusHomeStatusWarned__ = bag;
|
||||
if (bag.has(key)) return;
|
||||
bag.add(key);
|
||||
console.warn(`[useAssistantHomeStatus] ${key} unavailable, degrading to empty`, err);
|
||||
}
|
||||
|
||||
export function useAssistantHomeStatus(
|
||||
options: UseAssistantHomeStatusOptions,
|
||||
): AssistantHomeStatus {
|
||||
const {
|
||||
companyId,
|
||||
companyPrefix = null,
|
||||
now = () => Date.now(),
|
||||
maxStaleProjects = 3,
|
||||
maxRecentCompletions = 3,
|
||||
staleThresholdMs = DEFAULT_STALE_THRESHOLD_MS,
|
||||
recentWindowMs = DEFAULT_RECENT_WINDOW_MS,
|
||||
} = options;
|
||||
|
||||
const enabled = !!companyId;
|
||||
|
||||
const agentsQuery = useQuery<Agent[]>({
|
||||
queryKey: ["assistant-home", "agents", companyId],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await agentsApi.list(companyId!);
|
||||
} catch (err) {
|
||||
warnOnce("agents", err);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
enabled,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const approvalsQuery = useQuery<Approval[]>({
|
||||
queryKey: ["assistant-home", "approvals", companyId],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await approvalsApi.list(companyId!, "pending");
|
||||
} catch (err) {
|
||||
warnOnce("approvals", err);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
enabled,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const activityQuery = useQuery<ActivityEvent[]>({
|
||||
queryKey: ["assistant-home", "activity", companyId],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await activityApi.list(companyId!);
|
||||
} catch (err) {
|
||||
warnOnce("activity", err);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
enabled,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const projectsQuery = useQuery<Project[]>({
|
||||
queryKey: ["assistant-home", "projects", companyId],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await projectsApi.list(companyId!);
|
||||
} catch (err) {
|
||||
warnOnce("projects", err);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
enabled,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const loading =
|
||||
enabled &&
|
||||
(agentsQuery.isLoading ||
|
||||
approvalsQuery.isLoading ||
|
||||
activityQuery.isLoading ||
|
||||
projectsQuery.isLoading);
|
||||
|
||||
return useMemo(() => {
|
||||
const composed = composeHomeStatus({
|
||||
agents: agentsQuery.data ?? [],
|
||||
approvals: approvalsQuery.data ?? [],
|
||||
activity: activityQuery.data ?? [],
|
||||
projects: projectsQuery.data ?? [],
|
||||
nowMs: now(),
|
||||
companyPrefix,
|
||||
maxStaleProjects,
|
||||
maxRecentCompletions,
|
||||
staleThresholdMs,
|
||||
recentWindowMs,
|
||||
});
|
||||
return { ...composed, loading };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
agentsQuery.data,
|
||||
approvalsQuery.data,
|
||||
activityQuery.data,
|
||||
projectsQuery.data,
|
||||
loading,
|
||||
companyPrefix,
|
||||
maxStaleProjects,
|
||||
maxRecentCompletions,
|
||||
staleThresholdMs,
|
||||
recentWindowMs,
|
||||
]);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue