import React from "react"; import { useEditor, EditorContent } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; import TiptapUnderline from "@tiptap/extension-underline"; import TiptapLink from "@tiptap/extension-link"; import { Archive, ArrowLeft, Bold, Bot, CheckCircle2, ChevronDown, ChevronRight, FileText, Flag, Italic, Link2, Lock, Mail, MessageSquare, MoreHorizontal, Paperclip, Send, Underline, UserCheck, X, } from "lucide-react"; import { cn } from "@/lib/utils"; import { Button, buttonVariants } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { Separator } from "@/components/ui/separator"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { ChatInputArea } from "@/components/ui/chat-input-area"; import type { AiConvAttachment, AiConvChannel, AiConvContact, AiConvMessage, AiConvMode, AiConvStatus, } from "./types"; import { ContactAvatar, displayContactName, PANEL_HEADER_HEIGHT, } from "./helpers"; import { ConversationStatusChip } from "./list"; import { BubbleAvatar, ChatBubble } from "./bubble"; // --------------------------------------------------------------------------- // ChatComposer // --------------------------------------------------------------------------- export interface AiConvEmailPayload { content: string; to: string; cc: string; subject: string; /** `true` when composing a reply; `false` when composing a new email. */ isReply: boolean; /** Files attached before sending. */ attachments?: AiConvAttachment[]; } /** Formats a byte count as a human-readable size, e.g. 254000 -> "248 KB". */ function formatFileSize(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } let attachmentIdCounter = 0; /** Converts a browser FileList into staged AiConvAttachment entries (no upload — url stays unset). */ function filesToAttachments(files: FileList): AiConvAttachment[] { return Array.from(files).map((file) => ({ id: `pending-attachment-${++attachmentIdCounter}`, name: file.name, size: formatFileSize(file.size), })); } export interface ChatComposerProps { mode: AiConvMode; /** Active reply channel. Defaults to "chat". */ channel?: AiConvChannel; onChannelChange?: (channel: AiConvChannel) => void; /** * When true, the Email tab is shown in the composer. Defaults to false — * consumers must opt in once their tenant's email integration is wired up. */ isEmailIntegrated?: boolean; /** * Locks the composer to the email channel: the Chat/Email toggle is hidden * and the chat panel never renders. 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. Defaults to false. */ emailOnly?: boolean; /** Lead's email address — pre-fills the To field in email compose. */ contactEmail?: string; 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. */ onAttachFile?: (files: FileList) => void; /** Called when the user selects images via the image upload button. */ onAttachImage?: (files: FileList) => void; /** Pre-fills the Subject field with "Re: [emailReplySubject]" for email threads. */ emailReplySubject?: string; /** * Hides the "Reply / New email" toggle that otherwise shows whenever * `emailReplySubject` is set. For contexts with a dedicated "start a new * email" entry point elsewhere (e.g. the Kanban slide-out's Email & Notes * tab), so there's only one way to start a new thread. Defaults to false. */ hideEmailModeToggle?: boolean; className?: string; } function ComposerToolbarButton({ label, icon: Icon, pressed, onToggle, }: { label: string; icon: React.ElementType; pressed?: boolean; onToggle?: () => void; }) { return ( ); } function ComposerLinkPopover({ editor, }: { editor: ReturnType; }) { const [open, setOpen] = React.useState(false); const [url, setUrl] = React.useState(""); const handleApply = () => { if (url.trim()) { editor?.chain().focus().setLink({ href: url.trim() }).run(); } setOpen(false); setUrl(""); }; return ( { if (newOpen && editor?.isActive("link")) { editor.chain().focus().unsetLink().run(); return; } if (newOpen) setUrl(""); setOpen(newOpen); }} >
setUrl(e.target.value)} onKeyDown={(e) => e.key === "Enter" && handleApply()} placeholder="https://" className="min-w-0 flex-1 border border-border bg-transparent px-2 py-1.5 text-body-small text-foreground outline-none placeholder:text-muted-foreground focus:border-primary" autoFocus />
); } function ComposerEmailFieldRow({ label, children, }: { label: string; children: React.ReactNode; }) { return (
{label} {children}
); } export function ChatComposer({ mode, channel: channelProp = "chat", onChannelChange, isEmailIntegrated = false, emailOnly = false, contactEmail = "", inputValue = "", onInputChange, onSend, onSendEmail, onTakeOver, onLetAiHandle, onAttachFile, onAttachImage, emailReplySubject, hideEmailModeToggle = false, className, }: ChatComposerProps) { // Semi-controlled: owns channel state for uncontrolled usage, notifies parent on change. // Force chat when email isn't integrated so the panel never lands on a hidden tab. const initialChannelRef = React.useRef(emailOnly ? "email" : channelProp); const [channel, setChannel] = React.useState( emailOnly ? "email" : isEmailIntegrated ? channelProp : "chat", ); const [emailTo, setEmailTo] = React.useState(contactEmail); const [emailCc, setEmailCc] = React.useState(""); const [showCc, setShowCc] = React.useState(false); const [emailSubject, setEmailSubject] = React.useState( emailReplySubject ? `Re: ${emailReplySubject}` : "", ); const [emailMode, setEmailMode] = React.useState<"reply" | "new">( emailReplySubject ? "reply" : "new", ); const [emailAttachments, setEmailAttachments] = React.useState< AiConvAttachment[] >([]); const emailFileInputRef = React.useRef(null); const handleEmailFileChange = (e: React.ChangeEvent) => { if (e.target.files?.length) { setEmailAttachments((prev) => [ ...prev, ...filesToAttachments(e.target.files as FileList), ]); e.target.value = ""; } }; const [, forceUpdate] = React.useReducer((x: number) => x + 1, 0); const editor = useEditor({ extensions: [ StarterKit, TiptapUnderline, TiptapLink.configure({ openOnClick: false }), ], content: "", onTransaction: () => forceUpdate(), editorProps: { attributes: { class: "min-h-[150px] px-4 py-3 text-body-medium text-foreground outline-none prose prose-sm max-w-none [&_p]:m-0 [&_a]:text-primary [&_a]:underline [&_a]:cursor-pointer", }, }, }); const handleChannelChange = (c: AiConvChannel) => { setChannel(c); onChannelChange?.(c); }; const handleNewEmail = () => { setEmailSubject(""); setEmailCc(""); setShowCc(false); setEmailAttachments([]); editor?.commands.clearContent(); }; const handleEmailModeChange = (mode: "reply" | "new") => { setEmailMode(mode); if (mode === "new") { handleNewEmail(); } else { setEmailSubject(emailReplySubject ? `Re: ${emailReplySubject}` : ""); } }; return (
{isEmailIntegrated && !emailOnly && (
v && handleChannelChange(v as AiConvChannel)} > Chat Email
)} {mode === "ai" ? (
AI is handling this conversation. to reply directly.
) : ( /* Email panel stays in normal flow to anchor container height; chat panel is an absolute overlay so both tabs share identical dimensions */
setEmailTo(e.target.value)} placeholder="Recipient email" className="min-w-0 flex-1 bg-transparent text-body-medium text-foreground outline-none placeholder:text-muted-foreground" /> {showCc && ( setEmailCc(e.target.value)} placeholder="CC email" className="min-w-0 flex-1 bg-transparent text-body-medium text-foreground outline-none placeholder:text-muted-foreground" /> )} {emailMode !== "reply" && ( setEmailSubject(e.target.value)} placeholder="Email subject" className="min-w-0 flex-1 bg-transparent text-body-medium text-foreground outline-none placeholder:text-muted-foreground" /> )} {emailAttachments.length > 0 && (
{emailAttachments.map((attachment) => (
{attachment.name} {attachment.size}
))}
)}
{emailReplySubject && !hideEmailModeToggle && ( <> { const v = values[0] as "reply" | "new" | undefined; if (v) handleEmailModeChange(v); }} className="mr-1.5" > Reply New email )} editor?.chain().focus().toggleBold().run()} /> editor?.chain().focus().toggleItalic().run()} /> editor?.chain().focus().toggleUnderline().run() } /> 0 ? `Attach file — ${emailAttachments.length} attached` : "Attach file" } icon={Paperclip} pressed={emailAttachments.length > 0} onToggle={() => emailFileInputRef.current?.click()} />
{/* Chat compose — absolute overlay, fills exact same height as email panel */} {channel === "chat" && (
onInputChange?.(v)} onSend={(text) => onSend?.(text)} onAttachFile={onAttachFile} onAttachImage={onAttachImage} placeholder="Reply to lead…" hint={false} /> {initialChannelRef.current !== "email" && ( )}
)}
)}
); } // --------------------------------------------------------------------------- // 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 (
{/* Header */}
{onBack && ( )}
{displayContactName(contact.name)} {!hideStatusChip && ( )}
{contact.email && (

{contact.email}

)}
{isClosed && onReopen && ( )} {!isClosed && aiIsHandling && ( )} {!isClosed && !aiIsHandling && channel !== "email" && ( )}
{hasMenuActions && ( {onShowLeadInfo && ( <> Lead Info )} {status === "needs-attention" ? onUnmarkUrgent && ( Unmark Urgent ) : onMarkUrgent && ( Mark as Urgent )} {onAssignToAdvisor && ( Assign to advisor )} {!isClosed && onClose && ( Mark as Closed )} {onArchive && ( <> Archive )} )}
{/* Messages */}
{isLoadingMoreMessages && (
Loading older messages...
)} {messages.length === 0 ? (

No messages yet

) : ( messages.map((msg) => ( )) )} {isAiTyping && !isClosed && (
AI Assistant
)}
{/* Composer / locked banner */} {isClosed ? (
This conversation is closed.
) : ( )}
); }