mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-03-23 06:47:30 +00:00
- Add provider adapter layer (server/providers/) with registry pattern
- Claude, Cursor, Codex, Gemini adapters normalize native formats to NormalizedMessage
- Shared types.js defines ProviderAdapter interface and message kinds
- Registry enables polymorphic provider lookup
- Add unified REST endpoint: GET /api/sessions/:id/messages?provider=...
- Replaces four provider-specific message endpoints with one
- Delegates to provider adapters via registry
- Add frontend session-keyed store (useSessionStore)
- Per-session Map with serverMessages/realtimeMessages/merged
- Dedup by ID, stale threshold for re-fetch, background session accumulation
- No localStorage for messages — backend JSONL is source of truth
- Add normalizedToChatMessages converter (useChatMessages)
- Converts NormalizedMessage[] to existing ChatMessage[] UI format
- Wire unified store into ChatInterface, useChatSessionState, useChatRealtimeHandlers
- Session switch uses store cache for instant render
- Background WebSocket messages routed to correct session slot
736 lines
27 KiB
TypeScript
736 lines
27 KiB
TypeScript
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
import type { MutableRefObject } from 'react';
|
|
import { authenticatedFetch } from '../../../utils/api';
|
|
import type { ChatMessage, Provider } from '../types/types';
|
|
import type { Project, ProjectSession, SessionProvider } from '../../../types/app';
|
|
import { createCachedDiffCalculator, type DiffCalculator } from '../utils/messageTransforms';
|
|
import { normalizedToChatMessages } from './useChatMessages';
|
|
import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore';
|
|
|
|
const MESSAGES_PER_PAGE = 20;
|
|
const INITIAL_VISIBLE_MESSAGES = 100;
|
|
|
|
type PendingViewSession = {
|
|
sessionId: string | null;
|
|
startedAt: number;
|
|
};
|
|
|
|
interface UseChatSessionStateArgs {
|
|
selectedProject: Project | null;
|
|
selectedSession: ProjectSession | null;
|
|
ws: WebSocket | null;
|
|
sendMessage: (message: unknown) => void;
|
|
autoScrollToBottom?: boolean;
|
|
externalMessageUpdate?: number;
|
|
processingSessions?: Set<string>;
|
|
resetStreamingState: () => void;
|
|
pendingViewSessionRef: MutableRefObject<PendingViewSession | null>;
|
|
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: SessionProvider,
|
|
): 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,
|
|
processingSessions,
|
|
resetStreamingState,
|
|
pendingViewSessionRef,
|
|
sessionStore,
|
|
}: UseChatSessionStateArgs) {
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
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 [canAbortSession, setCanAbortSession] = useState(false);
|
|
const [isUserScrolledUp, setIsUserScrolledUp] = useState(false);
|
|
const [tokenBudget, setTokenBudget] = useState<Record<string, unknown> | null>(null);
|
|
const [visibleMessageCount, setVisibleMessageCount] = useState(INITIAL_VISIBLE_MESSAGES);
|
|
const [claudeStatus, setClaudeStatus] = useState<{ text: string; tokens: number; can_interrupt: boolean } | null>(null);
|
|
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);
|
|
|
|
const createDiff = useMemo<DiffCalculator>(() => createCachedDiffCalculator(), []);
|
|
|
|
/* ---------------------------------------------------------------- */
|
|
/* Derive chatMessages from the store */
|
|
/* ---------------------------------------------------------------- */
|
|
|
|
const activeSessionId = selectedSession?.id || currentSessionId || null;
|
|
const [pendingUserMessage, setPendingUserMessage] = useState<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);
|
|
}
|
|
|
|
// When a real session ID arrives and we have a pending user message, flush it to the store
|
|
const prevActiveSessionRef = useRef<string | null>(null);
|
|
if (activeSessionId && activeSessionId !== prevActiveSessionRef.current && pendingUserMessage) {
|
|
const prov = (localStorage.getItem('selected-provider') as SessionProvider) || 'claude';
|
|
const normalized = chatMessageToNormalized(pendingUserMessage, activeSessionId, prov);
|
|
if (normalized) {
|
|
sessionStore.appendRealtime(activeSessionId, normalized);
|
|
}
|
|
setPendingUserMessage(null);
|
|
}
|
|
prevActiveSessionRef.current = activeSessionId;
|
|
|
|
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 SessionProvider) || '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;
|
|
|
|
const sessionProvider = selectedSession.__provider || 'claude';
|
|
if (sessionProvider === 'cursor') return false;
|
|
|
|
isLoadingMoreRef.current = true;
|
|
const previousScrollHeight = container.scrollHeight;
|
|
const previousScrollTop = container.scrollTop;
|
|
|
|
try {
|
|
const slot = await sessionStore.fetchMore(selectedSession.id, {
|
|
provider: sessionProvider as SessionProvider,
|
|
projectName: selectedProject.name,
|
|
projectPath: selectedProject.fullPath || selectedProject.path || '',
|
|
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?.name, selectedSession?.id]);
|
|
|
|
// Initial scroll to bottom
|
|
useEffect(() => {
|
|
if (!pendingInitialScrollRef.current || !scrollContainerRef.current || isLoadingSessionMessages) return;
|
|
if (chatMessages.length === 0) { pendingInitialScrollRef.current = false; return; }
|
|
pendingInitialScrollRef.current = false;
|
|
if (!searchScrollActiveRef.current) setTimeout(() => scrollToBottom(), 200);
|
|
}, [chatMessages.length, isLoadingSessionMessages, scrollToBottom]);
|
|
|
|
// Main session loading effect — store-based
|
|
useEffect(() => {
|
|
if (!selectedSession || !selectedProject) {
|
|
resetStreamingState();
|
|
pendingViewSessionRef.current = null;
|
|
setClaudeStatus(null);
|
|
setCanAbortSession(false);
|
|
setIsLoading(false);
|
|
setCurrentSessionId(null);
|
|
sessionStorage.removeItem('cursorSessionId');
|
|
messagesOffsetRef.current = 0;
|
|
setHasMoreMessages(false);
|
|
setTotalMessages(0);
|
|
setTokenBudget(null);
|
|
lastLoadedSessionKeyRef.current = null;
|
|
return;
|
|
}
|
|
|
|
const provider = (selectedSession.__provider || localStorage.getItem('selected-provider') as Provider) || 'claude';
|
|
const sessionKey = `${selectedSession.id}:${selectedProject.name}:${provider}`;
|
|
|
|
// 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();
|
|
pendingViewSessionRef.current = null;
|
|
setClaudeStatus(null);
|
|
setCanAbortSession(false);
|
|
}
|
|
|
|
// 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);
|
|
setIsLoading(false);
|
|
}
|
|
|
|
setCurrentSessionId(selectedSession.id);
|
|
if (provider === 'cursor') {
|
|
sessionStorage.setItem('cursorSessionId', selectedSession.id);
|
|
}
|
|
|
|
// Check session status
|
|
if (ws) {
|
|
sendMessage({ type: 'check-session-status', sessionId: selectedSession.id, provider });
|
|
}
|
|
|
|
lastLoadedSessionKeyRef.current = sessionKey;
|
|
|
|
// Fetch from server → store updates → chatMessages re-derives automatically
|
|
setIsLoadingSessionMessages(true);
|
|
sessionStore.fetchFromServer(selectedSession.id, {
|
|
provider: (selectedSession.__provider || provider) as SessionProvider,
|
|
projectName: selectedProject.name,
|
|
projectPath: selectedProject.fullPath || selectedProject.path || '',
|
|
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);
|
|
});
|
|
}, [
|
|
pendingViewSessionRef,
|
|
resetStreamingState,
|
|
selectedProject,
|
|
selectedSession?.id,
|
|
sendMessage,
|
|
ws,
|
|
sessionStore,
|
|
]);
|
|
|
|
// External message update (e.g. WebSocket reconnect, background refresh)
|
|
useEffect(() => {
|
|
if (!externalMessageUpdate || !selectedSession || !selectedProject) return;
|
|
|
|
const reloadExternalMessages = async () => {
|
|
try {
|
|
const provider = (localStorage.getItem('selected-provider') as Provider) || 'claude';
|
|
|
|
// Skip store refresh during active streaming
|
|
if (!isLoading) {
|
|
await sessionStore.refreshFromServer(selectedSession.id, {
|
|
provider: (selectedSession.__provider || provider) as SessionProvider,
|
|
projectName: selectedProject.name,
|
|
projectPath: selectedProject.fullPath || selectedProject.path || '',
|
|
});
|
|
|
|
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,
|
|
isLoading,
|
|
]);
|
|
|
|
// 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]);
|
|
|
|
useEffect(() => {
|
|
if (selectedSession?.id) pendingViewSessionRef.current = null;
|
|
}, [pendingViewSessionRef, selectedSession?.id]);
|
|
|
|
// 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) {
|
|
const sessionProvider = selectedSession.__provider || 'claude';
|
|
if (sessionProvider !== 'cursor') {
|
|
try {
|
|
// Load all messages into the store for search navigation
|
|
const slot = await sessionStore.fetchFromServer(selectedSession.id, {
|
|
provider: sessionProvider as SessionProvider,
|
|
projectName: selectedProject.name,
|
|
projectPath: selectedProject.fullPath || selectedProject.path || '',
|
|
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]);
|
|
|
|
// Token usage fetch for Claude
|
|
useEffect(() => {
|
|
if (!selectedProject || !selectedSession?.id || selectedSession.id.startsWith('new-session-')) {
|
|
setTokenBudget(null);
|
|
return;
|
|
}
|
|
const sessionProvider = selectedSession.__provider || 'claude';
|
|
if (sessionProvider !== 'claude') return;
|
|
|
|
const fetchInitialTokenUsage = async () => {
|
|
try {
|
|
const url = `/api/projects/${selectedProject.name}/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, selectedSession?.__provider]);
|
|
|
|
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]);
|
|
|
|
useEffect(() => {
|
|
const activeViewSessionId = selectedSession?.id || currentSessionId;
|
|
if (!activeViewSessionId || !processingSessions) return;
|
|
const shouldBeProcessing = processingSessions.has(activeViewSessionId);
|
|
if (shouldBeProcessing && !isLoading) {
|
|
setIsLoading(true);
|
|
setCanAbortSession(true);
|
|
}
|
|
}, [currentSessionId, isLoading, processingSessions, selectedSession?.id]);
|
|
|
|
// "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 sessionProvider = selectedSession.__provider || 'claude';
|
|
if (sessionProvider === 'cursor') {
|
|
setVisibleMessageCount(Infinity);
|
|
setAllMessagesLoaded(true);
|
|
allMessagesLoadedRef.current = true;
|
|
setLoadAllJustFinished(true);
|
|
if (loadAllFinishedTimerRef.current) clearTimeout(loadAllFinishedTimerRef.current);
|
|
loadAllFinishedTimerRef.current = setTimeout(() => { setLoadAllJustFinished(false); setShowLoadAllOverlay(false); }, 1000);
|
|
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, {
|
|
provider: sessionProvider as SessionProvider,
|
|
projectName: selectedProject.name,
|
|
projectPath: selectedProject.fullPath || selectedProject.path || '',
|
|
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,
|
|
isLoading,
|
|
setIsLoading,
|
|
currentSessionId,
|
|
setCurrentSessionId,
|
|
isLoadingSessionMessages,
|
|
isLoadingMoreMessages,
|
|
hasMoreMessages,
|
|
totalMessages,
|
|
canAbortSession,
|
|
setCanAbortSession,
|
|
isUserScrolledUp,
|
|
setIsUserScrolledUp,
|
|
tokenBudget,
|
|
setTokenBudget,
|
|
visibleMessageCount,
|
|
visibleMessages,
|
|
loadEarlierMessages,
|
|
loadAllMessages,
|
|
allMessagesLoaded,
|
|
isLoadingAllMessages,
|
|
loadAllJustFinished,
|
|
showLoadAllOverlay,
|
|
claudeStatus,
|
|
setClaudeStatus,
|
|
createDiff,
|
|
scrollContainerRef,
|
|
scrollToBottom,
|
|
scrollToBottomAndReset,
|
|
isNearBottom,
|
|
handleScroll,
|
|
};
|
|
}
|