openChannelItem.test.tsx7.1 KBView on GitHub import { act, renderHook } from '@testing-library/react';
import { useCedarStore } from '@/modules/store';
import {
findSlackItem,
slackContainerKey,
useOpenChannelItem,
} from '@/modules/inbox/hooks/use-open-channel-item';
import type { InboxItem } from '@/modules/inbox/types';
import { settledChannelFeeds } from '../../lib/inboxFeed';
/**
* The SOURCE side of `?slack=` (slack-parity Phase 5). LayoutUrlSync can only project an
* artifact somebody sets, and opening a Slack channel in the unibox used to set nothing at
* all — the open chat was `useState` in mail.tsx. This hook is the mirror: Slack rows write
* the display artifact (which becomes the URL) and read it back when the URL changes.
*
* LinkedIn/WhatsApp deliberately stay local — they have no `ContextKind`, so a param for
* them would deep-link to a screen nothing can restore.
*/
const slackItem = (channelId: string): InboxItem =>
({
id: `slack:W1:${channelId}`,
channel: 'slack',
ref: { kind: 'slack', slackChannelId: channelId, workspaceId: 'W1', conversationId: 'conv_1' },
sortedAt: '2026-08-12T00:00:00.000Z',
snippet: '',
counterpart: { name: 'Slack' },
}) as unknown as InboxItem;
const linkedinItem = (chatId: string): InboxItem =>
({
id: `li:${chatId}`,
channel: 'linkedin',
ref: { kind: 'linkedin', chatId, unipileAccountId: 'A1' },
sortedAt: '2026-08-12T00:00:00.000Z',
snippet: '',
counterpart: { name: 'Someone' },
}) as unknown as InboxItem;
const seedFeed = (items: InboxItem[]) =>
act(() => {
useCedarStore.getState().setChannelFeeds(settledChannelFeeds(items));
});
beforeEach(() => {
useCedarStore.setState({
threadMap: {},
mainThreadId: '',
activeThreadId: '',
channelFeeds: {},
});
});
describe('slackContainerKey', () => {
it('is the Slack channel id, and nothing for the other channels', () => {
// The channel id (`C…`/`D…`) — the same key `channels.*` and the reconciler take.
expect(slackContainerKey(slackItem('C123'))).toBe('C123');
expect(slackContainerKey(linkedinItem('chat_1'))).toBeNull();
});
});
describe('findSlackItem', () => {
it('finds the row for a container key and ignores same-id rows on other channels', () => {
const items = [slackItem('C123'), linkedinItem('C123')];
expect(findSlackItem(items, 'C123')).toBe(items[0]);
expect(findSlackItem(items, 'C999')).toBeUndefined();
});
});
describe('useOpenChannelItem', () => {
it('opening a Slack row sets the display artifact the URL is projected from', () => {
const { result } = renderHook(() => useOpenChannelItem());
act(() => result.current.openChannel(slackItem('C123')));
expect(result.current.openChannelItem?.id).toBe('slack:W1:C123');
expect(useCedarStore.getState().getDisplayArtifact()).toEqual({
kind: 'slack_thread',
id: 'C123',
});
});
// Was "opening a LinkedIn row touches no artifact". It does now, and deliberately: the artifact
// is not only the address `?linkedin=` is projected from, it is what `artifactToContext` reads
// to pick the surface for the chat column. Leaving the slot empty meant a LinkedIn chat opened
// with the *mail* surface beside it, so the counterpart profile card — declared on the
// `channelChat` surface — could never render.
it('opening a LinkedIn row sets its artifact, exactly as a Slack row does', () => {
const { result } = renderHook(() => useOpenChannelItem());
act(() => result.current.openChannel(linkedinItem('chat_1')));
expect(result.current.openChannelItem?.id).toBe('li:chat_1');
expect(useCedarStore.getState().getDisplayArtifact()).toEqual({
kind: 'linkedin_chat',
id: 'chat_1',
});
});
it('closing clears the artifact, which is what strips the param', () => {
const { result } = renderHook(() => useOpenChannelItem());
act(() => result.current.openChannel(slackItem('C123')));
act(() => result.current.closeChannel());
expect(result.current.openChannelItem).toBeNull();
expect(useCedarStore.getState().getDisplayArtifact()).toBeNull();
});
it('closing leaves a non-channel artifact alone', () => {
// The kind guard still matters, but the reachable case has moved. Opening a LinkedIn row now
// TAKES the slot, so the conversation has to arrive afterwards — someone opening a deal from
// the chat panel while a chat is up. That conversation is not this close button's to clear.
const { result } = renderHook(() => useOpenChannelItem());
act(() => result.current.openChannel(linkedinItem('chat_1')));
act(() => {
useCedarStore.getState().setSelectedArtifact({ kind: 'conversation', id: 'conv_9' });
});
act(() => result.current.closeChannel());
expect(useCedarStore.getState().getDisplayArtifact()).toEqual({
kind: 'conversation',
id: 'conv_9',
});
});
it('opens the channel the URL restored, once the feed carries it', () => {
// The deep-link order: LayoutUrlSync sets the artifact from `?slack=` long before the
// first feed page lands, so the hook must re-resolve when the feed arrives rather than
// give up on the one pass where the row did not exist yet.
const { result } = renderHook(() => useOpenChannelItem());
act(() => {
useCedarStore.getState().setSelectedArtifact({ kind: 'slack_thread', id: 'C123' });
});
expect(result.current.openChannelItem).toBeNull();
seedFeed([slackItem('C123'), linkedinItem('chat_1')]);
expect(result.current.openChannelItem?.id).toBe('slack:W1:C123');
});
it('closes the open Slack chat when the artifact goes (the back button)', () => {
const { result } = renderHook(() => useOpenChannelItem());
seedFeed([slackItem('C123')]);
act(() => result.current.openChannel(slackItem('C123')));
act(() => {
useCedarStore.getState().setSelectedArtifact(null);
});
expect(result.current.openChannelItem).toBeNull();
});
// Was "does not close a LinkedIn chat when an unrelated artifact goes". Now that a LinkedIn chat
// IS the artifact, the back button closes it the same way it closes a Slack channel — which is
// the point of giving it an address at all.
it('closes the open LinkedIn chat when the artifact goes (the back button)', () => {
const { result } = renderHook(() => useOpenChannelItem());
seedFeed([linkedinItem('chat_1')]);
act(() => result.current.openChannel(linkedinItem('chat_1')));
act(() => {
useCedarStore.getState().setSelectedArtifact(null);
});
expect(result.current.openChannelItem).toBeNull();
});
it('opens the LinkedIn chat the URL restored, once the feed carries it', () => {
// Deep-link parity with Slack: `?linkedin=<chatId>` sets the artifact long before the first
// feed page lands, so the hook must re-resolve when the feed arrives.
const { result } = renderHook(() => useOpenChannelItem());
act(() => {
useCedarStore.getState().setSelectedArtifact({ kind: 'linkedin_chat', id: 'chat_1' });
});
expect(result.current.openChannelItem).toBeNull();
seedFeed([slackItem('C123'), linkedinItem('chat_1')]);
expect(result.current.openChannelItem?.id).toBe('li:chat_1');
});
});