ml-intern3 / frontend /src /components /SessionChat.tsx
cmpatino's picture
cmpatino HF Staff
Fix endpoint for free calls (#279)
f247952 unverified
/**
* 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<string | null>(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 (
<>
<MessageList
messages={messages}
isProcessing={busy}
sessionId={sessionId}
approveTools={approveTools}
onUndoLastTurn={undoLastTurn}
onEditAndRegenerate={editAndRegenerate}
/>
{isExpired ? (
<ExpiredBanner sessionId={sessionId} />
) : (
<>
{premiumBillingNotice && <BillingBanner />}
{chatError && (
<ChatErrorBanner
error={chatError}
sessionId={sessionId}
model={sessionMeta?.model}
onDismiss={() => setChatError(null)}
/>
)}
<ChatInput
sessionId={sessionId}
initialModelPath={sessionMeta?.model}
onSend={handleSendMessage}
onStop={handleStop}
onDatasetUploaded={refreshMessages}
isProcessing={busy}
disabled={!isConnected || activityStatus.type === 'waiting-approval'}
quota={quota}
refreshQuota={refreshQuota}
placeholder={
activityStatus.type === 'waiting-approval'
? 'Approve or reject pending tools first...'
: undefined
}
/>
</>
)}
</>
);
}