nexus/ui/src/hooks/useAssistantHomeStatus.test.ts
Nexus Dev 06c6317c25 refactor(nexus): rewire PersonalAssistant to use new frame pieces (phase 9)
Composes the Phase 9 assistant primitives at /assistant: full-bleed chat
canvas with a 760px centered column, AssistantHomeGreeting for the no-
conversation state, AssistantInputBar + ActionStrip anchored at the
bottom, and HistorySheet / MemorySheet slide-overs. Drops the 160px
inner conversation-list column and the custom MessageBubble, delegating
thread rendering to the shared ChatMessageList and streaming to the
shared useStreamingChat hook. Adds a ?prompt= query-param fallback for
Studio → Assistant hand-offs, preserves the existing assistantHandoff
call as the Promote action, and syncs the selected conversation id with
the shared ChatPanelContext so HistorySheet stays in lockstep.

Also fixes typecheck fallout from the rewrite: switches toast tones to
the documented info|success|warn|error set, narrows the stale-project
filter to use archivedAt (ProjectStatus never had "archived"), and
tightens the MemorySheet test render helper to JSX.Element.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 12:22:55 +00:00

296 lines
9 KiB
TypeScript

// @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 unknown 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",
archivedAt: new Date(NOW_MS - 60 * 24 * 60 * 60 * 1000),
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([]);
});
});