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 9d54e17bd957f..94848f7efd6fe 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 @@ -7,6 +7,7 @@ import { type ComposerAttachment } from '@/store/composer' import { resetBrowseState } from '@/store/composer-input-history' import { $queuedPromptsBySession, + canAutoDrainQueuedPrompt, enqueueQueuedPrompt, getQueuedPrompts, MAX_AUTO_DRAIN_ATTEMPTS, @@ -274,7 +275,11 @@ export function useComposerQueue({ const entry = pickDrainHead(queuedPrompts) - if (!entry || (drainFailuresRef.current.get(entry.id) ?? 0) >= MAX_AUTO_DRAIN_ATTEMPTS) { + if ( + !entry || + !canAutoDrainQueuedPrompt(entry) || + (drainFailuresRef.current.get(entry.id) ?? 0) >= MAX_AUTO_DRAIN_ATTEMPTS + ) { return } 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 e6cd960c142d4..be49bd82e2c7c 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 @@ -78,6 +78,22 @@ describe('useBackgroundQueueDrain', () => { expect(getQueuedPrompts('stored-session-a')).toHaveLength(1) }) + it('does not auto-send an entry restored from a previous Desktop process', async () => { + const runtimeMap = { current: new Map([['stored-session-a', 'rt-session-a']]) } + const submitText = vi.fn(async () => true) + + enqueueQueuedPrompt('stored-session-a', { text: 'month-old ghost prompt', attachments: [] }) + const entry = getQueuedPrompts('stored-session-a')[0]! + $queuedPromptsBySession.set({ 'stored-session-a': [{ ...entry, requiresManualSend: true }] }) + + render() + + await new Promise(resolve => window.setTimeout(resolve, 0)) + + expect(submitText).not.toHaveBeenCalled() + expect(getQueuedPrompts('stored-session-a')).toHaveLength(1) + }) + it('does not drain a background session that is still marked working', async () => { const runtimeMap = { current: new Map([['stored-session-a', 'rt-session-a']]) } const submitText = vi.fn(async () => true) 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 83d389e9767ba..48ad4634732f5 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 @@ -5,6 +5,7 @@ import { useI18n } from '@/i18n' import { resetBrowseState } from '@/store/composer-input-history' import { $queuedPromptsBySession, + canAutoDrainQueuedPrompt, getQueuedPrompts, MAX_AUTO_DRAIN_ATTEMPTS, type QueuedPromptEntry, @@ -164,7 +165,11 @@ export function useBackgroundQueueDrain({ const entry = entries[0] - if (!entry || (drainFailuresRef.current.get(entry.id) ?? 0) >= MAX_AUTO_DRAIN_ATTEMPTS) { + if ( + !entry || + !canAutoDrainQueuedPrompt(entry) || + (drainFailuresRef.current.get(entry.id) ?? 0) >= MAX_AUTO_DRAIN_ATTEMPTS + ) { continue } diff --git a/apps/desktop/src/store/composer-queue.test.ts b/apps/desktop/src/store/composer-queue.test.ts index 8012e2870f06b..bf8c7d3b6dd46 100644 --- a/apps/desktop/src/store/composer-queue.test.ts +++ b/apps/desktop/src/store/composer-queue.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it } from 'vitest' import type { ComposerAttachment } from './composer' import { $queuedPromptsBySession, + canAutoDrainQueuedPrompt, clearQueuedPrompts, dequeueQueuedPrompt, enqueueQueuedPrompt, @@ -168,3 +169,23 @@ describe('shouldAutoDrain', () => { expect(shouldAutoDrain({ isBusy: false, queueLength: 0 })).toBe(false) }) }) + +describe('canAutoDrainQueuedPrompt', () => { + it('allows entries queued in the current Desktop process', () => { + expect( + canAutoDrainQueuedPrompt({ id: 'fresh', text: 'send next', attachments: [], queuedAt: Date.now() }) + ).toBe(true) + }) + + it('requires an explicit send for entries restored from persistence', () => { + expect( + canAutoDrainQueuedPrompt({ + id: 'restored', + text: 'old prompt', + attachments: [], + queuedAt: Date.now() - 86_400_000, + requiresManualSend: true + }) + ).toBe(false) + }) +}) diff --git a/apps/desktop/src/store/composer-queue.ts b/apps/desktop/src/store/composer-queue.ts index 922e990fdce7d..d71c2cd7a4fb8 100644 --- a/apps/desktop/src/store/composer-queue.ts +++ b/apps/desktop/src/store/composer-queue.ts @@ -7,6 +7,11 @@ export interface QueuedPromptEntry { text: string attachments: ComposerAttachment[] queuedAt: number + /** Persisted entries restored by a later Desktop process require an explicit + * user send. A queue is an immediate sequencing aid, not a delayed-job + * scheduler; silently auto-draining an old entry after restart can submit a + * stale prompt into a rebound or recycled session. */ + requiresManualSend?: boolean } type QueueState = Record @@ -22,7 +27,16 @@ const load = (): QueueState => { const raw = window.localStorage.getItem(STORAGE_KEY) const parsed = raw ? JSON.parse(raw) : null - return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as QueueState) : {} + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return {} + } + + return Object.fromEntries( + Object.entries(parsed as QueueState).map(([sessionKey, entries]) => [ + sessionKey, + Array.isArray(entries) ? entries.map(entry => ({ ...entry, requiresManualSend: true })) : [] + ]) + ) } catch { return {} } @@ -258,6 +272,10 @@ export interface AutoDrainInput { */ export const shouldAutoDrain = ({ isBusy, queueLength }: AutoDrainInput): boolean => !isBusy && queueLength > 0 +/** Restored queue entries stay available for review/manual send, but never fire + * merely because Desktop restarted, reconnected, or rebound a runtime id. */ +export const canAutoDrainQueuedPrompt = (entry: QueuedPromptEntry): boolean => !entry.requiresManualSend + /** Auto-drain attempts for one entry before we stop retrying and toast. The * entry stays queued for a manual send; a remount/reconnect resets the count. */ export const MAX_AUTO_DRAIN_ATTEMPTS = 4