diff --git a/ui/src/components/assistant/AssistantHomeGreeting.test.tsx b/ui/src/components/assistant/AssistantHomeGreeting.test.tsx new file mode 100644 index 00000000..6cf262b5 --- /dev/null +++ b/ui/src/components/assistant/AssistantHomeGreeting.test.tsx @@ -0,0 +1,171 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { AssistantHomeStatus } from "../../hooks/useAssistantHomeStatus"; +import { + AssistantHomeGreeting, + buildGreetingMarkdown, +} from "./AssistantHomeGreeting"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +function emptyStatus(partial: Partial = {}): AssistantHomeStatus { + return { + activeAgents: 0, + pendingGates: [], + recentCompletions: [], + staleProjects: [], + companyPrefix: "NEX", + loading: false, + ...partial, + }; +} + +describe("buildGreetingMarkdown", () => { + const morning = new Date("2026-04-11T09:00:00"); + const afternoon = new Date("2026-04-11T14:00:00"); + const evening = new Date("2026-04-11T20:00:00"); + + it("picks the correct greeting based on hour and includes the user name", () => { + const status = emptyStatus(); + expect(buildGreetingMarkdown(status, "Mikkel", morning)).toContain("Good morning, Mikkel."); + expect(buildGreetingMarkdown(status, "Mikkel", afternoon)).toContain("Good afternoon, Mikkel."); + expect(buildGreetingMarkdown(status, "Mikkel", evening)).toContain("Good evening, Mikkel."); + }); + + it("drops the name suffix when no user name is provided", () => { + const body = buildGreetingMarkdown(emptyStatus(), null, morning); + expect(body).toContain("Good morning."); + expect(body).not.toContain("Good morning,"); + }); + + it("shows a quiet-state message when everything is empty", () => { + const body = buildGreetingMarkdown(emptyStatus(), "Mikkel", morning); + expect(body).toContain("Everything's quiet right now"); + expect(body).toContain("What do you want to do?"); + }); + + it("lists active agents, pending gates, completions and stale projects as bullets", () => { + const status = emptyStatus({ + activeAgents: 2, + pendingGates: [ + { + id: "g1", + projectName: "nexus", + gateName: "Phase 4 audit", + href: "/NEX/approvals/g1", + }, + ], + recentCompletions: [ + { + id: "c1", + projectName: "nexus", + summary: "Phase 4 shipped", + when: "2h ago", + }, + ], + staleProjects: [ + { + id: "p1", + name: "personal-finance", + lastActivity: "3d ago", + href: "/NEX/projects/personal-finance", + }, + ], + }); + const body = buildGreetingMarkdown(status, "Mikkel", morning); + expect(body).toContain("2 agents currently working"); + expect(body).toContain("nexus: Phase 4 audit awaiting approval"); + expect(body).toContain("nexus: Phase 4 shipped (2h ago)"); + expect(body).toContain("personal-finance: idle 3d ago"); + expect(body).toContain("Since we last talked:"); + }); + + it("uses singular form when there is exactly one active agent", () => { + const body = buildGreetingMarkdown( + emptyStatus({ activeAgents: 1 }), + null, + morning, + ); + expect(body).toContain("1 agent currently working"); + expect(body).not.toContain("1 agents"); + }); +}); + +describe("", () => { + let container: HTMLDivElement; + let root: ReturnType | null = null; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = null; + }); + + afterEach(() => { + if (root) { + act(() => { + root!.unmount(); + }); + root = null; + } + if (container.parentNode) container.remove(); + }); + + function render(node: React.ReactNode) { + root = createRoot(container); + act(() => { + root!.render(node); + }); + } + + it("renders a loading placeholder while status.loading is true", () => { + render( + , + ); + expect(container.querySelector('[data-testid="assistant-home-greeting-loading"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="assistant-home-greeting"]')).toBeNull(); + }); + + it("renders a greeting article with the user name when not loading", () => { + render( + , + ); + const article = container.querySelector('[data-testid="assistant-home-greeting"]'); + expect(article).not.toBeNull(); + expect(article?.textContent ?? "").toContain("Good morning, Mikkel."); + expect(article?.textContent ?? "").toContain("3 agents currently working"); + }); + + it("renders stale project entries when provided", () => { + render( + , + ); + const article = container.querySelector('[data-testid="assistant-home-greeting"]'); + expect(article?.textContent ?? "").toContain("personal-finance: idle 5d ago"); + }); +}); diff --git a/ui/src/components/assistant/AssistantHomeGreeting.tsx b/ui/src/components/assistant/AssistantHomeGreeting.tsx new file mode 100644 index 00000000..57b1ec83 --- /dev/null +++ b/ui/src/components/assistant/AssistantHomeGreeting.tsx @@ -0,0 +1,129 @@ +// [nexus] Assistant home-state greeting (Phase 9). +// +// Rendered as an assistant-turn message bubble when no conversation is +// active. Derives its body from `useAssistantHomeStatus` (or an injected +// status for unit testing) and presents a conversational "here's where +// you left off" summary instead of a dashboard grid. +import { ChatMarkdownMessage } from "../ChatMarkdownMessage"; +import type { AssistantHomeStatus } from "../../hooks/useAssistantHomeStatus"; +import { cn } from "@/lib/utils"; + +export interface AssistantHomeGreetingProps { + status: AssistantHomeStatus; + /** + * Display name used in the greeting line. If omitted, the greeting drops + * the personalised suffix. + */ + userName?: string | null; + /** + * Exposed for deterministic tests. Defaults to `new Date()`. + */ + now?: Date; + className?: string; +} + +function greetingForHour(hour: number, userName: string | null | undefined): string { + let prefix: string; + if (hour < 5 || hour >= 22) prefix = "Good evening"; + else if (hour < 12) prefix = "Good morning"; + else if (hour < 17) prefix = "Good afternoon"; + else prefix = "Good evening"; + const who = userName?.trim(); + return who ? `${prefix}, ${who}.` : `${prefix}.`; +} + +/** + * Build the markdown body of the greeting. Exposed for unit tests so they can + * assert bullet content without poking into the rendered DOM. + */ +export function buildGreetingMarkdown( + status: AssistantHomeStatus, + userName: string | null | undefined, + now: Date, +): string { + const lines: string[] = []; + lines.push(greetingForHour(now.getHours(), userName)); + lines.push(""); + + const bullets: string[] = []; + + if (status.activeAgents > 0) { + bullets.push( + `${status.activeAgents} agent${status.activeAgents === 1 ? "" : "s"} currently working`, + ); + } + + for (const gate of status.pendingGates) { + const label = gate.projectName + ? `${gate.projectName}: ${gate.gateName} awaiting approval` + : `${gate.gateName} awaiting approval`; + bullets.push(label); + } + + for (const completion of status.recentCompletions) { + const label = completion.projectName + ? `${completion.projectName}: ${completion.summary} (${completion.when})` + : `${completion.summary} (${completion.when})`; + bullets.push(label); + } + + for (const stale of status.staleProjects) { + bullets.push(`${stale.name}: idle ${stale.lastActivity}`); + } + + if (bullets.length > 0) { + lines.push("Since we last talked:"); + for (const b of bullets) lines.push(` - ${b}`); + lines.push(""); + } else if (!status.loading) { + lines.push("Everything's quiet right now. Nothing waiting on you."); + lines.push(""); + } + + lines.push("What do you want to do?"); + return lines.join("\n"); +} + +export function AssistantHomeGreeting({ + status, + userName, + now, + className, +}: AssistantHomeGreetingProps) { + if (status.loading) { + return ( +
+ Catching up… +
+ ); + } + + const body = buildGreetingMarkdown(status, userName, now ?? new Date()); + + return ( +
+ +
+ +
+
+ ); +}