openContextArtifact.test.tsx5.0 KBView on GitHub
import { act, renderHook } from '@testing-library/react';
import { useCedarStore } from '@/modules/cedar-os/src/store/CedarStore';
import { useOpenContextArtifact } from '@/modules/cedar-os/src/cedar-os-components/chatComponents/useOpenContextArtifact';
import { api } from '@/modules/trpc/trpc';

const mockNavigate = jest.fn();
// The route the hook thinks it is on — a standalone doc opens in place on the agent home
// and full-screen in /brain everywhere else, so each test sets this to the surface it means.
let mockPathname = '/mail/inbox';
jest.mock('react-router', () => ({
  useNavigate: () => mockNavigate,
  useLocation: () => ({ pathname: mockPathname }),
}));

const getDoc = api.documents.getDoc.query as jest.Mock;

beforeEach(() => {
  mockNavigate.mockReset();
  getDoc.mockReset();
  mockPathname = '/mail/inbox';
  useCedarStore.setState((state) => ({
    ...state,
    threadMap: { main: { id: 'main', lastLoaded: new Date().toISOString(), messages: [] } },
    mainThreadId: 'main',
    activeThreadId: 'main',
    activeConversationId: null,
    conversationSection: null,
    conversationOpenFile: null,
  }));
});

describe('useOpenContextArtifact', () => {
  it('opens a conversation-scoped file in the conversation Files tab, in place', async () => {
    getDoc.mockResolvedValue({ path: 'conversation/conv_9/notes/deck' });
    const { result } = renderHook(() => useOpenContextArtifact());

    await act(async () => {
      await result.current('file', 'doc_1');
    });

    const s = useCedarStore.getState();
    expect(s.activeConversationId).toBe('conv_9');
    expect(s.conversationSection).toBe('files');
    // The open file is set AFTER the section (setting the section clears the open file).
    expect(s.conversationOpenFile).toBe('doc_1');
    expect(mockNavigate).not.toHaveBeenCalled();
  });

  it("opens a standalone file full-screen in the Brain's Knowledge explorer", async () => {
    getDoc.mockResolvedValue({ path: 'user/scratch/idea' });
    const { result } = renderHook(() => useOpenContextArtifact());

    await act(async () => {
      await result.current('file', 'doc_2');
    });

    expect(mockNavigate).toHaveBeenCalledWith('/brain/knowledge?documentId=doc_2');
  });

  it('opens a standalone file in place as the display artifact on the agent home', async () => {
    getDoc.mockResolvedValue({ path: 'user/scratch/idea' });
    mockPathname = '/home';
    const { result } = renderHook(() => useOpenContextArtifact());

    await act(async () => {
      await result.current('file', 'doc_2');
    });

    // Navigating away here would swap out the surface and take the transcript with it.
    expect(mockNavigate).not.toHaveBeenCalled();
    expect(useCedarStore.getState().threadMap.main?.selectedArtifact).toEqual({
      kind: 'file',
      id: 'doc_2',
    });
  });

  it('falls back to full-screen when the document cannot be resolved', async () => {
    getDoc.mockRejectedValue(new Error('nope'));
    const { result } = renderHook(() => useOpenContextArtifact());

    await act(async () => {
      await result.current('file', 'doc_3');
    });

    expect(mockNavigate).toHaveBeenCalledWith('/brain/knowledge?documentId=doc_3');
  });

  it('deep-links a conversation-file when switching to another chat thread', async () => {
    getDoc.mockResolvedValue({ path: 'conversation/conv_9/notes/deck' });
    useCedarStore.setState((state) => ({
      ...state,
      threadMap: {
        ...state.threadMap,
        other: { id: 'other', lastLoaded: new Date().toISOString(), messages: [] },
      },
    }));
    const { result } = renderHook(() => useOpenContextArtifact());

    await act(async () => {
      await result.current('file', 'doc_1', 'other');
    });

    expect(useCedarStore.getState().mainThreadId).toBe('other');
    expect(mockNavigate).toHaveBeenCalledWith('/home?conversationId=conv_9/files/doc_1');
  });

  it('opens a conversation in place (no navigation) when already in the chat', async () => {
    const { result } = renderHook(() => useOpenContextArtifact());

    await act(async () => {
      await result.current('conversation', 'conv_5');
    });

    expect(useCedarStore.getState().activeConversationId).toBe('conv_5');
    expect(mockNavigate).not.toHaveBeenCalled();
  });

  it('going from a file back to its conversation closes the file, swapping the selection', async () => {
    getDoc.mockResolvedValue({ path: 'conversation/conv_9/notes/deck' });
    const { result } = renderHook(() => useOpenContextArtifact());

    await act(async () => {
      await result.current('file', 'doc_1');
    });
    expect(useCedarStore.getState().conversationOpenFile).toBe('doc_1');

    // Back to the parent conversation — the file must close so the CONVERSATION becomes the
    // selected context (otherwise the file stays lit and the selection never swaps).
    await act(async () => {
      await result.current('conversation', 'conv_9');
    });

    const s = useCedarStore.getState();
    expect(s.conversationOpenFile).toBeNull();
    expect(s.activeConversationId).toBe('conv_9');
  });
});