import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import type { MutableRefObject } from 'react'; import { api, authenticatedFetch } from '../../utils/api'; import type { ChatMessage, Provider } from '../../components/chat/types'; import type { Project, ProjectSession } from '../../types/app'; import { safeLocalStorage } from '../../components/chat/utils/chatStorage'; import { convertCursorSessionMessages, convertSessionMessages, createCachedDiffCalculator, type DiffCalculator, } from '../../components/chat/utils/messageTransforms'; const MESSAGES_PER_PAGE = 20; const INITIAL_VISIBLE_MESSAGES = 100; type PendingViewSession = { sessionId: string | null; startedAt: number; }; interface UseChatSessionStateArgs { selectedProject: Project | null; selectedSession: ProjectSession | null; ws: WebSocket | null; sendMessage: (message: unknown) => void; autoScrollToBottom?: boolean; externalMessageUpdate?: number; processingSessions?: Set; resetStreamingState: () => void; pendingViewSessionRef: MutableRefObject; } interface ScrollRestoreState { height: number; top: number; } export function useChatSessionState({ selectedProject, selectedSession, ws, sendMessage, autoScrollToBottom, externalMessageUpdate, processingSessions, resetStreamingState, pendingViewSessionRef, }: UseChatSessionStateArgs) { const [chatMessages, setChatMessages] = useState(() => { if (typeof window !== 'undefined' && selectedProject) { const saved = safeLocalStorage.getItem(`chat_messages_${selectedProject.name}`); return saved ? (JSON.parse(saved) as ChatMessage[]) : []; } return []; }); const [isLoading, setIsLoading] = useState(false); const [currentSessionId, setCurrentSessionId] = useState(selectedSession?.id || null); const [sessionMessages, setSessionMessages] = useState([]); const [isLoadingSessionMessages, setIsLoadingSessionMessages] = useState(false); const [isLoadingMoreMessages, setIsLoadingMoreMessages] = useState(false); const [messagesOffset, setMessagesOffset] = useState(0); const [hasMoreMessages, setHasMoreMessages] = useState(false); const [totalMessages, setTotalMessages] = useState(0); const [isSystemSessionChange, setIsSystemSessionChange] = useState(false); const [canAbortSession, setCanAbortSession] = useState(false); const [isUserScrolledUp, setIsUserScrolledUp] = useState(false); const [tokenBudget, setTokenBudget] = useState | null>(null); const [visibleMessageCount, setVisibleMessageCount] = useState(INITIAL_VISIBLE_MESSAGES); const [claudeStatus, setClaudeStatus] = useState<{ text: string; tokens: number; can_interrupt: boolean } | null>(null); const scrollContainerRef = useRef(null); const isLoadingSessionRef = useRef(false); const isLoadingMoreRef = useRef(false); const topLoadLockRef = useRef(false); const pendingScrollRestoreRef = useRef(null); const scrollPositionRef = useRef({ height: 0, top: 0 }); const createDiff = useMemo(() => createCachedDiffCalculator(), []); const loadSessionMessages = useCallback( async (projectName: string, sessionId: string, loadMore = false, provider: Provider | string = 'claude') => { if (!projectName || !sessionId) { return [] as any[]; } const isInitialLoad = !loadMore; if (isInitialLoad) { setIsLoadingSessionMessages(true); } else { setIsLoadingMoreMessages(true); } try { const currentOffset = loadMore ? messagesOffset : 0; const response = await (api.sessionMessages as any)( projectName, sessionId, MESSAGES_PER_PAGE, currentOffset, provider, ); if (!response.ok) { throw new Error('Failed to load session messages'); } const data = await response.json(); if (isInitialLoad && data.tokenUsage) { setTokenBudget(data.tokenUsage); } if (data.hasMore !== undefined) { setHasMoreMessages(Boolean(data.hasMore)); setTotalMessages(Number(data.total || 0)); setMessagesOffset(currentOffset + (data.messages?.length || 0)); return data.messages || []; } const messages = data.messages || []; setHasMoreMessages(false); setTotalMessages(messages.length); return messages; } catch (error) { console.error('Error loading session messages:', error); return []; } finally { if (isInitialLoad) { setIsLoadingSessionMessages(false); } else { setIsLoadingMoreMessages(false); } } }, [messagesOffset], ); const loadCursorSessionMessages = useCallback(async (projectPath: string, sessionId: string) => { if (!projectPath || !sessionId) { return [] as ChatMessage[]; } setIsLoadingSessionMessages(true); try { const url = `/api/cursor/sessions/${encodeURIComponent(sessionId)}?projectPath=${encodeURIComponent(projectPath)}`; const response = await authenticatedFetch(url); if (!response.ok) { return []; } const data = await response.json(); const blobs = (data?.session?.messages || []) as any[]; return convertCursorSessionMessages(blobs, projectPath); } catch (error) { console.error('Error loading Cursor session messages:', error); return []; } finally { setIsLoadingSessionMessages(false); } }, []); const convertedMessages = useMemo(() => { return convertSessionMessages(sessionMessages); }, [sessionMessages]); const scrollToBottom = useCallback(() => { const container = scrollContainerRef.current; if (!container) { return; } container.scrollTop = container.scrollHeight; }, []); const isNearBottom = useCallback(() => { const container = scrollContainerRef.current; if (!container) { return false; } const { scrollTop, scrollHeight, clientHeight } = container; return scrollHeight - scrollTop - clientHeight < 50; }, []); const loadOlderMessages = useCallback( async (container: HTMLDivElement) => { if (!container || isLoadingMoreRef.current || isLoadingMoreMessages) { return false; } if (!hasMoreMessages || !selectedSession || !selectedProject) { return false; } const sessionProvider = selectedSession.__provider || 'claude'; if (sessionProvider === 'cursor') { return false; } isLoadingMoreRef.current = true; const previousScrollHeight = container.scrollHeight; const previousScrollTop = container.scrollTop; try { const moreMessages = await loadSessionMessages( selectedProject.name, selectedSession.id, true, sessionProvider, ); if (moreMessages.length > 0) { pendingScrollRestoreRef.current = { height: previousScrollHeight, top: previousScrollTop, }; setSessionMessages((previous) => [...moreMessages, ...previous]); } return true; } finally { isLoadingMoreRef.current = false; } }, [hasMoreMessages, isLoadingMoreMessages, loadSessionMessages, selectedProject, selectedSession], ); const handleScroll = useCallback(async () => { const container = scrollContainerRef.current; if (!container) { return; } const nearBottom = isNearBottom(); setIsUserScrolledUp(!nearBottom); const scrolledNearTop = container.scrollTop < 100; if (!scrolledNearTop) { topLoadLockRef.current = false; return; } if (topLoadLockRef.current) { return; } const didLoad = await loadOlderMessages(container); if (didLoad) { topLoadLockRef.current = true; } }, [isNearBottom, loadOlderMessages]); useLayoutEffect(() => { if (!pendingScrollRestoreRef.current || !scrollContainerRef.current) { return; } const { height, top } = pendingScrollRestoreRef.current; const container = scrollContainerRef.current; const newScrollHeight = container.scrollHeight; const scrollDiff = newScrollHeight - height; container.scrollTop = top + Math.max(scrollDiff, 0); pendingScrollRestoreRef.current = null; }, [chatMessages.length]); useEffect(() => { const loadMessages = async () => { if (selectedSession && selectedProject) { const provider = (localStorage.getItem('selected-provider') as Provider) || 'claude'; isLoadingSessionRef.current = true; const sessionChanged = currentSessionId !== null && currentSessionId !== selectedSession.id; if (sessionChanged) { if (!isSystemSessionChange) { resetStreamingState(); pendingViewSessionRef.current = null; setChatMessages([]); setSessionMessages([]); setClaudeStatus(null); setCanAbortSession(false); } setMessagesOffset(0); setHasMoreMessages(false); setTotalMessages(0); setTokenBudget(null); setIsLoading(false); if (ws) { sendMessage({ type: 'check-session-status', sessionId: selectedSession.id, provider, }); } } else if (currentSessionId === null) { setMessagesOffset(0); setHasMoreMessages(false); setTotalMessages(0); if (ws) { sendMessage({ type: 'check-session-status', sessionId: selectedSession.id, provider, }); } } if (provider === 'cursor') { setCurrentSessionId(selectedSession.id); sessionStorage.setItem('cursorSessionId', selectedSession.id); if (!isSystemSessionChange) { const projectPath = selectedProject.fullPath || selectedProject.path || ''; const converted = await loadCursorSessionMessages(projectPath, selectedSession.id); setSessionMessages([]); setChatMessages(converted); } else { setIsSystemSessionChange(false); } } else { setCurrentSessionId(selectedSession.id); if (!isSystemSessionChange) { const messages = await loadSessionMessages( selectedProject.name, selectedSession.id, false, selectedSession.__provider || 'claude', ); setSessionMessages(messages); } else { setIsSystemSessionChange(false); } } } else { if (!isSystemSessionChange) { resetStreamingState(); pendingViewSessionRef.current = null; setChatMessages([]); setSessionMessages([]); setClaudeStatus(null); setCanAbortSession(false); setIsLoading(false); } setCurrentSessionId(null); sessionStorage.removeItem('cursorSessionId'); setMessagesOffset(0); setHasMoreMessages(false); setTotalMessages(0); setTokenBudget(null); } setTimeout(() => { isLoadingSessionRef.current = false; }, 250); }; loadMessages(); }, [ currentSessionId, isSystemSessionChange, loadCursorSessionMessages, loadSessionMessages, pendingViewSessionRef, resetStreamingState, selectedProject, selectedSession, sendMessage, ws, ]); useEffect(() => { if (!externalMessageUpdate || !selectedSession || !selectedProject) { return; } const reloadExternalMessages = async () => { try { const provider = (localStorage.getItem('selected-provider') as Provider) || 'claude'; if (provider === 'cursor') { const projectPath = selectedProject.fullPath || selectedProject.path || ''; const converted = await loadCursorSessionMessages(projectPath, selectedSession.id); setSessionMessages([]); setChatMessages(converted); return; } const messages = await loadSessionMessages( selectedProject.name, selectedSession.id, false, selectedSession.__provider || 'claude', ); setSessionMessages(messages); const shouldAutoScroll = Boolean(autoScrollToBottom) && isNearBottom(); if (shouldAutoScroll) { setTimeout(() => scrollToBottom(), 200); } } catch (error) { console.error('Error reloading messages from external update:', error); } }; reloadExternalMessages(); }, [ autoScrollToBottom, externalMessageUpdate, isNearBottom, loadCursorSessionMessages, loadSessionMessages, scrollToBottom, selectedProject, selectedSession, ]); useEffect(() => { if (selectedSession?.id) { pendingViewSessionRef.current = null; } }, [pendingViewSessionRef, selectedSession?.id]); useEffect(() => { if (sessionMessages.length > 0) { setChatMessages(convertedMessages); } }, [convertedMessages, sessionMessages.length]); useEffect(() => { if (selectedProject && chatMessages.length > 0) { safeLocalStorage.setItem(`chat_messages_${selectedProject.name}`, JSON.stringify(chatMessages)); } }, [chatMessages, selectedProject]); useEffect(() => { if (!selectedProject || !selectedSession?.id || selectedSession.id.startsWith('new-session-')) { setTokenBudget(null); return; } const sessionProvider = selectedSession.__provider || 'claude'; if (sessionProvider !== 'claude') { return; } const fetchInitialTokenUsage = async () => { try { const url = `/api/projects/${selectedProject.name}/sessions/${selectedSession.id}/token-usage`; const response = await authenticatedFetch(url); if (response.ok) { const data = await response.json(); setTokenBudget(data); } else { setTokenBudget(null); } } catch (error) { console.error('Failed to fetch initial token usage:', error); } }; fetchInitialTokenUsage(); }, [selectedProject, selectedSession?.id, selectedSession?.__provider]); const visibleMessages = useMemo(() => { if (chatMessages.length <= visibleMessageCount) { return chatMessages; } return chatMessages.slice(-visibleMessageCount); }, [chatMessages, visibleMessageCount]); useEffect(() => { if (!autoScrollToBottom && scrollContainerRef.current) { const container = scrollContainerRef.current; scrollPositionRef.current = { height: container.scrollHeight, top: container.scrollTop, }; } }); useEffect(() => { if (!scrollContainerRef.current || chatMessages.length === 0) { return; } if (autoScrollToBottom) { if (!isUserScrolledUp) { setTimeout(() => scrollToBottom(), 50); } return; } const container = scrollContainerRef.current; const prevHeight = scrollPositionRef.current.height; const prevTop = scrollPositionRef.current.top; const newHeight = container.scrollHeight; const heightDiff = newHeight - prevHeight; if (heightDiff > 0 && prevTop > 0) { container.scrollTop = prevTop + heightDiff; } }, [autoScrollToBottom, chatMessages.length, isUserScrolledUp, scrollToBottom]); useEffect(() => { if (!scrollContainerRef.current || chatMessages.length === 0 || isLoadingSessionRef.current) { return; } setIsUserScrolledUp(false); setTimeout(() => { scrollToBottom(); }, 200); }, [chatMessages.length, scrollToBottom, selectedProject?.name, selectedSession?.id]); useEffect(() => { const container = scrollContainerRef.current; if (!container) { return; } container.addEventListener('scroll', handleScroll); return () => container.removeEventListener('scroll', handleScroll); }, [handleScroll]); useEffect(() => { if (!currentSessionId || !processingSessions) { return; } const shouldBeProcessing = processingSessions.has(currentSessionId); if (shouldBeProcessing && !isLoading) { setIsLoading(true); setCanAbortSession(true); } }, [currentSessionId, isLoading, processingSessions]); const loadEarlierMessages = useCallback(() => { setVisibleMessageCount((previousCount) => previousCount + 100); }, []); return { chatMessages, setChatMessages, isLoading, setIsLoading, currentSessionId, setCurrentSessionId, sessionMessages, setSessionMessages, isLoadingSessionMessages, isLoadingMoreMessages, hasMoreMessages, totalMessages, isSystemSessionChange, setIsSystemSessionChange, canAbortSession, setCanAbortSession, isUserScrolledUp, setIsUserScrolledUp, tokenBudget, setTokenBudget, visibleMessageCount, visibleMessages, loadEarlierMessages, claudeStatus, setClaudeStatus, createDiff, scrollContainerRef, scrollToBottom, isNearBottom, handleScroll, loadSessionMessages, loadCursorSessionMessages, }; }