use-channel-artifact-item.ts6.8 KBView on GitHub import { useQuery } from '@tanstack/react-query';
import { CHANNEL_CONTEXT_KINDS } from '@/modules/cedar-os/src/store/messages/MessageTypes';
import type { ContextKind } from '@/modules/cedar-os/src/store/messages/MessageTypes';
import type { InboxItem, InboxChannel } from '@/modules/inbox/types';
import { useTRPC } from '@/providers/query-provider';
import { useCedarStore } from '@/modules/store';
import { selectLoadedInboxItems } from '@/modules/inbox/store/inboxSlice';
/** The channel a context kind names, or null for the kinds that are not channels at all. */
export function channelOfContextKind(kind: ContextKind): 'slack' | 'linkedin' | 'whatsapp' | null {
for (const [channel, contextKind] of Object.entries(CHANNEL_CONTEXT_KINDS)) {
if (contextKind === kind) return channel as 'slack' | 'linkedin' | 'whatsapp';
}
return null;
}
/**
* The provider container key a feed row opens — the Slack channel id or the Unipile chat id.
* (`slackContainerKey` answers the same question for Slack alone, because only Slack's key
* goes in the URL; this one is the whole-channel version the artifact needs.)
*/
export function containerKeyOf(item: InboxItem): string | null {
const ref = item.ref;
if (ref.kind === 'email') return null;
return ref.kind === 'slack' ? ref.slackChannelId : ref.chatId;
}
/**
* A stand-in feed row for a Slack channel neither the feed nor the container read can supply.
*
* Built to `slackMessageToItem`'s shape rather than to a plausible one, because `ChannelThreadView`
* reads a Slack channel's title off `counterpart.subtitle` and NOT off `counterpart.name` — the
* name slot holds the last SENDER on a real row. A hint whose channel name landed in `name` renders
* a header that says "Slack channel" over the right messages, which is indistinguishable from the
* feed row never arriving.
*
* `counterpart` is never null: the view reads it unguarded in several places, so a missing name
* here took the whole route down through the error boundary. 61 of 757 Slack tasks carry no channel
* name, so that is the common case, not the edge — the id stands in, which is at least identifying.
*/
function synthesiseSlackItem(
channelId: string,
hint: {
channelId: string;
workspaceId: string;
channelName: string | null;
conversationId: string | null;
} | null,
): InboxItem | undefined {
if (hint?.channelId !== channelId) return undefined;
return {
// The feed's own id shape (`slack:<workspace>:<channel>`), so nothing remounts when the real
// row lands and replaces this one.
id: `slack:${hint.workspaceId}:${channelId}`,
channel: 'slack',
sortedAt: '',
counterpart: {
name: 'Slack',
subtitle: `#${(hint.channelName ?? channelId).replace(/^#/, '')}`,
},
snippet: '',
unread: false,
starred: false,
hasDraft: false,
done: false,
labelIds: [],
participantCount: 1,
// The deal, carried through from the task that opened this. `ChannelThreadView` renders the
// conversation badge off it AND publishes it as the open channel's CRM context — dropping it
// opened a Slack task's channel with no sign of the deal the task belongs to.
conversationId: hint.conversationId ?? undefined,
ref: {
kind: 'slack',
conversationId: hint.conversationId ?? '',
slackChannelId: channelId,
workspaceId: hint.workspaceId,
},
};
}
/**
* The loaded feed's row for a container key.
*
* `find` was wrong here for the same reason a bare `LIMIT 1` was wrong in the container read:
* a container key is not unique on its own. A Slack Connect channel is synced by BOTH
* workspaces under the same `C…` id, so a rep in two workspaces has two feed rows answering to
* one key — distinguishable in the feed, whose item id is `slack:<workspace>:<channel>`, but
* not by the key the artifact carries. Load order is arbitrary, so `find` returned whichever
* page happened to arrive first.
*
* Most recently active wins, matching `fetchChannelItemFromContainers` exactly. The two MUST
* agree: this hook falls through from the feed to that read, and a panel that disagreed with
* the feed about which workspace a key means would open a different chat depending only on
* whether the inbox happened to be loaded.
*/
export function findFeedItem(
items: readonly InboxItem[],
channel: InboxChannel,
containerKey=[redacted],
): InboxItem | undefined {
let best: InboxItem | undefined;
for (const item of items) {
if (item.channel !== channel || containerKeyOf(item) !== containerKey) continue;
// `sortedAt` is the feed's own recency key. `id` breaks a tie the way `c.id` does server
// side, so an empty chat cannot flip between renders.
if (
!best ||
item.sortedAt > best.sortedAt ||
(item.sortedAt === best.sortedAt && item.id > best.id)
) {
best = item;
}
}
return best;
}
/**
* The feed row behind an open channel artifact (`linkedin_chat` / `whatsapp_chat` /
* `slack_thread`) — what `ChannelThreadView` needs to render the real chat.
*
* Three sources, richest first, because each one is missing where the next one works:
*
* 1. the LOADED FEED, which only exists on the inbox route, but is live there and is what the
* optimistic done/snooze/star actions patch;
* 2. the CONTAINER READ (`inbox.channelItem`), which works from anywhere — the agent home,
* /tasks, a cold deep link — and is the reason a chat opened from a context chip can draw
* itself at all rather than reciting its own id back;
* 3. the SLACK HINT left behind by whatever opened the channel, for the one case the other two
* cannot cover: a channel the seat has no container row for yet.
*
* Every surface that opens a channel artifact resolves it through here, so none of them can
* disagree about whether a chat is openable.
*/
export function useChannelArtifactItem(
kind: ContextKind | null,
id: string | null,
): { item: InboxItem | null; isPending: boolean } {
const trpc = useTRPC();
const channel = kind ? channelOfContextKind(kind) : null;
const loadedItems = useCedarStore(selectLoadedInboxItems);
const slackHint = useCedarStore((state) => state.slackChannelHint);
const feedItem = channel && id ? findFeedItem(loadedItems, channel, id) : undefined;
const { data, isPending } = useQuery({
...trpc.inbox.channelItem.queryOptions({
channel: channel ?? 'slack',
containerKey: id ?? '',
}),
enabled: !!channel && !!id && !feedItem,
});
if (feedItem) return { item: feedItem, isPending: false };
if (!channel || !id) return { item: null, isPending: false };
if (data) return { item: data, isPending: false };
const hinted = channel === 'slack' ? synthesiseSlackItem(id, slackHint) : undefined;
if (hinted) return { item: hinted, isPending: false };
return { item: null, isPending };
}