fix(desktop): keep the session loader up while known history is empty

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.
This commit is contained in:
Brooklyn Nicholson
2026-08-28 11:36:04 -05:00
committed by brooklyn!
parent b6eb17d01c
commit d229648511
3 changed files with 87 additions and 8 deletions
+21 -7
View File
@@ -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
@@ -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)
})
})
@@ -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
}