From 5e5b40079a16968399f80482c8c14ac1539f5850 Mon Sep 17 00:00:00 2001 From: Timothy Laurent Date: Tue, 5 May 2026 16:31:49 -0700 Subject: [PATCH] fix(dashboard): prevent quick chat init race condition and infinite retry loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three interacting bugs caused "Failed to initialize chat" toast spam and prevented fresh session creation: 1. Race condition in handleCreateFreshSession — setting chatMode/ selectedAgentId before startFreshSession triggered the session-init useEffect, which called switchSession and resumed the old session instead of creating a new one. Fixed with skipNextSessionInitRef. 2. Infinite retry loop — shouldRetrySessionInit retried indefinitely when initializeSession failed, producing unbounded toast spam. Fixed with a 3-retry cap (initRetryCountRef). 3. Cascading re-renders — switchSession depended on activeSession in its closure, getting a new identity on every state change and re-triggering the consumer's useEffect. Fixed by reading activeSession from a ref instead. Co-Authored-By: Claude Opus 4.6 --- .../dashboard/app/components/QuickChatFAB.tsx | 15 ++++++ packages/dashboard/app/hooks/useQuickChat.ts | 52 +++++++++++++++++-- 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/packages/dashboard/app/components/QuickChatFAB.tsx b/packages/dashboard/app/components/QuickChatFAB.tsx index 132135b31..caeaf8671 100644 --- a/packages/dashboard/app/components/QuickChatFAB.tsx +++ b/packages/dashboard/app/components/QuickChatFAB.tsx @@ -954,6 +954,7 @@ export function QuickChatFAB({ startModelChat, startFreshSession, refreshSessions, + skipNextSessionInitRef, } = useQuickChat(projectId, addToast); const panelRef = useRef(null); @@ -1181,6 +1182,10 @@ export function QuickChatFAB({ }, [isOpen, refreshSessions]); // Initialize/switch quick chat session whenever the selected target changes. + // NOTE: activeSession and sessionsLoading are in the dependency array to + // enable retry-when-null (see shouldRetrySessionInit), but the hook's + // switchSession now reads activeSession from a ref so it doesn't get a + // new identity on every activeSession change. useEffect(() => { if (!isOpen) { prevSessionTargetRef.current = ""; @@ -1192,6 +1197,15 @@ export function QuickChatFAB({ return; } + // When startFreshSession is in progress, skip the automatic init to + // prevent racing with the explicit fresh-session creation. Record the + // target key as "seen" so a later render won't re-trigger for the same + // target. + if (skipNextSessionInitRef.current) { + prevSessionTargetRef.current = sessionTargetKey; + return; + } + const shouldRetrySessionInit = sessionTargetKey === prevSessionTargetRef.current && !activeSession && !sessionsLoading; @@ -1220,6 +1234,7 @@ export function QuickChatFAB({ sessionsLoading, startModelChat, switchSession, + skipNextSessionInitRef, ]); useEffect(() => { diff --git a/packages/dashboard/app/hooks/useQuickChat.ts b/packages/dashboard/app/hooks/useQuickChat.ts index f9e1e7259..372702592 100644 --- a/packages/dashboard/app/hooks/useQuickChat.ts +++ b/packages/dashboard/app/hooks/useQuickChat.ts @@ -58,6 +58,14 @@ export interface UseQuickChatReturn { refreshSessions: () => Promise; loadMessages: () => Promise; reloadMessages: () => Promise; + + /** + * When true, the consuming component's session-init useEffect should + * skip its automatic switchSession call. Set during startFreshSession + * to prevent the useEffect from racing with an explicit fresh-session + * creation. + */ + skipNextSessionInitRef: React.MutableRefObject; } function normalizeModelSelection(modelProvider?: string, modelId?: string): ModelSelection { @@ -208,6 +216,24 @@ export function useQuickChat( const currentSessionKeyRef = useRef(""); const currentSessionTargetRef = useRef(null); + // Ref mirror of activeSession to avoid cascading re-renders through + // switchSession's dependency array. Reading activeSession from the + // closure causes switchSession to get a new identity every time + // activeSession changes — which then re-triggers the consuming + // component's useEffect that depends on switchSession. + const activeSessionRef = useRef(activeSession); + activeSessionRef.current = activeSession; + + // Max retries for session init to prevent infinite toast loops + const initRetryCountRef = useRef(0); + const INIT_MAX_RETRIES = 3; + + // When true, the consuming component's session-init useEffect should + // skip its switchSession call. Set by startFreshSession (and the + // component's handleCreateFreshSession) to prevent the automatic + // useEffect from racing with an explicit fresh-session creation. + const skipNextSessionInitRef = useRef(false); + useEffect(() => { pendingMessageRef.current = pendingMessage; }, [pendingMessage]); @@ -276,9 +302,18 @@ export function useQuickChat( setActiveSession(newSession); currentSessionKeyRef.current = sessionKey; } + + // Reset retry counter on success so a later failure can retry again + initRetryCountRef.current = 0; } catch (err) { console.error("[useQuickChat] Failed to initialize session:", err); - addToast?.("Failed to initialize chat", "error"); + // Only show the toast while under the retry limit — once the limit + // is reached the user has already seen the warning and further + // toasts just create noise. + initRetryCountRef.current += 1; + if (initRetryCountRef.current <= INIT_MAX_RETRIES) { + addToast?.("Failed to initialize chat", "error"); + } } finally { setSessionsLoading(false); } @@ -379,7 +414,10 @@ export function useQuickChat( const targetSessionKey = buildSessionKey(target.agentId, target.modelProvider, target.modelId); currentSessionTargetRef.current = target; - const isSameSession = targetSessionKey === currentSessionKeyRef.current && activeSession; + // Use ref to avoid cascading re-renders: reading activeSession from + // the closure would make switchSession change identity every time + // activeSession changes, triggering the consumer's useEffect again. + const isSameSession = targetSessionKey === currentSessionKeyRef.current && activeSessionRef.current; if (!isSameSession) { // Close any existing stream @@ -407,7 +445,7 @@ export function useQuickChat( currentSessionKeyRef.current = targetSessionKey; await initializeSession(target.agentId, target.modelProvider, target.modelId); }, - [activeSession, initializeSession, reloadMessages, resetTransientComposerState], + [initializeSession, reloadMessages, resetTransientComposerState], ); const selectSession = useCallback(async (session: ChatSession) => { @@ -444,6 +482,12 @@ export function useQuickChat( // This preserves normal switchSession resume behavior while allowing multiple threads per target. const targetSessionKey = buildSessionKey(target.agentId, target.modelProvider, target.modelId); + // Prevent the consuming component's automatic session-init useEffect + // from racing with this explicit fresh-session creation. The effect + // will see the flag, record the target key as "seen", and skip. + skipNextSessionInitRef.current = true; + initRetryCountRef.current = 0; + if (streamRef.current) { streamRef.current.close(); streamRef.current = null; @@ -465,6 +509,7 @@ export function useQuickChat( console.error("[useQuickChat] Failed to start a fresh session:", err); addToast?.("Failed to start a new chat", "error"); } finally { + skipNextSessionInitRef.current = false; setSessionsLoading(false); } }, [addToast, createSessionForTarget, projectId, resetTransientComposerState]); @@ -656,6 +701,7 @@ export function useQuickChat( refreshSessions, loadMessages, reloadMessages, + skipNextSessionInitRef, }), [ activeSession, sessions,