Refactor frontend to hook-based architecture

Extract logic from god components into custom hooks (useAuthCheck,
useConversations, useChat, usePresignedUrl, useAdminUsers, useOIDCAuth).
Eliminate unnecessary useEffects per React guidelines — scroll is now
imperative, isAdmin comes from useAuthCheck instead of a separate fetch.
ConversationList becomes a pure presentational component. Wrap bubble
components in React.memo.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-24 09:11:57 -04:00
parent bac773ae4b
commit 4ac0754ea7
14 changed files with 495 additions and 371 deletions
+3 -38
View File
@@ -1,48 +1,13 @@
import { useState, useEffect } from "react";
import "./App.css"; import "./App.css";
import { AuthProvider } from "./contexts/AuthContext"; import { AuthProvider } from "./contexts/AuthContext";
import { ChatScreen } from "./components/ChatScreen"; import { ChatScreen } from "./components/ChatScreen";
import { LoginScreen } from "./components/LoginScreen"; import { LoginScreen } from "./components/LoginScreen";
import { conversationService } from "./api/conversationService"; import { useAuthCheck } from "./hooks/useAuthCheck";
import catIcon from "./assets/cat.png"; import catIcon from "./assets/cat.png";
const AppContainer = () => { const AppContainer = () => {
const [isAuthenticated, setAuthenticated] = useState<boolean>(false); const { isAuthenticated, isChecking, isAdmin, setAuthenticated } = useAuthCheck();
const [isChecking, setIsChecking] = useState<boolean>(true);
useEffect(() => {
const checkAuth = async () => {
const accessToken = localStorage.getItem("access_token");
const refreshToken = localStorage.getItem("refresh_token");
// No tokens at all, not authenticated
if (!accessToken && !refreshToken) {
setIsChecking(false);
setAuthenticated(false);
return;
}
// Try to verify token by making a request
try {
await conversationService.getAllConversations();
// If successful, user is authenticated
setAuthenticated(true);
} catch (error) {
// Token is invalid or expired
console.error("Authentication check failed:", error);
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
setAuthenticated(false);
} finally {
setIsChecking(false);
}
};
checkAuth();
}, []);
// Show loading state while checking authentication
if (isChecking) { if (isChecking) {
return ( return (
<div className="h-screen flex flex-col items-center justify-center bg-cream gap-4"> <div className="h-screen flex flex-col items-center justify-center bg-cream gap-4">
@@ -61,7 +26,7 @@ const AppContainer = () => {
return ( return (
<> <>
{isAuthenticated ? ( {isAuthenticated ? (
<ChatScreen setAuthenticated={setAuthenticated} /> <ChatScreen setAuthenticated={setAuthenticated} isAdmin={isAdmin} />
) : ( ) : (
<LoginScreen setAuthenticated={setAuthenticated} /> <LoginScreen setAuthenticated={setAuthenticated} />
)} )}
+15 -29
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react"; import { useState } from "react";
import { X, Phone, PhoneOff, Pencil, Check, Mail, Copy } from "lucide-react"; import { X, Phone, PhoneOff, Pencil, Check, Mail, Copy } from "lucide-react";
import { userService, type AdminUserRecord } from "../api/userService"; import { userService, type AdminUserRecord } from "../api/userService";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
@@ -12,27 +12,19 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "./ui/table"; } from "./ui/table";
import { useAdminUsers } from "../hooks/useAdminUsers";
type Props = { type Props = {
onClose: () => void; onClose: () => void;
}; };
export const AdminPanel = ({ onClose }: Props) => { export const AdminPanel = ({ onClose }: Props) => {
const [users, setUsers] = useState<AdminUserRecord[]>([]); const { users, loading, updateUser } = useAdminUsers();
const [loading, setLoading] = useState(true);
const [editingId, setEditingId] = useState<string | null>(null); const [editingId, setEditingId] = useState<string | null>(null);
const [editValue, setEditValue] = useState(""); const [editValue, setEditValue] = useState("");
const [rowError, setRowError] = useState<Record<string, string>>({}); const [rowError, setRowError] = useState<Record<string, string>>({});
const [rowSuccess, setRowSuccess] = useState<Record<string, string>>({}); const [rowSuccess, setRowSuccess] = useState<Record<string, string>>({});
useEffect(() => {
userService
.adminListUsers()
.then(setUsers)
.catch(() => {})
.finally(() => setLoading(false));
}, []);
const startEdit = (user: AdminUserRecord) => { const startEdit = (user: AdminUserRecord) => {
setEditingId(user.id); setEditingId(user.id);
setEditValue(user.whatsapp_number ?? ""); setEditValue(user.whatsapp_number ?? "");
@@ -49,8 +41,8 @@ export const AdminPanel = ({ onClose }: Props) => {
setRowError((p) => ({ ...p, [userId]: "" })); setRowError((p) => ({ ...p, [userId]: "" }));
try { try {
const updated = await userService.adminSetWhatsapp(userId, editValue); const updated = await userService.adminSetWhatsapp(userId, editValue);
setUsers((p) => p.map((u) => (u.id === userId ? updated : u))); updateUser(userId, () => updated);
setRowSuccess((p) => ({ ...p, [userId]: "Saved" })); setRowSuccess((p) => ({ ...p, [userId]: "Saved" }));
setEditingId(null); setEditingId(null);
setTimeout(() => setRowSuccess((p) => ({ ...p, [userId]: "" })), 2000); setTimeout(() => setRowSuccess((p) => ({ ...p, [userId]: "" })), 2000);
} catch (err) { } catch (err) {
@@ -65,10 +57,8 @@ export const AdminPanel = ({ onClose }: Props) => {
setRowError((p) => ({ ...p, [userId]: "" })); setRowError((p) => ({ ...p, [userId]: "" }));
try { try {
await userService.adminUnlinkWhatsapp(userId); await userService.adminUnlinkWhatsapp(userId);
setUsers((p) => updateUser(userId, (u) => ({ ...u, whatsapp_number: null }));
p.map((u) => (u.id === userId ? { ...u, whatsapp_number: null } : u)), setRowSuccess((p) => ({ ...p, [userId]: "Unlinked" }));
);
setRowSuccess((p) => ({ ...p, [userId]: "Unlinked ✓" }));
setTimeout(() => setRowSuccess((p) => ({ ...p, [userId]: "" })), 2000); setTimeout(() => setRowSuccess((p) => ({ ...p, [userId]: "" })), 2000);
} catch (err) { } catch (err) {
setRowError((p) => ({ setRowError((p) => ({
@@ -82,8 +72,8 @@ export const AdminPanel = ({ onClose }: Props) => {
setRowError((p) => ({ ...p, [userId]: "" })); setRowError((p) => ({ ...p, [userId]: "" }));
try { try {
const updated = await userService.adminToggleEmail(userId); const updated = await userService.adminToggleEmail(userId);
setUsers((p) => p.map((u) => (u.id === userId ? updated : u))); updateUser(userId, () => updated);
setRowSuccess((p) => ({ ...p, [userId]: "Email enabled" })); setRowSuccess((p) => ({ ...p, [userId]: "Email enabled" }));
setTimeout(() => setRowSuccess((p) => ({ ...p, [userId]: "" })), 2000); setTimeout(() => setRowSuccess((p) => ({ ...p, [userId]: "" })), 2000);
} catch (err) { } catch (err) {
setRowError((p) => ({ setRowError((p) => ({
@@ -97,10 +87,8 @@ export const AdminPanel = ({ onClose }: Props) => {
setRowError((p) => ({ ...p, [userId]: "" })); setRowError((p) => ({ ...p, [userId]: "" }));
try { try {
await userService.adminDisableEmail(userId); await userService.adminDisableEmail(userId);
setUsers((p) => updateUser(userId, (u) => ({ ...u, email_enabled: false, email_address: null }));
p.map((u) => (u.id === userId ? { ...u, email_enabled: false, email_address: null } : u)), setRowSuccess((p) => ({ ...p, [userId]: "Email disabled" }));
);
setRowSuccess((p) => ({ ...p, [userId]: "Email disabled ✓" }));
setTimeout(() => setRowSuccess((p) => ({ ...p, [userId]: "" })), 2000); setTimeout(() => setRowSuccess((p) => ({ ...p, [userId]: "" })), 2000);
} catch (err) { } catch (err) {
setRowError((p) => ({ setRowError((p) => ({
@@ -112,7 +100,7 @@ export const AdminPanel = ({ onClose }: Props) => {
const copyToClipboard = (text: string, userId: string) => { const copyToClipboard = (text: string, userId: string) => {
navigator.clipboard.writeText(text); navigator.clipboard.writeText(text);
setRowSuccess((p) => ({ ...p, [userId]: "Copied" })); setRowSuccess((p) => ({ ...p, [userId]: "Copied" }));
setTimeout(() => setRowSuccess((p) => ({ ...p, [userId]: "" })), 2000); setTimeout(() => setRowSuccess((p) => ({ ...p, [userId]: "" })), 2000);
}; };
@@ -128,7 +116,6 @@ export const AdminPanel = ({ onClose }: Props) => {
"border border-sand-light/60", "border border-sand-light/60",
)} )}
> >
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-sand-light/60"> <div className="flex items-center justify-between px-6 py-4 border-b border-sand-light/60">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-xl bg-leaf-pale flex items-center justify-center"> <div className="w-8 h-8 rounded-xl bg-leaf-pale flex items-center justify-center">
@@ -146,7 +133,6 @@ export const AdminPanel = ({ onClose }: Props) => {
</button> </button>
</div> </div>
{/* Body */}
<div className="overflow-y-auto flex-1 rounded-b-3xl"> <div className="overflow-y-auto flex-1 rounded-b-3xl">
{loading ? ( {loading ? (
<div className="px-6 py-12 text-center text-warm-gray text-sm"> <div className="px-6 py-12 text-center text-warm-gray text-sm">
@@ -155,7 +141,7 @@ export const AdminPanel = ({ onClose }: Props) => {
<span className="loading-dot w-2 h-2 rounded-full bg-amber-soft inline-block" /> <span className="loading-dot w-2 h-2 rounded-full bg-amber-soft inline-block" />
<span className="loading-dot w-2 h-2 rounded-full bg-amber-soft inline-block" /> <span className="loading-dot w-2 h-2 rounded-full bg-amber-soft inline-block" />
</div> </div>
Loading users Loading users...
</div> </div>
) : ( ) : (
<Table> <Table>
@@ -204,7 +190,7 @@ export const AdminPanel = ({ onClose }: Props) => {
: "text-warm-gray/40 italic", : "text-warm-gray/40 italic",
)} )}
> >
{user.whatsapp_number ?? ""} {user.whatsapp_number ?? "\u2014"}
</span> </span>
{rowSuccess[user.id] && ( {rowSuccess[user.id] && (
<span className="text-xs text-leaf-dark"> <span className="text-xs text-leaf-dark">
@@ -235,7 +221,7 @@ export const AdminPanel = ({ onClose }: Props) => {
</button> </button>
</div> </div>
) : ( ) : (
<span className="text-sm text-warm-gray/40 italic"></span> <span className="text-sm text-warm-gray/40 italic">\u2014</span>
)} )}
</div> </div>
</TableCell> </TableCell>
@@ -1,3 +1,4 @@
import React from "react";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
@@ -6,7 +7,7 @@ type AnswerBubbleProps = {
loading?: boolean; loading?: boolean;
}; };
export const AnswerBubble = ({ text, loading }: AnswerBubbleProps) => { export const AnswerBubble = React.memo(({ text, loading }: AnswerBubbleProps) => {
return ( return (
<div className="flex justify-start message-enter"> <div className="flex justify-start message-enter">
<div <div
@@ -17,7 +18,6 @@ export const AnswerBubble = ({ text, loading }: AnswerBubbleProps) => {
"overflow-hidden", "overflow-hidden",
)} )}
> >
{/* amber accent bar */}
<div className="h-0.5 w-full bg-gradient-to-r from-amber-soft via-amber-glow/50 to-transparent" /> <div className="h-0.5 w-full bg-gradient-to-r from-amber-soft via-amber-glow/50 to-transparent" />
<div className="px-4 py-3"> <div className="px-4 py-3">
@@ -36,4 +36,4 @@ export const AnswerBubble = ({ text, loading }: AnswerBubbleProps) => {
</div> </div>
</div> </div>
); );
}; });
+60 -200
View File
@@ -1,7 +1,5 @@
import { useCallback, useEffect, useState, useRef } from "react"; import { useCallback, useState, useRef } from "react";
import { LogOut, Shield, PanelLeftClose, PanelLeftOpen, Menu, X } from "lucide-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 { QuestionBubble } from "./QuestionBubble";
import { AnswerBubble } from "./AnswerBubble"; import { AnswerBubble } from "./AnswerBubble";
import { ToolBubble } from "./ToolBubble"; import { ToolBubble } from "./ToolBubble";
@@ -9,205 +7,79 @@ import { MessageInput } from "./MessageInput";
import { ConversationList } from "./ConversationList"; import { ConversationList } from "./ConversationList";
import { AdminPanel } from "./AdminPanel"; import { AdminPanel } from "./AdminPanel";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { useConversations } from "../hooks/useConversations";
import { useChat } from "../hooks/useChat";
import catIcon from "../assets/cat.png"; 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 = { type ChatScreenProps = {
setAuthenticated: (isAuth: boolean) => void; setAuthenticated: (isAuth: boolean) => void;
isAdmin: boolean;
}; };
const TOOL_MESSAGES: Record<string, string> = { export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
simba_search: "🔍 Searching Simba's records...", const [query, setQuery] = useState("");
web_search: "🌐 Searching the web...", const [simbaMode, setSimbaMode] = useState(false);
get_current_date: "📅 Checking today's date...", const [showConversations, setShowConversations] = useState(false);
ynab_budget_summary: "💰 Checking budget summary...", const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
ynab_search_transactions: "💳 Looking up transactions...", const [showAdminPanel, setShowAdminPanel] = useState(false);
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<string>("");
const [simbaMode, setSimbaMode] = useState<boolean>(false);
const [messages, setMessages] = useState<Message[]>([]);
const [conversations, setConversations] = useState<Conversation[]>([]);
const [showConversations, setShowConversations] = useState<boolean>(false);
const [selectedConversation, setSelectedConversation] =
useState<Conversation | null>(null);
const [sidebarCollapsed, setSidebarCollapsed] = useState<boolean>(false);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [isAdmin, setIsAdmin] = useState<boolean>(false);
const [showAdminPanel, setShowAdminPanel] = useState<boolean>(false);
const [pendingImage, setPendingImage] = useState<File | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const isMountedRef = useRef<boolean>(true); const isLoadingRef = useRef(false);
const abortControllerRef = useRef<AbortController | null>(null);
const simbaAnswers = ["meow.", "hiss...", "purrrrrr", "yowOWROWWowowr"];
const scrollToBottom = useCallback(() => { const scrollToBottom = useCallback(() => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
messagesEndRef.current?.scrollIntoView({ messagesEndRef.current?.scrollIntoView({
behavior: isLoading ? "instant" : "smooth", behavior: isLoadingRef.current ? "instant" : "smooth",
}); });
}); });
}, [isLoading]);
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
abortControllerRef.current?.abort();
};
}, []); }, []);
const handleSelectConversation = (conversation: Conversation) => { const {
conversations,
selectedConversation,
selectConversation,
createConversation,
refreshConversations,
} = useConversations();
const onSessionExpired = useCallback(() => setAuthenticated(false), [setAuthenticated]);
const {
messages,
setMessages,
isLoading,
pendingImage,
setPendingImage,
sendMessage,
} = useChat({
selectedConversation,
createConversation,
refreshConversations,
onSessionExpired,
scrollToBottom,
});
// Keep ref in sync for scrollToBottom behavior
isLoadingRef.current = isLoading;
const handleSelectConversation = useCallback(
async (conversation: { title: string; id: string }) => {
setShowConversations(false); setShowConversations(false);
setSelectedConversation(conversation); const loaded = await selectConversation(conversation);
const load = async () => { setMessages(loaded);
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]);
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, [selectConversation, setMessages],
imageKey,
); );
} catch (error) {
if (error instanceof Error && error.name === "AbortError") { const handleCreateNewConversation = useCallback(async () => {
console.log("Request was aborted"); await createConversation();
} else { setMessages([]);
console.error("Failed to send query:", error); }, [createConversation, setMessages]);
if (error instanceof Error && error.message.includes("Session expired")) {
setAuthenticated(false); const handleQuestionSubmit = useCallback(() => {
} sendMessage(query, simbaMode);
} setQuery("");
} finally { }, [query, simbaMode, sendMessage]);
if (isMountedRef.current) {
setIsLoading(false);
loadConversations();
}
abortControllerRef.current = null;
}
}, [query, pendingImage, isLoading, selectedConversation, simbaMode, messages, setAuthenticated]);
const handleQueryChange = useCallback((event: React.ChangeEvent<HTMLTextAreaElement>) => { const handleQueryChange = useCallback((event: React.ChangeEvent<HTMLTextAreaElement>) => {
setQuery(event.target.value); setQuery(event.target.value);
@@ -221,8 +93,8 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
} }
}, [handleQuestionSubmit]); }, [handleQuestionSubmit]);
const handleImageSelect = useCallback((file: File) => setPendingImage(file), []); const handleImageSelect = useCallback((file: File) => setPendingImage(file), [setPendingImage]);
const handleClearImage = useCallback(() => setPendingImage(null), []); const handleClearImage = useCallback(() => setPendingImage(null), [setPendingImage]);
const handleLogout = () => { const handleLogout = () => {
localStorage.removeItem("access_token"); localStorage.removeItem("access_token");
@@ -232,7 +104,7 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
return ( return (
<div className="h-screen h-[100dvh] flex flex-row bg-cream overflow-hidden"> <div className="h-screen h-[100dvh] flex flex-row bg-cream overflow-hidden">
{/* ── Desktop Sidebar ─────────────────────────────── */} {/* Desktop Sidebar */}
<aside <aside
className={cn( className={cn(
"hidden md:flex md:flex-col", "hidden md:flex md:flex-col",
@@ -241,7 +113,6 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
)} )}
> >
{sidebarCollapsed ? ( {sidebarCollapsed ? (
/* Collapsed state */
<div className="flex flex-col items-center py-4 gap-4 h-full"> <div className="flex flex-col items-center py-4 gap-4 h-full">
<button <button
onClick={() => setSidebarCollapsed(false)} onClick={() => setSidebarCollapsed(false)}
@@ -256,9 +127,7 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
/> />
</div> </div>
) : ( ) : (
/* Expanded state */
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{/* Header */}
<div className="flex items-center justify-between px-4 py-4 border-b border-white/8"> <div className="flex items-center justify-between px-4 py-4 border-b border-white/8">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<img src={catIcon} alt="Simba" className="w-12 h-12" /> <img src={catIcon} alt="Simba" className="w-12 h-12" />
@@ -277,7 +146,6 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
</button> </button>
</div> </div>
{/* Conversations */}
<div className="flex-1 overflow-y-auto px-2 py-3"> <div className="flex-1 overflow-y-auto px-2 py-3">
<ConversationList <ConversationList
conversations={conversations} conversations={conversations}
@@ -287,7 +155,6 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
/> />
</div> </div>
{/* Footer */}
<div className="px-2 pb-3 pt-2 border-t border-white/8 flex flex-col gap-0.5"> <div className="px-2 pb-3 pt-2 border-t border-white/8 flex flex-col gap-0.5">
{isAdmin && ( {isAdmin && (
<button <button
@@ -310,12 +177,9 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
)} )}
</aside> </aside>
{/* Admin Panel modal */}
{showAdminPanel && <AdminPanel onClose={() => setShowAdminPanel(false)} />} {showAdminPanel && <AdminPanel onClose={() => setShowAdminPanel(false)} />}
{/* ── Main chat area ──────────────────────────────── */}
<div className="flex-1 flex flex-col h-full overflow-hidden min-w-0"> <div className="flex-1 flex flex-col h-full overflow-hidden min-w-0">
{/* Mobile header */}
<header className="md:hidden flex items-center justify-between px-4 py-3 bg-warm-white border-b border-sand-light/60"> <header className="md:hidden flex items-center justify-between px-4 py-3 bg-warm-white border-b border-sand-light/60">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<img src={catIcon} alt="Simba" className="w-12 h-12" /> <img src={catIcon} alt="Simba" className="w-12 h-12" />
@@ -343,9 +207,7 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
</header> </header>
{messages.length === 0 ? ( {messages.length === 0 ? (
/* ── Empty / homepage state ── */
<div className="flex-1 flex flex-col items-center justify-center px-4 gap-6"> <div className="flex-1 flex flex-col items-center justify-center px-4 gap-6">
{/* Mobile conversation drawer */}
{showConversations && ( {showConversations && (
<div className="md:hidden w-full max-w-2xl bg-warm-white rounded-2xl border border-sand-light p-3 shadow-sm"> <div className="md:hidden w-full max-w-2xl bg-warm-white rounded-2xl border border-sand-light p-3 shadow-sm">
<ConversationList <ConversationList
@@ -382,11 +244,9 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
</div> </div>
</div> </div>
) : ( ) : (
/* ── Active chat state ── */
<> <>
<div className="flex-1 overflow-y-auto px-4 py-6"> <div className="flex-1 overflow-y-auto px-4 py-6">
<div className="max-w-2xl mx-auto flex flex-col gap-3"> <div className="max-w-2xl mx-auto flex flex-col gap-3">
{/* Mobile conversation drawer */}
{showConversations && ( {showConversations && (
<div className="md:hidden mb-3 bg-warm-white rounded-2xl border border-sand-light p-3 shadow-sm"> <div className="md:hidden mb-3 bg-warm-white rounded-2xl border border-sand-light p-3 shadow-sm">
<ConversationList <ConversationList
@@ -422,8 +282,8 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
setSimbaMode={setSimbaMode} setSimbaMode={setSimbaMode}
isLoading={isLoading} isLoading={isLoading}
pendingImage={pendingImage} pendingImage={pendingImage}
onImageSelect={(file) => setPendingImage(file)} onImageSelect={handleImageSelect}
onClearImage={() => setPendingImage(null)} onClearImage={handleClearImage}
/> />
</div> </div>
</footer> </footer>
@@ -1,7 +1,5 @@
import { useState, useEffect } from "react";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { conversationService } from "../api/conversationService";
type Conversation = { type Conversation = {
title: string; title: string;
@@ -23,32 +21,8 @@ export const ConversationList = ({
selectedId, selectedId,
variant = "dark", variant = "dark",
}: ConversationProps) => { }: ConversationProps) => {
const [items, setItems] = useState(conversations);
useEffect(() => {
const load = async () => {
try {
let fetched = await conversationService.getAllConversations();
if (fetched.length === 0) {
await conversationService.createConversation();
fetched = await conversationService.getAllConversations();
}
setItems(fetched.map((c) => ({ id: c.id, title: c.name })));
} catch (err) {
console.error("Failed to load conversations:", err);
}
};
load();
}, []);
// Keep in sync when parent updates conversations
useEffect(() => {
setItems(conversations);
}, [conversations]);
return ( return (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{/* New thread button */}
<button <button
onClick={onCreateNewConversation} onClick={onCreateNewConversation}
className={cn( className={cn(
@@ -63,8 +37,7 @@ export const ConversationList = ({
<span>New thread</span> <span>New thread</span>
</button> </button>
{/* Conversation items */} {conversations.map((conv) => {
{items.map((conv) => {
const isActive = conv.id === selectedId; const isActive = conv.id === selectedId;
return ( return (
<button <button
+6 -57
View File
@@ -1,66 +1,19 @@
import { useState, useEffect } from "react";
import { userService } from "../api/userService";
import { oidcService } from "../api/oidcService";
import catIcon from "../assets/cat.png"; import catIcon from "../assets/cat.png";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { useOIDCAuth } from "../hooks/useOIDCAuth";
type LoginScreenProps = { type LoginScreenProps = {
setAuthenticated: (isAuth: boolean) => void; setAuthenticated: (isAuth: boolean) => void;
}; };
export const LoginScreen = ({ setAuthenticated }: LoginScreenProps) => { export const LoginScreen = ({ setAuthenticated }: LoginScreenProps) => {
const [error, setError] = useState<string>(""); const { isChecking, isLoggingIn, error, handleLogin } = useOIDCAuth({
const [isChecking, setIsChecking] = useState<boolean>(true); setAuthenticated,
const [isLoggingIn, setIsLoggingIn] = useState<boolean>(false); });
useEffect(() => {
const initAuth = async () => {
const callbackParams = oidcService.getCallbackParamsFromURL();
if (callbackParams) {
try {
setIsLoggingIn(true);
const result = await oidcService.handleCallback(
callbackParams.code,
callbackParams.state,
);
localStorage.setItem("access_token", result.access_token);
localStorage.setItem("refresh_token", result.refresh_token);
oidcService.clearCallbackParams();
setAuthenticated(true);
setIsChecking(false);
return;
} catch (err) {
console.error("OIDC callback error:", err);
setError("Login failed. Please try again.");
oidcService.clearCallbackParams();
setIsLoggingIn(false);
setIsChecking(false);
return;
}
}
const isValid = await userService.validateToken();
if (isValid) setAuthenticated(true);
setIsChecking(false);
};
initAuth();
}, [setAuthenticated]);
const handleOIDCLogin = async () => {
try {
setIsLoggingIn(true);
setError("");
const authUrl = await oidcService.initiateLogin();
window.location.href = authUrl;
} catch {
setError("Failed to initiate login. Please try again.");
setIsLoggingIn(false);
}
};
if (isChecking || isLoggingIn) { if (isChecking || isLoggingIn) {
return ( return (
<div className="h-screen flex flex-col items-center justify-center bg-cream gap-4"> <div className="h-screen flex flex-col items-center justify-center bg-cream gap-4">
{/* Subtle dot grid */}
<div <div
className="fixed inset-0 pointer-events-none opacity-[0.035]" className="fixed inset-0 pointer-events-none opacity-[0.035]"
style={{ style={{
@@ -85,7 +38,6 @@ export const LoginScreen = ({ setAuthenticated }: LoginScreenProps) => {
return ( return (
<div className="h-screen bg-cream flex items-center justify-center p-4 relative overflow-hidden"> <div className="h-screen bg-cream flex items-center justify-center p-4 relative overflow-hidden">
{/* Background dot texture */}
<div <div
className="fixed inset-0 pointer-events-none opacity-[0.04]" className="fixed inset-0 pointer-events-none opacity-[0.04]"
style={{ style={{
@@ -94,12 +46,10 @@ export const LoginScreen = ({ setAuthenticated }: LoginScreenProps) => {
}} }}
/> />
{/* Decorative background blobs */}
<div className="absolute top-1/4 -left-20 w-72 h-72 rounded-full bg-leaf-pale/60 blur-3xl pointer-events-none" /> <div className="absolute top-1/4 -left-20 w-72 h-72 rounded-full bg-leaf-pale/60 blur-3xl pointer-events-none" />
<div className="absolute bottom-1/4 -right-20 w-64 h-64 rounded-full bg-amber-pale/70 blur-3xl pointer-events-none" /> <div className="absolute bottom-1/4 -right-20 w-64 h-64 rounded-full bg-amber-pale/70 blur-3xl pointer-events-none" />
<div className="relative w-full max-w-sm"> <div className="relative w-full max-w-sm">
{/* Branding */}
<div className="flex flex-col items-center mb-8"> <div className="flex flex-col items-center mb-8">
<div className="relative mb-5"> <div className="relative mb-5">
<div className="absolute -inset-5 bg-amber-soft/30 rounded-full blur-2xl" /> <div className="absolute -inset-5 bg-amber-soft/30 rounded-full blur-2xl" />
@@ -120,7 +70,6 @@ export const LoginScreen = ({ setAuthenticated }: LoginScreenProps) => {
</p> </p>
</div> </div>
{/* Card */}
<div <div
className={cn( className={cn(
"bg-warm-white rounded-3xl border border-sand-light", "bg-warm-white rounded-3xl border border-sand-light",
@@ -138,7 +87,7 @@ export const LoginScreen = ({ setAuthenticated }: LoginScreenProps) => {
</p> </p>
<button <button
onClick={handleOIDCLogin} onClick={handleLogin}
disabled={isLoggingIn} disabled={isLoggingIn}
className={cn( className={cn(
"w-full py-3.5 px-4 rounded-2xl text-sm font-semibold tracking-wide", "w-full py-3.5 px-4 rounded-2xl text-sm font-semibold tracking-wide",
@@ -154,7 +103,7 @@ export const LoginScreen = ({ setAuthenticated }: LoginScreenProps) => {
</div> </div>
<p className="text-center text-sand mt-5 text-xs tracking-widest select-none"> <p className="text-center text-sand mt-5 text-xs tracking-widest select-none">
meow * meow *
</p> </p>
</div> </div>
</div> </div>
@@ -1,26 +1,14 @@
import { useEffect, useState } from "react"; import React from "react";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { conversationService } from "../api/conversationService"; import { usePresignedUrl } from "../hooks/usePresignedUrl";
type QuestionBubbleProps = { type QuestionBubbleProps = {
text: string; text: string;
image_key?: string | null; image_key?: string | null;
}; };
export const QuestionBubble = ({ text, image_key }: QuestionBubbleProps) => { export const QuestionBubble = React.memo(({ text, image_key }: QuestionBubbleProps) => {
const [imageUrl, setImageUrl] = useState<string | null>(null); const { imageUrl, imageError } = usePresignedUrl(image_key);
const [imageError, setImageError] = useState(false);
useEffect(() => {
if (!image_key) return;
conversationService
.getPresignedImageUrl(image_key)
.then(setImageUrl)
.catch((err) => {
console.error("Failed to load image:", err);
setImageError(true);
});
}, [image_key]);
return ( return (
<div className="flex justify-end message-enter"> <div className="flex justify-end message-enter">
@@ -34,7 +22,6 @@ export const QuestionBubble = ({ text, image_key }: QuestionBubbleProps) => {
> >
{imageError && ( {imageError && (
<div className="flex items-center gap-2 text-xs text-charcoal/50 bg-charcoal/5 rounded-xl px-3 py-2 mb-2"> <div className="flex items-center gap-2 text-xs text-charcoal/50 bg-charcoal/5 rounded-xl px-3 py-2 mb-2">
<span>🖼</span>
<span>Image failed to load</span> <span>Image failed to load</span>
</div> </div>
)} )}
@@ -49,4 +36,4 @@ export const QuestionBubble = ({ text, image_key }: QuestionBubbleProps) => {
</div> </div>
</div> </div>
); );
}; });
+3 -2
View File
@@ -1,6 +1,7 @@
import React from "react";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
export const ToolBubble = ({ text }: { text: string }) => ( export const ToolBubble = React.memo(({ text }: { text: string }) => (
<div className="flex justify-center message-enter"> <div className="flex justify-center message-enter">
<div <div
className={cn( className={cn(
@@ -12,4 +13,4 @@ export const ToolBubble = ({ text }: { text: string }) => (
{text} {text}
</div> </div>
</div> </div>
); ));
+21
View File
@@ -0,0 +1,21 @@
import { useState, useEffect } from "react";
import { userService, type AdminUserRecord } from "../api/userService";
export function useAdminUsers() {
const [users, setUsers] = useState<AdminUserRecord[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
userService
.adminListUsers()
.then(setUsers)
.catch(() => {})
.finally(() => setLoading(false));
}, []);
const updateUser = (userId: string, updater: (u: AdminUserRecord) => AdminUserRecord) => {
setUsers((prev) => prev.map((u) => (u.id === userId ? updater(u) : u)));
};
return { users, loading, updateUser };
}
+37
View File
@@ -0,0 +1,37 @@
import { useState, useEffect } from "react";
import { userService } from "../api/userService";
export function useAuthCheck() {
const [isAuthenticated, setAuthenticated] = useState(false);
const [isChecking, setIsChecking] = useState(true);
const [isAdmin, setIsAdmin] = useState(false);
useEffect(() => {
const checkAuth = async () => {
const accessToken = localStorage.getItem("access_token");
const refreshToken = localStorage.getItem("refresh_token");
if (!accessToken && !refreshToken) {
setIsChecking(false);
setAuthenticated(false);
return;
}
try {
const me = await userService.getMe();
setAuthenticated(true);
setIsAdmin(me.is_admin);
} catch {
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
setAuthenticated(false);
} finally {
setIsChecking(false);
}
};
checkAuth();
}, []);
return { isAuthenticated, isChecking, isAdmin, setAuthenticated };
}
+183
View File
@@ -0,0 +1,183 @@
import { useState, useCallback, useEffect, useRef } from "react";
import { conversationService } from "../api/conversationService";
import type { Conversation } from "./useConversations";
type Message = {
text: string;
speaker: "simba" | "user" | "tool";
image_key?: string | null;
};
const TOOL_MESSAGES: Record<string, string> = {
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...",
};
const simbaAnswers = ["meow.", "hiss...", "purrrrrr", "yowOWROWWowowr"];
type UseChatOptions = {
selectedConversation: Conversation | null;
createConversation: () => Promise<Conversation>;
refreshConversations: () => Promise<void>;
onSessionExpired: () => void;
scrollToBottom: () => void;
};
export function useChat({
selectedConversation,
createConversation,
refreshConversations,
onSessionExpired,
scrollToBottom,
}: UseChatOptions) {
const [messages, setMessages] = useState<Message[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [pendingImage, setPendingImage] = useState<File | null>(null);
const isMountedRef = useRef(true);
const abortControllerRef = useRef<AbortController | null>(null);
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
abortControllerRef.current?.abort();
};
}, []);
const updateMessages = useCallback(
(updater: Message[] | ((prev: Message[]) => Message[])) => {
setMessages(updater);
scrollToBottom();
},
[scrollToBottom],
);
const sendMessage = useCallback(
async (query: string, simbaMode: boolean) => {
if ((!query.trim() && !pendingImage) || isLoading) return;
let activeConversation = selectedConversation;
let createdNew = false;
if (!activeConversation) {
activeConversation = await createConversation();
createdNew = true;
}
const imageFile = pendingImage;
updateMessages((prev) => prev.concat([{ text: query, speaker: "user" }]));
setPendingImage(null);
setIsLoading(true);
if (simbaMode) {
const randomElement =
simbaAnswers[Math.floor(Math.random() * simbaAnswers.length)];
updateMessages((prev) =>
prev.concat([{ text: randomElement, speaker: "simba" }]),
);
setIsLoading(false);
return;
}
const abortController = new AbortController();
abortControllerRef.current = abortController;
try {
let imageKey: string | undefined;
if (imageFile) {
const uploadResult = await conversationService.uploadImage(
imageFile,
activeConversation.id,
);
imageKey = uploadResult.image_key;
updateMessages((prev) => {
const updated = [...prev];
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}...`;
updateMessages((prev) =>
prev.concat([{ text: friendly, speaker: "tool" }]),
);
} else if (event.type === "response") {
updateMessages((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")
) {
onSessionExpired();
}
}
} finally {
if (isMountedRef.current) {
setIsLoading(false);
if (createdNew) {
refreshConversations();
}
}
abortControllerRef.current = null;
}
},
[
pendingImage,
isLoading,
selectedConversation,
createConversation,
refreshConversations,
onSessionExpired,
updateMessages,
],
);
return {
messages,
setMessages: updateMessages,
isLoading,
pendingImage,
setPendingImage,
sendMessage,
};
}
@@ -0,0 +1,69 @@
import { useState, useCallback, useEffect } from "react";
import { conversationService } from "../api/conversationService";
export type Conversation = {
title: string;
id: string;
};
type Message = {
text: string;
speaker: "simba" | "user" | "tool";
image_key?: string | null;
};
export function useConversations() {
const [conversations, setConversations] = useState<Conversation[]>([]);
const [selectedConversation, setSelectedConversation] =
useState<Conversation | null>(null);
const refreshConversations = useCallback(async () => {
try {
const fetched = await conversationService.getAllConversations();
setConversations(fetched.map((c) => ({ id: c.id, title: c.name })));
} catch (err) {
console.error("Failed to load conversations:", err);
}
}, []);
useEffect(() => {
refreshConversations();
}, [refreshConversations]);
const selectConversation = useCallback(
async (conversation: Conversation): Promise<Message[]> => {
setSelectedConversation(conversation);
try {
const fetched = await conversationService.getConversation(
conversation.id,
);
return fetched.messages.map((m) => ({
text: m.text,
speaker: m.speaker,
image_key: m.image_key,
}));
} catch (err) {
console.error("Failed to load messages:", err);
return [];
}
},
[],
);
const createConversation = useCallback(async (): Promise<Conversation> => {
const newConv = await conversationService.createConversation();
const conversation = { title: newConv.name, id: newConv.id };
setConversations((prev) => [conversation, ...prev]);
setSelectedConversation(conversation);
return conversation;
}, []);
return {
conversations,
selectedConversation,
setSelectedConversation,
selectConversation,
createConversation,
refreshConversations,
};
}
+59
View File
@@ -0,0 +1,59 @@
import { useState, useEffect } from "react";
import { userService } from "../api/userService";
import { oidcService } from "../api/oidcService";
type UseOIDCAuthOptions = {
setAuthenticated: (isAuth: boolean) => void;
};
export function useOIDCAuth({ setAuthenticated }: UseOIDCAuthOptions) {
const [isChecking, setIsChecking] = useState(true);
const [isLoggingIn, setIsLoggingIn] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
const initAuth = async () => {
const callbackParams = oidcService.getCallbackParamsFromURL();
if (callbackParams) {
try {
setIsLoggingIn(true);
const result = await oidcService.handleCallback(
callbackParams.code,
callbackParams.state,
);
localStorage.setItem("access_token", result.access_token);
localStorage.setItem("refresh_token", result.refresh_token);
oidcService.clearCallbackParams();
setAuthenticated(true);
setIsChecking(false);
return;
} catch (err) {
console.error("OIDC callback error:", err);
setError("Login failed. Please try again.");
oidcService.clearCallbackParams();
setIsLoggingIn(false);
setIsChecking(false);
return;
}
}
const isValid = await userService.validateToken();
if (isValid) setAuthenticated(true);
setIsChecking(false);
};
initAuth();
}, [setAuthenticated]);
const handleLogin = async () => {
try {
setIsLoggingIn(true);
setError("");
const authUrl = await oidcService.initiateLogin();
window.location.href = authUrl;
} catch {
setError("Failed to initiate login. Please try again.");
setIsLoggingIn(false);
}
};
return { isChecking, isLoggingIn, error, handleLogin };
}
@@ -0,0 +1,34 @@
import { useState, useEffect } from "react";
import { conversationService } from "../api/conversationService";
const urlCache = new Map<string, string>();
export function usePresignedUrl(imageKey: string | null | undefined) {
const [imageUrl, setImageUrl] = useState<string | null>(
imageKey ? (urlCache.get(imageKey) ?? null) : null,
);
const [imageError, setImageError] = useState(false);
useEffect(() => {
if (!imageKey) return;
const cached = urlCache.get(imageKey);
if (cached) {
setImageUrl(cached);
return;
}
conversationService
.getPresignedImageUrl(imageKey)
.then((url) => {
urlCache.set(imageKey, url);
setImageUrl(url);
})
.catch((err) => {
console.error("Failed to load image:", err);
setImageError(true);
});
}, [imageKey]);
return { imageUrl, imageError };
}