Introduced 1 production defect in 180 days, median 28 days to fix.
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react';
import { ArrowLeft, Building2, Loader2, Lock, MessageSquare } from 'lucide-react';
import { Archive, Clock, Star } from '@/components/icons/icons';
import { useMutation, useQuery } from '@tanstack/react-query';
import { toast } from 'sonner';
import { SlackMentionTextarea } from '@/modules/conversations/components/timeline/composer/SlackMentionTextarea';
import { useChannelDraft } from '@/modules/inbox/store/pending-channel-draft';
import { useSlackPrincipalsForMessages } from '@/modules/conversations/components/timeline/use-slack-principals';
import { ConversationCompanyAvatar } from '@/modules/conversationsPage/components/ConversationCompanyAvatar';
import {
ChannelMessageList,
type ThreadMessage,
} from '@/modules/inbox/components/ChannelMessageList';
import { renderSlackBody } from '@/modules/conversations/components/timeline/SlackStructuredBody';
import { useChannelReactions } from '@/modules/inbox/hooks/use-channel-reactions';
import { useChatReactions } from '@/modules/inbox/hooks/use-chat-reactions';
import { FieldBadge } from '@/modules/crm/components/ConversationCellComponents/FieldBadge';
import { AttachToConversation } from '@/modules/inbox/components/AttachToConversation';
import { useInboxItemActions } from '@/modules/inbox/hooks/use-inbox-item-actions';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { SlackThreadPanel } from '@/modules/inbox/components/SlackThreadPanel';
import { RemindDialog } from '@/modules/threads/thread/components/remind-dialog';
import { ChannelBadgeIcon } from '@/modules/inbox/components/channel-icons';
import { SendButton } from '@/modules/drafting/components/send-button';
import { useActiveConnection } from '@/hooks/use-connections';
import { useSession } from '@/modules/auth/utils/auth-client';
import type { EventReaction } from '@/modules/crm/types';
import type { InboxItem } from '@/modules/inbox/types';
import { useTRPC } from '@/providers/query-provider';
import { useCedarStore } from '@/modules/store';
import { linkifyPlainText } from '@/lib/linkify';
import { cn } from '@/lib/utils';
interface ChannelThreadViewProps {
item: InboxItem;
onClose: () => void;
}
/**
* Loosened message shape — the underlying source (crm mirror vs live unipile) varies, and so do
* its field names. LinkedIn's rows say `direction` / `occurredAt`; WhatsApp's live DTO says
* `fromSelf` / `at`. Both are read here, so both spellings belong in the type: reading only
* LinkedIn's left every WhatsApp message rendered as inbound with no timestamp — which also
* suppressed the day dividers, since those need a date to divide on.
*/
export type LooseMessage = {
id?: string;
/** Unipile's message id — the key the reaction write path needs. */
providerMessageId?: string | null;
text?: string | null;
direction?: string | null;
fromSelf?: boolean;
/**
* A DATE, not a string, on the mirrored branches: these come off drizzle `timestamp` columns
* and tRPC's superjson transformer revives them as `Date` on the client. WhatsApp's live DTO
* sends an ISO string. Both spellings, both types — see `occurredAtOf`.
*/
occurredAt?: string | Date | null;
createdAt?: string | Date | null;
at?: string | Date | null;
reactions?: EventReaction[];
};
export function isOutbound(msg: LooseMessage): boolean {
if (typeof msg.fromSelf === 'boolean') return msg.fromSelf;
return !!msg.direction && /out|self|sent|^to$/i.test(msg.direction);
}
/**
* When a message happened, as an ISO string, under either source's spelling and either type.
*
* The normalization is the point. `String(new Date())` is `"Sun Aug 16 2026 13:31:21 GMT-0700"`,
* so sorting messages by `String(occurredAt)` sorted them BY WEEKDAY NAME — Fri, Mon, Sat, Sun,
* Thu, Tue, Wed — which is why a day pill could appear above a message from a different day.
* The bug was invisible in types: `LooseMessage` said `string`, superjson delivers `Date`.
*/
export function occurredAtOf(msg: LooseMessage): string | undefined {
const raw = msg.occurredAt ?? msg.at ?? msg.createdAt;
if (!raw) return undefined;
return raw instanceof Date ? raw.toISOString() : String(raw);
}
/** Sort key for the above — epoch ms, so the comparison is chronological rather than lexical. */
export function sortKeyOf(msg: LooseMessage): number {
const iso = occurredAtOf(msg);
const ms = iso ? Date.parse(iso) : NaN;
// A message with no usable timestamp sorts LAST rather than to the epoch: it is almost always
// one we just sent and have not heard back about, which belongs at the bottom of the thread.
return Number.isNaN(ms) ? Number.MAX_SAFE_INTEGER : ms;
}
/** Header action button — matches ThreadDisplay's icon buttons. */
function ActionButton({
onClick,
label,
children,
}: {
onClick?: () => void;
label: string;
children: ReactNode;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={onClick}
// The label lives in a tooltip, which exists only while hovered — so without this
// these icon-only controls have no accessible name at all.
aria-label={label}
className="hover:bg-sunken inline-flex h-7 w-7 cursor-pointer items-center justify-center gap-1 overflow-hidden rounded-lg transition-colors"
>
{children}
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className="bg-white dark:bg-[#313131]">
{label}
</TooltipContent>
</Tooltip>
);
}
/**
* Full-screen LinkedIn / WhatsApp / Slack chat, laid out to match the email
* `ThreadDisplay`: sticky left back-gutter, a centred `max-w-[75ch]` column,
* a sticky header, individual message senders (no bubbles, no dividers), and a
* floating reply composer. The header's TOP row is the linked CRM conversation
* (or a "link here" affordance when none); the SECOND row — the email "subject"
* slot — is the counterpart the chat is with (a person for LinkedIn/WhatsApp,
* the #channel for Slack). Slack renders through the same layout as LinkedIn,
* resolving senders + @mentions via the conversation's events. See
* apps/mail/docs/omni-channel-inbox.md.
*/
export function ChannelThreadView({ item, onClose }: ChannelThreadViewProps) {
const trpc = useTRPC();
const ref = item.ref;
const { data: activeConnection } = useActiveConnection();
const { data: session } = useSession();
const myEmail = activeConnection?.email ?? undefined;
/**
* Our own face, resolved the way the rest of the app resolves it (see LeftSidebarContent).
*
* Outbound messages passed no avatar at all and fell back to a BIMI lookup on our email — which
* resolves nothing for a personal domain, so every message we sent showed initials. Neither
* LinkedIn nor WhatsApp can supply it: the seat owner appears in `channel_container_participants`
* 277 times with an avatar on TWO of those rows, and `connection.picture` is null for every
* connection on this account. The session image is the one that is actually populated.
*/
const myAvatarUrl = activeConnection?.picture ?? session?.user?.image ?? undefined;
// Per-chat, and cleared on the row change like every other piece of it below — unless
// something handed this chat a message on the way in, which a table's output cell does
// precisely so you can send what it drafted. See `useChannelDraft`.
//
// The key is the PROVIDER's container id on every channel, matching what the URL carries:
// a Slack channel id, or the Unipile chat id. It used to be Slack's alone, which is why a
// LinkedIn draft handed over from a table arrived at an empty composer.
const handoffKey=[redacted] === 'slack' ? ref.slackChannelId : ref.kind === 'email' ? null : ref.chatId;
const [draft, setDraft] = useChannelDraft(item.id, handoffKey);
const [gutterHovered, setGutterHovered] = useState(false);
/** Messages sent from here that the server has not handed back yet — see `appendOptimisticMessage`. */
const [pending, setPending] = useState<LooseMessage[]>([]);
// ── message sources ──
const linkedinQ = useQuery({
...trpc.linkedin.messaging.listChatMessages.queryOptions({
chatId: ref.kind === 'linkedin' ? ref.chatId : '',
}),
enabled: ref.kind === 'linkedin',
});
const whatsappQ = useQuery({
...trpc.outbound.whatsapp.chatMessages.queryOptions({
unipileAccountId: ref.kind === 'whatsapp' ? ref.unipileAccountId : '',
chatId: ref.kind === 'whatsapp' ? ref.chatId : '',
phoneE164: ref.kind === 'whatsapp' ? (ref.phoneE164 ?? null) : null,
}),
enabled: ref.kind === 'whatsapp',
});
// Slack messages are read at CHANNEL grain — the same grain as the row — so the
// thread can never lag the snippet. (Reading them off the linked conversation's
// events filtered by `conversation_id`, which the channel's messages routinely
// miss, is what desynced the two.) See services/inbox/slack-messages.ts.
/**
* How far back the thread currently reaches, grown by scrolling to the top.
*
* A window rather than a cursor, deliberately: the read returns the newest N and re-reads what
* is already on screen when N grows, which at these sizes costs a single indexed scan and
* avoids threading a cursor through a query key, an accumulator and the thread panel. The
* ceiling is the route's own `max(1000)`; past that, `loadOlder` stops asking the database and
* starts asking Slack.
*/
const PAGE = 50;
const MAX_WINDOW = 1000;
const [historyLimit, setHistoryLimit] = useState(PAGE);
// Reset when the opened row changes — this view stays mounted while you move between rows, so
// a window grown on a busy channel would otherwise be inherited by the next one.
useEffect(() => setHistoryLimit(PAGE), [item.id]);
// This view stays mounted while you move between rows, so a message pending on one chat would
// otherwise appear at the bottom of the next one.
useEffect(() => setPending([]), [item.id]);
const slackMessagesQ = useQuery({
...trpc.inbox.slackChannelMessages.queryOptions({
workspaceId: ref.kind === 'slack' ? ref.workspaceId : '',
channelId: ref.kind === 'slack' ? ref.slackChannelId : '',
limit: historyLimit,
}),
enabled: ref.kind === 'slack',
// Keep the shorter window on screen while the longer one loads, so growing it scrolls
// smoothly instead of blanking the thread back to a spinner.
placeholderData: (prev) => prev,
});
// The header's top row (deal name) for every channel. Fetched whenever the item
// carries a conversation.
const conversationQ = useQuery({
...trpc.crm.getConversation.queryOptions({ id: item.conversationId ?? '' }),
enabled: !!item.conversationId,
});
// Catch-up sync on open, for every channel: ingest lags (Slack's webhook→buffer→cron
// pipeline drains on a ~30-min delay; a LinkedIn chat that never promoted was never
// backfilled at all), so pull whatever is missing straight in — agent-free and
// idempotent — then refetch.
//
// Keyed by item id, NOT a bare boolean: this view stays mounted while you move
// between rows, so a once-per-mount guard silently skipped the catch-up (and the
// refetch) for every chat after the first.
const catchUp = useMutation(trpc.channels.catchUp.mutationOptions());
const {
markChannelRead,
markDone: markItemDone,
snooze: snoozeItem,
toggleStar: toggleItemStar,
} = useInboxItemActions();
const [remindOpen, setRemindOpen] = useState(false);
const syncedItemRef = useRef<string | null>(null);
useEffect(() => {
if (ref.kind === 'email' || syncedItemRef.current === item.id) return;
syncedItemRef.current = item.id;
// Catch up, then clear the dot — in that order. Ingesting a newer inbound message
// re-lights the container, so marking read first would leave it unread.
//
// ALL THREE CHANNELS (design: channel-sync-architecture.md Phase 4). This was gated
// on `ref.kind === 'slack'`, so opening a LinkedIn or WhatsApp chat triggered no
// fetch at all — which is why 48 LinkedIn chats and 116 WhatsApp chats sat with a
// correct snippet above an empty thread. `channels.catchUp` runs no agent and every
// fetcher is idempotent, so firing it on every open is cheap.
const containerKey=[redacted] === 'slack' ? ref.slackChannelId : ref.chatId;
const refetch =
ref.kind === 'slack'
? slackMessagesQ.refetch
: ref.kind === 'whatsapp'
? whatsappQ.refetch
: linkedinQ.refetch;
catchUp.mutate(
{ channel: ref.kind, containerKey },
{
onSettled: () => {
void refetch();
markChannelRead(item);
},
},
);
// Fires once per opened item; `item.id` identifies the channel row.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ref, item.id]);
const conversationData = conversationQ.data as
| {
conversation?: { name?: string | null };
company?: { name?: string | null } | null;
}
| undefined;
const conversation = conversationData?.conversation;
// Match the email ConversationBadge label: company name first, then the
// conversation/deal name, then a neutral fallback (never a raw "Unknown").
const conversationLabel = conversationData?.company?.name || conversation?.name || 'Conversation';
// Every message in this workspace+channel, ordered oldest→newest by the server,
// whatever conversation each one was attributed to.
const slackMessages = useMemo(
() =>
(slackMessagesQ.data ?? []).map((m) => ({
...m,
slackWorkspaceId: ref.kind === 'slack' ? ref.workspaceId : null,
})),
[slackMessagesQ.data, ref],
);
const resolveSlackPrincipal = useSlackPrincipalsForMessages(slackMessages);
// The open thread's root ts, or null. Reset when the opened channel changes — the
// view stays mounted while you move between rows, so a stale ts would otherwise
// leave a panel from the previous channel on screen.
const [openThreadTs, setOpenThreadTs] = useState<string | null>(null);
useEffect(() => {
setOpenThreadTs(null);
}, [item.id]);
const resolveReplierFace = useMemo(() => {
if (ref.kind !== 'slack') return undefined;
const workspaceId = ref.workspaceId;
return (userId: string) => {
const p = resolveSlackPrincipal(workspaceId, userId);
return p ? { displayName: p.displayName, avatarUrl: p.avatarUrl } : null;
};
}, [ref, resolveSlackPrincipal]);
const messages: ThreadMessage[] = useMemo(() => {
if (ref.kind === 'slack') {
const workspaceId = ref.workspaceId;
const resolveUserId = (userId: string) => {
const p = resolveSlackPrincipal(workspaceId, userId);
return p ? { displayName: p.displayName } : null;
};
return slackMessages.map((m) => {
const principal = m.slackUserId
? resolveSlackPrincipal(workspaceId, m.slackUserId)
: undefined;
return {
id: m.eventId,
senderName: principal?.displayName ?? m.userName ?? 'Slack',
// Slack knows its own members' faces, so the principal wins; ours is the fallback for
// a message we sent that Slack has no avatar for.
avatarUrl: principal?.avatarUrl ?? (m.outbound ? myAvatarUrl : undefined),
avatarEmail: m.userEmail ?? undefined,
when: m.occurredAt,
outbound: m.outbound,
// The message's own words, then the structure Slack sends beside them — an unfurl
// card, a Block Kit section — instead of the loose "Website: …" / "Stars: …" lines the
// ingest-time flattening used to leave behind (slack-parity.md Phase 2). `displayText`
// is `text` with exactly those lines subtracted server-side, so nothing prints twice;
// a bot post whose whole body was blocks has an empty one and renders as the card
// alone, which is what Slack shows.
body: renderSlackBody(m, resolveUserId),
reactions: m.reactions,
attachments: m.attachmentRefs,
// A root with replies gets the affordance. An orphan reply — one the server
// kept in the stream because its own root was never ingested — carries a
// `slackThreadTs` and no rollup, and still opens the thread: the panel's read
// live-fills the missing root, which is the only place it can come from.
...(m.replyCount > 0
? {
thread: {
threadTs: m.slackMessageTs,
replyCount: m.replyCount,
lastReplyAt: m.lastReplyAt,
replierUserIds: m.replierUserIds,
},
}
: m.slackThreadTs && m.slackThreadTs !== m.slackMessageTs
? {
thread: {
threadTs: m.slackThreadTs,
replyCount: 0,
lastReplyAt: null,
replierUserIds: [],
},
}
: {}),
};
});
}
const activeQuery = ref.kind === 'whatsapp' ? whatsappQ : linkedinQ;
const stored = (activeQuery.data as { messages?: LooseMessage[] } | undefined)?.messages ?? [];
// A pending message drops out the moment the same text comes back FROM THE SERVER as our
// own — matched on text rather than id, because the provider assigns the id and we never
// learn it here. Not simply cleared on refetch: WhatsApp's row is written by the webhook
// echo, which can land after the refetch, and the message would blink out and back.
const raw = [
...stored,
...pending.filter((p) => !stored.some((m) => isOutbound(m) && m.text === p.text)),
];
// Optional-chained deliberately: a caller can synthesise an item for a channel the feed
// has not loaded, and a missing counterpart here crashed the whole route through the error
// boundary rather than degrading to an unnamed chat.
const counterpart = item.counterpart?.name || 'Chat';
return [...raw]
.sort((a, b) => sortKeyOf(a) - sortKeyOf(b))
.map((msg, i) => {
const out = isOutbound(msg);
return {
// The PROVIDER's message id where there is one, because that is what a reaction is
// written against — LinkedIn's mirrored rows carry a Cedar uuid in `id` and Unipile's
// in `providerMessageId`, while WhatsApp's live DTO has only the latter, as `id`.
id: msg.providerMessageId ?? msg.id ?? String(i),
senderName: out ? 'You' : counterpart,
avatarUrl: out ? myAvatarUrl : item.counterpart?.avatarUrl,
avatarEmail: out ? myEmail : item.counterpart?.email,
when: occurredAtOf(msg),
outbound: out,
// LinkedIn and WhatsApp send their words as plain text — no markup, no link
// tokens — so a URL someone pasted arrives as characters and rendered as characters.
// Linkified here so a link in a chat thread is a link, the way it is in Slack's
// `<url|label>` tokens and in an email body.
body: msg.text ? (
linkifyPlainText(msg.text)
) : (
<span className="opacity-60">(no text)</span>
),
reactions: msg.reactions,
};
});
}, [
ref,
pending,
slackMessages,
resolveSlackPrincipal,
whatsappQ.data,
linkedinQ.data,
item.counterpart?.name,
item.counterpart?.avatarUrl,
item.counterpart?.email,
myEmail,
myAvatarUrl,
]);
const messagesLoading =
ref.kind === 'slack'
? slackMessagesQ.isLoading
: ref.kind === 'whatsapp'
? whatsappQ.isLoading
: linkedinQ.isLoading;
// The server already returns exactly the window we asked for, oldest→newest, so there is
// nothing left to slice here — growing `historyLimit` IS the "show more".
const shownMessages = messages;
/**
* Load older history when the user reaches the top.
*
* Two sources, in order, because they answer different questions. Widening the window asks the
* DATABASE for more of what we already hold — instant, and the common case. Only when that
* stops yielding anything (the window is bigger than the stored history, or has hit the route's
* ceiling) is there a reason to ask the PROVIDER, which costs an API call against the user's
* rate limit and is why it is not what a scroll does first.
*/
const backfill = useMutation(trpc.channels.backfill.mutationOptions());
const slackRefetch = slackMessagesQ.refetch;
const linkedinRefetch = linkedinQ.refetch;
const [historyExhausted, setHistoryExhausted] = useState(false);
useEffect(() => setHistoryExhausted(false), [item.id]);
const loadingOlder = backfill.isPending || slackMessagesQ.isFetching;
const loadOlder = useCallback(() => {
if (ref.kind === 'email' || loadingOlder || historyExhausted) return;
// The database still has more than we are showing: widen and stop. SLACK ONLY — it is the
// only channel whose read takes a limit, so `historyLimit` does nothing for the other two and
// gating them on it would just skip straight past this branch by accident rather than by
// intent.
if (ref.kind === 'slack' && messages.length >= historyLimit && historyLimit < MAX_WINDOW) {
setHistoryLimit((n) => Math.min(n + PAGE, MAX_WINDOW));
return;
}
// Stored history is exhausted. Slack can page further back; LinkedIn's "older" is a full
// re-walk of the chat (its provider read has no backward cursor), and WhatsApp has neither.
if (ref.kind === 'slack') {
backfill.mutate(
{ channel: 'slack', containerKey=[redacted], pages: 1, limit: 200 },
{
onSuccess: (res) => {
// `reachedStart` is Slack's own word for "there is nothing older" — the only
// trustworthy end-of-history signal, and what stops this asking again forever.
if (res.reachedStart || res.ingested === 0) setHistoryExhausted(true);
if (res.ingested > 0) setHistoryLimit((n) => Math.min(n + PAGE, MAX_WINDOW));
void slackRefetch();
},
onError: () => setHistoryExhausted(true),
},
);
return;
}
if (ref.kind === 'linkedin') {
// One full walk is all there is to do — the provider read has no backward cursor, so a
// second identical call would re-fetch the same history and find nothing. Marked exhausted
// either way, including on error: retrying on every scroll tick would hammer Unipile with
// the request that just failed.
catchUp.mutate(
{ channel: 'linkedin', containerKey=[redacted], full: true },
{
onSettled: () => {
setHistoryExhausted(true);
void linkedinRefetch();
},
},
);
return;
}
setHistoryExhausted(true);
// `refetch` rather than the query objects: those get a new identity every render, which would
// rebuild `loadOlder` (and through it the scroll handler) on every single render.
}, [
ref,
loadingOlder,
historyExhausted,
messages.length,
historyLimit,
backfill,
catchUp,
slackRefetch,
linkedinRefetch,
]);
/**
* Two jobs, and they are opposites — which is why they share one layout effect.
*
* At the BOTTOM: stay pinned there, so a new message or a send keeps the newest in view.
*
* At the TOP: hold the reader's place. Older messages are PREPENDED, so the content above the
* viewport grows while `scrollTop` stays put — the message being read slides down the page and
* the reader is left sitting at the top again, which then re-triggers `loadOlder` on the next
* scroll event. Adding the height delta back is what makes "load more" feel like nothing
* happened above you, and is also what stops the load from chaining into itself.
*
* `useLayoutEffect`, not `useEffect`: this must run before the browser paints, or the jump is
* visible as a flicker.
*/
const scrollRef = useRef<HTMLDivElement>(null);
const pinnedToBottom = useRef(true);
const prevScrollHeight = useRef(0);
useLayoutEffect(() => {
const el = scrollRef.current;
if (!el) return;
if (pinnedToBottom.current) {
el.scrollTop = el.scrollHeight;
} else if (prevScrollHeight.current && el.scrollHeight > prevScrollHeight.current) {
el.scrollTop += el.scrollHeight - prevScrollHeight.current;
}
prevScrollHeight.current = el.scrollHeight;
}, [messagesLoading, shownMessages.length]);
// Opening a different row starts a fresh thread: forget the previous one's height, or the
// delta above would be computed against a channel that is no longer on screen.
useEffect(() => {
pinnedToBottom.current = true;
prevScrollHeight.current = 0;
}, [item.id]);
/**
* Fire `loadOlder` at the top, and remember whether we are pinned to the bottom.
*
* The threshold is generous (120px) so the fetch starts before the user actually hits the
* ceiling — a scroll that stops dead at the top while a request runs reads as the end of the
* history rather than as loading.
*/
const onScroll = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
pinnedToBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
if (el.scrollTop < 120) loadOlder();
}, [loadOlder]);
// Opening a chat puts the cursor straight in the composer — you're here to
// reply. Re-runs when the open item changes, since the view stays mounted
// while switching threads (an `autoFocus` attribute would only fire once).
const composerRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
composerRef.current?.focus();
}, [item.id]);
const refetchMessages = () => {
if (ref.kind === 'slack') void slackMessagesQ.refetch();
else if (ref.kind === 'whatsapp') void whatsappQ.refetch();
else void linkedinQ.refetch();
};
// ── actions ──
const sendDm = useMutation(trpc.linkedin.messaging.sendDm.mutationOptions());
const sendWhatsapp = useMutation(trpc.outbound.whatsapp.sendMessage.mutationOptions());
const sendSlack = useMutation(trpc.integrations.slack.sendMessage.mutationOptions());
const clearDraft = useMutation(trpc.inbox.clearChannelDraft.mutationOptions());
const saveDraft = useMutation(trpc.inbox.saveChannelDraft.mutationOptions());
/**
* The ONLY way a channel counterparty is ever enriched
* (design: apps/server/docs/channel-sync-architecture.md §3.2 step 13, Phase 12).
*
* Deliberately a button and not a background job. Resolving a headline like "VP Sales at
* Mintlify" into a company is an inference, and a wrong one writes a bad deal link into the CRM,
* so the server exposes it nowhere else — no sweep, no cron, and no path from the linking
* ladder. The rep looking at the chat is the authorisation. Linking benefits afterwards: the
* mutation re-runs the ladder for this person's chats before returning, so the conversation
* badge above can fill in on the same click.
*/
const enrich = useMutation(
trpc.linkedin.messaging.enrichCounterparty.mutationOptions({
onSuccess: (res: { companyId: string | null } | null) => {
if (!res) toast.info('No resolved counterpart to look up');
else if (!res.companyId) toast.info('No current employer found on their profile');
void conversationQ.refetch();
refetchMessages();
},
onError: (e: unknown) => toast.error((e as { message?: string })?.message ?? 'Lookup failed'),
}),
);
const sending = sendDm.isPending || sendWhatsapp.isPending || sendSlack.isPending;
const persistDraft = () => {
if (!draft.trim() || (ref.kind !== 'linkedin' && ref.kind !== 'whatsapp')) return;
saveDraft.mutate({ channel: ref.kind, chatId: ref.chatId, body: draft });
};
/**
* Show the message the instant it is sent, and return the undo.
*
* Held as `LooseMessage`s and concatenated BEFORE the mapping below, so an optimistic message
* flows through the identical code path as a real one — same sort, same day divider, same
* "You". Building a `ThreadMessage` directly would be the shorter route and is exactly how a
* sent message ends up looking subtly different from the same message one refetch later.
*
* Not the query cache, which types each read to its own row shape: a partial row would have to
* be cast past 25 required columns, and the cast is the bug — it hides that the object is not
* really one of those rows.
*
* Slack is deliberately excluded: its rows are keyed by `crm_events.id`, which reactions write
* against, and inventing one would create a message whose reaction toggle addresses nothing.
*/
const appendOptimisticMessage = useCallback(
(text: string): (() => void) => {
if (ref.kind !== 'linkedin' && ref.kind !== 'whatsapp') return () => {};
const now = new Date();
// Distinct enough to never collide with a provider id, and recognisable in a stack trace.
const id = `pending:${now.getTime()}`;
setPending((prev) => [
...prev,
{ id, providerMessageId: id, text, direction: 'outbound', fromSelf: true, occurredAt: now },
]);
return () => setPending((prev) => prev.filter((m) => m.id !== id));
},
[ref.kind],
);
const handleSend = async () => {
const text = draft.trim();
if (!text) return;
// Paint it before the provider has heard of it. A send is a round-trip to Unipile and back
// through our own ingest, and waiting for that made a sent message appear a second later —
// long enough to read as dropped. `rollbackOptimistic` puts the thread back if the send
// fails, so the only state the user ever sees is "sent" or "not sent", never a phantom.
const rollbackOptimistic = appendOptimisticMessage(text);
setDraft('');
try {
if (ref.kind === 'linkedin') {
await sendDm.mutateAsync({
unipileAccountId: ref.unipileAccountId,
chatId: ref.chatId,
text,
});
clearDraft.mutate({ channel: 'linkedin', chatId: ref.chatId });
} else if (ref.kind === 'whatsapp') {
await sendWhatsapp.mutateAsync({
unipileAccountId: ref.unipileAccountId,
chatId: ref.chatId,
phoneE164: ref.phoneE164 ?? null,
text,
});
clearDraft.mutate({ channel: 'whatsapp', chatId: ref.chatId });
} else if (ref.kind === 'slack') {
await sendSlack.mutateAsync({
workspaceId: ref.workspaceId,
channelId: ref.slackChannelId,
message: text,
});
}
refetchMessages();
} catch {
rollbackOptimistic();
setDraft(text);
toast.error('Failed to send');
}
};
const markDone = () => {
markItemDone(item);
onClose();
};
// Same shape as Mark done — the chat leaves the inbox until the chosen time (or until
// the counterpart replies, which un-hides it server-side), so there is nothing left here
// to look at. Snooze IS the reminder for a chat: `inbox.setItemState` is what the row's
// clock writes too, so both entry points mean one thing.
const remindAt = (date: Date) => {
void snoozeItem(item, date);
onClose();
};
const toggleStar = () => toggleItemStar(item);
// ── header identities ──
// Second row (the "subject" slot): who/what the chat is with.
const subjectLine =
ref.kind === 'slack'
? item.counterpart?.subtitle || item.counterpart?.name || 'Slack channel'
: item.counterpart?.name || 'Chat';
// A muted headline under the subject — LinkedIn member title, if any.
const subHeadline = ref.kind === 'linkedin' ? item.counterpart?.subtitle : undefined;
const composerName = ref.kind === 'slack' ? subjectLine : item.counterpart?.name || 'them';
const openConversationContext = useCedarStore((s) => s.openConversationContext);
// ── agent context ──
// Publish the open chat + the reply being composed into the Cedar store, so the chat
// agent sees a Slack/LinkedIn/WhatsApp chat the way it already sees an open email thread.
// Nothing else puts a channel chat in the store — the messages are held in this view's own
// queries — so without this the agent could not tell the chat was open at all.
const setOpenChannelThread = useCedarStore((s) => s.setOpenChannelThread);
const setChannelDraftBody = useCedarStore((s) => s.setChannelDraftBody);
// `ref.kind` is 'email' only in the union — the unibox routes email rows to ThreadDisplay
// and never mounts this view for one — so it publishes nothing rather than being coerced.
const channelKind = ref.kind === 'email' ? null : ref.kind;
const containerId = ref.kind === 'slack' ? ref.slackChannelId : ref.kind === 'email' ? '' : ref.chatId;
useEffect(() => {
if (!channelKind) return;
setOpenChannelThread({
channel: channelKind,
id: containerId,
label: channelKind === 'slack' ? item.counterpart?.subtitle : item.counterpart?.name,
threadTs: openThreadTs ?? undefined,
conversationId: item.conversationId,
});
// Cleared on unmount, not on item change: this view stays mounted while you move between
// rows, and the effect above overwrites the pointer for the next one anyway.
return () => setOpenChannelThread(null);
}, [
channelKind,
containerId,
item.counterpart?.name,
item.counterpart?.subtitle,
item.conversationId,
openThreadTs,
setOpenChannelThread,
]);
useEffect(() => {
setChannelDraftBody(draft);
}, [draft, setChannelDraftBody]);
const showThreadPanel = ref.kind === 'slack' && !!openThreadTs;
// Every channel reacts, through the hook that matches its provider's model: Slack keys on the
// `crm_events` row and lets one person stack several emoji; LinkedIn and WhatsApp key on
// Unipile's message id and allow exactly one per person. Empty strings keep both hooks
// unconditional — neither issues a request until something is actually reacted to.
const { toggle: toggleSlackReaction, react: reactSlack } = useChannelReactions({
workspaceId: ref.kind === 'slack' ? ref.workspaceId : '',
channelId: ref.kind === 'slack' ? ref.slackChannelId : '',
});
const { toggle: toggleChatReaction, react: reactChat } = useChatReactions({
channel: ref.kind === 'whatsapp' ? 'whatsapp' : 'linkedin',
unipileAccountId: ref.kind === 'linkedin' || ref.kind === 'whatsapp' ? ref.unipileAccountId : '',
chatId: ref.kind === 'linkedin' || ref.kind === 'whatsapp' ? ref.chatId : '',
});
/**
* The reaction handlers for whichever channel is open, or none at all.
*
* Email has no reactions to give — it is a mailbox, not a chat — so its rows stay read-only,
* which is also what makes the toolbar absent rather than inert there.
*/
const reactionHandlers:
| {
onToggleReaction: (messageId: string, reaction: EventReaction) => void;
onReact: (messageId: string, emoji: string) => void;
}
| undefined =
ref.kind === 'slack'
? {
onToggleReaction: (messageId, reaction) =>
toggleSlackReaction(messageId, reaction.key, reaction.emojiUnicode),
onReact: reactSlack,
}
: ref.kind === 'linkedin' || ref.kind === 'whatsapp'
? {
onToggleReaction: (messageId, reaction) =>
toggleChatReaction(messageId, reaction.key, reaction.reactedByMe),
onReact: (messageId, emoji) => reactChat(messageId, emoji),
}
: undefined;
/**
* WhatsApp is consent-gated per chat, so `chatMessages` answers `{ gated: true }` — an
* empty `messages` array — for a chat nobody has allowed. Rendered through the generic
* empty state that becomes "No messages yet.", which reads as a broken sync rather than
* as a deliberate privacy default, and offers no way out of it.
*/
const whatsappGated =
ref.kind === 'whatsapp' &&
(whatsappQ.data as { gated?: boolean } | undefined)?.gated === true;
const trackWhatsapp = useMutation(
trpc.outbound.whatsapp.track.mutationOptions({
onSuccess: () => {
// Consent first, THEN history: `catchUp` reads nothing for a chat it has no
// consent for, so firing them together would leave the thread empty.
if (ref.kind !== 'whatsapp') return;
catchUp.mutate(
{ channel: 'whatsapp', containerKey=[redacted] },
{ onSettled: () => void whatsappQ.refetch() },
);
},
onError: (e: unknown) =>
toast.error((e as { message?: string })?.message ?? 'Could not allow this chat'),
}),
);
return (
// Two columns: the channel, and the thread panel when one is open. The OUTER
// element is the @container the panel's breakpoint reads (there is not enough
// room for both below @3xl); the channel column is its own @container so its
// internal `@max-3xl` rules keep measuring the channel, not the pair.
<div className="@container flex h-full w-full min-w-0">
<div
className={cn(
'@container bg-background relative flex h-full min-w-0 flex-1 flex-col',
'[scrollbar-color:rgba(156,163,175,0.8)_transparent] dark:[scrollbar-color:rgba(255,255,255,0.2)_transparent]',
showThreadPanel && '@max-3xl:hidden',
)}
>
{/* Left back-gutter — an overlay on the WHOLE panel (not inside the scroll
region), so it's always full height AND never adds phantom scroll space
when the messages are short. Green gradient on hover, ArrowLeft. */}
<button
type="button"
onClick={onClose}
onMouseEnter={() => setGutterHovered(true)}
onMouseLeave={() => setGutterHovered(false)}
aria-label="Back"
className="absolute left-0 top-0 z-10 h-full w-[max(2rem,calc((100%-75ch)/2-1rem))] cursor-pointer @max-3xl:hidden"
>
<div
className={cn(
'absolute inset-0 bg-gradient-to-r from-[#15803d]/[0.10] to-transparent transition-opacity duration-300 dark:from-[#15803d]/[0.14]',
gutterHovered ? 'opacity-100' : 'opacity-0',
)}
/>
<ArrowLeft
className={cn(
'absolute left-4 top-3 h-4 w-4 transition-opacity duration-200',
gutterHovered ? 'opacity-80' : 'opacity-40',
)}
/>
</button>
{/* Scroll region */}
<div ref={scrollRef} onScroll={onScroll} className="relative flex-1 overflow-y-auto">
<div className="min-h-full">
<div className="mx-auto flex w-full max-w-[75ch] flex-col">
{/* Sticky header — top row: linked conversation + actions;
second row: the "subject" (counterpart / #channel). */}
<div className="bg-background sticky top-0 z-20">
<div className="flex items-center px-4 py-2">
<div className="flex flex-1 items-center gap-1.5 pl-[2.25rem] @max-3xl:pl-0">
<button
type="button"
onClick={onClose}
aria-label="Back"
className="text-muted-foreground hover:bg-muted hover:text-foreground hidden h-6 w-6 shrink-0 cursor-pointer items-center justify-center rounded transition-colors @max-3xl:flex"
>
<ArrowLeft className="h-4 w-4" />
</button>
{item.conversationId ? (
// Identical to the email thread's ConversationBadge: bg-sunken
// pill, company avatar + label, click opens the conversation.
<FieldBadge
onClick={() => openConversationContext(item.conversationId!)}
title="Open conversation"
colorClass="text-foreground"
className="flex h-7 cursor-pointer items-center gap-1.5 px-2.5 text-sm font-normal hover:brightness-95"
>
<ConversationCompanyAvatar
conversationId={item.conversationId}
fallback={conversationLabel}
className="size-4 shrink-0"
/>
<span className="max-w-[16rem] truncate">{conversationLabel}</span>
</FieldBadge>
) : (
<AttachToConversation
item={item}
onAttached={() => void conversationQ.refetch()}
trigger={
<button
type="button"
className="bg-sunken border-border text-muted-foreground flex h-7 cursor-pointer items-center gap-1.5 rounded-full border border-dashed px-2.5 text-sm hover:brightness-95"
>
<MessageSquare className="h-3.5 w-3.5 shrink-0" />
<span>No conversation linked</span>
</button>
}
/>
)}
</div>
<div className="flex items-center gap-2">
{item.conversationId && (
<AttachToConversation
item={item}
onAttached={() => void conversationQ.refetch()}
/>
)}
{ref.kind === 'linkedin' && (
<ActionButton
label={enrich.isPending ? 'Looking up…' : 'Find company'}
onClick={() => enrich.mutate({ chatId: ref.chatId })}
>
<Building2 className="fill-transparent stroke-[#9D9D9D] h-4 w-4" />
</ActionButton>
)}
<ActionButton label={item.starred ? 'Unstar' : 'Star'} onClick={toggleStar}>
<Star
className={cn(
'ml-[2px] mt-[2.4px] h-5 w-5',
item.starred
? 'fill-yellow-400 stroke-yellow-400'
: 'fill-transparent stroke-[#9D9D9D] dark:stroke-[#9D9D9D]',
)}
/>
</ActionButton>
<ActionButton label="Remind me" onClick={() => setRemindOpen(true)}>
<Clock className="fill-iconLight dark:fill-iconDark h-3.5 w-3.5" />
</ActionButton>
<ActionButton label="Mark done" onClick={markDone}>
<Archive className="fill-iconLight dark:fill-iconDark h-4 w-4" />
</ActionButton>
</div>
</div>
{/* Subject line — the counterpart / channel, in the email-subject slot. */}
<div className="px-4 pb-2">
<div className="flex items-center gap-1.5 pl-[2.25rem] @max-3xl:pl-0">
<h1 className="truncate text-lg font-semibold">{subjectLine}</h1>
<ChannelBadgeIcon channel={item.channel} className="h-4 w-4 shrink-0" />
</div>
{subHeadline && (
<p className="text-muted-foreground truncate pl-[2.25rem] text-sm @max-3xl:pl-0">
{subHeadline}
</p>
)}
</div>
</div>
{/* Messages — avatar + sender + time, grouped Slack-style, with a
"N replies" button under any root that has a thread. */}
<div className="px-4 pb-6 pt-2">
{messagesLoading ? (
<div className="flex items-center justify-center py-10">
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin" />
</div>
) : whatsappGated && ref.kind === 'whatsapp' ? (
<div className="flex flex-col items-center justify-center gap-3 py-16 text-center">
<Lock className="text-muted-foreground h-6 w-6" />
<div className="max-w-[42ch]">
<p className="text-foreground text-sm font-medium">
This chat isn't synced
</p>
<p className="text-muted-foreground mt-1 text-sm">
WhatsApp is a personal line, so Cedar reads a chat only once you allow
it. Nothing from this conversation has been stored.
</p>
</div>
<button
type="button"
disabled={trackWhatsapp.isPending || catchUp.isPending}
onClick={() =>
trackWhatsapp.mutate({
unipileAccountId: ref.unipileAccountId,
chatId: ref.chatId,
phoneE164: ref.phoneE164 ?? null,
name: item.counterpart?.name || undefined,
})
}
className="bg-foreground text-background inline-flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 text-sm font-medium transition-opacity hover:opacity-90 disabled:opacity-50"
>
{(trackWhatsapp.isPending || catchUp.isPending) && (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
)}
Allow this chat
</button>
<p className="text-muted-foreground text-xs">
To allow every chat on this line, use Settings → Connections.
</p>
</div>
) : shownMessages.length === 0 ? (
<div className="text-muted-foreground py-10 text-center text-sm">
No messages yet.
</div>
) : (
<>
{/* The top of the thread says which it is — still loading, or genuinely the
beginning. Silence at the top of a scroll region is indistinguishable from
a broken fetch. */}
{loadingOlder ? (
<div className="flex items-center justify-center py-3">
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
</div>
) : historyExhausted ? (
<div className="text-muted-foreground py-3 text-center text-xs">
Beginning of {ref.kind === 'slack' ? subjectLine : 'this conversation'}
</div>
) : null}
<ChannelMessageList
messages={shownMessages}
onOpenThread={setOpenThreadTs}
resolveReplier={resolveReplierFace}
{...(reactionHandlers ?? {})}
/>
</>
)}
</div>
</div>
</div>
</div>
{/* Reply composer — IN DOCUMENT FLOW (shrink-0), so it occupies space and
you can't scroll past it. Same 75ch column, mirrors the email composer
but simpler (no "To"). */}
<div className="shrink-0 pb-4">
{/* px-2 (vs the messages' px-4) so the input sits 2 units wider than the bubbles. */}
<div className="mx-auto w-full max-w-[75ch] px-2">
<div className="border-border bg-raised flex flex-col gap-2 rounded-2xl border px-3 py-2 shadow-lg">
{/* Row 1 — the message content */}
{/* Slack gets an `@` mention menu over the workspace's members; the
other channels pass no workspace and this is a plain textarea. */}
<SlackMentionTextarea
ref={composerRef}
value={draft}
onValueChange={setDraft}
workspaceId={ref.kind === 'slack' ? ref.workspaceId : null}
channelId={ref.kind === 'slack' ? ref.slackChannelId : null}
onBlur={persistDraft}
onSubmit={() => void handleSend()}
rows={1}
autoGrow
placeholder={`Message ${composerName}…`}
/* One row at rest, growing to half the viewport before it scrolls — a long
message stays readable in full while you write it, and the thread above
still keeps the other half. */
className="max-h-[50vh] min-h-[2.25rem] w-full resize-none bg-transparent px-1 pt-1 text-sm outline-none"
/>
{/* Row 2 — secondary actions on the left, send on the right */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-1" />
<SendButton onSend={() => void handleSend()} disabled={!draft.trim() || sending} />
</div>
</div>
</div>
</div>
</div>
{/* Thread panel — a sibling column, not an overlay, so the channel stays
readable beside the thread the way it does in Slack. Narrow enough and there
is no room for two columns, so the panel takes the whole width and the
channel hides (see `@max-3xl:hidden` on the column above) rather than both
being squeezed to unreadable. */}
{showThreadPanel && (
<div className="w-full shrink-0 @3xl:w-[26rem]">
<SlackThreadPanel
workspaceId={ref.workspaceId}
channelId={ref.slackChannelId}
threadTs={openThreadTs!}
channelLabel={subjectLine}
onClose={() => setOpenThreadTs(null)}
// A reply changes the root's reply count and the channel's envelope.
onReplySent={() => void slackMessagesQ.refetch()}
/>
</div>
)}
{/* The same picker the email thread uses, so "remind me" reads identically on
either side of the unibox. */}
<RemindDialog open={remindOpen} onOpenChange={setRemindOpen} onSelect={remindAt} />
</div>
);
}