From fad7cfca422e68cc30be7527d5bff7c5cfefbcfe Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Wed, 9 Sep 2026 20:42:49 -0400 Subject: [PATCH] fix(desktop): wait for session discovery without discarding queued prompts (#106986) * fix(desktop): wait for session list before auto-draining queued prompts Restored composer-queue entries were retrying as soon as the gateway socket opened, burning the drain budget against reaped runtimes and toasting "Queued message not sent" on every launch. * test(desktop): cover composer-queue drain while sessions are still loading * docs(desktop): clarify queue-drain discovery gates * test(desktop): preserve hidden-session queues after failed drains --------- Co-authored-by: xxxigm --- .../hooks/use-composer-queue.test.tsx | 35 +++++++++ .../chat/composer/hooks/use-composer-queue.ts | 10 ++- .../hooks/use-background-queue-drain.test.tsx | 54 +++++++++++++- .../hooks/use-background-queue-drain.ts | 9 ++- ...use-background-queue-preservation.test.tsx | 72 +++++++++++++++++++ 5 files changed, 176 insertions(+), 4 deletions(-) create mode 100644 apps/desktop/src/app/session/hooks/use-background-queue-preservation.test.tsx diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-queue.test.tsx b/apps/desktop/src/app/chat/composer/hooks/use-composer-queue.test.tsx index ad3328cae8..8a2894e650 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-queue.test.tsx +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-queue.test.tsx @@ -10,6 +10,7 @@ import { MAX_AUTO_DRAIN_ATTEMPTS, parkQueuedPrompts } from '@/store/composer-queue' +import { setSessionsLoading } from '@/store/session' import type { QueueEditState } from '../composer-utils' import type { ChatBarProps } from '../types' @@ -58,6 +59,7 @@ describe('useComposerQueue park integration', () => { window.localStorage.clear() $queuedPromptsBySession.set({}) $parkedQueueSessions.set({}) + setSessionsLoading(false) }) afterEach(() => { @@ -65,6 +67,7 @@ describe('useComposerQueue park integration', () => { vi.restoreAllMocks() $queuedPromptsBySession.set({}) $parkedQueueSessions.set({}) + setSessionsLoading(true) }) it('reschedules rejected foreground drains to a bounded stop and keeps manual recovery', async () => { @@ -253,4 +256,36 @@ describe('useComposerQueue park integration', () => { expect(isQueueParked(SESSION_KEY)).toBe(false) expect(getQueuedPrompts(SESSION_KEY)).toHaveLength(1) }) + + it('does not auto-drain restored queues while the session list is still loading', async () => { + setSessionsLoading(true) + enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'wait for session list' }) + + const { onSubmit } = renderQueueHook() + + await act(async () => { + await Promise.resolve() + }) + + expect(onSubmit).not.toHaveBeenCalled() + expect(getQueuedPrompts(SESSION_KEY)).toHaveLength(1) + }) + + it('auto-drains a restored queue once the session list finishes loading', async () => { + setSessionsLoading(true) + enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'send after load' }) + + const { hook, onSubmit } = renderQueueHook() + + await act(async () => { + await Promise.resolve() + }) + expect(onSubmit).not.toHaveBeenCalled() + + setSessionsLoading(false) + hook.rerender({ busy: false }) + + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)) + expect(getQueuedPrompts(SESSION_KEY)).toHaveLength(0) + }) }) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-queue.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-queue.ts index 5787a54a5b..958364e303 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-queue.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-queue.ts @@ -22,6 +22,7 @@ import { updateQueuedPrompt } from '@/store/composer-queue' import { notify } from '@/store/notifications' +import { $sessionsLoading } from '@/store/session' import { cloneAttachments, type QueueEditState } from '../composer-utils' import { useComposerScope } from '../scope' @@ -80,6 +81,7 @@ export function useComposerQueue({ // is fine; the auto-drain effect below reads it as a gate. const parkedSessions = useStore($parkedQueueSessions) const queueParked = Boolean(activeQueueSessionKey && parkedSessions[activeQueueSessionKey]) + const sessionsLoading = useStore($sessionsLoading) const [queueEdit, setQueueEdit] = useState(null) queueEditRef.current = queueEdit @@ -402,10 +404,16 @@ export function useComposerQueue({ // strand them. A park (explicit Stop/Esc) is the one gate: those entries wait // for the user. To cancel queued turns, the user deletes them from the panel. useEffect(() => { + // Match the background drainer: preserve the retry budget while session + // discovery runs at boot, on a gateway/profile switch, or over an empty list. + if (sessionsLoading) { + return + } + if (shouldAutoDrain({ isBusy: busy, parked: queueParked, queueLength: queuedPrompts.length })) { return autoDrainNext() } - }, [autoDrainNext, busy, drainRetryTick, queueParked, queuedPrompts.length]) + }, [autoDrainNext, busy, drainRetryTick, queueParked, queuedPrompts.length, sessionsLoading]) // Queue-edit cleanup: on session swap the scope effect already stashed the // edit snapshot; only restore into the composer when still on the same scope. diff --git a/apps/desktop/src/app/session/hooks/use-background-queue-drain.test.tsx b/apps/desktop/src/app/session/hooks/use-background-queue-drain.test.tsx index f906098229..c102e08f89 100644 --- a/apps/desktop/src/app/session/hooks/use-background-queue-drain.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-background-queue-drain.test.tsx @@ -10,7 +10,7 @@ import { getQueuedPrompts, parkQueuedPrompts } from '@/store/composer-queue' -import { $sessions, setSessions } from '@/store/session' +import { $sessions, setSessions, setSessionsLoading } from '@/store/session' import { clearAllSessionStates, publishSessionState } from '@/store/session-states' import type { SessionInfo } from '@/types/hermes' @@ -62,6 +62,9 @@ describe('useBackgroundQueueDrain', () => { beforeEach(() => { vi.useRealTimers() clearAllSessionStates() + // Production drain waits for the sidebar list. Tests that assert drain + // behavior are post-load unless they opt into the loading gate. + setSessionsLoading(false) }) afterEach(() => { @@ -71,6 +74,7 @@ describe('useBackgroundQueueDrain', () => { $queuedPromptsBySession.set({}) $parkedQueueSessions.set({}) $sessions.set([]) + setSessionsLoading(true) clearAllSessionStates() }) @@ -222,4 +226,52 @@ describe('useBackgroundQueueDrain', () => { expect(submitText).toHaveBeenCalledTimes(2) expect(getQueuedPrompts('stored-session-a')).toHaveLength(0) }) + + it('does not drain restored queues while the session list is still loading', async () => { + vi.useFakeTimers() + setSessionsLoading(true) + + const runtimeMap = { current: new Map([['stored-session-a', 'rt-session-a']]) } + const submitText = vi.fn(async () => true) + + enqueueQueuedPrompt('stored-session-a', { text: 'wait for session list', attachments: [] }) + + render() + + // Four 750ms retries is what used to burn the drain budget and toast on boot. + await act(async () => { + await vi.advanceTimersByTimeAsync(750 * 4) + await Promise.resolve() + }) + + expect(submitText).not.toHaveBeenCalled() + expect(getQueuedPrompts('stored-session-a')).toHaveLength(1) + }) + + it('drains a restored background queue once the session list finishes loading', async () => { + setSessionsLoading(true) + + const runtimeMap = { current: new Map([['stored-session-a', 'rt-session-a']]) } + const submitText = vi.fn(async () => true) + + enqueueQueuedPrompt('stored-session-a', { text: 'send after load', attachments: [] }) + + render() + + await new Promise(resolve => window.setTimeout(resolve, 0)) + expect(submitText).not.toHaveBeenCalled() + + setSessionsLoading(false) + + await waitFor(() => { + expect(submitText).toHaveBeenCalledWith('send after load', { + attachments: [], + fromQueue: true, + sessionId: 'rt-session-a', + storedSessionId: 'stored-session-a' + }) + }) + + await waitFor(() => expect(getQueuedPrompts('stored-session-a')).toHaveLength(0)) + }) }) diff --git a/apps/desktop/src/app/session/hooks/use-background-queue-drain.ts b/apps/desktop/src/app/session/hooks/use-background-queue-drain.ts index 8573ab9c8a..46929653ad 100644 --- a/apps/desktop/src/app/session/hooks/use-background-queue-drain.ts +++ b/apps/desktop/src/app/session/hooks/use-background-queue-drain.ts @@ -13,7 +13,7 @@ import { shouldAutoDrain } from '@/store/composer-queue' import { notify } from '@/store/notifications' -import { $sessions, idsShareLineage } from '@/store/session' +import { $sessions, $sessionsLoading, idsShareLineage } from '@/store/session' import { $workingSessionIds } from '@/store/session-states' import type { SubmitTextOptions } from './use-prompt-actions/utils' @@ -46,6 +46,7 @@ export function useBackgroundQueueDrain({ const { t } = useI18n() const queuedPromptsBySession = useStore($queuedPromptsBySession) const parkedQueueSessions = useStore($parkedQueueSessions) + const sessionsLoading = useStore($sessionsLoading) const workingSessionIds = useStore($workingSessionIds) const submitTextRef = useRef(submitText) const drainingSessionIdsRef = useRef(new Set()) @@ -151,7 +152,10 @@ export function useBackgroundQueueDrain({ ) useEffect(() => { - if (!enabled) { + // Preserve the retry budget while session discovery runs at boot, on a + // gateway/profile switch, or during a refresh over an empty list. + // Once discovery settles, submitText can resume by stored id. + if (!enabled || sessionsLoading) { return } @@ -194,6 +198,7 @@ export function useBackgroundQueueDrain({ queuedPromptsBySession, retryTick, selectedStoredSessionId, + sessionsLoading, workingSessionIds ]) } diff --git a/apps/desktop/src/app/session/hooks/use-background-queue-preservation.test.tsx b/apps/desktop/src/app/session/hooks/use-background-queue-preservation.test.tsx new file mode 100644 index 0000000000..8df29f2d2c --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-background-queue-preservation.test.tsx @@ -0,0 +1,72 @@ +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' + +import { + $parkedQueueSessions, + $queuedPromptsBySession, + enqueueQueuedPrompt, + getQueuedPrompts, + MAX_AUTO_DRAIN_ATTEMPTS +} from '@/store/composer-queue' +import { clearNotifications } from '@/store/notifications' +import { $sessions, forgetSessionOwnerHintsForSession, setSessionOwnerHint, setSessionsLoading } from '@/store/session' +import { clearAllSessionStates } from '@/store/session-states' + +import { useBackgroundQueueDrain } from './use-background-queue-drain' + +afterEach(() => { + cleanup() + vi.useRealTimers() + $queuedPromptsBySession.set({}) + $parkedQueueSessions.set({}) + $sessions.set([]) + setSessionsLoading(true) + forgetSessionOwnerHintsForSession('hidden-bot-chat') + clearNotifications() + clearAllSessionStates() +}) + +it.each(['rejection', 'transport error'])( + 'preserves a known hidden session queue after transient %s', + async failure => { + vi.useFakeTimers() + $sessions.set([]) + setSessionsLoading(false) + clearAllSessionStates() + setSessionOwnerHint('hidden-bot-chat', { connectionId: 'remote-a', profile: 'bot' }) + const first = enqueueQueuedPrompt('hidden-bot-chat', { text: 'retry this later', attachments: [] })! + const second = enqueueQueuedPrompt('hidden-bot-chat', { text: 'not attempted yet', attachments: [] })! + const submitText = vi.fn<(text: string) => Promise>() + + if (failure === 'transport error') { + submitText.mockRejectedValue(new Error('WebSocket temporarily disconnected')) + } else { + submitText.mockResolvedValue(false) + } + + const runtimeMap = { current: new Map([['hidden-bot-chat', 'live-runtime']]) } + + renderHook(() => + useBackgroundQueueDrain({ + enabled: true, + runtimeIdByStoredSessionIdRef: runtimeMap, + selectedStoredSessionId: 'foreground', + submitText + }) + ) + + await act(async () => { + await Promise.resolve() + }) + + for (let attempt = 1; attempt < MAX_AUTO_DRAIN_ATTEMPTS; attempt++) { + await act(async () => { + await vi.advanceTimersByTimeAsync(750) + }) + } + + expect(submitText).toHaveBeenCalledTimes(MAX_AUTO_DRAIN_ATTEMPTS) + expect(submitText.mock.calls.every(call => call[0] === first.text)).toBe(true) + expect(getQueuedPrompts('hidden-bot-chat').map(entry => entry.id)).toEqual([first.id, second.id]) + } +)