import * as React from "react"; import ReactMarkdown from "react-markdown"; import rehypeRaw from "rehype-raw"; import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; import { Bot, ChevronLeft, ChevronRight, SquarePen, X } from "lucide-react"; import { cn } from "@/lib/utils"; import { Sheet, SheetContent } from "@/components/ui/sheet"; import { Button } from "@/components/ui/button"; import { ChatInputArea } from "@/components/ui/chat-input-area"; import { Spinner } from "@/components/ui/spinner"; import { SupportContextChip, SupportSuggestedQuestion, SupportStepGuideCard, SupportArticleCard, } from "./support-agent-primitives"; import type { SupportAgentContext, SupportAgentRichContent, } from "./support-agent-primitives"; /** * SupportAgentPanel — WealthX DS (Organism) * * Right-side slide-over panel providing system guidance to backoffice users. * Answers questions about how to use WealthX, optionally grounded in the * current page and entity context (Rovo pattern). * * Follows Jira Rovo's sidebar chat pattern exactly: * — Non-blocking: no overlay, main UI remains interactive behind the panel * — Two header modes: home (bot icon) vs chat (back arrow + conversation title) * — Context chip: optional page+entity label below header (Rovo: context awareness) * — Home state: New Chat CTA button + Recents list + Suggested questions * — Chat state: message thread with optional rich content cards * — Rich messages: AI responses can embed StepGuideCard or ArticleCard * * Pure display component — all state and API calls are managed by the consumer. * Only local state: textarea value (input) and textarea height (auto-resize). * * Layout — Home state: * Header — [Bot icon] Support Assistant [✕] * Content — [✏ New chat] * RECENTS * > Previous conversation title * View all → * SUGGESTED * > Suggested question card * Footer — textarea + send * * Layout — Chat state: * Header — [←] Conversation title [✕] * Content — chat thread (bubbles + optional rich cards) * Footer — textarea + send */ // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export interface SupportAgentMessage { id: string; role: "user" | "assistant"; /** Plain text content always shown. */ content: string; /** * Optional rich content rendered below the text bubble. * Supported: step-guide | article */ richContent?: SupportAgentRichContent; /** True while the assistant response is streaming in. */ isStreaming?: boolean; /** * Status text shown beside the animated dots while the response streams * (e.g. "Checking the help center"). Rendered only while the bubble is empty * and streaming. The trailing "…" animates on its own — do not include dots here. */ streamingLabel?: string; /** True if the message errored. */ isErrored?: boolean; } /** A recent conversation entry shown in the home-state Recents list. */ export interface SupportAgentRecentConversation { id: string; /** Short title (first user message or AI-generated summary). */ title: string; } export interface SupportAgentPanelProps { open: boolean; onClose: () => void; /** Chat message history. Empty array = show home state. */ messages?: SupportAgentMessage[]; /** * Recent conversations shown in the home state Recents list (Rovo pattern). * Clicking an entry fires onOpenConversation(id). */ recentConversations?: SupportAgentRecentConversation[]; /** * Pre-set help questions shown in the home state (Rovo: conversation starters). * Shown below Recents when provided. */ suggestedQuestions?: string[]; /** * Title shown in the chat-mode header (replaces bot icon row). * Typically the first user message or an AI-generated session title. * Required for the back-arrow chat header to appear. */ conversationTitle?: string; /** * Current backoffice page context — page name and optional entity being viewed. * When provided, renders a SupportContextChip below the panel header so the * AI can ground responses to the user's current location (Rovo pattern). * * Example: { pageLabel: "Loan Applications", entityLabel: "John Smith — #4521" } */ context?: SupportAgentContext; /** True while the assistant is generating a response. */ isStreaming?: boolean; /** True while initial data is loading — shows full-panel spinner. */ isLoading?: boolean; /** Called when the user submits a message. Input is cleared after firing. */ onSendMessage?: (text: string) => 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; /** Called when the user clicks "New chat" — resets to home state. */ onNewChat?: () => void; /** * Called when the user clicks ← in the chat header to return to the home state. * If not provided, no back button is shown. */ onBack?: () => void; /** Called when the user clicks a recent conversation entry. */ onOpenConversation?: (id: string) => void; /** Called when the user clicks "View all conversations". */ onViewAllConversations?: () => void; /** * Pagination for the all-conversations list. When true, a "Load more" control is * shown at the bottom of the all-conversations view; clicking it fires * {@link onLoadMoreConversations}. The consumer appends the next page to * `recentConversations`. */ hasMoreConversations?: boolean; /** Called when the user requests the next page in the all-conversations view. */ onLoadMoreConversations?: () => void; /** True while the next page is loading — shows a spinner on the "Load more" control. */ isLoadingMoreConversations?: boolean; /** True when older messages can be loaded for the active conversation. */ hasMoreMessages?: boolean; /** True while an `onLoadMoreMessages` request is in flight. */ isLoadingMoreMessages?: boolean; /** Fired when the user scrolls near the top of the chat — fetch older messages. */ onLoadMoreMessages?: () => void; className?: string; } // --------------------------------------------------------------------------- // TypingIndicator — three bouncing dots (same pattern as AiAssistantDrawer) // --------------------------------------------------------------------------- function SupportTypingIndicator() { return ( {[0, 150, 300].map((delay) => ( ); } // --------------------------------------------------------------------------- // StreamingStatus — optional status label with an animated "…" ellipsis // (e.g. "Checking the help center" + animated dots), shown while streaming. // --------------------------------------------------------------------------- function StreamingStatus({ label }: { label?: string }) { // No label → the standard DS typing indicator (3 bouncing dots). if (!label) return ; // With label → status text + the same DS typing dots: "Checking the help center • • •" return ( {label} ); } // --------------------------------------------------------------------------- // MessageBubble — renders a single message with optional rich content // --------------------------------------------------------------------------- interface MessageBubbleProps { message: SupportAgentMessage; } function MessageBubble({ message }: MessageBubbleProps) { const isUser = message.role === "user"; const isEmpty = !message.content.trim(); return (
{/* Text bubble */}
{isEmpty && message.isStreaming ? ( ) : isUser ? ( {message.content} ) : ( )} {message.isErrored && (

Failed to send. Please try again.

)}
{/* Rich content — only on assistant messages */} {!isUser && message.richContent && (
)}
); } // --------------------------------------------------------------------------- // RichContentRenderer — picks the right card for richContent.type // --------------------------------------------------------------------------- interface RichContentRendererProps { richContent: SupportAgentRichContent; } function RichContentRenderer({ richContent }: RichContentRendererProps) { if (richContent.type === "step-guide") { return ( ); } if (richContent.type === "article") { return ( ); } return null; } // --------------------------------------------------------------------------- // Local sub-components // --------------------------------------------------------------------------- /** Reusable close button — consistent across all panel header variants. */ function CloseButton({ onClick }: { onClick: () => void }) { return ( ); } /** Single conversation row — used in both home recents and all-conversations list. */ function ConversationRow({ conv, onClick, className, }: { conv: SupportAgentRecentConversation; onClick: () => void; className?: string; }) { return ( ); } // --------------------------------------------------------------------------- // SupportAgentPanel // --------------------------------------------------------------------------- export function SupportAgentPanel({ open, onClose, messages = [], recentConversations, suggestedQuestions = [], conversationTitle, context, isStreaming = false, isLoading = false, onSendMessage, onAttachFile, onAttachImage, onNewChat, onBack, onOpenConversation, onViewAllConversations, hasMoreConversations = false, onLoadMoreConversations, isLoadingMoreConversations = false, hasMoreMessages, isLoadingMoreMessages, onLoadMoreMessages, className, }: SupportAgentPanelProps) { const [inputValue, setInputValue] = React.useState(""); const [showAllConversations, setShowAllConversations] = React.useState(false); const scrollRef = React.useRef(null); // Captures scrollHeight just before older messages prepend, to restore offset. const preLoadScrollHeightRef = React.useRef(null); const prevLastMessageIdRef = React.useRef(undefined); const hasMessages = messages.length > 0; // Chat header mode: has messages AND a conversation title const isChatMode = hasMessages && !!conversationTitle; // Reset "all conversations" view when panel closes React.useEffect(() => { if (!open) setShowAllConversations(false); }, [open]); // Scroll near the top of the chat thread → load older messages. const handleScroll = (e: React.UIEvent) => { if (!hasMoreMessages || isLoadingMoreMessages || !onLoadMoreMessages) return; if (e.currentTarget.scrollTop <= 80) { preLoadScrollHeightRef.current = e.currentTarget.scrollHeight; onLoadMoreMessages(); } }; // Append (new message / open) → pin to bottom. Prepend (older history just // loaded, tail unchanged) → restore offset so the reader stays anchored. React.useLayoutEffect(() => { const el = scrollRef.current; if (!el || !hasMessages) return; if (preLoadScrollHeightRef.current !== null) { el.scrollTop = el.scrollHeight - preLoadScrollHeightRef.current; preLoadScrollHeightRef.current = null; prevLastMessageIdRef.current = messages[messages.length - 1]?.id; return; } const currentLastId = messages[messages.length - 1]?.id; if (prevLastMessageIdRef.current !== currentLastId) { el.scrollTop = el.scrollHeight; } prevLastMessageIdRef.current = currentLastId; }, [messages, hasMessages]); // Typing indicator adds DOM height — keep the view pinned to bottom. React.useLayoutEffect(() => { if (!isStreaming) return; const el = scrollRef.current; if (el) el.scrollTop = el.scrollHeight; }, [isStreaming]); const handleSend = React.useCallback( (text: string) => { onSendMessage?.(text); setInputValue(""); }, [onSendMessage], ); const handleQuestionSelect = React.useCallback( (question: string) => { onSendMessage?.(question); }, [onSendMessage], ); const hasRecents = !!recentConversations?.length; // Home state shows only the 3 most recent; "View all" expands to the full list. const recentsPreview = React.useMemo( () => recentConversations?.slice(0, 3) ?? [], [recentConversations], ); const handleViewAll = React.useCallback(() => { setShowAllConversations(true); onViewAllConversations?.(); // optional analytics callback }, [onViewAllConversations]); return ( !o && onClose()}> {/* ── Header ── */}
{showAllConversations ? ( /* All-conversations mode: [←] All conversations [✕] */
All conversations
) : isChatMode ? ( /* Chat mode: [←] Conversation title [✕] */
{onBack && ( )} {conversationTitle}
) : ( /* Home mode: [Bot icon] Support Assistant [✕] */
Support Assistant
)} {/* Context chip — shown below header title in home and chat modes only */} {context && !showAllConversations && (
)}
{/* ── Content ── */}
{isLoading ? ( /* Loading state */

Loading…

) : showAllConversations ? ( /* All conversations — full scrollable list (Jira Rovo pattern) */
{recentConversations?.length ? ( <> {recentConversations.map((conv) => ( { setShowAllConversations(false); onOpenConversation?.(conv.id); }} className="border-b border-border px-4 py-3" /> ))} {hasMoreConversations && (
)} ) : (

No conversations yet.

)}
) : !hasMessages ? ( /* Home state — Rovo pattern: New Chat CTA + Recents + Suggested */
{/* New Chat — prominent CTA button */} {onNewChat && ( )} {/* Recents — 3 most recent conversations */} {hasRecents && (

Recents

{recentsPreview.map((conv) => ( onOpenConversation?.(conv.id)} className="px-1 py-2.5" /> ))}
)} {/* Suggested — conversation starters */} {suggestedQuestions.length > 0 && (

Suggested

{suggestedQuestions.map((q) => ( ))}
)} {/* Fallback — nothing to show */} {!onNewChat && !hasRecents && !suggestedQuestions.length && (

How can I help you today?

)}
) : ( /* Chat thread */
{isLoadingMoreMessages && (
)} {messages.map((msg) => ( ))} {/* Streaming indicator — shown when last message is from the user */} {isStreaming && messages[messages.length - 1]?.role === "user" && (
)}
)}
{/* ── Footer ── */}
); }