From 397e12a8fd0bd26d2a08ce46c8e6a3bce4c1a0bd Mon Sep 17 00:00:00 2001 From: Nexus Dev Date: Sat, 11 Apr 2026 12:14:47 +0000 Subject: [PATCH] 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) --- ui/src/hooks/useAssistantHomeStatus.test.ts | 296 ++++++++++++++++++ ui/src/hooks/useAssistantHomeStatus.ts | 324 ++++++++++++++++++++ 2 files changed, 620 insertions(+) create mode 100644 ui/src/hooks/useAssistantHomeStatus.test.ts create mode 100644 ui/src/hooks/useAssistantHomeStatus.ts diff --git a/ui/src/hooks/useAssistantHomeStatus.test.ts b/ui/src/hooks/useAssistantHomeStatus.test.ts new file mode 100644 index 00000000..9decca9d --- /dev/null +++ b/ui/src/hooks/useAssistantHomeStatus.test.ts @@ -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 { + 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 { + 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 { + 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 { + 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([]); + }); +}); diff --git a/ui/src/hooks/useAssistantHomeStatus.ts b/ui/src/hooks/useAssistantHomeStatus.ts new file mode 100644 index 00000000..1e54d935 --- /dev/null +++ b/ui/src/hooks/useAssistantHomeStatus.ts @@ -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 | 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)["summary"]; + if (typeof summary === "string" && summary.trim()) return summary.trim(); + const title = (details as Record)["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 { + 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(); + 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 | 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(); + // 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({ + 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({ + 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({ + 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({ + 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, + ]); +}