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:
Nexus Dev 2026-04-11 12:14:47 +00:00
parent 533490f1a2
commit 397e12a8fd
2 changed files with 620 additions and 0 deletions

View 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([]);
});
});

View 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,
]);
}