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);
  });
});