import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import type { MutableRefObject } from 'react'; import { authenticatedFetch } from '../../../utils/api'; import type { ChatMessage, Provider } from '../types/types'; import type { Project, ProjectSession, SessionProvider } from '../../../types/app'; import { createCachedDiffCalculator, type DiffCalculator } from '../utils/messageTransforms'; import { normalizedToChatMessages } from './useChatMessages'; import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore'; 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; sessionStore: SessionStore; } interface ScrollRestoreState { height: number; top: number; } /* ------------------------------------------------------------------ */ /* Helper: Convert a ChatMessage to a NormalizedMessage for the store */ /* ------------------------------------------------------------------ */ function chatMessageToNormalized( msg: ChatMessage, sessionId: string, provider: SessionProvider, ): NormalizedMessage | null { const id = `local_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; const ts = msg.timestamp instanceof Date ? msg.timestamp.toISOString() : typeof msg.timestamp === 'number' ? new Date(msg.timestamp).toISOString() : String(msg.timestamp); const base = { id, sessionId, timestamp: ts, provider }; if (msg.isToolUse) { return { ...base, kind: 'tool_use', toolName: msg.toolName, toolInput: msg.toolInput, toolId: msg.toolId || id, } as NormalizedMessage; } if (msg.isThinking) { return { ...base, kind: 'thinking', content: msg.content || '' } as NormalizedMessage; } if (msg.isInteractivePrompt) { return { ...base, kind: 'interactive_prompt', content: msg.content || '' } as NormalizedMessage; } if ((msg as any).isTaskNotification) { return { ...base, kind: 'task_notification', status: (msg as any).taskStatus || 'completed', summary: msg.content || '', } as NormalizedMessage; } if (msg.type === 'error') { return { ...base, kind: 'error', content: msg.content || '' } as NormalizedMessage; } return { ...base, kind: 'text', role: msg.type === 'user' ? 'user' : 'assistant', content: msg.content || '', } as NormalizedMessage; } /* ------------------------------------------------------------------ */ /* Hook */ /* ------------------------------------------------------------------ */ export function useChatSessionState({ selectedProject, selectedSession, ws, sendMessage, autoScrollToBottom, externalMessageUpdate, processingSessions, resetStreamingState, pendingViewSessionRef, sessionStore, }: UseChatSessionStateArgs) { const [isLoading, setIsLoading] = useState(false); const [currentSessionId, setCurrentSessionId] = useState(selectedSession?.id || null); const [isLoadingSessionMessages, setIsLoadingSessionMessages] = useState(false); const [isLoadingMoreMessages, setIsLoadingMoreMessages] = useState(false); const [hasMoreMessages, setHasMoreMessages] = useState(false); const [totalMessages, setTotalMessages] = useState(0); 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 [allMessagesLoaded, setAllMessagesLoaded] = useState(false); const [isLoadingAllMessages, setIsLoadingAllMessages] = useState(false); const [loadAllJustFinished, setLoadAllJustFinished] = useState(false); const [showLoadAllOverlay, setShowLoadAllOverlay] = useState(false); const [viewHiddenCount, setViewHiddenCount] = useState(0); const scrollContainerRef = useRef(null); const [searchTarget, setSearchTarget] = useState<{ timestamp?: string; uuid?: string; snippet?: string } | null>(null); const searchScrollActiveRef = useRef(false); const isLoadingSessionRef = useRef(false); const isLoadingMoreRef = useRef(false); const allMessagesLoadedRef = useRef(false); const topLoadLockRef = useRef(false); const pendingScrollRestoreRef = useRef(null); const pendingInitialScrollRef = useRef(true); const messagesOffsetRef = useRef(0); const scrollPositionRef = useRef({ height: 0, top: 0 }); const loadAllFinishedTimerRef = useRef | null>(null); const loadAllOverlayTimerRef = useRef | null>(null); const lastLoadedSessionKeyRef = useRef(null); const createDiff = useMemo(() => createCachedDiffCalculator(), []); /* ---------------------------------------------------------------- */ /* Derive chatMessages from the store */ /* ---------------------------------------------------------------- */ const activeSessionId = selectedSession?.id || currentSessionId || null; const [pendingUserMessage, setPendingUserMessage] = useState(null); // Tell the store which session we're viewing so it only re-renders for this one const prevActiveForStoreRef = useRef(null); if (activeSessionId !== prevActiveForStoreRef.current) { prevActiveForStoreRef.current = activeSessionId; sessionStore.setActiveSession(activeSessionId); } // When a real session ID arrives and we have a pending user message, flush it to the store const prevActiveSessionRef = useRef(null); if (activeSessionId && activeSessionId !== prevActiveSessionRef.current && pendingUserMessage) { const prov = (localStorage.getItem('selected-provider') as SessionProvider) || 'claude'; const normalized = chatMessageToNormalized(pendingUserMessage, activeSessionId, prov); if (normalized) { sessionStore.appendRealtime(activeSessionId, normalized); } setPendingUserMessage(null); } prevActiveSessionRef.current = activeSessionId; const storeMessages = activeSessionId ? sessionStore.getMessages(activeSessionId) : []; // Reset viewHiddenCount when store messages change const prevStoreLenRef = useRef(0); if (storeMessages.length !== prevStoreLenRef.current) { prevStoreLenRef.current = storeMessages.length; if (viewHiddenCount > 0) setViewHiddenCount(0); } const chatMessages = useMemo(() => { const all = normalizedToChatMessages(storeMessages); // Show pending user message when no session data exists yet (new session, pre-backend-response) if (pendingUserMessage && all.length === 0) { return [pendingUserMessage]; } if (viewHiddenCount > 0 && viewHiddenCount < all.length) return all.slice(0, -viewHiddenCount); return all; }, [storeMessages, viewHiddenCount, pendingUserMessage]); /* ---------------------------------------------------------------- */ /* addMessage / clearMessages / rewindMessages */ /* ---------------------------------------------------------------- */ const addMessage = useCallback((msg: ChatMessage) => { if (!activeSessionId) { // No session yet — show as pending until the backend creates one setPendingUserMessage(msg); return; } const prov = (localStorage.getItem('selected-provider') as SessionProvider) || 'claude'; const normalized = chatMessageToNormalized(msg, activeSessionId, prov); if (normalized) { sessionStore.appendRealtime(activeSessionId, normalized); } }, [activeSessionId, sessionStore]); const clearMessages = useCallback(() => { if (!activeSessionId) return; sessionStore.clearRealtime(activeSessionId); }, [activeSessionId, sessionStore]); const rewindMessages = useCallback((count: number) => setViewHiddenCount(count), []); const scrollToBottom = useCallback(() => { const container = scrollContainerRef.current; if (!container) return; container.scrollTop = container.scrollHeight; }, []); const scrollToBottomAndReset = useCallback(() => { scrollToBottom(); if (allMessagesLoaded) { setVisibleMessageCount(INITIAL_VISIBLE_MESSAGES); setAllMessagesLoaded(false); allMessagesLoadedRef.current = false; } }, [allMessagesLoaded, scrollToBottom]); 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 (allMessagesLoadedRef.current) 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 slot = await sessionStore.fetchMore(selectedSession.id, { provider: sessionProvider as SessionProvider, projectName: selectedProject.name, projectPath: selectedProject.fullPath || selectedProject.path || '', limit: MESSAGES_PER_PAGE, }); if (!slot || slot.serverMessages.length === 0) return false; pendingScrollRestoreRef.current = { height: previousScrollHeight, top: previousScrollTop }; setHasMoreMessages(slot.hasMore); setTotalMessages(slot.total); setVisibleMessageCount((prev) => prev + MESSAGES_PER_PAGE); return true; } finally { isLoadingMoreRef.current = false; } }, [hasMoreMessages, isLoadingMoreMessages, selectedProject, selectedSession, sessionStore], ); const handleScroll = useCallback(async () => { const container = scrollContainerRef.current; if (!container) return; const nearBottom = isNearBottom(); setIsUserScrolledUp(!nearBottom); if (!allMessagesLoadedRef.current) { const scrolledNearTop = container.scrollTop < 100; if (!scrolledNearTop) { topLoadLockRef.current = false; return; } if (topLoadLockRef.current) { if (container.scrollTop > 20) topLoadLockRef.current = false; 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; container.scrollTop = top + Math.max(newScrollHeight - height, 0); pendingScrollRestoreRef.current = null; }, [chatMessages.length]); // Reset scroll/pagination state on session change useEffect(() => { if (!searchScrollActiveRef.current) { pendingInitialScrollRef.current = true; setVisibleMessageCount(INITIAL_VISIBLE_MESSAGES); } topLoadLockRef.current = false; pendingScrollRestoreRef.current = null; setIsUserScrolledUp(false); }, [selectedProject?.name, selectedSession?.id]); // Initial scroll to bottom useEffect(() => { if (!pendingInitialScrollRef.current || !scrollContainerRef.current || isLoadingSessionMessages) return; if (chatMessages.length === 0) { pendingInitialScrollRef.current = false; return; } pendingInitialScrollRef.current = false; if (!searchScrollActiveRef.current) setTimeout(() => scrollToBottom(), 200); }, [chatMessages.length, isLoadingSessionMessages, scrollToBottom]); // Main session loading effect — store-based useEffect(() => { if (!selectedSession || !selectedProject) { resetStreamingState(); pendingViewSessionRef.current = null; setClaudeStatus(null); setCanAbortSession(false); setIsLoading(false); setCurrentSessionId(null); sessionStorage.removeItem('cursorSessionId'); messagesOffsetRef.current = 0; setHasMoreMessages(false); setTotalMessages(0); setTokenBudget(null); lastLoadedSessionKeyRef.current = null; return; } const provider = (selectedSession.__provider || localStorage.getItem('selected-provider') as Provider) || 'claude'; const sessionKey = `${selectedSession.id}:${selectedProject.name}:${provider}`; // Skip if already loaded and fresh if (lastLoadedSessionKeyRef.current === sessionKey && sessionStore.has(selectedSession.id) && !sessionStore.isStale(selectedSession.id)) { return; } const sessionChanged = currentSessionId !== null && currentSessionId !== selectedSession.id; if (sessionChanged) { resetStreamingState(); pendingViewSessionRef.current = null; setClaudeStatus(null); setCanAbortSession(false); } // Reset pagination/scroll state messagesOffsetRef.current = 0; setHasMoreMessages(false); setTotalMessages(0); setVisibleMessageCount(INITIAL_VISIBLE_MESSAGES); setAllMessagesLoaded(false); allMessagesLoadedRef.current = false; setIsLoadingAllMessages(false); setLoadAllJustFinished(false); setShowLoadAllOverlay(false); setViewHiddenCount(0); if (loadAllOverlayTimerRef.current) clearTimeout(loadAllOverlayTimerRef.current); if (loadAllFinishedTimerRef.current) clearTimeout(loadAllFinishedTimerRef.current); if (sessionChanged) { setTokenBudget(null); setIsLoading(false); } setCurrentSessionId(selectedSession.id); if (provider === 'cursor') { sessionStorage.setItem('cursorSessionId', selectedSession.id); } // Check session status if (ws) { sendMessage({ type: 'check-session-status', sessionId: selectedSession.id, provider }); } lastLoadedSessionKeyRef.current = sessionKey; // Fetch from server → store updates → chatMessages re-derives automatically setIsLoadingSessionMessages(true); sessionStore.fetchFromServer(selectedSession.id, { provider: (selectedSession.__provider || provider) as SessionProvider, projectName: selectedProject.name, projectPath: selectedProject.fullPath || selectedProject.path || '', limit: MESSAGES_PER_PAGE, offset: 0, }).then(slot => { if (slot) { setHasMoreMessages(slot.hasMore); setTotalMessages(slot.total); if (slot.tokenUsage) setTokenBudget(slot.tokenUsage as Record); } setIsLoadingSessionMessages(false); }).catch(() => { setIsLoadingSessionMessages(false); }); }, [ pendingViewSessionRef, resetStreamingState, selectedProject, selectedSession?.id, sendMessage, ws, sessionStore, ]); // External message update (e.g. WebSocket reconnect, background refresh) useEffect(() => { if (!externalMessageUpdate || !selectedSession || !selectedProject) return; const reloadExternalMessages = async () => { try { const provider = (localStorage.getItem('selected-provider') as Provider) || 'claude'; // Skip store refresh during active streaming if (!isLoading) { await sessionStore.refreshFromServer(selectedSession.id, { provider: (selectedSession.__provider || provider) as SessionProvider, projectName: selectedProject.name, projectPath: selectedProject.fullPath || selectedProject.path || '', }); if (Boolean(autoScrollToBottom) && isNearBottom()) { setTimeout(() => scrollToBottom(), 200); } } } catch (error) { console.error('Error reloading messages from external update:', error); } }; reloadExternalMessages(); }, [ autoScrollToBottom, externalMessageUpdate, isNearBottom, scrollToBottom, selectedProject, selectedSession, sessionStore, isLoading, ]); // Search navigation target useEffect(() => { const session = selectedSession as Record | null; const targetSnippet = session?.__searchTargetSnippet; const targetTimestamp = session?.__searchTargetTimestamp; if (typeof targetSnippet === 'string' && targetSnippet) { searchScrollActiveRef.current = true; setSearchTarget({ snippet: targetSnippet, timestamp: typeof targetTimestamp === 'string' ? targetTimestamp : undefined, }); } }, [selectedSession]); useEffect(() => { if (selectedSession?.id) pendingViewSessionRef.current = null; }, [pendingViewSessionRef, selectedSession?.id]); // Scroll to search target useEffect(() => { if (!searchTarget || chatMessages.length === 0 || isLoadingSessionMessages) return; const target = searchTarget; setSearchTarget(null); const scrollToTarget = async () => { if (!allMessagesLoadedRef.current && selectedSession && selectedProject) { const sessionProvider = selectedSession.__provider || 'claude'; if (sessionProvider !== 'cursor') { try { // Load all messages into the store for search navigation const slot = await sessionStore.fetchFromServer(selectedSession.id, { provider: sessionProvider as SessionProvider, projectName: selectedProject.name, projectPath: selectedProject.fullPath || selectedProject.path || '', limit: null, offset: 0, }); if (slot) { setHasMoreMessages(false); setTotalMessages(slot.total); messagesOffsetRef.current = slot.total; setVisibleMessageCount(Infinity); setAllMessagesLoaded(true); allMessagesLoadedRef.current = true; await new Promise(resolve => setTimeout(resolve, 300)); } } catch { // Fall through and scroll in current messages } } } setVisibleMessageCount(Infinity); const findAndScroll = (retriesLeft: number) => { const container = scrollContainerRef.current; if (!container) return; let targetElement: Element | null = null; if (target.snippet) { const cleanSnippet = target.snippet.replace(/^\.{3}/, '').replace(/\.{3}$/, '').trim(); const searchPhrase = cleanSnippet.slice(0, 80).toLowerCase().trim(); if (searchPhrase.length >= 10) { const messageElements = container.querySelectorAll('.chat-message'); for (const el of messageElements) { const text = (el.textContent || '').toLowerCase(); if (text.includes(searchPhrase)) { targetElement = el; break; } } } } if (!targetElement && target.timestamp) { const targetDate = new Date(target.timestamp).getTime(); const messageElements = container.querySelectorAll('[data-message-timestamp]'); let closestDiff = Infinity; for (const el of messageElements) { const ts = el.getAttribute('data-message-timestamp'); if (!ts) continue; const diff = Math.abs(new Date(ts).getTime() - targetDate); if (diff < closestDiff) { closestDiff = diff; targetElement = el; } } } if (targetElement) { targetElement.scrollIntoView({ block: 'center', behavior: 'smooth' }); targetElement.classList.add('search-highlight-flash'); setTimeout(() => targetElement?.classList.remove('search-highlight-flash'), 4000); searchScrollActiveRef.current = false; } else if (retriesLeft > 0) { setTimeout(() => findAndScroll(retriesLeft - 1), 200); } else { searchScrollActiveRef.current = false; } }; setTimeout(() => findAndScroll(15), 150); }; scrollToTarget(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [chatMessages.length, isLoadingSessionMessages, searchTarget]); // Token usage fetch for Claude 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) { setTokenBudget(await response.json()); } 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 (isLoadingMoreRef.current || isLoadingMoreMessages || pendingScrollRestoreRef.current) return; if (searchScrollActiveRef.current) 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, isLoadingMoreMessages, isUserScrolledUp, scrollToBottom]); useEffect(() => { const container = scrollContainerRef.current; if (!container) return; container.addEventListener('scroll', handleScroll); return () => container.removeEventListener('scroll', handleScroll); }, [handleScroll]); useEffect(() => { const activeViewSessionId = selectedSession?.id || currentSessionId; if (!activeViewSessionId || !processingSessions) return; const shouldBeProcessing = processingSessions.has(activeViewSessionId); if (shouldBeProcessing && !isLoading) { setIsLoading(true); setCanAbortSession(true); } }, [currentSessionId, isLoading, processingSessions, selectedSession?.id]); // "Load all" overlay const prevLoadingRef = useRef(false); useEffect(() => { const wasLoading = prevLoadingRef.current; prevLoadingRef.current = isLoadingMoreMessages; if (wasLoading && !isLoadingMoreMessages && hasMoreMessages) { if (loadAllOverlayTimerRef.current) clearTimeout(loadAllOverlayTimerRef.current); setShowLoadAllOverlay(true); loadAllOverlayTimerRef.current = setTimeout(() => setShowLoadAllOverlay(false), 2000); } if (!hasMoreMessages && !isLoadingMoreMessages) { if (loadAllOverlayTimerRef.current) clearTimeout(loadAllOverlayTimerRef.current); setShowLoadAllOverlay(false); } return () => { if (loadAllOverlayTimerRef.current) clearTimeout(loadAllOverlayTimerRef.current); }; }, [isLoadingMoreMessages, hasMoreMessages]); const loadAllMessages = useCallback(async () => { if (!selectedSession || !selectedProject) return; if (isLoadingAllMessages) return; const sessionProvider = selectedSession.__provider || 'claude'; if (sessionProvider === 'cursor') { setVisibleMessageCount(Infinity); setAllMessagesLoaded(true); allMessagesLoadedRef.current = true; setLoadAllJustFinished(true); if (loadAllFinishedTimerRef.current) clearTimeout(loadAllFinishedTimerRef.current); loadAllFinishedTimerRef.current = setTimeout(() => { setLoadAllJustFinished(false); setShowLoadAllOverlay(false); }, 1000); return; } const requestSessionId = selectedSession.id; allMessagesLoadedRef.current = true; isLoadingMoreRef.current = true; setIsLoadingAllMessages(true); setShowLoadAllOverlay(true); const container = scrollContainerRef.current; const previousScrollHeight = container ? container.scrollHeight : 0; const previousScrollTop = container ? container.scrollTop : 0; try { const slot = await sessionStore.fetchFromServer(requestSessionId, { provider: sessionProvider as SessionProvider, projectName: selectedProject.name, projectPath: selectedProject.fullPath || selectedProject.path || '', limit: null, offset: 0, }); if (currentSessionId !== requestSessionId) return; if (slot) { if (container) { pendingScrollRestoreRef.current = { height: previousScrollHeight, top: previousScrollTop }; } setHasMoreMessages(false); setTotalMessages(slot.total); messagesOffsetRef.current = slot.total; setVisibleMessageCount(Infinity); setAllMessagesLoaded(true); setLoadAllJustFinished(true); if (loadAllFinishedTimerRef.current) clearTimeout(loadAllFinishedTimerRef.current); loadAllFinishedTimerRef.current = setTimeout(() => { setLoadAllJustFinished(false); setShowLoadAllOverlay(false); }, 1000); } else { allMessagesLoadedRef.current = false; setShowLoadAllOverlay(false); } } catch (error) { console.error('Error loading all messages:', error); allMessagesLoadedRef.current = false; setShowLoadAllOverlay(false); } finally { isLoadingMoreRef.current = false; setIsLoadingAllMessages(false); } }, [selectedSession, selectedProject, isLoadingAllMessages, currentSessionId, sessionStore]); const loadEarlierMessages = useCallback(() => { setVisibleMessageCount((prev) => prev + 100); }, []); return { chatMessages, addMessage, clearMessages, rewindMessages, isLoading, setIsLoading, currentSessionId, setCurrentSessionId, isLoadingSessionMessages, isLoadingMoreMessages, hasMoreMessages, totalMessages, canAbortSession, setCanAbortSession, isUserScrolledUp, setIsUserScrolledUp, tokenBudget, setTokenBudget, visibleMessageCount, visibleMessages, loadEarlierMessages, loadAllMessages, allMessagesLoaded, isLoadingAllMessages, loadAllJustFinished, showLoadAllOverlay, claudeStatus, setClaudeStatus, createDiff, scrollContainerRef, scrollToBottom, scrollToBottomAndReset, isNearBottom, handleScroll, }; }