From 37d3919c98bcf4b13fb9864df5223b054e5824f0 Mon Sep 17 00:00:00 2001 From: Nexus Dev Date: Sat, 11 Apr 2026 12:15:50 +0000 Subject: [PATCH] feat(nexus): add AssistantHomeGreeting component (phase 9) Renders a conversational home-state message as an assistant-turn bubble when no chat is active, summarising active agents, pending gates, recent completions, and stale projects via markdown bullets. Replaces the old Dashboard grid for the Assistant landing route. Exposes a pure `buildGreetingMarkdown` helper driving the unit tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../assistant/AssistantHomeGreeting.test.tsx | 171 ++++++++++++++++++ .../assistant/AssistantHomeGreeting.tsx | 129 +++++++++++++ 2 files changed, 300 insertions(+) create mode 100644 ui/src/components/assistant/AssistantHomeGreeting.test.tsx create mode 100644 ui/src/components/assistant/AssistantHomeGreeting.tsx 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 ( +
+ +
+ +
+
+ ); +}