import { useCallback, useEffect, useRef, useState } from 'react'; import { toast } from 'sonner'; import { api } from '@/api/client'; import type { Chat } from '@/api/types'; import { sessionEvents } from '@/hooks/sessionEvents'; export interface UseSessionChatsOpts { removeChatFromPanes: (chatId: string) => void; openChatInPane: (paneIdx: number, chatId: string) => void; // Thin wrapper around openChatInPane(activePaneIdxRef.current, chatId); // built by Workspace and passed in so this hook doesn't need to know // about pane indexing. openChatInActivePane: (chatId: string) => void; initializeFirstChatIfEmpty: (chatId: string) => void; validatePanes: (validChatIds: Set) => void; } export interface UseSessionChatsResult { chats: Chat[]; setChats: React.Dispatch>; createChat: (paneIdx: number) => Promise; archiveChat: (chatId: string) => Promise; unarchiveChat: (chatId: string) => Promise; deleteChat: (chatId: string) => Promise; renameChat: (chatId: string, name: string) => Promise; handleLandingSend: (paneIdx: number, content: string) => Promise; } export function useSessionChats( sessionId: string, opts: UseSessionChatsOpts, ): UseSessionChatsResult { const [chats, setChats] = useState([]); const chatsRef = useRef([]); chatsRef.current = chats; // Stable refs to opts callbacks so the subscription effect — which only // re-runs on sessionId change — always sees the latest closures without // unsubscribe/resubscribe churn. const removeChatFromPanesRef = useRef(opts.removeChatFromPanes); removeChatFromPanesRef.current = opts.removeChatFromPanes; const openChatInPaneRef = useRef(opts.openChatInPane); openChatInPaneRef.current = opts.openChatInPane; const openChatInActivePaneRef = useRef(opts.openChatInActivePane); openChatInActivePaneRef.current = opts.openChatInActivePane; const initializeFirstChatIfEmptyRef = useRef(opts.initializeFirstChatIfEmpty); initializeFirstChatIfEmptyRef.current = opts.initializeFirstChatIfEmpty; const validatePanesRef = useRef(opts.validatePanes); validatePanesRef.current = opts.validatePanes; useEffect(() => { let cancelled = false; api.chats.listForSession(sessionId).then((list) => { if (cancelled) return; setChats(list); validatePanesRef.current(new Set(list.map((c) => c.id))); const openChat = list.find((c) => c.status === 'open'); if (openChat) { initializeFirstChatIfEmptyRef.current(openChat.id); } }).catch(() => {}); return () => { cancelled = true; }; }, [sessionId]); useEffect(() => { return sessionEvents.subscribe((event) => { if (event.type === 'chat_created' && event.session_id === sessionId) { setChats((prev) => { if (prev.some((c) => c.id === event.chat.id)) return prev; return [event.chat, ...prev]; }); } if (event.type === 'chat_updated') { setChats((prev) => prev.map((c) => c.id === event.chat_id ? { ...c, name: event.name, updated_at: event.updated_at } : c )); } if (event.type === 'chat_archived') { setChats((prev) => prev.map((c) => c.id === event.chat_id ? { ...c, status: 'archived' as const } : c )); removeChatFromPanesRef.current(event.chat_id); } if (event.type === 'chat_unarchived') { setChats((prev) => { if (prev.some((c) => c.id === event.chat.id)) { return prev.map((c) => c.id === event.chat.id ? { ...c, status: 'open' as const } : c); } return [event.chat, ...prev]; }); } if (event.type === 'chat_deleted') { setChats((prev) => prev.filter((c) => c.id !== event.chat_id)); removeChatFromPanesRef.current(event.chat_id); } if (event.type === 'open_chat_in_active_pane') { openChatInActivePaneRef.current(event.chat_id); } }); }, [sessionId]); const createChat = useCallback(async (paneIdx: number) => { try { const chat = await api.chats.create(sessionId); // Optimistic local insert; the WS chat_created echo will be deduped by id. setChats((prev) => { if (prev.some((c) => c.id === chat.id)) return prev; return [chat, ...prev]; }); openChatInPaneRef.current(paneIdx, chat.id); } catch (err) { toast.error(err instanceof Error ? err.message : 'Failed to create chat'); } }, [sessionId]); const archiveChat = useCallback(async (chatId: string) => { try { await api.chats.archive(chatId); // Server publishes chat_archived; bus forwarder updates state. } catch (err) { toast.error(err instanceof Error ? err.message : 'Failed to archive chat'); } }, []); const unarchiveChat = useCallback(async (chatId: string) => { try { await api.chats.unarchive(chatId); // Server publishes chat_unarchived. } catch (err) { toast.error(err instanceof Error ? err.message : 'Failed to restore chat'); } }, []); const deleteChat = useCallback(async (chatId: string) => { try { await api.chats.remove(chatId); setChats((prev) => prev.filter((c) => c.id !== chatId)); removeChatFromPanesRef.current(chatId); } catch (err) { toast.error(err instanceof Error ? err.message : 'Failed to delete chat'); } }, []); const renameChat = useCallback(async (chatId: string, name: string) => { try { await api.chats.update(chatId, { name }); setChats((prev) => prev.map((c) => c.id === chatId ? { ...c, name } : c )); } catch (err) { toast.error(err instanceof Error ? err.message : 'Failed to rename chat'); } }, []); const handleLandingSend = useCallback(async (paneIdx: number, content: string) => { try { const chat = await api.chats.create(sessionId); setChats((prev) => { if (prev.some((c) => c.id === chat.id)) return prev; return [chat, ...prev]; }); openChatInPaneRef.current(paneIdx, chat.id); await api.messages.send(chat.id, content); } catch (err) { toast.error(err instanceof Error ? err.message : 'Failed to send'); } }, [sessionId]); return { chats, setChats, createChat, archiveChat, unarchiveChat, deleteChat, renameChat, handleLandingSend, }; }