agentHomeHero.test.tsx8.1 KBView on GitHub /**
* The /agent home hero's stack (design: curated-agenda phase 5).
*
* The hero now reads chat input → the day's tasks, with the widget rail beside it.
* Two things are being pinned here:
*
* 1. ORDER AND CONTENT — the hero below the composer is the pills and the day's TASKS, and
* nothing else. The agent list moved to the widget rail's Agents widget and the Top Deals
* list the agenda replaced is gone entirely (still reachable from `DisplayArtifactPanel`,
* which is a different surface).
* 2. THE COMPOSER IS UNTOUCHED — the hero is deliberately split into
* `AgentHomeHero` (above the composer) and `AgentHomeBelowChat` (below it) so
* the composer keeps ONE stable position in the chat's tree. The swap happened
* entirely inside `AgentHomeBelowChat`, so it cannot have remounted the
* composer — and the harness below proves that against the real tree shape.
*/
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import type { ReactNode } from 'react';
// The hero's entrance animation. `initial`/`animate`/`transition` are not DOM
// attributes and the hero only ever reaches for `motion.div`, so every tag on
// the proxy resolves to the same plain div — the className is what carries the
// `gap-8` stack this test asserts on.
jest.mock('motion/react', () => {
const MotionDiv = ({ children, className }: { children?: ReactNode; className?: string }) => (
<div className={className}>{children}</div>
);
return { motion: new Proxy({}, { get: () => MotionDiv }) };
});
jest.mock('next-themes', () => ({ useTheme: () => ({ resolvedTheme: 'light' }) }));
// The shared auth-client mock only carries `authClient`; the hero imports the
// standalone `useSession` re-export for the greeting.
jest.mock('@/modules/auth/utils/auth-client', () => ({
useSession: () => ({ data: { user: { id: 'user-1', name: 'Jesse Li' } }, isPending: false }),
}));
jest.mock('@/hooks/use-media-query', () => ({
useMediaQuery: () => false,
REDUCED_MOTION_QUERY: '(prefers-reduced-motion: reduce)',
}));
jest.mock('@/app/(full-width)/SunlitBackground/SunlitBackground', () => ({
SunlitBackground: () => null,
}));
jest.mock('@/modules/cedar-os/src/cedar-os-components/chatComponents/welcomeShortcuts', () => ({
useAllActionsModal: () => [false, jest.fn()],
}));
// The pills now carry one action that does real work — "Agentic Table" creates a document
// before anything renders. Its two clients are stubbed here; the action itself is covered in
// agenticTable.test.ts, and this file stays about the hero's stack.
jest.mock('@tanstack/react-query', () => ({ useMutation: () => ({ mutateAsync: jest.fn() }) }));
jest.mock('@/providers/query-provider', () => ({
useTRPC: () => ({ files: { createChatDocument: { mutationOptions: () => ({}) } } }),
}));
jest.mock('@/modules/home/components/agentic-table', () => ({ startAgenticTable: jest.fn() }));
// The "Full calendar" pill is the button form of the global ⇧Q overlay toggle. The toggle
// itself reads the store and a query client; here it only has to be the SAME callable the
// key fires, so the shared hook stands in as a spy. The spy is created INSIDE the factory —
// the mock is hoisted above every `const` in this file — and read back through the import.
jest.mock('@/modules/calendar/hooks/use-calendar-canvas-overlay', () => {
const toggleCalendarCanvas = jest.fn();
return { useCalendarCanvasOverlay: () => ({ isCalendarCanvasOpen: false, toggleCalendarCanvas }) };
});
jest.mock('sonner', () => ({ toast: { error: jest.fn() } }));
// The two blocks the hero stacks. Both pull heavy trees of their own (the calendar
// stack; TipTap + the collaborative document), so they stand in as sentinels — this
// test is about the stack, not about what either one renders.
jest.mock('@/modules/home/components/HomeAgenda', () => ({
HomeAgenda: () => <div data-testid="home-agenda">DAILY AGENDA</div>,
}));
// The Top Deals list the agenda replaced. Left un-mocked-away deliberately: if
// anything in the hero still reaches for it, this sentinel shows up.
jest.mock('@/modules/canvas/components/CardListCanvasView', () => ({
CardListCanvasView: () => <div data-testid="card-list">TOP DEALS</div>,
}));
import { useCalendarCanvasOverlay } from '@/modules/calendar/hooks/use-calendar-canvas-overlay';
import { AgentHomeBelowChat, AgentHomeHero } from '@/modules/home/components/AgentHomeHero';
import { startAgenticTable } from '@/modules/home/components/agentic-table';
/**
* The real tree shape: greeting, then the composer the CHAT owns, then everything
* below it. `EmbeddedCedarChat` mounts these three in exactly this order.
*/
function Hero({ showBelow = true }: { showBelow?: boolean }) {
return (
<>
<AgentHomeHero />
<textarea data-testid="composer" readOnly />
{showBelow ? <AgentHomeBelowChat onPrompt={jest.fn()} /> : null}
</>
);
}
describe('AgentHomeBelowChat — the hero stack', () => {
it('renders the day as ONE block, with no separate next-meeting card above it', () => {
const { container } = render(<Hero />);
// The tasks and the meetings live inside HomeAgenda's single card now, so the hero
// stacks exactly two things under the pills. A next-meeting card here would be the
// old shape, which fitted one meeting and pushed the tasks under the fold.
expect(screen.getByTestId('home-agenda')).toBeInTheDocument();
expect(screen.queryByTestId('next-meeting')).not.toBeInTheDocument();
expect(container).toBeTruthy();
});
it('no longer stacks the agent list under the agenda', () => {
render(<Hero />);
// Agents live in the widget rail now, as a shortlist the user pins. A full agent list
// back under the agenda is the old single-column shape.
expect(screen.getByTestId('home-agenda')).toBeInTheDocument();
expect(screen.queryByTestId('agents-list')).not.toBeInTheDocument();
});
it('no longer renders the Top Deals list', () => {
render(<Hero />);
expect(screen.queryByTestId('card-list')).not.toBeInTheDocument();
expect(screen.queryByText(/deals that need attention/i)).not.toBeInTheDocument();
});
it('offers Agentic Table as the second pill, and clicking it runs the action', async () => {
render(<Hero />);
const pills = screen.getAllByRole('button');
expect(pills[1]).toHaveTextContent('Agentic Table');
// The pill it replaced. A prompt pill and a create-something pill are different
// affordances, and the hero only has room for four.
expect(screen.queryByText(/draft follow-ups/i)).not.toBeInTheDocument();
fireEvent.click(pills[1]!);
await waitFor(() => expect(startAgenticTable).toHaveBeenCalledTimes(1));
});
it('offers Full calendar as the third pill, teaching ⇧Q, and clicking it opens the overlay', () => {
render(<Hero />);
const pills = screen.getAllByRole('button');
expect(pills[2]).toHaveTextContent('Full calendar');
// The pill carries the key that does the same thing, so the button teaches the shortcut.
expect(pills[2]).toHaveTextContent('⇧Q');
// The prompt pill it replaced. The hero already shows today below; the pill beside it
// widens that to the week rather than asking the agent to write about it.
expect(screen.queryByText(/prep my meetings/i)).not.toBeInTheDocument();
fireEvent.click(pills[2]!);
// The mocked hook hands back one stable spy, so calling it here reads the same function
// the pill just fired.
expect(useCalendarCanvasOverlay().toggleCalendarCanvas).toHaveBeenCalledTimes(1);
});
it('keeps the composer out of both hero blocks, so the swap cannot remount it', () => {
const { rerender } = render(<Hero showBelow={false} />);
const composerBefore = screen.getByTestId('composer');
// Mount the whole below-chat stack — the block the agenda swap lives in.
rerender(<Hero showBelow />);
expect(screen.getByTestId('home-agenda')).toBeInTheDocument();
// Same DOM node: the composer sits between the two hero blocks rather than
// inside either, so nothing that changes below it touches it.
expect(screen.getByTestId('composer')).toBe(composerBefore);
});
});