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