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