import { useCallback, useEffect, useState, useRef } from "react"; import { LogOut, Shield, PanelLeftClose, PanelLeftOpen, Menu, X } from "lucide-react"; import { conversationService } from "../api/conversationService"; import { userService } from "../api/userService"; import { QuestionBubble } from "./QuestionBubble"; import { AnswerBubble } from "./AnswerBubble"; import { ToolBubble } from "./ToolBubble"; import { MessageInput } from "./MessageInput"; import { ConversationList } from "./ConversationList"; import { AdminPanel } from "./AdminPanel"; import { cn } from "../lib/utils"; import catIcon from "../assets/cat.png"; type Message = { text: string; speaker: "simba" | "user" | "tool"; image_key?: string | null; }; type Conversation = { title: string; id: string; }; type ChatScreenProps = { setAuthenticated: (isAuth: boolean) => void; }; const TOOL_MESSAGES: Record = { simba_search: "🔍 Searching Simba's records...", web_search: "🌐 Searching the web...", get_current_date: "📅 Checking today's date...", ynab_budget_summary: "💰 Checking budget summary...", ynab_search_transactions: "💳 Looking up transactions...", ynab_category_spending: "📊 Analyzing category spending...", ynab_insights: "📈 Generating budget insights...", obsidian_search_notes: "📝 Searching notes...", obsidian_read_note: "📖 Reading note...", obsidian_create_note: "✏️ Saving note...", obsidian_create_task: "✅ Creating task...", journal_get_today: "📔 Reading today's journal...", journal_get_tasks: "📋 Getting tasks...", journal_add_task: "➕ Adding task...", journal_complete_task: "✔️ Completing task...", }; export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => { const [query, setQuery] = useState(""); const [simbaMode, setSimbaMode] = useState(false); const [messages, setMessages] = useState([]); const [conversations, setConversations] = useState([]); const [showConversations, setShowConversations] = useState(false); const [selectedConversation, setSelectedConversation] = useState(null); const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [isLoading, setIsLoading] = useState(false); const [isAdmin, setIsAdmin] = useState(false); const [showAdminPanel, setShowAdminPanel] = useState(false); const [pendingImage, setPendingImage] = useState(null); const messagesEndRef = useRef(null); const isMountedRef = useRef(true); const abortControllerRef = useRef(null); const simbaAnswers = ["meow.", "hiss...", "purrrrrr", "yowOWROWWowowr"]; const scrollToBottom = useCallback(() => { requestAnimationFrame(() => { messagesEndRef.current?.scrollIntoView({ behavior: isLoading ? "instant" : "smooth", }); }); }, [isLoading]); useEffect(() => { isMountedRef.current = true; return () => { isMountedRef.current = false; abortControllerRef.current?.abort(); }; }, []); const handleSelectConversation = (conversation: Conversation) => { setShowConversations(false); setSelectedConversation(conversation); const load = async () => { try { const fetched = await conversationService.getConversation(conversation.id); setMessages( fetched.messages.map((m) => ({ text: m.text, speaker: m.speaker, image_key: m.image_key })), ); } catch (err) { console.error("Failed to load messages:", err); } }; load(); }; const loadConversations = async () => { try { const fetched = await conversationService.getAllConversations(); const parsed = fetched.map((c) => ({ id: c.id, title: c.name })); setConversations(parsed); } catch (err) { console.error("Failed to load conversations:", err); } }; const handleCreateNewConversation = async () => { const newConv = await conversationService.createConversation(); await loadConversations(); setSelectedConversation({ title: newConv.name, id: newConv.id }); }; useEffect(() => { loadConversations(); userService.getMe().then((me) => setIsAdmin(me.is_admin)).catch(() => {}); }, []); useEffect(() => { scrollToBottom(); }, [messages]); useEffect(() => { const load = async () => { if (!selectedConversation) return; try { const conv = await conversationService.getConversation(selectedConversation.id); setSelectedConversation({ id: conv.id, title: conv.name }); setMessages(conv.messages.map((m) => ({ text: m.text, speaker: m.speaker, image_key: m.image_key }))); } catch (err) { console.error("Failed to load messages:", err); } }; load(); }, [selectedConversation?.id]); const handleQuestionSubmit = useCallback(async () => { if ((!query.trim() && !pendingImage) || isLoading) return; let activeConversation = selectedConversation; if (!activeConversation) { const newConv = await conversationService.createConversation(); activeConversation = { title: newConv.name, id: newConv.id }; setSelectedConversation(activeConversation); setConversations((prev) => [activeConversation!, ...prev]); } // Capture pending image before clearing state const imageFile = pendingImage; const currMessages = messages.concat([{ text: query, speaker: "user" }]); setMessages(currMessages); setQuery(""); setPendingImage(null); setIsLoading(true); if (simbaMode) { const randomElement = simbaAnswers[Math.floor(Math.random() * simbaAnswers.length)]; setMessages((prev) => prev.concat([{ text: randomElement, speaker: "simba" }])); setIsLoading(false); return; } const abortController = new AbortController(); abortControllerRef.current = abortController; try { // Upload image first if present let imageKey: string | undefined; if (imageFile) { const uploadResult = await conversationService.uploadImage( imageFile, activeConversation.id, ); imageKey = uploadResult.image_key; // Update the user message with the image key setMessages((prev) => { const updated = [...prev]; // Find the last user message we just added for (let i = updated.length - 1; i >= 0; i--) { if (updated[i].speaker === "user") { updated[i] = { ...updated[i], image_key: imageKey }; break; } } return updated; }); } await conversationService.streamQuery( query, activeConversation.id, (event) => { if (!isMountedRef.current) return; if (event.type === "tool_start") { const friendly = TOOL_MESSAGES[event.tool] ?? `🔧 Using ${event.tool}...`; setMessages((prev) => prev.concat([{ text: friendly, speaker: "tool" }])); } else if (event.type === "response") { setMessages((prev) => prev.concat([{ text: event.message, speaker: "simba" }])); } else if (event.type === "error") { console.error("Stream error:", event.message); } }, abortController.signal, imageKey, ); } catch (error) { if (error instanceof Error && error.name === "AbortError") { console.log("Request was aborted"); } else { console.error("Failed to send query:", error); if (error instanceof Error && error.message.includes("Session expired")) { setAuthenticated(false); } } } finally { if (isMountedRef.current) setIsLoading(false); abortControllerRef.current = null; } }, [query, pendingImage, isLoading, selectedConversation, simbaMode, messages, setAuthenticated]); const handleQueryChange = useCallback((event: React.ChangeEvent) => { setQuery(event.target.value); }, []); const handleKeyDown = useCallback((event: React.ChangeEvent) => { const kev = event as unknown as React.KeyboardEvent; if (kev.key === "Enter" && !kev.shiftKey) { kev.preventDefault(); handleQuestionSubmit(); } }, [handleQuestionSubmit]); const handleImageSelect = useCallback((file: File) => setPendingImage(file), []); const handleClearImage = useCallback(() => setPendingImage(null), []); const handleLogout = () => { localStorage.removeItem("access_token"); localStorage.removeItem("refresh_token"); setAuthenticated(false); }; return (
{/* ── Desktop Sidebar ─────────────────────────────── */} {/* Admin Panel modal */} {showAdminPanel && setShowAdminPanel(false)} />} {/* ── Main chat area ──────────────────────────────── */}
{/* Mobile header */}
Simba

asksimba

{messages.length === 0 ? ( /* ── Empty / homepage state ── */
{/* Mobile conversation drawer */} {showConversations && (
)}
Simba

Ask me anything

) : ( /* ── Active chat state ── */ <>
{/* Mobile conversation drawer */} {showConversations && (
)} {messages.map((msg, index) => { if (msg.speaker === "tool") return ; if (msg.speaker === "simba") return ; return ; })} {isLoading && }
)}
); };