/** * Per-session chat component. * * Each session renders its own SessionChat. The hook (useAgentChat) always * runs — processing events — but only the active session renders visible * UI (MessageList + ChatInput). */ import { useCallback, useEffect, useState } from 'react'; import { useAgentChat } from '@/hooks/useAgentChat'; import { useAgentStore } from '@/store/agentStore'; import { useSessionStore } from '@/store/sessionStore'; import MessageList from '@/components/Chat/MessageList'; import ChatInput from '@/components/Chat/ChatInput'; import { apiFetch } from '@/utils/api'; import { logger } from '@/utils/logger'; interface SessionChatProps { sessionId: string; isActive: boolean; onSessionDead: (sessionId: string) => void; } export default function SessionChat({ sessionId, isActive, onSessionDead }: SessionChatProps) { const { isConnected, isProcessing, activityStatus, updateSession } = useAgentStore(); const { updateSessionTitle } = useSessionStore(); const [wasCancelled, setWasCancelled] = useState(false); const { messages, sendMessage, stop, status, undoLastTurn, approveTools } = useAgentChat({ sessionId, isActive, onReady: () => logger.log(`Session ${sessionId} ready`), onError: (error) => logger.error(`Session ${sessionId} error:`, error), onSessionDead, }); // When this session becomes active, restore its per-session state to the // global flat fields. The per-session state map is kept up-to-date by // side-channel callbacks even while the session is in the background. useEffect(() => { if (isActive) { useAgentStore.getState().switchActiveSession(sessionId); useAgentStore.getState().setConnected(true); } }, [isActive, sessionId]); // Re-sync state when the browser tab regains focus (Chrome throttles // timers in background tabs which can stall the AI SDK's update flushing). // Fires for ALL sessions so background sessions also recover after sleep. useEffect(() => { const onVisible = () => { if (document.visibilityState === 'visible' && isActive) { useAgentStore.getState().switchActiveSession(sessionId); } }; document.addEventListener('visibilitychange', onVisible); return () => document.removeEventListener('visibilitychange', onVisible); }, [isActive, sessionId]); // Wrap stop to track cancellation const handleStop = useCallback(() => { stop(); setWasCancelled(true); }, [stop]); // SDK status is the ground truth — if it's streaming/submitted, agent is busy const sdkBusy = status === 'streaming' || status === 'submitted'; const busy = isProcessing || sdkBusy; const handleSendMessage = useCallback( async (text: string) => { if (!text.trim() || busy) return; setWasCancelled(false); updateSession(sessionId, { isProcessing: true }); sendMessage({ text: text.trim(), metadata: { createdAt: new Date().toISOString() } }); // Auto-title the session from the first user message const isFirstMessage = messages.filter((m) => m.role === 'user').length <= 1; if (isFirstMessage) { apiFetch('/api/title', { method: 'POST', body: JSON.stringify({ session_id: sessionId, text: text.trim() }), }) .then((res) => res.json()) .then((data) => { if (data.title) updateSessionTitle(sessionId, data.title); }) .catch(() => { const raw = text.trim(); updateSessionTitle(sessionId, raw.length > 40 ? raw.slice(0, 40) + '\u2026' : raw); }); } }, [sessionId, sendMessage, messages, updateSessionTitle, busy, updateSession], ); // Don't render UI for background sessions — hooks still run if (!isActive) return null; return ( <> ); }