Spaces:
Sleeping
Sleeping
File size: 7,662 Bytes
27da720 564aab6 27da720 c9a83ae 27da720 962191f c9a83ae f247952 c9a83ae 27da720 f56fa2e 6cf406a 092f909 f247952 27da720 2b4c539 27da720 6cf406a 27da720 f247952 27da720 f56fa2e 27da720 f56fa2e 27da720 f56fa2e d7f2a7c f56fa2e d7f2a7c f56fa2e 27da720 f56fa2e 27da720 3b6a2ca 887da19 3b6a2ca 887da19 a13e8cc c9a83ae a13e8cc 27da720 a13e8cc 27da720 f247952 3b6a2ca 6cf406a 27da720 2e60856 27da720 6cf406a 27da720 a13e8cc 2a2e170 27da720 0611031 27da720 962191f c9a83ae f247952 c9a83ae 962191f 27da720 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 | /**
* 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
}
/>
</>
)}
</>
);
}
|