);
}
// ---------------------------------------------------------------------------
// ChatThread
// ---------------------------------------------------------------------------
export interface ChatThreadProps {
contact: AiConvContact;
status: AiConvStatus;
/** Hides the status chip next to the contact name — for threads with no real status yet (e.g. composing a brand-new email). */
hideStatusChip?: boolean;
mode: AiConvMode;
messages: AiConvMessage[];
isAiTyping?: boolean;
/** Active reply channel — "chat" (default) or "email". */
channel?: AiConvChannel;
onChannelChange?: (channel: AiConvChannel) => void;
/** When true, the Email tab is shown in the composer. Defaults to false. */
isEmailIntegrated?: boolean;
/**
* Locks the thread to the email channel: the composer's Chat/Email toggle
* is hidden and AI hand-off actions never show. For embedded email-only
* contexts (e.g. the Kanban slide-out's Email & Notes tab). Implies email
* is integrated; `channel` / `isEmailIntegrated` are ignored while set.
*/
emailOnly?: boolean;
inputValue?: string;
onInputChange?: (v: string) => void;
/** Fired when the user sends a chat message. */
onSend?: (content: string) => void;
/** Fired when the user sends an email. */
onSendEmail?: (payload: AiConvEmailPayload) => void;
onTakeOver?: () => void;
onLetAiHandle?: () => void;
/** Called when the user selects files via the attachment button in the composer. */
onAttachFile?: (files: FileList) => void;
/** Called when the user selects images via the image upload button in the composer. */
onAttachImage?: (files: FileList) => void;
/** Pre-fills the email Subject field with "Re: [emailReplySubject]" for reply threads. */
emailReplySubject?: string;
/** Hides the composer's "Reply / New email" toggle. See `ChatComposerProps`. Defaults to false. */
hideEmailModeToggle?: boolean;
onReopen?: () => void;
/** Marks an open conversation as resolved. Shows a "Mark as Closed" menu item when set. */
onClose?: () => void;
onMarkUrgent?: () => void;
onUnmarkUrgent?: () => void;
onArchive?: () => void;
onAssignToAdvisor?: () => void;
/** True when older messages can be loaded (e.g. paginated history). */
hasMoreMessages?: boolean;
/** True while a `onLoadMoreMessages` request is in-flight. */
isLoadingMoreMessages?: boolean;
/** Fired when the consumer should fetch older messages. */
onLoadMoreMessages?: () => void;
/** Back to conversation list. Renders on mobile only unless `showBackButton` is set. */
onBack?: () => void;
/**
* Show the back button at every breakpoint (default: mobile only). For
* embedded drill-in layouts where the list and thread swap in place.
*/
showBackButton?: boolean;
/** Mobile only — show lead info panel. */
onShowLeadInfo?: () => void;
className?: string;
}
export function ChatThread({
contact,
status,
hideStatusChip = false,
mode,
messages,
isAiTyping = false,
channel: channelProp,
onChannelChange,
isEmailIntegrated,
emailOnly = false,
inputValue,
onInputChange,
onSend,
onSendEmail,
onTakeOver,
onLetAiHandle,
onAttachFile,
onAttachImage,
emailReplySubject,
hideEmailModeToggle,
onReopen,
onClose,
onMarkUrgent,
onUnmarkUrgent,
onArchive,
onAssignToAdvisor,
hasMoreMessages,
isLoadingMoreMessages,
onLoadMoreMessages,
onBack,
showBackButton = false,
onShowLeadInfo,
className,
}: ChatThreadProps) {
const channel = emailOnly ? "email" : channelProp;
const aiIsHandling = mode === "ai";
const isClosed = status === "closed";
const hasUrgentAction =
status === "needs-attention"
? Boolean(onUnmarkUrgent)
: Boolean(onMarkUrgent);
const hasMenuActions =
Boolean(onShowLeadInfo) ||
hasUrgentAction ||
Boolean(onAssignToAdvisor) ||
(!isClosed && Boolean(onClose)) ||
Boolean(onArchive);
const scrollRef = React.useRef(null);
// Captures scrollHeight just before older messages are prepended, so we can
// restore the user's visible scroll offset once the new nodes render.
const preLoadScrollHeightRef = React.useRef(null);
const handleScroll = (e: React.UIEvent) => {
if (!hasMoreMessages || isLoadingMoreMessages || !onLoadMoreMessages) {
return;
}
if (e.currentTarget.scrollTop <= 80) {
preLoadScrollHeightRef.current = e.currentTarget.scrollHeight;
onLoadMoreMessages();
}
};
// Tracks the last "tail" message id so we can tell an append (new message,
// tail changed) apart from a prepend (older history loaded, tail unchanged).
const prevLastMessageIdRef = React.useRef(undefined);
const prevContactIdRef = React.useRef(contact.id);
React.useLayoutEffect(() => {
const el = scrollRef.current;
if (!el) return;
// Prepend (older messages just loaded) — restore scroll so the user
// stays anchored to the message they were reading.
if (preLoadScrollHeightRef.current !== null) {
el.scrollTop = el.scrollHeight - preLoadScrollHeightRef.current;
preLoadScrollHeightRef.current = null;
prevLastMessageIdRef.current = messages[messages.length - 1]?.id;
prevContactIdRef.current = contact.id;
return;
}
const currentLastId = messages[messages.length - 1]?.id;
const contactChanged = prevContactIdRef.current !== contact.id;
const tailChanged = prevLastMessageIdRef.current !== currentLastId;
// Opening a conversation or appending a new message (sent, received,
// or system) — pin to the bottom.
if (contactChanged || tailChanged) {
el.scrollTop = el.scrollHeight;
}
prevLastMessageIdRef.current = currentLastId;
prevContactIdRef.current = contact.id;
}, [contact.id, messages]);
// Typing indicator adds DOM height — keep the view pinned to bottom.
React.useLayoutEffect(() => {
if (!isAiTyping) return;
const el = scrollRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [isAiTyping]);
return (