Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(<Harness runtimeMap={runtimeMap} submitText={submitText} />)

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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
}

Expand Down
21 changes: 21 additions & 0 deletions apps/desktop/src/store/composer-queue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it } from 'vitest'
import type { ComposerAttachment } from './composer'
import {
$queuedPromptsBySession,
canAutoDrainQueuedPrompt,
clearQueuedPrompts,
dequeueQueuedPrompt,
enqueueQueuedPrompt,
Expand Down Expand Up @@ -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)
})
})
20 changes: 19 additions & 1 deletion apps/desktop/src/store/composer-queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, QueuedPromptEntry[]>
Expand All @@ -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 {}
}
Expand Down Expand Up @@ -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