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) <noreply@anthropic.com>
This commit is contained in:
parent
6010a105fe
commit
37d3919c98
2 changed files with 300 additions and 0 deletions
171
ui/src/components/assistant/AssistantHomeGreeting.test.tsx
Normal file
171
ui/src/components/assistant/AssistantHomeGreeting.test.tsx
Normal file
|
|
@ -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> = {}): 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("<AssistantHomeGreeting />", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: ReturnType<typeof createRoot> | 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(
|
||||
<AssistantHomeGreeting
|
||||
status={emptyStatus({ loading: true })}
|
||||
userName="Mikkel"
|
||||
now={new Date("2026-04-11T09:00:00")}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<AssistantHomeGreeting
|
||||
status={emptyStatus({ activeAgents: 3 })}
|
||||
userName="Mikkel"
|
||||
now={new Date("2026-04-11T09:00:00")}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<AssistantHomeGreeting
|
||||
status={emptyStatus({
|
||||
staleProjects: [
|
||||
{
|
||||
id: "p1",
|
||||
name: "personal-finance",
|
||||
lastActivity: "5d ago",
|
||||
href: "/NEX/projects/personal-finance",
|
||||
},
|
||||
],
|
||||
})}
|
||||
userName="Mikkel"
|
||||
now={new Date("2026-04-11T09:00:00")}
|
||||
/>,
|
||||
);
|
||||
const article = container.querySelector('[data-testid="assistant-home-greeting"]');
|
||||
expect(article?.textContent ?? "").toContain("personal-finance: idle 5d ago");
|
||||
});
|
||||
});
|
||||
129
ui/src/components/assistant/AssistantHomeGreeting.tsx
Normal file
129
ui/src/components/assistant/AssistantHomeGreeting.tsx
Normal file
|
|
@ -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 (
|
||||
<div
|
||||
data-testid="assistant-home-greeting-loading"
|
||||
className={cn("py-8 text-sm text-muted-foreground", className)}
|
||||
>
|
||||
Catching up…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const body = buildGreetingMarkdown(status, userName, now ?? new Date());
|
||||
|
||||
return (
|
||||
<article
|
||||
data-testid="assistant-home-greeting"
|
||||
aria-label="Assistant home greeting"
|
||||
className={cn("flex gap-3 py-6", className)}
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="mt-1 flex h-7 w-7 shrink-0 items-center justify-center rounded-[4px] border border-border text-primary"
|
||||
>
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.1em]">AI</span>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-[620px] rounded-[8px] border border-border bg-card px-4 py-3",
|
||||
"text-sm leading-relaxed text-foreground",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
||||
)}
|
||||
>
|
||||
<ChatMarkdownMessage content={body} />
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue