Files
claudecodeui/src/components/chat/hooks/useChatSessionState.ts
Haileyesus dc5d73936a refactor(projects): identify projects by DB projectId instead of folder-derived name
GET /api/projects used to scan ~/.claude/projects/ on every request, derive
each project's identity from the encoded folder name, and re-parse JSONL
files to build session lists. Using the folder-derived name as the project
identifier leaked the Claude CLI's on-disk encoding into every API route,
forced every downstream endpoint to re-resolve a real path via JSONL
'cwd' inspection, and made the project list endpoint O(projects x sessions)
on disk I/O.

This change switches the entire API surface to identify projects by the
stable primary key from the 'projects' table and drives the listing
straight from the DB:

- Add projectsDb.getProjectPathById as the canonical projectId -> path
  resolver so routes no longer need to touch the filesystem to figure out
  where a project lives.

- Rewrite getProjects so it reads the project list from the 'projects'
  table and the per-project session list from the 'sessions' table (one
  SELECT per project). No filesystem scanning happens for this endpoint
  anymore, which removes the dependency on ~/.claude/projects existing,
  on Cursor's MD5-hashed chat folders being discoverable, and on Codex's
  JSONL history being on disk. Per the migration spec each session now
  exposes 'summary' sourced from sessions.custom_name, 'messageCount' = 0
  (message counting is not implemented), and sessionMeta.hasMore is
  pinned to false since this endpoint doesn't drive session pagination.

- Introduce id-based wrappers (getSessionsById, renameProjectById,
  deleteSessionById, deleteProjectById, getProjectTaskMasterById) so
  every caller can pass projectId and resolve the real path through the
  DB. renameProjectById also writes to projects.custom_project_name so
  the DB-driven getProjects response reflects renames immediately; it
  keeps project-config.json in sync for any legacy reader that still
  consults the JSON file.

- Migrate every /api/projects/:projectName route in server/index.js,
  server/routes/taskmaster.js, and server/routes/messages.js to
  :projectId, and change server/routes/git.js so the 'project'
  query/body parameter carries a projectId that is resolved through the
  DB before any git command runs. TaskMaster WebSocket broadcasts emit
  'projectId' for the same reason so the frontend can match
  notifications against its current selection without another lookup.

- Delete helpers that existed only to feed the old getProjects path
  (getCursorSessions, getGeminiCliSessions, getProjectTaskMaster) along
  with their unused imports (better-sqlite3's Database,
  applyCustomSessionNames). The legacy folder-name helpers (getSessions,
  renameProject, deleteSession, deleteProject, extractProjectDirectory)
  are kept as internal implementation details of the id-based wrappers
  and of destructive cleanup / conversation search, but they are no
  longer re-exported.

- searchConversations still walks JSONL to produce match snippets (that
  data doesn't live in the DB), but it now includes the resolved
  projectId in each result so the sidebar can cross-reference hits with
  its already loaded project list without a second round-trip.

Frontend migration:

- Project.name is replaced by Project.projectId in src/types/app.ts, and
  ProjectSession.__projectName becomes __projectId so session tagging
  and sidebar state keys stay aligned with the backend identifier.
  Settings continues to use SettingsProject.name for legacy consumers,
  but it is populated from projectId by normalizeProjectForSettings.

- All places that previously indexed per-project state by project.name
  (sidebar expanded/starred/loading/deletingProjects sets,
  additionalSessions map, projectHasMoreOverrides, starredProjects
  localStorage, command history and draft-input localStorage,
  TaskMaster caches) now key on projectId so state survives
  display-name edits and is consistent across the app.

- src/utils/api.js renames every endpoint parameter to projectId, the
  unified messages endpoint takes projectId in its query string, and
  useSessionStore forwards projectId on fetchFromServer / fetchMore /
  refreshFromServer. Git panel, file tree, code editor, PRD editor,
  plugins context, MCP server flows and TaskMaster hooks are all
  updated to pass projectId.

- DEFAULT_PROJECT_FOR_EMPTY_SHELL is updated to carry a 'default'
  projectId sentinel so the empty-shell placeholder still satisfies the
  Project contract.

Bug fix bundled in:

- sessionsDb.setName no longer bumps updated_at when a row already
  exists. Renaming is a label change, not activity, so there is no
  reason for it to reset 'last activity' in the sidebar. It also no
  longer relies on SQLite's CURRENT_TIMESTAMP, which stores a naive
  'YYYY-MM-DD HH:MM:SS' value that JavaScript parses as local time and
  caused renamed sessions to appear shifted backwards by the client's
  UTC offset. When an INSERT actually happens it now writes ISO-8601
  UTC with a 'Z' suffix.

- buildSessionsByProviderFromDb normalizes any legacy naive timestamps
  in the sessions table to ISO-8601 UTC on the way out so rows written
  before this change also render correctly on the client.

Other cleanup:

- Removed the filesystem-first project-discovery comment block at the
  top of server/projects.js and replaced it with a short note that
  describes the new DB-driven flow and lists the few remaining
  filesystem-dependent helpers (message reads, search, destructive
  delete, manual project registration).

- server/modules/providers/index.ts is added as a small barrel so the
  providers module exposes a stable public surface.

Made-with: Cursor
2026-04-24 18:12:10 +03:00

738 lines
28 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, LLMProvider } 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: 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,
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 LLMProvider) || '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 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;
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 LLMProvider,
// DB-assigned projectId replaces the legacy folder-derived name.
projectId: selectedProject.projectId,
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?.projectId, 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.projectId}:${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 LLMProvider,
projectId: selectedProject.projectId,
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 LLMProvider,
projectId: selectedProject.projectId,
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 LLMProvider,
projectId: selectedProject.projectId,
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 {
// Token usage endpoint is now keyed by the DB projectId.
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, 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 LLMProvider,
projectId: selectedProject.projectId,
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,
};
}