From 9063918c1fc3e95f69c4ec6d60d5ef49f7e8efb6 Mon Sep 17 00:00:00 2001 From: Haileyesus <118998054+blackmammoth@users.noreply.github.com> Date: Thu, 30 Apr 2026 17:30:31 +0300 Subject: [PATCH] fix: reset-state-on-new-session-click --- src/components/app/AppContent.tsx | 2 + .../chat/hooks/useChatSessionState.ts | 71 +++++++++++++++++++ src/components/chat/types/types.ts | 1 + src/components/chat/view/ChatInterface.tsx | 2 + src/components/main-content/types/types.ts | 1 + .../main-content/view/MainContent.tsx | 2 + src/hooks/useProjectsState.ts | 23 ++++++ 7 files changed, 102 insertions(+) diff --git a/src/components/app/AppContent.tsx b/src/components/app/AppContent.tsx index 432447e4..a542082a 100644 --- a/src/components/app/AppContent.tsx +++ b/src/components/app/AppContent.tsx @@ -44,6 +44,7 @@ function AppContentInner() { sidebarOpen, isLoadingProjects, externalMessageUpdate, + newSessionTrigger, setActiveTab, setSidebarOpen, setIsInputFocused, @@ -194,6 +195,7 @@ function AppContentInner() { onNavigateToSession={(targetSessionId: string) => navigate(`/session/${targetSessionId}`)} onShowSettings={() => setShowSettings(true)} externalMessageUpdate={externalMessageUpdate} + newSessionTrigger={newSessionTrigger} /> diff --git a/src/components/chat/hooks/useChatSessionState.ts b/src/components/chat/hooks/useChatSessionState.ts index 3ad66f82..a241e7bc 100644 --- a/src/components/chat/hooks/useChatSessionState.ts +++ b/src/components/chat/hooks/useChatSessionState.ts @@ -22,6 +22,7 @@ interface UseChatSessionStateArgs { sendMessage: (message: unknown) => void; autoScrollToBottom?: boolean; externalMessageUpdate?: number; + newSessionTrigger?: number; processingSessions?: Set; resetStreamingState: () => void; pendingViewSessionRef: MutableRefObject; @@ -95,6 +96,7 @@ export function useChatSessionState({ sendMessage, autoScrollToBottom, externalMessageUpdate, + newSessionTrigger, processingSessions, resetStreamingState, pendingViewSessionRef, @@ -131,9 +133,78 @@ export function useChatSessionState({ const loadAllFinishedTimerRef = useRef | null>(null); const loadAllOverlayTimerRef = useRef | null>(null); const lastLoadedSessionKeyRef = useRef(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(() => 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 */ /* ---------------------------------------------------------------- */ diff --git a/src/components/chat/types/types.ts b/src/components/chat/types/types.ts index 526b8cc7..6cce91fc 100644 --- a/src/components/chat/types/types.ts +++ b/src/components/chat/types/types.ts @@ -113,6 +113,7 @@ export interface ChatInterfaceProps { autoScrollToBottom?: boolean; sendByCtrlEnter?: boolean; externalMessageUpdate?: number; + newSessionTrigger?: number; onTaskClick?: (...args: unknown[]) => void; onShowAllTasks?: (() => void) | null; } diff --git a/src/components/chat/view/ChatInterface.tsx b/src/components/chat/view/ChatInterface.tsx index 2e923d7a..8589f29a 100644 --- a/src/components/chat/view/ChatInterface.tsx +++ b/src/components/chat/view/ChatInterface.tsx @@ -43,6 +43,7 @@ function ChatInterface({ autoScrollToBottom, sendByCtrlEnter, externalMessageUpdate, + newSessionTrigger, onShowAllTasks, }: ChatInterfaceProps) { const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings(); @@ -123,6 +124,7 @@ function ChatInterface({ sendMessage, autoScrollToBottom, externalMessageUpdate, + newSessionTrigger, processingSessions, resetStreamingState, pendingViewSessionRef, diff --git a/src/components/main-content/types/types.ts b/src/components/main-content/types/types.ts index d4e708df..17a7c9ec 100644 --- a/src/components/main-content/types/types.ts +++ b/src/components/main-content/types/types.ts @@ -53,6 +53,7 @@ export type MainContentProps = { onNavigateToSession: (targetSessionId: string) => void; onShowSettings: () => void; externalMessageUpdate: number; + newSessionTrigger: number; }; export type MainContentHeaderProps = { diff --git a/src/components/main-content/view/MainContent.tsx b/src/components/main-content/view/MainContent.tsx index a86dcbc9..1a9c7349 100644 --- a/src/components/main-content/view/MainContent.tsx +++ b/src/components/main-content/view/MainContent.tsx @@ -51,6 +51,7 @@ function MainContent({ onNavigateToSession, onShowSettings, externalMessageUpdate, + newSessionTrigger, }: MainContentProps) { const { preferences } = useUiPreferences(); const { autoExpandTools, showRawParameters, showThinking, autoScrollToBottom, sendByCtrlEnter } = preferences; @@ -145,6 +146,7 @@ function MainContent({ autoScrollToBottom={autoScrollToBottom} sendByCtrlEnter={sendByCtrlEnter} externalMessageUpdate={externalMessageUpdate} + newSessionTrigger={newSessionTrigger} onShowAllTasks={tasksEnabled ? () => setActiveTab('tasks') : null} /> diff --git a/src/hooks/useProjectsState.ts b/src/hooks/useProjectsState.ts index c1c9344c..b5cba267 100644 --- a/src/hooks/useProjectsState.ts +++ b/src/hooks/useProjectsState.ts @@ -261,6 +261,27 @@ export function useProjectsState({ const [showSettings, setShowSettings] = useState(false); const [settingsInitialTab, setSettingsInitialTab] = useState('agents'); const [externalMessageUpdate, setExternalMessageUpdate] = useState(0); + /** + * `newSessionTrigger` is an explicit, monotonic intent signal for user-driven + * New Session actions. + * + * It exists because `handleNewSession` can be invoked while the app is already in + * the same visible state (`selectedSession === null`, `activeTab === 'chat'`, + * route already `/`). In that case, React/router updates are idempotent and no + * downstream reset logic runs. + * + * Usage across the codebase: + * 1) Produced here in `handleNewSession` via increment (always changes). + * 2) Returned from this hook and threaded through: + * useProjectsState -> AppContent -> MainContent -> ChatInterface. + * 3) Consumed in `useChatSessionState` as an effect dependency to forcibly clear + * chat-local state (`currentSessionId`, pending draft message, streaming flags, + * pending session storage keys, pagination/scroll artifacts). + * + * Keeping this signal dedicated avoids coupling resets to unrelated counters/events + * (for example websocket/project refresh updates) that could cause accidental resets. + */ + const [newSessionTrigger, setNewSessionTrigger] = useState(0); const loadingProgressTimeoutRef = useRef | null>(null); const lastHandledMessageRef = useRef(null); @@ -587,6 +608,7 @@ export function useProjectsState({ setSelectedProject(project); setSelectedSession(null); setActiveTab('chat'); + setNewSessionTrigger((previous) => previous + 1); navigate('/'); if (isMobile) { @@ -806,6 +828,7 @@ export function useProjectsState({ showSettings, settingsInitialTab, externalMessageUpdate, + newSessionTrigger, setActiveTab, setSidebarOpen, setIsInputFocused,