mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-09 22:18:19 +00:00
Fix New session issues and websocket issues (#738)
* fix: reset-state-on-new-session-click * fix(chat): preserve continuity while session ids settle New conversations were crossing a short but important consistency gap. The route could already point at a newly created session id while the projects payload had not refreshed yet, and realtime/optimistic messages could still be keyed under a provisional id. In that window the UI could stop reading the active session store, briefly render the conversation as missing, and then repopulate it a moment later. That same gap also made duplication more likely. Optimistic local user messages could survive long enough to appear beside the persisted copy, and finalized assistant streaming rows could sit directly next to the server-backed assistant message with the same content before realtime state was cleared. The result was a chat view that felt unstable exactly when a new session was being created. This commit makes session-id reconciliation a first-class part of the chat flow instead of assuming every layer will agree immediately. The session store now understands canonical session aliases and can migrate one conversation from a provisional id to the real id without dropping its in-memory state. The route navigation path can replace the provisional URL entry instead of stacking it in history, and the project/session selection logic keeps a synthetic selected session alive long enough for the sidebar and project payloads to catch up. The practical goal is to keep one visible conversation throughout the whole creation lifecycle: no dead window between websocket events and project refresh, no stale provisional URL after the real id is known, and no extra optimistic/local bubbles when server history catches up. * fix(cli): resolve executable path for Claude CLI on Windows * fix(session-synchronizer): improve session name extraction for Claude and Codex
This commit is contained in:
@@ -1,11 +1,13 @@
|
||||
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';
|
||||
import type { ChatMessage, Provider } from '../types/types';
|
||||
import { createCachedDiffCalculator, type DiffCalculator } from '../utils/messageTransforms';
|
||||
|
||||
import { normalizedToChatMessages } from './useChatMessages';
|
||||
|
||||
const MESSAGES_PER_PAGE = 20;
|
||||
const INITIAL_VISIBLE_MESSAGES = 100;
|
||||
@@ -22,6 +24,7 @@ interface UseChatSessionStateArgs {
|
||||
sendMessage: (message: unknown) => void;
|
||||
autoScrollToBottom?: boolean;
|
||||
externalMessageUpdate?: number;
|
||||
newSessionTrigger?: number;
|
||||
processingSessions?: Set<string>;
|
||||
resetStreamingState: () => void;
|
||||
pendingViewSessionRef: MutableRefObject<PendingViewSession | null>;
|
||||
@@ -95,6 +98,7 @@ export function useChatSessionState({
|
||||
sendMessage,
|
||||
autoScrollToBottom,
|
||||
externalMessageUpdate,
|
||||
newSessionTrigger,
|
||||
processingSessions,
|
||||
resetStreamingState,
|
||||
pendingViewSessionRef,
|
||||
@@ -131,15 +135,85 @@ export function useChatSessionState({
|
||||
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 pending session IDs in sessionStorage.
|
||||
* - 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();
|
||||
pendingViewSessionRef.current = null;
|
||||
setClaudeStatus(null);
|
||||
setCanAbortSession(false);
|
||||
setIsLoading(false);
|
||||
setCurrentSessionId(null);
|
||||
setPendingUserMessage(null);
|
||||
sessionStorage.removeItem('pendingSessionId');
|
||||
sessionStorage.removeItem('cursorSessionId');
|
||||
messagesOffsetRef.current = 0;
|
||||
setHasMoreMessages(false);
|
||||
setTotalMessages(0);
|
||||
setTokenBudget(null);
|
||||
setVisibleMessageCount(INITIAL_VISIBLE_MESSAGES);
|
||||
setAllMessagesLoaded(false);
|
||||
allMessagesLoadedRef.current = false;
|
||||
setIsLoadingAllMessages(false);
|
||||
setLoadAllJustFinished(false);
|
||||
setShowLoadAllOverlay(false);
|
||||
setViewHiddenCount(0);
|
||||
setSearchTarget(null);
|
||||
searchScrollActiveRef.current = false;
|
||||
topLoadLockRef.current = false;
|
||||
pendingScrollRestoreRef.current = null;
|
||||
pendingInitialScrollRef.current = true;
|
||||
lastLoadedSessionKeyRef.current = null;
|
||||
|
||||
if (loadAllOverlayTimerRef.current) {
|
||||
clearTimeout(loadAllOverlayTimerRef.current);
|
||||
loadAllOverlayTimerRef.current = null;
|
||||
}
|
||||
if (loadAllFinishedTimerRef.current) {
|
||||
clearTimeout(loadAllFinishedTimerRef.current);
|
||||
loadAllFinishedTimerRef.current = null;
|
||||
}
|
||||
}, [newSessionTrigger, pendingViewSessionRef, resetStreamingState]);
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Derive chatMessages from the store */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
const activeSessionId = selectedSession?.id || currentSessionId || null;
|
||||
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);
|
||||
@@ -148,17 +222,29 @@ export function useChatSessionState({
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
prevActiveSessionRef.current = activeSessionId;
|
||||
}, [activeSessionId, pendingUserMessage, sessionStore]);
|
||||
|
||||
const storeMessages = activeSessionId ? sessionStore.getMessages(activeSessionId) : [];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user