/** * 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 ExpiredBanner from '@/components/Chat/ExpiredBanner'; import BillingBanner from '@/components/Chat/BillingBanner'; import ChatErrorBanner from '@/components/Chat/ChatErrorBanner'; import { useUserQuota } from '@/hooks/useUserQuota'; import { isPremiumPath } from '@/utils/model'; 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, sessions, setSessionProcessing } = useSessionStore(); const sessionMeta = sessions.find((s) => s.id === sessionId); const isExpired = sessionMeta?.expired === true; const [chatError, setChatError] = useState(null); const { messages, sendMessage, stop, status, undoLastTurn, editAndRegenerate, approveTools, refreshMessages, } = useAgentChat({ sessionId, isActive, // A backgrounded session that the backend reports mid-turn still needs to // mount its live subscription; idle backgrounded sessions do not (that's // what stops app load from reactivating every historical runtime). isProcessing: sessionMeta?.isProcessing ?? false, onReady: () => logger.log(`Session ${sessionId} ready`), onError: (error) => { logger.error(`Session ${sessionId} error:`, error); setChatError(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 show cancelled shimmer const handleStop = useCallback(() => { stop(); updateSession(sessionId, { activityStatus: { type: 'cancelled' } }); }, [stop, updateSession, sessionId]); // 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 { quota, refresh: refreshQuota } = useUserQuota({ enabled: isActive }); // Whether this session's premium usage is being billed to the user's own HF // account (past the subsidized daily allowance). Re-read after each turn, // since the backend flips it at submit time. Only premium-model sessions can // ever be user-billed, so skip the fetch for free models. const [premiumBilled, setPremiumBilled] = useState(false); const [premiumQuotaCounted, setPremiumQuotaCounted] = useState(false); const onPremiumModel = isPremiumPath(sessionMeta?.model ?? undefined); useEffect(() => { if (!isActive || !onPremiumModel) { setPremiumBilled(false); setPremiumQuotaCounted(false); return; } if (busy) return; let cancelled = false; apiFetch(`/api/session/${sessionId}`) .then((r) => (r.ok ? r.json() : null)) .then((d) => { if (!cancelled && d) { setPremiumBilled(Boolean(d.premium_user_billed)); setPremiumQuotaCounted(Boolean(d.premium_quota_counted)); } }) .catch(() => {}); return () => { cancelled = true; }; }, [busy, isActive, onPremiumModel, sessionId]); const sessionPremiumBilled = premiumBilled || Boolean(sessionMeta?.premiumUserBilled); const sessionPremiumQuotaCounted = premiumQuotaCounted || Boolean(sessionMeta?.premiumQuotaCounted); const premiumBillingNotice = sessionPremiumBilled || (isActive && onPremiumModel && quota?.premiumRemaining === 0 && !sessionPremiumQuotaCounted); const handleSendMessage = useCallback( async (text: string) => { if (!text.trim() || busy) return; setChatError(null); updateSession(sessionId, { isProcessing: true, activityStatus: { type: 'thinking' } }); setSessionProcessing(sessionId, 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 === 0; 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, setSessionProcessing], ); // Don't render UI for background sessions — hooks still run if (!isActive) return null; return ( <> {isExpired ? ( ) : ( <> {premiumBillingNotice && } {chatError && ( setChatError(null)} /> )} )} ); }