import * as React from "react"; import { BrainCircuit, ChevronDown, Minus, RotateCcw, X, } from "lucide-react"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { Spinner } from "@/components/ui/spinner"; import { ChatInputArea } from "@/components/ui/chat-input-area"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { ChatWidgetMessage } from "@/components/ui/chat-widget-primitives"; import { PolicySingleBankAnswer, PolicyComparisonTable, PolicyRankedList, } from "./policy-ai-responses"; import type { PolicyAIMessage, PolicyQueryContext, PolicyResponseContent, } from "./policy-ai-primitives"; // --------------------------------------------------------------------------- // PolicyAIFAB props // --------------------------------------------------------------------------- export interface PolicyAIFABProps { onClick: () => void; hasNudge?: boolean; className?: string; } // --------------------------------------------------------------------------- // PolicyAIPanelProps // --------------------------------------------------------------------------- export interface PolicyAIPanelProps { /** Required in float mode; ignored in `inline` mode. */ open?: boolean; /** Required in float mode; ignored in `inline` mode. */ onClose?: () => void; messages?: PolicyAIMessage[]; suggestedQuestions?: Record; isStreaming?: boolean; isLoading?: boolean; /** * Live thinking steps for the streaming reply, in the order received from the * SSE stream. The last item is rendered in-progress, earlier ones as done. * Falls back to a generic placeholder when empty. (Not used while `isLoading`.) */ thinkingSteps?: string[]; onSendMessage?: (text: string) => void; /** Called when the user selects files via the paperclip attachment button. */ onAttachFile?: (files: FileList) => void; /** Called when the user selects images via the image upload button. */ onAttachImage?: (files: FileList) => void; onReset?: () => void; /** True when older messages can be loaded (cursor pagination). */ hasMoreMessages?: boolean; /** True while a `onLoadMoreMessages` request is in flight. */ isLoadingMoreMessages?: boolean; /** Fired when the user scrolls near the top — fetch older messages. */ onLoadMoreMessages?: () => void; /** Position relative to viewport — defaults to bottom-right. Ignored when `inline` is true. */ position?: "bottom-right" | "bottom-left"; /** * When true, renders as an inline block filling its parent container instead * of a fixed-position float widget. Use inside drawers, sheets, or tab panels. * Hides the minimize and close buttons. */ inline?: boolean; className?: string; } // --------------------------------------------------------------------------- // Internal helpers // --------------------------------------------------------------------------- type PolicyTypeTab = | "Income" | "Security" | "Serviceability" | "Loan Type" | "Borrower"; const DEFAULT_SUGGESTED: Record = { Income: [ "Which banks accept casual income?", "Which lenders accept bonus income, ranked best to worst?", "How is self-employed income assessed across lenders?", ], Security: [ "Which banks accept apartments under 40sqm?", "What is the maximum LVR for a serviced apartment?", "Do any lenders accept hobby farm properties?", ], Serviceability: [ "Which banks have the most flexible DTI ratio?", "How is HECS debt treated by different lenders?", "Which lenders apply the lowest assessment buffer rate?", ], "Loan Type": [ "Which lenders offer cashback on refinances?", "Which banks accept low-doc construction loans?", "What are the pre-approval policies across lenders?", ], Borrower: [ "Which banks accept non-resident borrowers?", "Which lenders offer LMI waiver for medical professionals?", "How do lenders treat prior credit impairment?", ], }; // Shown while streaming before the first real SSE step arrives. const FALLBACK_THINKING_STEPS = ["Thinking…"]; // --------------------------------------------------------------------------- // PolicyAIThinkingSteps // --------------------------------------------------------------------------- /** * Thinking steps shown while the AI is working, **driven by the SSE stream**. * The caller pushes each real step as it arrives; the latest is in-progress and * earlier ones are marked done. There is NO internal timer — progress changes * only when a new step is added (and the list never restarts). */ function PolicyAIThinkingSteps({ steps }: { steps: string[] }) { const current = steps[steps.length - 1]; if (!current) return null; return (
{[0, 1, 2].map((j) => ( ))} {current}
); } // --------------------------------------------------------------------------- // ResponseCard — dispatches to the correct response molecule // --------------------------------------------------------------------------- function ResponseCard({ content, queryContext, }: { content: PolicyResponseContent; queryContext?: PolicyQueryContext; }) { if (content.type === "single_bank") { return ( ); } if (content.type === "cross_bank_comparison") { return ( ); } if (content.type === "ranked_list") { return ( ); } return null; } // --------------------------------------------------------------------------- // PolicyAIFAB (Molecule) // --------------------------------------------------------------------------- /** * Floating action button that opens the PolicyAIPanel. * * @example * setOpen(true)} /> */ export function PolicyAIFAB({ onClick, hasNudge, className, }: PolicyAIFABProps) { return (
{hasNudge && ( )}
); } // --------------------------------------------------------------------------- // PolicyAIPanel (Organism) — Jira Rovo-style floating / inline widget // --------------------------------------------------------------------------- /** * Policy AI chat panel — shows suggested questions in home state and * structured response cards (SingleBankAnswer / ComparisonTable / RankedList) * in chat state. * * Float mode: fixed-position overlay next to the FAB. * Inline mode: fills its parent container — use inside drawers or tab panels. * * @example * setOpen(true)} /> * setOpen(false)} * messages={messages} * onSendMessage={handleSend} * onReset={() => setMessages([])} * /> */ export function PolicyAIPanel({ open = true, onClose, messages = [], suggestedQuestions, isStreaming = false, isLoading = false, thinkingSteps, onSendMessage, onAttachFile, onAttachImage, onReset, hasMoreMessages, isLoadingMoreMessages, onLoadMoreMessages, position = "bottom-right", inline = false, className, }: PolicyAIPanelProps) { // Steps shown while streaming: the real SSE steps once they arrive, else a // single generic placeholder. Driven entirely by `thinkingSteps` (no timer). const streamingSteps = thinkingSteps && thinkingSteps.length > 0 ? thinkingSteps : FALLBACK_THINKING_STEPS; const [inputValue, setInputValue] = React.useState(""); const [minimised, setMinimised] = React.useState(false); const [activeTab, setActiveTab] = React.useState("Income"); 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 isChatMode = messages.length > 0; // Scroll near the top → load older messages (cursor pagination). 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 || minimised || !open) 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, open, minimised]); const handleSend = React.useCallback( (text: string) => { if (!text || isStreaming) return; onSendMessage?.(text); setInputValue(""); }, [isStreaming, onSendMessage], ); if (!open) return null; const positionClass = position === "bottom-left" ? "left-6" : "right-6"; return (
{/* ── Header — float mode only ── */} {!inline && (
)} {/* ── Body ── */} {(!minimised || inline) && ( <> {/* Scroll container — always flex-1 + overflow-y-auto so the footer stays pinned at the bottom in both float and inline modes. */}
{isLoading ? ( /* Loading a saved conversation's messages — a neutral spinner, NOT the thinking-steps (which are only for in-flight AI replies). */

Loading…

) : !isChatMode ? ( /* Home state — suggested questions by policy type */
{/* Description */}

Ask me about lending policies across 40+ Australian banks — income, LVR, security types, serviceability, and more.

{/* Suggested questions — wrapped in a Card for visual separation */}

Suggested

{/* Policy type tabs */}
setActiveTab(v as PolicyTypeTab)} > {( Object.keys(DEFAULT_SUGGESTED) as PolicyTypeTab[] ).map((type) => ( {type} ))}
{/* Question list */}
{( (suggestedQuestions ?? DEFAULT_SUGGESTED)[activeTab] ?? [] ).map((q) => ( ))}
) : ( /* Chat state — messages + structured response cards */
{isLoadingMoreMessages && (
)} {messages.map((msg) => msg.role === "user" ? ( ) : (
{msg.content && (

{msg.content}

)} {msg.responseContent && ( )}
), )} {isStreaming && }
)}
{/* Footer input — hidden during loading. shrink-0 pins it to the flex column bottom in both modes. */} {!isLoading && (
)} )}
); }