mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-17 05:12:02 +08:00
Session history and token usage reads already have a stable app session id. Passing provider and project hints from the frontend kept those reads coupled with provider-specific state that the backend can resolve from the session row. Resolve token usage provider server-side and narrow the session store read API to session id plus pagination. This keeps provider-specific storage decisions behind the backend boundary and makes reconnect, pagination, and load-all use the same session-owned contract.
820 lines
30 KiB
TypeScript
820 lines
30 KiB
TypeScript
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
import type { MutableRefObject } from 'react';
|
|
|
|
import { authenticatedFetch } from '../../../utils/api';
|
|
import type { MarkSessionIdle, SessionActivityMap } from '../../../hooks/useSessionProtection';
|
|
import type { Project, ProjectSession, LLMProvider } from '../../../types/app';
|
|
import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore';
|
|
import type { ChatMessage } from '../types/types';
|
|
import { createCachedDiffCalculator, type DiffCalculator } from '../utils/messageTransforms';
|
|
|
|
import { normalizedToChatMessages } from './useChatMessages';
|
|
|
|
const MESSAGES_PER_PAGE = 20;
|
|
const INITIAL_VISIBLE_MESSAGES = 100;
|
|
|
|
interface UseChatSessionStateArgs {
|
|
selectedProject: Project | null;
|
|
selectedSession: ProjectSession | null;
|
|
ws: WebSocket | null;
|
|
sendMessage: (message: unknown) => void;
|
|
autoScrollToBottom?: boolean;
|
|
externalMessageUpdate?: number;
|
|
newSessionTrigger?: number;
|
|
processingSessions?: SessionActivityMap;
|
|
onSessionIdle?: MarkSessionIdle;
|
|
resetStreamingState: () => void;
|
|
/** When each session's `chat.subscribe` was last sent; guards stale idle acks. */
|
|
statusCheckSentAtRef: MutableRefObject<Map<string, number>>;
|
|
/** Highest live seq observed per session; sent as `lastSeq` on subscribe. */
|
|
lastSeqRef: MutableRefObject<Map<string, number>>;
|
|
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: LLMProvider,
|
|
): 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,
|
|
newSessionTrigger,
|
|
processingSessions,
|
|
onSessionIdle,
|
|
resetStreamingState,
|
|
statusCheckSentAtRef,
|
|
lastSeqRef,
|
|
sessionStore,
|
|
}: UseChatSessionStateArgs) {
|
|
const [currentSessionId, setCurrentSessionId] = useState<string | null>(selectedSession?.id || null);
|
|
const [isLoadingSessionMessages, setIsLoadingSessionMessages] = useState(false);
|
|
const [isLoadingMoreMessages, setIsLoadingMoreMessages] = useState(false);
|
|
const [hasMoreMessages, setHasMoreMessages] = useState(false);
|
|
const [totalMessages, setTotalMessages] = useState(0);
|
|
const [isUserScrolledUp, setIsUserScrolledUp] = useState(false);
|
|
const [tokenBudget, setTokenBudget] = useState<Record<string, unknown> | null>(null);
|
|
const [visibleMessageCount, setVisibleMessageCount] = useState(INITIAL_VISIBLE_MESSAGES);
|
|
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<HTMLDivElement>(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<ScrollRestoreState | null>(null);
|
|
const pendingInitialScrollRef = useRef(true);
|
|
const messagesOffsetRef = useRef(0);
|
|
const scrollPositionRef = useRef({ height: 0, top: 0 });
|
|
const loadAllFinishedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const loadAllOverlayTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const lastLoadedSessionKeyRef = useRef<string | null>(null);
|
|
/**
|
|
* Tracks the last processed value from `useProjectsState.newSessionTrigger`.
|
|
*
|
|
* The trigger itself is intentionally increment-only and routed via:
|
|
* useProjectsState -> AppContent -> MainContent -> ChatInterface -> this hook.
|
|
* We compare values to ensure each explicit New Session click runs exactly one
|
|
* reset pass in this local chat state domain.
|
|
*/
|
|
const previousNewSessionTriggerRef = useRef(newSessionTrigger ?? 0);
|
|
|
|
const createDiff = useMemo<DiffCalculator>(() => createCachedDiffCalculator(), []);
|
|
|
|
useEffect(() => {
|
|
const trigger = newSessionTrigger ?? 0;
|
|
if (trigger === previousNewSessionTriggerRef.current) {
|
|
return;
|
|
}
|
|
previousNewSessionTriggerRef.current = trigger;
|
|
|
|
/**
|
|
* Consumer-side reset for explicit New Session intent.
|
|
*
|
|
* Why this is essential:
|
|
* - Chat keeps local state that is not fully derived from `selectedSession`:
|
|
* `currentSessionId`, `pendingUserMessage`, streaming/status flags, message
|
|
* pagination/scroll bookkeeping, and provider-specific sessionStorage keys.
|
|
* - If the user clicks New Session while already on the same route with no
|
|
* selected session, parent state updates can be idempotent and this local
|
|
* state would otherwise persist, making the click appear to "do nothing".
|
|
*
|
|
* What this reset guarantees:
|
|
* - A deterministic clean draft state on every New Session click.
|
|
* - No dependence on route/tab/session-object identity changes.
|
|
* - No coupling to unrelated external update signals.
|
|
*/
|
|
resetStreamingState();
|
|
setCurrentSessionId(null);
|
|
setPendingUserMessage(null);
|
|
messagesOffsetRef.current = 0;
|
|
setHasMoreMessages(false);
|
|
setTotalMessages(0);
|
|
|
|
setTokenBudget(null);
|
|
setVisibleMessageCount(INITIAL_VISIBLE_MESSAGES);
|
|
setAllMessagesLoaded(false);
|
|
allMessagesLoadedRef.current = false;
|
|
setIsLoadingAllMessages(false);
|
|
setLoadAllJustFinished(false);
|
|
setShowLoadAllOverlay(false);
|
|
setViewHiddenCount(0);
|
|
setSearchTarget(null);
|
|
searchScrollActiveRef.current = false;
|
|
topLoadLockRef.current = false;
|
|
pendingScrollRestoreRef.current = null;
|
|
pendingInitialScrollRef.current = true;
|
|
lastLoadedSessionKeyRef.current = null;
|
|
|
|
if (loadAllOverlayTimerRef.current) {
|
|
clearTimeout(loadAllOverlayTimerRef.current);
|
|
loadAllOverlayTimerRef.current = null;
|
|
}
|
|
if (loadAllFinishedTimerRef.current) {
|
|
clearTimeout(loadAllFinishedTimerRef.current);
|
|
loadAllFinishedTimerRef.current = null;
|
|
}
|
|
}, [newSessionTrigger, onSessionIdle, resetStreamingState]);
|
|
|
|
/* ---------------------------------------------------------------- */
|
|
/* Derive processing state for the viewed session */
|
|
/* ---------------------------------------------------------------- */
|
|
|
|
const activeSessionId = selectedSession?.id || currentSessionId || null;
|
|
|
|
// The activity indicator always reflects the latest status of the session
|
|
// being viewed — never stale local UI state from the last time it was
|
|
// open. Session ids are concrete before any send, so no pending
|
|
// placeholder entry exists anymore.
|
|
const sessionActivity = (activeSessionId && processingSessions?.get(activeSessionId)) || null;
|
|
const isProcessing = sessionActivity !== null;
|
|
const canAbortSession = isProcessing && sessionActivity.canInterrupt;
|
|
|
|
// Ref mirror so effects can read the latest map without re-running on
|
|
// every activity transition.
|
|
const processingSessionsRef = useRef(processingSessions);
|
|
processingSessionsRef.current = processingSessions;
|
|
|
|
/* ---------------------------------------------------------------- */
|
|
/* Derive chatMessages from the store */
|
|
/* ---------------------------------------------------------------- */
|
|
const [pendingUserMessage, setPendingUserMessage] = useState<ChatMessage | null>(null);
|
|
const flushedPendingUserMessageRef = useRef<ChatMessage | null>(null);
|
|
|
|
// Tell the store which session we're viewing so it only re-renders for this one
|
|
const prevActiveForStoreRef = useRef<string | null>(null);
|
|
if (activeSessionId !== prevActiveForStoreRef.current) {
|
|
prevActiveForStoreRef.current = activeSessionId;
|
|
sessionStore.setActiveSession(activeSessionId);
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (!pendingUserMessage) {
|
|
flushedPendingUserMessageRef.current = null;
|
|
return;
|
|
}
|
|
|
|
if (!activeSessionId) {
|
|
return;
|
|
}
|
|
|
|
if (flushedPendingUserMessageRef.current === pendingUserMessage) {
|
|
return;
|
|
}
|
|
|
|
const prov = (localStorage.getItem('selected-provider') as LLMProvider) || 'claude';
|
|
const normalized = chatMessageToNormalized(pendingUserMessage, activeSessionId, prov);
|
|
if (normalized) {
|
|
sessionStore.appendRealtime(activeSessionId, normalized);
|
|
}
|
|
|
|
flushedPendingUserMessageRef.current = pendingUserMessage;
|
|
setPendingUserMessage(null);
|
|
}, [activeSessionId, pendingUserMessage, sessionStore]);
|
|
|
|
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 LLMProvider) || '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;
|
|
|
|
isLoadingMoreRef.current = true;
|
|
const previousScrollHeight = container.scrollHeight;
|
|
const previousScrollTop = container.scrollTop;
|
|
|
|
try {
|
|
const slot = await sessionStore.fetchMore(selectedSession.id, {
|
|
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?.projectId, selectedSession?.id]);
|
|
|
|
// Initial scroll to bottom — robust to lazy content reflow.
|
|
// The previous implementation fired one scrollToBottom() at +200ms and
|
|
// cleared the pending flag. When markdown blocks, code highlighting, or
|
|
// images finished rendering after that window, scrollHeight grew but
|
|
// nothing re-anchored the viewport, leaving the chat tab visually
|
|
// "scrolled way up" with the latest assistant message off-screen.
|
|
//
|
|
// This version re-scrolls every animation frame while scrollHeight is
|
|
// still growing, capped at ~1s (60 frames) or 3 consecutive stable
|
|
// frames. Cancels cleanly on session change via the pending flag.
|
|
useEffect(() => {
|
|
if (!pendingInitialScrollRef.current || !scrollContainerRef.current || isLoadingSessionMessages) return;
|
|
if (chatMessages.length === 0) { pendingInitialScrollRef.current = false; return; }
|
|
if (searchScrollActiveRef.current) { pendingInitialScrollRef.current = false; return; }
|
|
|
|
const container = scrollContainerRef.current;
|
|
let frame = 0;
|
|
let lastHeight = 0;
|
|
let stableCount = 0;
|
|
let rafId = 0;
|
|
|
|
const tick = () => {
|
|
if (!pendingInitialScrollRef.current || !scrollContainerRef.current) return;
|
|
container.scrollTop = container.scrollHeight;
|
|
if (container.scrollHeight === lastHeight) {
|
|
stableCount++;
|
|
} else {
|
|
stableCount = 0;
|
|
lastHeight = container.scrollHeight;
|
|
}
|
|
frame++;
|
|
if (stableCount < 3 && frame < 60) {
|
|
rafId = requestAnimationFrame(tick);
|
|
} else {
|
|
pendingInitialScrollRef.current = false;
|
|
}
|
|
};
|
|
rafId = requestAnimationFrame(tick);
|
|
return () => {
|
|
if (rafId) cancelAnimationFrame(rafId);
|
|
};
|
|
}, [chatMessages.length, isLoadingSessionMessages, scrollToBottom]);
|
|
|
|
// Main session loading effect — store-based
|
|
useEffect(() => {
|
|
if (!selectedSession || !selectedProject) {
|
|
// A freshly created session can be mid-run before the router has a
|
|
// canonical selectedSession (the URL effect synthesizes one on the
|
|
// next render). Keep the active view intact instead of wiping it.
|
|
if (currentSessionId && processingSessionsRef.current?.has(currentSessionId)) {
|
|
return;
|
|
}
|
|
|
|
resetStreamingState();
|
|
setCurrentSessionId(null);
|
|
messagesOffsetRef.current = 0;
|
|
setHasMoreMessages(false);
|
|
setTotalMessages(0);
|
|
setTokenBudget(null);
|
|
lastLoadedSessionKeyRef.current = null;
|
|
return;
|
|
}
|
|
|
|
const sessionKey = `${selectedSession.id}:${selectedProject.projectId}`;
|
|
|
|
// 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();
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
|
|
setCurrentSessionId(selectedSession.id);
|
|
|
|
// Subscribe to the session's live run (if any): the ack reconciles the
|
|
// processing indicator, re-attaches a mid-flight stream to this socket,
|
|
// and replays any live events missed since `lastSeq`. Recording the send
|
|
// time lets the ack handler discard idle acks that a newer request has
|
|
// since outdated.
|
|
if (ws) {
|
|
statusCheckSentAtRef.current.set(selectedSession.id, Date.now());
|
|
sendMessage({
|
|
type: 'chat.subscribe',
|
|
sessions: [{
|
|
sessionId: selectedSession.id,
|
|
lastSeq: lastSeqRef.current.get(selectedSession.id) ?? 0,
|
|
}],
|
|
});
|
|
}
|
|
|
|
lastLoadedSessionKeyRef.current = sessionKey;
|
|
|
|
// Fetch from server → store updates → chatMessages re-derives automatically
|
|
setIsLoadingSessionMessages(true);
|
|
sessionStore.fetchFromServer(selectedSession.id, {
|
|
limit: MESSAGES_PER_PAGE,
|
|
offset: 0,
|
|
}).then(slot => {
|
|
if (slot) {
|
|
setHasMoreMessages(slot.hasMore);
|
|
setTotalMessages(slot.total);
|
|
if (slot.tokenUsage) setTokenBudget(slot.tokenUsage as Record<string, unknown>);
|
|
}
|
|
setIsLoadingSessionMessages(false);
|
|
}).catch(() => {
|
|
setIsLoadingSessionMessages(false);
|
|
});
|
|
}, [
|
|
resetStreamingState,
|
|
selectedProject,
|
|
selectedSession?.id,
|
|
sendMessage,
|
|
statusCheckSentAtRef,
|
|
lastSeqRef,
|
|
ws,
|
|
sessionStore,
|
|
]);
|
|
|
|
// External message update (e.g. WebSocket reconnect, background refresh)
|
|
useEffect(() => {
|
|
if (!externalMessageUpdate || !selectedSession || !selectedProject) return;
|
|
|
|
const reloadExternalMessages = async () => {
|
|
try {
|
|
// Skip store refresh during active streaming
|
|
if (!isProcessing) {
|
|
await sessionStore.refreshFromServer(selectedSession.id);
|
|
|
|
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,
|
|
isProcessing,
|
|
]);
|
|
|
|
// Search navigation target
|
|
useEffect(() => {
|
|
const session = selectedSession as Record<string, unknown> | 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]);
|
|
|
|
// 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) {
|
|
try {
|
|
// Load all messages into the store for search navigation
|
|
const slot = await sessionStore.fetchFromServer(selectedSession.id, {
|
|
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]);
|
|
|
|
// Initial token usage fetch for providers with file-backed usage data.
|
|
useEffect(() => {
|
|
if (!selectedProject || !selectedSession?.id) {
|
|
setTokenBudget(null);
|
|
return;
|
|
}
|
|
const fetchInitialTokenUsage = async () => {
|
|
try {
|
|
// The backend resolves the provider from the indexed session row.
|
|
const url = `/api/projects/${selectedProject.projectId}/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]);
|
|
|
|
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]);
|
|
|
|
// "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 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, {
|
|
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,
|
|
sessionActivity,
|
|
isProcessing,
|
|
canAbortSession,
|
|
currentSessionId,
|
|
setCurrentSessionId,
|
|
isLoadingSessionMessages,
|
|
isLoadingMoreMessages,
|
|
hasMoreMessages,
|
|
totalMessages,
|
|
isUserScrolledUp,
|
|
setIsUserScrolledUp,
|
|
tokenBudget,
|
|
setTokenBudget,
|
|
visibleMessageCount,
|
|
visibleMessages,
|
|
loadEarlierMessages,
|
|
loadAllMessages,
|
|
allMessagesLoaded,
|
|
isLoadingAllMessages,
|
|
loadAllJustFinished,
|
|
showLoadAllOverlay,
|
|
createDiff,
|
|
scrollContainerRef,
|
|
scrollToBottom,
|
|
scrollToBottomAndReset,
|
|
isNearBottom,
|
|
handleScroll,
|
|
};
|
|
}
|