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
+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 };
}