Spaces:
Sleeping
Sleeping
| "use client"; | |
| import { useEffect, useRef, useState } from "react"; | |
| import { motion, AnimatePresence } from "framer-motion"; | |
| import { Bot } from "lucide-react"; | |
| import { MessageBubble } from "./MessageBubble"; | |
| import { FeedbackWidget } from "./FeedbackWidget"; | |
| import { TypingIndicator } from "./TypingIndicator"; | |
| import { useChatStore } from "@/stores/chatStore"; | |
| import { useAuthStore } from "@/stores/authStore"; | |
| import { getChatbotConfig } from "@/lib/chatbotConfig"; | |
| import type { ChatMessage, SummaryPack } from "@/types"; | |
| function getAudioText(msg: ChatMessage): string | null { | |
| if (msg.audio_text) return msg.audio_text; | |
| const summary = msg.data?.datasets?.find( | |
| (d): d is SummaryPack => "chart_type" in d && d.chart_type === "summary" | |
| ); | |
| return summary?.audio_text ?? null; | |
| } | |
| interface ChatWindowProps { | |
| showFeedback?: boolean; | |
| onFeedbackDismiss?: () => void; | |
| } | |
| export function ChatWindow({ showFeedback, onFeedbackDismiss }: ChatWindowProps) { | |
| const bottomRef = useRef<HTMLDivElement>(null); | |
| const lastMessageRef = useRef<HTMLDivElement>(null); | |
| const prevMessageCount = useRef(0); | |
| const prevIsSending = useRef(false); | |
| const isFirstRender = useRef(true); | |
| const messages = useChatStore((s) => s.getActiveMessages()); | |
| const isSending = useChatStore((s) => s.isSending); | |
| const userInfo = useAuthStore((s) => s.userInfo); | |
| const [chatbotConfig] = useState(() => getChatbotConfig()); | |
| // Scroll to bottom when typing indicator appears | |
| useEffect(() => { | |
| if (isSending !== prevIsSending.current) { | |
| prevIsSending.current = isSending; | |
| if (isSending) { | |
| bottomRef.current?.scrollIntoView({ behavior: "smooth" }); | |
| } | |
| } | |
| }, [isSending]); | |
| // On initial mount scroll to bottom; on new AI message scroll to its top | |
| useEffect(() => { | |
| if (isFirstRender.current) { | |
| isFirstRender.current = false; | |
| prevMessageCount.current = messages.length; | |
| bottomRef.current?.scrollIntoView({ behavior: "smooth" }); | |
| return; | |
| } | |
| if (messages.length > prevMessageCount.current) { | |
| const lastMsg = messages[messages.length - 1]; | |
| if (lastMsg?.role === "ai") { | |
| lastMessageRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }); | |
| } else { | |
| bottomRef.current?.scrollIntoView({ behavior: "smooth" }); | |
| } | |
| } | |
| prevMessageCount.current = messages.length; | |
| }, [messages]); | |
| if (messages.length === 0) { | |
| return ( | |
| <div className="flex-1 flex flex-col items-center justify-center gap-6 px-6 pb-4"> | |
| {/* Welcome state */} | |
| <motion.div | |
| initial={{ opacity: 0, scale: 0.9 }} | |
| animate={{ opacity: 1, scale: 1 }} | |
| transition={{ duration: 0.5, ease: "easeOut" }} | |
| className="flex flex-col items-center gap-4 text-center max-w-md" | |
| > | |
| <div className="w-20 h-20 rounded-3xl bg-gradient-to-br from-brand-green-light to-brand-green flex items-center justify-center shadow-xl shadow-brand-green/25"> | |
| <Bot className="h-10 w-10 text-white" /> | |
| </div> | |
| <div> | |
| <h2 className="text-2xl font-bold text-neutral-900"> | |
| Halo, {userInfo?.fullname?.split(" ")[0] || ""}! π | |
| </h2> | |
| <p className="mt-2 text-neutral-500 text-sm leading-relaxed"> | |
| {chatbotConfig.description} | |
| </p> | |
| </div> | |
| <div className="grid grid-cols-2 gap-3 w-full mt-2"> | |
| {[ | |
| { icon: "π", title: "Analisis Data", desc: "PA, Bad Actor, Pareto, dll." }, | |
| { icon: "π", title: "Knowledge Base", desc: "SOP, Handbook & Dokumen" }, | |
| { icon: "π", title: "Pareto & Trend", desc: "Identifikasi pola masalah" }, | |
| { icon: "π", title: "Report Ready", desc: "Grafik & insight otomatis" }, | |
| ].map((item) => ( | |
| <div | |
| key={item.title} | |
| className="flex flex-col gap-1 p-3 rounded-xl bg-white border border-neutral-100 shadow-sm text-left" | |
| > | |
| <span className="text-xl">{item.icon}</span> | |
| <p className="text-sm font-semibold text-neutral-800">{item.title}</p> | |
| <p className="text-xs text-neutral-400">{item.desc}</p> | |
| </div> | |
| ))} | |
| </div> | |
| </motion.div> | |
| </div> | |
| ); | |
| } | |
| return ( | |
| <div className="flex-1 overflow-y-auto py-4 space-y-1"> | |
| <AnimatePresence initial={false}> | |
| {messages.map((message, i) => ( | |
| <motion.div | |
| key={i} | |
| ref={i === messages.length - 1 ? lastMessageRef : null} | |
| initial={{ opacity: 0, y: 12 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| transition={{ duration: 0.3, ease: "easeOut" }} | |
| > | |
| <MessageBubble message={message} index={i} /> | |
| {showFeedback && | |
| i === messages.length - 1 && | |
| message.role === "ai" && ( | |
| <FeedbackWidget | |
| message={message.content} | |
| audioText={getAudioText(message)} | |
| /> | |
| )} | |
| </motion.div> | |
| ))} | |
| </AnimatePresence> | |
| {/* Typing indicator */} | |
| {isSending && <TypingIndicator />} | |
| <div ref={bottomRef} /> | |
| </div> | |
| ); | |
| } | |