mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-07-02 10:33:00 +08:00
* fix(shell): hide prompt options on desktop * fix(chat): group continuous same-tool runs more consistently Consecutive tool calls (Edit, Read, Grep, etc.) grouped inconsistently: - The group threshold was 3, so a run of only 2 calls stayed ungrouped while a run of 3 collapsed — making two back-to-back edits look different from three. - A run was broken by any interleaved message, including ones that render nothing (reasoning hidden when showThinking is off). Providers like Codex interleave hidden reasoning between tool calls, so visually continuous edits intermittently failed to group. Lower TOOL_GROUP_THRESHOLD to 2 and skip non-rendered messages when extending a run, so any 2+ consecutive same-tool calls collapse reliably. ChatMessagesPane now passes showThinking into groupConsecutiveTools. * fix(chat): stabilize message scroll controls * fix: update command menu positioning * fix(chat): refine load all overlay behavior * fix(chat): hide load all prompt after final page * fix(chat): remove auto scroll quick setting * fix(chat): unify messages and composer into centered column Constrain both ChatMessagesPane content and ChatComposer to the same max-w-3xl centered column. Previously only the composer had a max-width, causing messages to fill the full width while the input stayed narrow, making them visually misaligned with large empty gutters on either side. * style(ui): rework light/dark theme to make it visually consistent Rework the color system around warm neutrals and route hardcoded surfaces through theme tokens for consistency. - Theme tokens (index.css, ThemeContext): warm cream light mode and neutral charcoal dark mode, replacing the pure-white/blue-tinted palette; update PWA theme-color meta - Code blocks: soft grey background in light mode via oneLight/oneDark, and drop the Tailwind Typography <pre> shell that framed the highlighter in a dark box - Dropdowns/panels: convert CommandMenu, Quick Settings, and the JSON response block from hardcoded gray/slate to popover/muted/border tokens - Git panel: Publish button purple -> primary blue - Composer: drop top padding so the input sits flush with the thread * fix: use app theme for code editor * style(chat): unify composer toolbar heights and declutter slash-command modal - Composer: give the permission-mode and token-usage buttons a fixed h-8 so every bottom-toolbar control shares one height - CommandResultModal: replace the blue gradient header (gradient fill, glow blobs, blue eyebrow + icon chip) with a clean neutral header on popover/muted tokens * fix(chat): header ellipsis, Codex logo on light theme, portal copy menu - MainContentTitle: truncate the session title with an ellipsis instead of horizontal-scrolling it - MessageComponent: use text-foreground for the provider logo chip so the currentColor Codex/OpenAI mark is visible on the light theme - MessageCopyControl: render the copy-format dropdown in a portal so it escapes the chat message's `contain: paint` clip box; anchor it to the trigger, flip above near the viewport bottom, close on scroll/resize * style(mcp): remove purple accents and portal the server form modal - Replace the purple provider-button colors, heading icon, and form submit button with the primary token (no purple in the MCP UI) - Portal the add/edit MCP server modal to document.body so its fixed overlay covers the full viewport, fixing the white band at the top caused by the Settings dialog's transformed tab content becoming the containing block * style(ui): use Merriweather serif for chat text and Encode Sans for the rest of the UI * fix: align activity indicator with composer input width Wrap ActivityIndicator in the same mx-auto max-w-3xl container as the text input so the "Analyzing…" label and Stop button stay within the input's boundaries instead of spanning the full window width. * style: improve thinking and stop button placements * style(auth): modernize login, setup, and onboarding screens * fix(chat): correct invalid dark-mode hover on AskUserQuestion options * fix: remove unnecessary auto expand tools * fix: resolve coderabbit comments * fix(chat): widen chat layout and sidebar titles * fix(branding): update CloudCLI wordmark styling --------- Co-authored-by: Simos Mikelatos <simosmik@gmail.com>
860 lines
31 KiB
TypeScript
860 lines
31 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;
|
|
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,
|
|
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 wasNearTopRef = useRef(false);
|
|
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);
|
|
wasNearTopRef.current = false;
|
|
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) return false;
|
|
if (slot.serverMessages.length === 0) {
|
|
if (!slot.hasMore) {
|
|
setHasMoreMessages(false);
|
|
allMessagesLoadedRef.current = true;
|
|
setAllMessagesLoaded(true);
|
|
if (loadAllOverlayTimerRef.current) {
|
|
clearTimeout(loadAllOverlayTimerRef.current);
|
|
loadAllOverlayTimerRef.current = null;
|
|
}
|
|
setShowLoadAllOverlay(false);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
pendingScrollRestoreRef.current = { height: previousScrollHeight, top: previousScrollTop };
|
|
setHasMoreMessages(slot.hasMore);
|
|
setTotalMessages(slot.total);
|
|
setVisibleMessageCount((prev) => prev + MESSAGES_PER_PAGE);
|
|
if (!slot.hasMore) {
|
|
allMessagesLoadedRef.current = true;
|
|
setAllMessagesLoaded(true);
|
|
if (loadAllOverlayTimerRef.current) {
|
|
clearTimeout(loadAllOverlayTimerRef.current);
|
|
loadAllOverlayTimerRef.current = null;
|
|
}
|
|
setShowLoadAllOverlay(false);
|
|
}
|
|
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);
|
|
|
|
const scrolledNearTop = container.scrollTop < 100;
|
|
|
|
// "Load all" prompt: appear (with fade-in) when the user reaches the top
|
|
if (scrolledNearTop && hasMoreMessages && !allMessagesLoadedRef.current) {
|
|
if (!wasNearTopRef.current) {
|
|
wasNearTopRef.current = true;
|
|
if (loadAllOverlayTimerRef.current) clearTimeout(loadAllOverlayTimerRef.current);
|
|
|
|
setShowLoadAllOverlay(true);
|
|
loadAllOverlayTimerRef.current = setTimeout(() => {
|
|
setShowLoadAllOverlay(false);
|
|
loadAllOverlayTimerRef.current = null;
|
|
}, 2500);
|
|
}
|
|
} else if (!scrolledNearTop) {
|
|
wasNearTopRef.current = false;
|
|
}
|
|
|
|
if (!allMessagesLoadedRef.current) {
|
|
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;
|
|
}
|
|
}, [hasMoreMessages, 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;
|
|
wasNearTopRef.current = false;
|
|
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 selectedSessionId = selectedSession.id;
|
|
const sessionKey = `${selectedSessionId}:${selectedProject.projectId}`;
|
|
|
|
const subscribeToSelectedSession = () => {
|
|
if (!ws) {
|
|
return;
|
|
}
|
|
|
|
statusCheckSentAtRef.current.set(selectedSessionId, Date.now());
|
|
sendMessage({
|
|
type: 'chat.subscribe',
|
|
sessions: [{
|
|
sessionId: selectedSessionId,
|
|
lastSeq: lastSeqRef.current.get(selectedSessionId) ?? 0,
|
|
}],
|
|
});
|
|
};
|
|
|
|
// Skip if already loaded and fresh
|
|
if (lastLoadedSessionKeyRef.current === sessionKey && sessionStore.has(selectedSessionId) && !sessionStore.isStale(selectedSessionId)) {
|
|
subscribeToSelectedSession();
|
|
return;
|
|
}
|
|
|
|
const sessionChanged = currentSessionId !== null && currentSessionId !== selectedSessionId;
|
|
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);
|
|
wasNearTopRef.current = false;
|
|
if (loadAllOverlayTimerRef.current) clearTimeout(loadAllOverlayTimerRef.current);
|
|
if (loadAllFinishedTimerRef.current) clearTimeout(loadAllFinishedTimerRef.current);
|
|
|
|
if (sessionChanged) {
|
|
setTokenBudget(null);
|
|
}
|
|
|
|
setCurrentSessionId(selectedSessionId);
|
|
|
|
// 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.
|
|
subscribeToSelectedSession();
|
|
|
|
lastLoadedSessionKeyRef.current = sessionKey;
|
|
|
|
// Fetch from server → store updates → chatMessages re-derives automatically
|
|
setIsLoadingSessionMessages(true);
|
|
sessionStore.fetchFromServer(selectedSessionId, {
|
|
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 (isNearBottom()) {
|
|
setTimeout(() => scrollToBottom(), 200);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Error reloading messages from external update:', error);
|
|
}
|
|
};
|
|
|
|
reloadExternalMessages();
|
|
}, [
|
|
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(() => {
|
|
const container = scrollContainerRef.current;
|
|
if (!container) return;
|
|
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 (!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;
|
|
}, [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 visibility is driven by scroll-to-top in handleScroll;
|
|
// timers are cleared on session change via the reset effect above.
|
|
|
|
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);
|
|
if (loadAllOverlayTimerRef.current) {
|
|
clearTimeout(loadAllOverlayTimerRef.current);
|
|
loadAllOverlayTimerRef.current = null;
|
|
}
|
|
|
|
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);
|
|
loadAllFinishedTimerRef.current = null;
|
|
}, 2500);
|
|
} 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,
|
|
};
|
|
}
|