unibox-navigation-parity.test.ts9.5 KBView on GitHub
import { act } from '@testing-library/react';
import { useCedarStore } from '@/modules/cedar-os/src/store/CedarStore';
import type { InboxItem } from '@/modules/inbox/types';
import { settledChannelFeeds } from '../../lib/inboxFeed';

/**
 * The unibox (`?channel=all|slack|linkedin|whatsapp`) and the email list share
 * `selectedThreadId` / `bulkSelected` / `focusedIndex` — the unibox just holds unified
 * selection ids. So every keyboard action has to walk the list the user can SEE.
 *
 * Before `activeListSource`, navigation always read `currentThreadList`, so j/k in the
 * unibox stepped through whatever stale email list the last mail visit had left behind.
 */
// Distinct timestamps on purpose: the client merges the per-channel windows by `sortedAt`,
// so a shared one would put these two rows in id order and the assertions below would be
// pinning the tie-break rather than the list the user sees.
const emailItem = (
  threadId: string,
  conversationId?: string,
  sortedAt = '2026-01-02T00:00:00.000Z',
): InboxItem =>
  ({
    id: `email:${threadId}`,
    channel: 'email',
    ref: { kind: 'email', threadId },
    sortedAt,
    snippet: '',
    counterpart: {},
    conversationId,
  }) as unknown as InboxItem;

const slackItem = (
  id: string,
  conversationId?: string,
  sortedAt = '2026-01-01T00:00:00.000Z',
): InboxItem =>
  ({
    id,
    channel: 'slack',
    ref: { kind: 'slack', slackChannelId: 'C1', workspaceId: 'W1' },
    sortedAt,
    snippet: '',
    counterpart: {},
    conversationId,
  }) as unknown as InboxItem;

const reset = () =>
  useCedarStore.setState((s) => ({
    ...s,
    activeListSource: 'threads' as const,
    currentThreadList: [{ id: 'mail-1' }, { id: 'mail-2' }] as never[],
    channelFeeds: {},
    selectedThreadId: null,
    focusedIndex: null,
    bulkSelected: [],
    isThreadOpen: false,
    isConversationOpen: false,
    activeConversationId: null,
    threadData: {},
    threadToConversation: {},
  }));

beforeEach(reset);
afterEach(reset);

const seedFeed = (items: InboxItem[]) =>
  act(() => {
    useCedarStore.getState().setChannelFeeds(settledChannelFeeds(items));
  });

const seedUnifiedFeed = () =>
  seedFeed([emailItem('mail-1', 'conv-mail'), slackItem('slack-1', 'conv-slack')]);

describe('getActiveListItems', () => {
  it('returns the email thread list by default', () => {
    seedUnifiedFeed();
    expect(useCedarStore.getState().getActiveListItems().map((i) => i.id)).toEqual([
      'mail-1',
      'mail-2',
    ]);
  });

  it('returns the unified feed once the unibox is on screen', () => {
    seedUnifiedFeed();
    act(() => useCedarStore.getState().setActiveListSource('unified'));
    // Email rows carry their BARE thread id as the selection id (not `email:<id>`),
    // which is what bulkSelected / selectedThreadId / data-thread-id all speak.
    expect(useCedarStore.getState().getActiveListItems().map((i) => i.id)).toEqual([
      'mail-1',
      'slack-1',
    ]);
  });
});

/**
 * Checkbox selection has the same contract as keyboard nav, and it regressed
 * separately: `handleCheckboxChange` (thread.tsx / draft.tsx) resolved the row's
 * index with `getCurrentThreadList()` — the EMAIL-only list — and bailed on
 * `findIndex(...) === -1`. In the unibox that miss is the normal case, so clicking
 * an email row's checkbox did nothing at all, silently. Reported as "in inbox/all,
 * i can't select emails".
 */
describe('checkbox selection can find every visible row', () => {
  /** The guard both checkbox handlers apply before touching `bulkSelected`. */
  const indexIn = (list: { id: string }[], id: string) => list.findIndex((i) => i.id === id);

  beforeEach(() => {
    act(() => {
      // An email row the unibox shows but the stale email list has never heard of —
      // i.e. any inbox whose feed is not byte-identical to the last /mail visit.
      useCedarStore.getState().setChannelFeeds(
        settledChannelFeeds([emailItem('mail-9'), slackItem('slack-9')]),
      );
      useCedarStore.getState().setActiveListSource('unified');
    });
  });

  it('finds a unibox-only email row in the list selection walks', () => {
    const state = useCedarStore.getState();
    expect(indexIn(state.getActiveListItems(), 'mail-9')).toBe(0);
  });

  it('would have bailed on the email-only list — the bug this pins', () => {
    const state = useCedarStore.getState();
    expect(indexIn(state.getCurrentThreadList(), 'mail-9')).toBe(-1);
  });

  it('finds channel rows too, so range-select spans channels', () => {
    const state = useCedarStore.getState();
    expect(indexIn(state.getActiveListItems(), 'slack-9')).toBe(1);
  });

  it('still resolves email rows from the email list when the unibox is off', () => {
    act(() => useCedarStore.getState().setActiveListSource('threads'));
    const state = useCedarStore.getState();
    expect(indexIn(state.getActiveListItems(), 'mail-1')).toBe(0);
  });
});

describe('keyboard navigation follows the visible list', () => {
  beforeEach(() => {
    seedUnifiedFeed();
    act(() => useCedarStore.getState().setActiveListSource('unified'));
  });

  it('j from nothing selected lands on the first unified row', () => {
    act(() => useCedarStore.getState().navigateToNextThread());
    expect(useCedarStore.getState().selectedThreadId).toBe('mail-1');
    expect(useCedarStore.getState().focusedIndex).toBe(0);
  });

  it('steps onto a channel row, not the stale email list', () => {
    act(() => {
      useCedarStore.getState().navigateToNextThread();
      useCedarStore.getState().navigateToNextThread();
    });
    // 'mail-2' only exists in currentThreadList — reaching it would mean walking the wrong list.
    expect(useCedarStore.getState().selectedThreadId).toBe('slack-1');
  });

  it('does not run past the end of the unified feed', () => {
    act(() => {
      useCedarStore.getState().navigateToNextThread();
      useCedarStore.getState().navigateToNextThread();
      useCedarStore.getState().navigateToNextThread();
    });
    expect(useCedarStore.getState().focusedIndex).toBe(1);
  });

  it('k walks back up the unified feed', () => {
    act(() => {
      useCedarStore.getState().navigateToNextThread();
      useCedarStore.getState().navigateToNextThread();
      useCedarStore.getState().navigateToPreviousThread();
    });
    expect(useCedarStore.getState().selectedThreadId).toBe('mail-1');
  });
});

/**
 * Selecting a row has to move the chat's ambient conversation with it. The email
 * resolution chain (threadData → threadToConversation → currentThreadList.$raw) is keyed on
 * thread state the unified feed never populates, so before the feed became a resolution
 * source, selecting anything in the unibox left `activeConversationId` at null — and a Slack
 * row has no thread data to hydrate at all.
 */
describe('selecting a unibox row moves the active conversation', () => {
  beforeEach(() => {
    seedUnifiedFeed();
    act(() => useCedarStore.getState().setActiveListSource('unified'));
  });

  it('resolves an email row off the unified feed', () => {
    act(() => useCedarStore.getState().selectThreadId('mail-1'));
    expect(useCedarStore.getState().activeConversationId).toBe('conv-mail');
  });

  it('resolves a channel row, which has no thread data at all', () => {
    act(() => useCedarStore.getState().selectThreadId('slack-1'));
    expect(useCedarStore.getState().activeConversationId).toBe('conv-slack');
  });

  it('follows keyboard navigation too', () => {
    act(() => {
      useCedarStore.getState().navigateToNextThread();
      useCedarStore.getState().navigateToNextThread();
    });
    expect(useCedarStore.getState().selectedThreadId).toBe('slack-1');
    expect(useCedarStore.getState().activeConversationId).toBe('conv-slack');

    act(() => useCedarStore.getState().navigateToPreviousThread());
    expect(useCedarStore.getState().activeConversationId).toBe('conv-mail');
  });

  it('clears when the row has no conversation', () => {
    act(() => {
      useCedarStore.getState().setChannelFeeds(settledChannelFeeds([slackItem('slack-2')]));
      useCedarStore.getState().selectThreadId('slack-2');
    });
    expect(useCedarStore.getState().activeConversationId).toBeNull();
  });

  it('still prefers loaded thread data over the feed', () => {
    useCedarStore.setState((s) => ({
      ...s,
      threadData: { 'mail-1': { conversationId: 'conv-hydrated' } } as never,
    }));
    act(() => useCedarStore.getState().selectThreadId('mail-1'));
    expect(useCedarStore.getState().activeConversationId).toBe('conv-hydrated');
  });
});

describe('setChannelFeeds', () => {
  it('does not auto-select anything', () => {
    act(() => useCedarStore.getState().setActiveListSource('unified'));
    seedUnifiedFeed();
    expect(useCedarStore.getState().selectedThreadId).toBeNull();
    expect(useCedarStore.getState().focusedIndex).toBeNull();
  });

  it('keeps the cursor on the selected row when the feed re-orders', () => {
    act(() => useCedarStore.getState().setActiveListSource('unified'));
    seedUnifiedFeed();
    act(() => useCedarStore.getState().selectThreadId('slack-1'));
    // Slack row is now newest.
    seedFeed([slackItem('slack-1', undefined, '2026-01-03T00:00:00.000Z'), emailItem('mail-1')]);
    expect(useCedarStore.getState().focusedIndex).toBe(0);
  });

  it('leaves the email list cursor alone while the email list is on screen', () => {
    useCedarStore.setState((s) => ({ ...s, focusedIndex: 1, selectedThreadId: 'mail-2' }));
    seedUnifiedFeed(); // background refetch of the unibox query
    expect(useCedarStore.getState().focusedIndex).toBe(1);
  });
});