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