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