From d229648511e23fa3ad7bed82aad146eacd78efbb Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 28 Aug 2026 11:36:04 -0500 Subject: [PATCH] fix(desktop): keep the session loader up while known history is empty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brand-new drafts are empty on purpose. A routed session the list already knows has messages must not drop the loader just because a runtime id is bound — that is the blank frame during an unproven warm hold and a cold switch. --- apps/desktop/src/app/chat/index.tsx | 28 +++++++++++---- .../src/app/chat/thread-loading.test.ts | 32 ++++++++++++++++- apps/desktop/src/app/chat/thread-loading.ts | 35 +++++++++++++++++++ 3 files changed, 87 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index d263ff9fad..baed10c47e 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -7,6 +7,7 @@ import { memo, Suspense, useCallback, useEffect, useId, useMemo, useRef, useStat import { useLocation } from 'react-router' import type { SubmitTextOptions } from '@/app/session/hooks/use-prompt-actions/utils' +import { sessionShouldHaveTranscript } from '@/app/session/hooks/use-session-actions/utils' import { Thread } from '@/components/assistant-ui/thread' import { TranscriptWindowProvider } from '@/components/assistant-ui/thread/transcript-window' import { Backdrop } from '@/components/Backdrop' @@ -71,7 +72,7 @@ import { useRuntimeMessageRepository } from './runtime-repository' import { ScrollToBottomButton } from './scroll-to-bottom-button' import { useSessionView } from './session-view' import { SessionActionsMenu } from './sidebar/session-actions-menu' -import { threadLoadingState } from './thread-loading' +import { routedSessionIsLoading, threadLoadingState } from './thread-loading' import { backfillOlderTranscriptPage, mergeOlderTranscriptPage, @@ -502,10 +503,11 @@ const ChatViewContent = memo(function ChatViewContent({ }) // Session is still loading if the route references a session we haven't - // resumed yet. Once `activeSessionId` is set (runtime has resumed), the - // session exists — even if it has zero messages (a brand-new routed - // session). The flicker where `busy` flips true briefly during hydrate - // is handled by `threadLoadingState`'s last-visible-user gate. + // resumed yet. Brand-new routed drafts are empty on purpose once a runtime + // is bound. A session the list already knows has history must keep the + // loader up until a display-authoritative transcript arrives — including + // the unproven warm-cache hold, where the runtime is bound but messages + // are still suppressed. // // resumeExhausted: the bounded auto-retry in use-route-resume gave up on this // routed session (gateway RPC + REST fallback failed through every attempt). @@ -513,9 +515,21 @@ const ChatViewContent = memo(function ChatViewContent({ // spinning forever. Gated on the route matching so a stale latch from another // session can't blank the current one. const resumeExhausted = isPrimary && isRoutedSessionView && resumeExhaustedSessionId === routedSessionId + const routedHasHistory = Boolean( + routedSessionId && + sessions.some( + session => sessionMatchesStoredId(session, routedSessionId) && sessionShouldHaveTranscript(session) + ) + ) - const loadingSession = - !resumeExhausted && isRoutedSessionView && (routeSessionMismatch || (messagesEmpty && !activeSessionId)) + const loadingSession = routedSessionIsLoading({ + activeSessionId, + knownHistory: routedHasHistory, + messagesEmpty, + resumeExhausted, + routeSessionMismatch, + routedSessionView: isRoutedSessionView + }) const threadLoading = threadLoadingState(loadingSession, busy, awaitingResponse, lastVisibleIsUser) // Hide the composer in the exhausted error state too: there's no live runtime diff --git a/apps/desktop/src/app/chat/thread-loading.test.ts b/apps/desktop/src/app/chat/thread-loading.test.ts index 63ddf98b35..db4d7117a7 100644 --- a/apps/desktop/src/app/chat/thread-loading.test.ts +++ b/apps/desktop/src/app/chat/thread-loading.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import type { ChatMessage } from '@/lib/chat-messages' -import { lastVisibleMessageIsUser, threadLoadingState } from './thread-loading' +import { lastVisibleMessageIsUser, routedSessionIsLoading, threadLoadingState } from './thread-loading' function message(id: string, role: ChatMessage['role'], hidden = false): ChatMessage { return { @@ -32,3 +32,33 @@ describe('thread loading state', () => { expect(threadLoadingState(false, true, true, lastVisibleMessageIsUser(messages))).toBeUndefined() }) }) + +describe('routedSessionIsLoading', () => { + const base = { + activeSessionId: 'runtime-1' as string | null, + knownHistory: false, + messagesEmpty: false, + resumeExhausted: false, + routeSessionMismatch: false, + routedSessionView: true + } + + it('keeps the session loader up when known history is held off the view', () => { + expect( + routedSessionIsLoading({ + ...base, + knownHistory: true, + messagesEmpty: true + }) + ).toBe(true) + }) + + it('does not treat a brand-new empty routed draft as still loading', () => { + expect( + routedSessionIsLoading({ + ...base, + messagesEmpty: true + }) + ).toBe(false) + }) +}) diff --git a/apps/desktop/src/app/chat/thread-loading.ts b/apps/desktop/src/app/chat/thread-loading.ts index 05cfb08671..c9313966c6 100644 --- a/apps/desktop/src/app/chat/thread-loading.ts +++ b/apps/desktop/src/app/chat/thread-loading.ts @@ -29,3 +29,38 @@ export function threadLoadingState( return undefined } + +export function routedSessionIsLoading({ + activeSessionId, + knownHistory, + messagesEmpty, + resumeExhausted, + routeSessionMismatch, + routedSessionView +}: { + activeSessionId: string | null + knownHistory: boolean + messagesEmpty: boolean + resumeExhausted: boolean + routeSessionMismatch: boolean + routedSessionView: boolean +}): boolean { + if (resumeExhausted || !routedSessionView) { + return false + } + + if (routeSessionMismatch) { + return true + } + + if (!messagesEmpty) { + return false + } + + // Brand-new routed drafts are empty on purpose. A session the list already + // knows has history must keep the loader up until a display-authoritative + // transcript arrives — including the unproven warm-cache hold, where the + // runtime is bound but messages are still suppressed. + return !activeSessionId || knownHistory +} +