fix(desktop): drain queued prompts for background sessions (#66001)

Co-authored-by: Jakub Wolniewicz <4850809+frizikk@users.noreply.github.com>
This commit is contained in:
brooklyn! 2026-07-16 20:39:55 -04:00 committed by GitHub
parent bd00212337
commit 432fca55a7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 573 additions and 86 deletions

View file

@ -8,6 +8,7 @@ import { resetBrowseState } from '@/store/composer-input-history'
import {
$queuedPromptsBySession,
enqueueQueuedPrompt,
getQueuedPrompts,
MAX_AUTO_DRAIN_ATTEMPTS,
migrateQueuedPrompts,
promoteQueuedPrompt,
@ -189,7 +190,9 @@ export function useComposerQueue({
return false
}
const entry = pickEntry(queuedPrompts)
const drainQueueSessionKey = activeQueueSessionKey
const drainRuntimeSessionId = sessionId ?? null
const entry = pickEntry(getQueuedPrompts(drainQueueSessionKey))
if (!entry) {
return false
@ -199,7 +202,12 @@ export function useComposerQueue({
try {
const accepted = await Promise.resolve(
onSubmit(entry.text, { attachments: entry.attachments, fromQueue: true })
onSubmit(entry.text, {
attachments: entry.attachments,
fromQueue: true,
sessionId: drainRuntimeSessionId,
storedSessionId: drainQueueSessionKey
})
)
if (accepted === false) {
@ -207,15 +215,15 @@ export function useComposerQueue({
}
drainFailuresRef.current.delete(entry.id)
removeQueuedPrompt(activeQueueSessionKey, entry.id)
resetBrowseState(sessionId)
removeQueuedPrompt(drainQueueSessionKey, entry.id)
resetBrowseState(drainRuntimeSessionId)
return true
} finally {
drainingQueueRef.current = false
}
},
[activeQueueSessionKey, onSubmit, queuedPrompts, sessionId]
[activeQueueSessionKey, onSubmit, sessionId]
)
const pickDrainHead = useCallback(

View file

@ -1,7 +1,7 @@
import type { ReactNode } from 'react'
import type { SubmitTextOptions } from '@/app/session/hooks/use-prompt-actions/utils'
import type { HermesGateway } from '@/hermes'
import type { ComposerAttachment } from '@/store/composer'
import type { DroppedFile } from '../hooks/use-composer-actions'
@ -52,10 +52,7 @@ export interface ChatBarProps {
onPickImages?: () => void
onRemoveAttachment?: (id: string) => void
onSteer?: (text: string) => Promise<boolean> | boolean
onSubmit: (
value: string,
options?: { attachments?: ComposerAttachment[]; fromQueue?: boolean }
) => Promise<boolean> | boolean
onSubmit: (value: string, options?: SubmitTextOptions) => Promise<boolean> | boolean
onTranscribeAudio?: (audio: Blob) => Promise<string>
}

View file

@ -5,6 +5,7 @@ import type * as React from 'react'
import { Suspense, useCallback, useMemo } from 'react'
import { useLocation } from 'react-router-dom'
import type { SubmitTextOptions } from '@/app/session/hooks/use-prompt-actions/utils'
import { Thread } from '@/components/assistant-ui/thread'
import { Backdrop } from '@/components/Backdrop'
import { COMPOSER_HEART_CONFIG, HeartField } from '@/components/chat/vibe-hearts'
@ -19,7 +20,6 @@ import type { ChatMessage } from '@/lib/chat-messages'
import { quickModelOptions, sessionTitle } from '@/lib/chat-runtime'
import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime'
import { cn } from '@/lib/utils'
import type { ComposerAttachment } from '@/store/composer'
import { $pinnedSessionIds } from '@/store/layout'
import { $petActive } from '@/store/pet'
import { $petOverlayActive } from '@/store/pet-overlay'
@ -74,10 +74,7 @@ interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
onPickImages: () => void
onRemoveAttachment: (id: string) => void
onSteer: (text: string) => Promise<boolean> | boolean
onSubmit: (
text: string,
options?: { attachments?: ComposerAttachment[]; fromQueue?: boolean }
) => Promise<boolean> | boolean
onSubmit: (text: string, options?: SubmitTextOptions) => Promise<boolean> | boolean
onThreadMessagesChange: (messages: readonly ThreadMessage[]) => void
onEdit: (message: AppendMessage) => Promise<void>
onReload: (parentId: string | null) => Promise<void>

View file

@ -75,6 +75,7 @@ import { PersistentTerminal } from '../right-sidebar/terminal/persistent'
import { CRON_ROUTE, routeSessionId, sessionRoute, SETTINGS_ROUTE, syncWorkspaceIsPage } from '../routes'
import { SessionPickerOverlay } from '../session-picker-overlay'
import { SessionSwitcher } from '../session-switcher'
import { useBackgroundQueueDrain } from '../session/hooks/use-background-queue-drain'
import { useContextSuggestions } from '../session/hooks/use-context-suggestions'
import { useCwdActions } from '../session/hooks/use-cwd-actions'
import { useHermesConfig } from '../session/hooks/use-hermes-config'
@ -536,6 +537,15 @@ export function ContribWiring({ children }: { children: ReactNode }) {
updateSessionState
})
// Runs outside the selected ChatBar so queues belonging to background
// sessions continue once those sessions are idle.
useBackgroundQueueDrain({
enabled: gatewayState === 'open',
runtimeIdByStoredSessionIdRef,
selectedStoredSessionId,
submitText
})
// Session-tile delegate (resume/submit/interrupt/slash + the session verbs
// the tile TAB menu needs, without touching the primary view).
useSessionTileDelegate({

View file

@ -0,0 +1,139 @@
import { act, cleanup, render, waitFor } from '@testing-library/react'
import type { MutableRefObject } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $queuedPromptsBySession, enqueueQueuedPrompt, getQueuedPrompts } from '@/store/composer-queue'
import { $workingSessionIds } from '@/store/session'
import { useBackgroundQueueDrain } from './use-background-queue-drain'
import type { SubmitTextOptions } from './use-prompt-actions/utils'
function Harness({
enabled = true,
runtimeMap,
selectedStoredSessionId = 'stored-session-b',
submitText
}: {
enabled?: boolean
runtimeMap: MutableRefObject<Map<string, string>>
selectedStoredSessionId?: string | null
submitText: (text: string, options?: SubmitTextOptions) => Promise<boolean> | boolean
}) {
useBackgroundQueueDrain({
enabled,
runtimeIdByStoredSessionIdRef: runtimeMap,
selectedStoredSessionId,
submitText
})
return null
}
describe('useBackgroundQueueDrain', () => {
beforeEach(() => {
vi.useRealTimers()
})
afterEach(() => {
cleanup()
vi.restoreAllMocks()
vi.useRealTimers()
$queuedPromptsBySession.set({})
$workingSessionIds.set([])
})
it('drains an idle queued prompt for a non-selected background session', async () => {
const runtimeMap = { current: new Map([['stored-session-a', 'rt-session-a']]) }
const submitText = vi.fn(async () => true)
enqueueQueuedPrompt('stored-session-a', { text: 'continue in the background', attachments: [] })
$workingSessionIds.set([])
render(<Harness runtimeMap={runtimeMap} submitText={submitText} />)
await waitFor(() => {
expect(submitText).toHaveBeenCalledWith('continue in the background', {
attachments: [],
fromQueue: true,
sessionId: 'rt-session-a',
storedSessionId: 'stored-session-a'
})
})
await waitFor(() => expect(getQueuedPrompts('stored-session-a')).toHaveLength(0))
})
it('leaves the selected session queue to the mounted ChatBar drainer', async () => {
const runtimeMap = { current: new Map([['stored-session-a', 'rt-session-a']]) }
const submitText = vi.fn(async () => true)
enqueueQueuedPrompt('stored-session-a', { text: 'visible queue entry', attachments: [] })
$workingSessionIds.set([])
render(<Harness runtimeMap={runtimeMap} selectedStoredSessionId="stored-session-a" 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)
enqueueQueuedPrompt('stored-session-a', { text: 'wait for current turn', attachments: [] })
$workingSessionIds.set(['stored-session-a'])
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('passes a null runtime id so submitText can resume stale background sessions by stored id', async () => {
const runtimeMap = { current: new Map<string, string>() }
const submitText = vi.fn(async () => true)
enqueueQueuedPrompt('stored-session-a', { text: 'resume then send', attachments: [] })
render(<Harness runtimeMap={runtimeMap} submitText={submitText} />)
await waitFor(() => {
expect(submitText).toHaveBeenCalledWith('resume then send', {
attachments: [],
fromQueue: true,
sessionId: null,
storedSessionId: 'stored-session-a'
})
})
})
it('retries a rejected background drain without waiting for another queue or busy-state change', async () => {
vi.useFakeTimers()
const runtimeMap = { current: new Map([['stored-session-a', 'rt-session-a']]) }
const submitText = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true)
enqueueQueuedPrompt('stored-session-a', { text: 'retry me', attachments: [] })
render(<Harness runtimeMap={runtimeMap} submitText={submitText} />)
await act(async () => {
await Promise.resolve()
})
expect(submitText).toHaveBeenCalledTimes(1)
expect(getQueuedPrompts('stored-session-a')).toHaveLength(1)
await act(async () => {
await vi.advanceTimersByTimeAsync(750)
await Promise.resolve()
})
expect(submitText).toHaveBeenCalledTimes(2)
expect(getQueuedPrompts('stored-session-a')).toHaveLength(0)
})
})

View file

@ -0,0 +1,174 @@
import { useStore } from '@nanostores/react'
import { type MutableRefObject, useCallback, useEffect, useRef, useState } from 'react'
import { useI18n } from '@/i18n'
import { resetBrowseState } from '@/store/composer-input-history'
import {
$queuedPromptsBySession,
getQueuedPrompts,
MAX_AUTO_DRAIN_ATTEMPTS,
type QueuedPromptEntry,
removeQueuedPrompt,
shouldAutoDrain
} from '@/store/composer-queue'
import { notify } from '@/store/notifications'
import { $workingSessionIds } from '@/store/session'
import type { SubmitTextOptions } from './use-prompt-actions/utils'
type SubmitQueuedPrompt = (text: string, options?: SubmitTextOptions) => Promise<boolean> | boolean
interface BackgroundQueueDrainOptions {
enabled: boolean
runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>>
selectedStoredSessionId: string | null
submitText: SubmitQueuedPrompt
}
const BACKGROUND_DRAIN_RETRY_MS = 750
/**
* Drain queued prompts for sessions that are not currently rendered by ChatBar.
*
* The visible ChatBar owns the interactive queue panel for the selected session.
* Without this background drain, a prompt queued in Session A can sit forever
* after the user switches to Session B: the only auto-drain effect lives inside
* the mounted ChatBar, so Session A's queue is not observed when A is offscreen.
*/
export function useBackgroundQueueDrain({
enabled,
runtimeIdByStoredSessionIdRef,
selectedStoredSessionId,
submitText
}: BackgroundQueueDrainOptions) {
const { t } = useI18n()
const queuedPromptsBySession = useStore($queuedPromptsBySession)
const workingSessionIds = useStore($workingSessionIds)
const submitTextRef = useRef(submitText)
const drainingSessionIdsRef = useRef(new Set<string>())
const drainFailuresRef = useRef(new Map<string, number>())
const retryTimersRef = useRef<number[]>([])
const [retryTick, setRetryTick] = useState(0)
useEffect(() => {
submitTextRef.current = submitText
}, [submitText])
const scheduleRetry = useCallback(() => {
if (typeof window === 'undefined') {
return
}
const timer = window.setTimeout(() => {
retryTimersRef.current = retryTimersRef.current.filter(id => id !== timer)
setRetryTick(tick => tick + 1)
}, BACKGROUND_DRAIN_RETRY_MS)
retryTimersRef.current.push(timer)
}, [])
useEffect(
() => () => {
for (const timer of retryTimersRef.current) {
window.clearTimeout(timer)
}
retryTimersRef.current = []
},
[]
)
const drainSessionQueue = useCallback(
(sessionKey: string, entry: QueuedPromptEntry) => {
if (drainingSessionIdsRef.current.has(sessionKey)) {
return
}
drainingSessionIdsRef.current.add(sessionKey)
const onFail = () => {
const failures = (drainFailuresRef.current.get(entry.id) ?? 0) + 1
drainFailuresRef.current.set(entry.id, failures)
if (failures >= MAX_AUTO_DRAIN_ATTEMPTS) {
notify({
id: `composer-background-queue-stuck-${sessionKey}`,
kind: 'error',
title: t.composer.queueStuckTitle,
message: t.composer.queueStuckBody
})
return
}
scheduleRetry()
}
void Promise.resolve()
.then(async () => {
const liveEntry = getQueuedPrompts(sessionKey).find(candidate => candidate.id === entry.id)
if (!liveEntry) {
return true
}
const runtimeSessionId = runtimeIdByStoredSessionIdRef.current.get(sessionKey) ?? null
const accepted = await Promise.resolve(
submitTextRef.current(liveEntry.text, {
attachments: liveEntry.attachments,
fromQueue: true,
sessionId: runtimeSessionId,
storedSessionId: sessionKey
})
)
if (accepted === false) {
return false
}
drainFailuresRef.current.delete(liveEntry.id)
removeQueuedPrompt(sessionKey, liveEntry.id)
resetBrowseState(runtimeSessionId)
return true
})
.then(accepted => {
if (!accepted) {
onFail()
}
})
.catch(onFail)
.finally(() => {
drainingSessionIdsRef.current.delete(sessionKey)
})
},
[runtimeIdByStoredSessionIdRef, scheduleRetry, t]
)
useEffect(() => {
if (!enabled) {
return
}
const working = new Set(workingSessionIds)
for (const [sessionKey, entries] of Object.entries(queuedPromptsBySession)) {
if (
sessionKey === selectedStoredSessionId ||
drainingSessionIdsRef.current.has(sessionKey) ||
!shouldAutoDrain({ isBusy: working.has(sessionKey), queueLength: entries.length })
) {
continue
}
const entry = entries[0]
if (!entry || (drainFailuresRef.current.get(entry.id) ?? 0) >= MAX_AUTO_DRAIN_ATTEMPTS) {
continue
}
drainSessionQueue(sessionKey, entry)
}
}, [drainSessionQueue, enabled, queuedPromptsBySession, retryTick, selectedStoredSessionId, workingSessionIds])
}

View file

@ -8,6 +8,8 @@ import { $composerAttachments, $composerDraft, type ComposerAttachment, setCompo
import { $busy, $connection, $messages, $sessions, $turnStartedAt, setSessions } from '@/store/session'
import type { SessionInfo } from '@/types/hermes'
import type { SubmitTextOptions } from './utils'
import { uploadComposerAttachment, usePromptActions } from '.'
vi.mock('@/hermes', () => ({
@ -56,10 +58,11 @@ async function actRender(ui: React.ReactElement) {
}
interface HarnessHandle {
activeSessionIdRef: MutableRefObject<string | null>
cancelRun: () => Promise<void>
restoreToMessage: (messageId: string, target?: { text?: string; userOrdinal?: number | null }) => Promise<void>
steerPrompt: (text: string) => Promise<boolean>
submitText: (text: string, options?: { attachments?: ComposerAttachment[]; fromQueue?: boolean }) => Promise<boolean>
submitText: (text: string, options?: SubmitTextOptions) => Promise<boolean>
}
function Harness({
@ -68,6 +71,7 @@ function Harness({
getRoutedStoredSessionId,
getRuntimeIdForStoredSession,
getRouteToken,
onUpdateState,
onReady,
onSeedState,
openMemoryGraph,
@ -85,6 +89,11 @@ function Harness({
getRoutedStoredSessionId?: () => null | string
getRuntimeIdForStoredSession?: (storedSessionId: string) => null | string
getRouteToken?: () => string
onUpdateState?: (
sessionId: string,
storedSessionId: null | string | undefined,
state: Record<string, unknown>
) => void
onReady: (handle: HarnessHandle) => void
onSeedState?: (state: Record<string, unknown>) => void
openMemoryGraph?: () => void
@ -97,9 +106,8 @@ function Harness({
activeSessionId?: null | string
createBackendSessionForSend?: () => Promise<null | string>
}) {
const activeSessionIdRef: MutableRefObject<string | null> = activeSessionIdRefProp ?? {
current: activeSessionId === undefined ? RUNTIME_SESSION_ID : activeSessionId
}
const localActiveSessionIdRef = useRef<string | null>(activeSessionId === undefined ? RUNTIME_SESSION_ID : activeSessionId)
const activeSessionIdRef = activeSessionIdRefProp ?? localActiveSessionIdRef
const selectedStoredSessionIdRef: MutableRefObject<string | null> = selectedStoredSessionIdRefProp ?? {
current: storedSessionId === undefined ? RUNTIME_SESSION_ID : storedSessionId
@ -131,11 +139,12 @@ function Harness({
selectedStoredSessionIdRef,
startFreshSessionDraft: () => undefined,
sttEnabled: false,
updateSessionState: (_sessionId, updater) => {
updateSessionState: (sessionId, updater, storedSessionId) => {
// Seed with interrupted:true so we can prove a fresh submit clears it.
const next = updater(stateRef.current) as unknown as Record<string, unknown>
stateRef.current = next as never
onSeedState?.(next)
onUpdateState?.(sessionId, storedSessionId, next)
return next as never
}
@ -143,6 +152,7 @@ function Harness({
useEffect(() => {
onReady({
activeSessionIdRef,
cancelRun: (...args: Parameters<typeof actions.cancelRun>) =>
act(async () => actions.cancelRun(...args)) as Promise<void>,
restoreToMessage: (...args: Parameters<typeof actions.restoreToMessage>) =>
@ -152,7 +162,7 @@ function Harness({
submitText: (...args: Parameters<typeof actions.submitText>) =>
act(async () => actions.submitText(...args)) as Promise<boolean>
})
}, [actions.cancelRun, actions.restoreToMessage, actions.steerPrompt, actions.submitText, onReady])
}, [actions.cancelRun, actions.restoreToMessage, actions.steerPrompt, actions.submitText, activeSessionIdRef, onReady])
return null
}
@ -540,6 +550,45 @@ describe('usePromptActions submit / queue drain semantics', () => {
)
})
it('a fromQueue drain sends to its queued session even after the active session changes', async () => {
$busy.set(false)
const updates: { sessionId: string; state: Record<string, unknown>; storedSessionId: null | string | undefined }[] = []
const requestGateway = vi.fn(async () => ({}) as never)
let handle: HarnessHandle | null = null
render(
<Harness
onReady={h => (handle = h)}
onUpdateState={(sessionId, storedSessionId, state) => updates.push({ sessionId, state, storedSessionId })}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
/>
)
const accepted = await handle!.submitText('queued for background session', {
fromQueue: true,
sessionId: 'rt-session-a',
storedSessionId: 'stored-session-a'
})
expect(accepted).toBe(true)
expect(requestGateway).toHaveBeenCalledWith(
'prompt.submit',
{
session_id: 'rt-session-a',
text: 'queued for background session'
},
1_800_000
)
expect(requestGateway).not.toHaveBeenCalledWith('session.resume', expect.anything())
expect(
updates.some(update => update.sessionId === 'rt-session-a' && update.storedSessionId === 'stored-session-a')
).toBe(true)
// Offscreen queue drains must not flip the foreground composer into Thinking.
expect($busy.get()).toBe(false)
})
it('a rejected fromQueue drain returns false (entry stays queued) and a later retry sends it', async () => {
// A stale-session 404 must not strand the queued entry: submitPrompt returns
// false on failure so the composer keeps it, and the edge-independent
@ -1132,6 +1181,58 @@ describe('usePromptActions sleep/wake session recovery', () => {
expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID, text: 'message after wake' })
})
it('background queue resume uses the queued stored id and leaves foreground runtime selected', async () => {
const calls: { method: string; params?: Record<string, unknown> }[] = []
let submitAttempts = 0
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
calls.push({ method, params })
if (method === 'prompt.submit') {
submitAttempts += 1
if (submitAttempts === 1) {
throw new Error('session not found')
}
return {} as never
}
if (method === 'session.resume') {
return { session_id: RECOVERED_SESSION_ID } as never
}
return {} as never
})
let handle: HarnessHandle | null = null
render(
<Harness
onReady={h => (handle = h)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
storedSessionId="stored-foreground"
/>
)
await waitFor(() => expect(handle).not.toBeNull())
const ok = await handle!.submitText('queued background message after wake', {
fromQueue: true,
sessionId: 'rt-background-stale',
storedSessionId: STORED_SESSION_ID
})
expect(ok).toBe(true)
expect(calls.map(c => c.method)).toEqual(['prompt.submit', 'session.resume', 'prompt.submit'])
expect(calls[0]?.params).toEqual({ session_id: 'rt-background-stale', text: 'queued background message after wake' })
expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop' })
expect(calls[2]?.params).toEqual({
session_id: RECOVERED_SESSION_ID,
text: 'queued background message after wake'
})
expect(handle!.activeSessionIdRef.current).toBe(RUNTIME_SESSION_ID)
})
it('resumes the stored session and retries once when session.interrupt reports "session not found"', async () => {
const calls: { method: string; params?: Record<string, unknown> }[] = []
let interruptAttempts = 0
@ -1346,7 +1447,7 @@ describe('usePromptActions sleep/wake session recovery', () => {
expect(ok).toBe(true)
expect(createBackendSessionForSend).not.toHaveBeenCalled()
expect(calls.map(c => c.method)).toEqual(['session.resume', 'prompt.submit'])
expect(calls[0]?.params).toEqual({ session_id: STORED_SESSION_ID })
expect(calls[0]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop' })
expect(calls[1]?.params).toMatchObject({ session_id: RECOVERED_SESSION_ID })
})
@ -1655,7 +1756,8 @@ describe('usePromptActions submit session-context isolation (#54527)', () => {
expect(await submitting).toBe(false)
expect(calls.some(c => c.method === 'prompt.submit')).toBe(false)
expect(calls.find(c => c.method === 'session.resume')?.params).toEqual({
session_id: STORED_SESSION_A
session_id: STORED_SESSION_A,
source: 'desktop'
})
})

View file

@ -14,7 +14,7 @@ import {
} from '@/lib/desktop-slash-commands'
import { setSessionYolo } from '@/lib/yolo-session'
import { openCommandPalettePage } from '@/store/command-palette'
import { type ComposerAttachment, setComposerDraft } from '@/store/composer'
import { setComposerDraft } from '@/store/composer'
import { notify, notifyError } from '@/store/notifications'
import { setPetScale } from '@/store/pet-gallery'
import { $petGenInput, openPetGenerate } from '@/store/pet-generate'
@ -31,7 +31,13 @@ import {
import type { BrowserManageResponse, SessionTitleResponse, SlashExecResponse } from '../../../types'
import { type GatewayRequest, isSessionIdCandidate, renderCommandsCatalog, slashStatusText } from './utils'
import {
type GatewayRequest,
isSessionIdCandidate,
renderCommandsCatalog,
slashStatusText,
type SubmitTextOptions
} from './utils'
/** Everything a slash handler needs about the invocation it's serving. */
interface SlashActionCtx {
@ -59,10 +65,7 @@ interface SlashCommandDeps {
requestGateway: GatewayRequest
resumeStoredSession: (storedSessionId: string) => Promise<void> | void
startFreshSessionDraft: () => void
submitPromptText: (
rawText: string,
options?: { attachments?: ComposerAttachment[]; fromQueue?: boolean }
) => Promise<boolean>
submitPromptText: (rawText: string, options?: SubmitTextOptions) => Promise<boolean>
}
/** The /slash command dispatcher, extracted from usePromptActions. */

View file

@ -141,9 +141,19 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
return false
}
// Pin the session context for the whole async submit pipeline. Without
// this, a fast session switch during session.resume / file.attach can
// redirect the user's text into a different chat (#54527). Mutable —
// Queue drains carry their source session explicitly. A background drain
// must never inherit the currently selected session after the user moves
// to another chat.
const targetStoredSessionId = options?.storedSessionId ?? selectedStoredSessionIdRef.current
const targetStartedInCurrentView =
!targetStoredSessionId || targetStoredSessionId === selectedStoredSessionIdRef.current
let sessionId: null | string = options?.sessionId ?? activeSessionIdRef.current
// Pin the foreground session context for the whole async submit pipeline.
// Without this, a fast session switch during session.resume / file.attach
// can redirect the user's text into a different chat (#54527). Mutable —
// not const — because a new-chat submit legitimately re-homes to the
// session it creates (see the re-pin after createBackendSessionForSend).
const startingActiveSessionId = activeSessionIdRef.current
@ -166,11 +176,16 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
let startingRouteToken = getRouteToken()
const sessionContextDrifted = (): boolean =>
selectedStoredSessionIdRef.current !== startingStoredSessionId || getRouteToken() !== startingRouteToken
targetStartedInCurrentView &&
(selectedStoredSessionIdRef.current !== startingStoredSessionId || getRouteToken() !== startingRouteToken)
const targetIsCurrentView = (): boolean => targetStartedInCurrentView && !sessionContextDrifted()
// One submit in flight per session — drop any concurrent re-fire so a
// stalled turn can't stack the same prompt into multiple real turns.
const submitLockKey = startingStoredSessionId || startingActiveSessionId || '__pending_new__'
// stalled turn can't stack the same prompt into multiple real turns. The
// foreground ChatBar and background drainers can briefly overlap during a
// session switch; this per-session lock makes that safe.
const submitLockKey = targetStoredSessionId || sessionId || startingActiveSessionId || '__pending_new__'
if (_submitInFlight.has(submitLockKey)) {
return false
@ -197,9 +212,12 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
const releaseBusy = () => {
releaseSubmitLock()
setMutableRef(busyRef, false)
scope.setBusy(false)
scope.setAwaitingResponse(false)
if (targetIsCurrentView()) {
setMutableRef(busyRef, false)
scope.setBusy(false)
scope.setAwaitingResponse(false)
}
}
// Idempotent optimistic insert — re-running with the resolved sessionId
@ -221,7 +239,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
// (what made drained-after-interrupt sends go silent).
interrupted: false
}),
startingStoredSessionId
targetStoredSessionId
)
// After sync rewrites refs, refresh the optimistic message in place so the
@ -233,12 +251,14 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
...state,
messages: state.messages.map(message => (message.id === optimisticId ? buildUserMessage() : message))
}),
startingStoredSessionId
targetStoredSessionId
)
const dropOptimistic = (sid: null | string) => {
if (!sid) {
scope.setMessages(current => current.filter(m => m.id !== optimisticId))
if (targetIsCurrentView()) {
scope.setMessages(current => current.filter(m => m.id !== optimisticId))
}
return
}
@ -252,7 +272,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
awaitingResponse: false,
pendingBranchGroup: null
}),
startingStoredSessionId
targetStoredSessionId
)
}
@ -263,19 +283,26 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
return false
}
setMutableRef(busyRef, true)
scope.setBusy(true)
scope.setAwaitingResponse(true)
clearNotifications()
// Foreground-only state: a background queue drain must never write the
// selected view's busy/awaiting flags or clear its notifications.
if (targetIsCurrentView()) {
setMutableRef(busyRef, true)
scope.setBusy(true)
scope.setAwaitingResponse(true)
clearNotifications()
}
// A route whose selected/runtime binding is incomplete or cross-wired
// outranks any stale render-time runtime id (often from the previous
// profile). Force the full routed resume path below in that case.
let sessionId: null | string = routedSessionNeedsResume ? null : activeSessionIdRef.current
// outranks a stale render-time runtime id (often from the previous
// profile): force the full routed resume path below. An explicit queued
// runtime id (background drain) is authoritative and is left untouched.
if (!options?.sessionId && routedSessionNeedsResume) {
sessionId = null
}
if (sessionId) {
seedOptimistic(sessionId)
} else {
} else if (targetIsCurrentView()) {
scope.setMessages(current => [...current, buildUserMessage()])
}
@ -313,17 +340,15 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
seedOptimistic(sessionId)
}
if (!sessionId && startingStoredSessionId) {
// A stored session is SELECTED but its runtime binding is gone (the
// live session was orphan-reaped, or a timeout/reconnect cleared
// activeSessionId). Continuing the selected conversation must mean
// resuming it — minting a brand-new backend session here silently
// splits the user's chat in two (#55578 symptom b). Only fall through
// to session creation when NO stored session is selected (a genuine
// new-chat draft).
if (!sessionId && targetStoredSessionId) {
// A target stored session exists but its runtime binding is gone (the
// live session was orphan-reaped, a timeout/reconnect cleared it, or a
// background queue drain only has the durable id). Continue that target
// conversation; only a genuine new-chat draft may create a new session.
try {
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
session_id: startingStoredSessionId
session_id: targetStoredSessionId,
source: 'desktop'
})
if (sessionContextDrifted()) {
@ -332,12 +357,17 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
if (resumed?.session_id) {
sessionId = resumed.session_id
activeSessionIdRef.current = sessionId
if (targetIsCurrentView()) {
activeSessionIdRef.current = sessionId
}
}
} catch {
// A selected stored conversation is not a new-chat draft. If its
// A target stored conversation is not a new-chat draft. If its
// runtime cannot be rebound, stop here rather than silently replacing
// it with a contextless session.
// it with a contextless session (#55578). For a background/queued
// drain this abort is a no-op on foreground state (both helpers are
// targetIsCurrentView-guarded) and simply drops the queued send.
return abortForSessionSwitch(null)
}
@ -358,7 +388,10 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
} catch (err) {
dropOptimistic(null)
releaseBusy()
notifyError(err, copy.sessionUnavailable)
if (targetIsCurrentView()) {
notifyError(err, copy.sessionUnavailable)
}
return false
}
@ -373,7 +406,10 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
dropOptimistic(null)
releaseBusy()
notify({ kind: 'error', title: copy.sessionUnavailable, message: copy.createSessionFailed })
if (targetIsCurrentView()) {
notify({ kind: 'error', title: copy.sessionUnavailable, message: copy.createSessionFailed })
}
return false
}
@ -424,14 +460,19 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
requestGateway('prompt.submit', { session_id: sessionId, text }, PROMPT_SUBMIT_REQUEST_TIMEOUT_MS)
)
} catch (firstErr) {
if ((isSessionNotFoundError(firstErr) || isGatewayTimeoutError(firstErr)) && startingStoredSessionId) {
const recoverStoredSessionId = targetStoredSessionId ?? selectedStoredSessionIdRef.current
if (
(isSessionNotFoundError(firstErr) || isGatewayTimeoutError(firstErr)) &&
recoverStoredSessionId
) {
// Re-register the session in the gateway and get a fresh live ID.
// Timeouts recover the same way as "session not found": a starved
// backend loop (#55578 symptom d) rejects the submit even though
// the stored session is fine — resume + retry instead of erroring
// out and losing the session binding.
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
session_id: startingStoredSessionId,
session_id: recoverStoredSessionId,
source: 'desktop'
})
@ -442,7 +483,10 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
const recoveredId = resumed?.session_id
if (recoveredId) {
activeSessionIdRef.current = recoveredId
if (targetIsCurrentView()) {
activeSessionIdRef.current = recoveredId
}
await withSessionBusyRetry(() =>
requestGateway('prompt.submit', { session_id: recoveredId, text }, PROMPT_SUBMIT_REQUEST_TIMEOUT_MS)
)
@ -479,31 +523,37 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
const message = inlineErrorMessage(err, copy.promptFailed)
updateSessionState(sessionId, state => ({
...state,
messages: [
...state.messages,
{
id: `assistant-error-${Date.now()}`,
role: 'assistant',
parts: [],
error: message || copy.promptFailed,
branchGroupId: state.pendingBranchGroup ?? undefined
}
],
busy: false,
awaitingResponse: false,
pendingBranchGroup: null,
sawAssistantPayload: true
}))
updateSessionState(
sessionId,
state => ({
...state,
messages: [
...state.messages,
{
id: `assistant-error-${Date.now()}`,
role: 'assistant',
parts: [],
error: message || copy.promptFailed,
branchGroupId: state.pendingBranchGroup ?? undefined
}
],
busy: false,
awaitingResponse: false,
pendingBranchGroup: null,
sawAssistantPayload: true
}),
targetStoredSessionId
)
if (isProviderSetupError(err)) {
if (targetIsCurrentView() && isProviderSetupError(err)) {
requestDesktopOnboarding(copy.providerCredentialRequired)
return false
}
notifyError(err, copy.promptFailed)
if (targetIsCurrentView()) {
notifyError(err, copy.promptFailed)
}
return false
}

View file

@ -226,4 +226,11 @@ export function visibleUserIndexAtOrdinal(messages: readonly ChatMessage[], targ
export interface SubmitTextOptions {
attachments?: ComposerAttachment[]
fromQueue?: boolean
/** Runtime session id to submit into. Queue drains pass this so a
* backgrounded/source session cannot be replaced by the current foreground
* session between enqueue and drain. */
sessionId?: string | null
/** Stable stored session id for optimistic/cache updates and stale-runtime
* recovery. Distinct from the runtime session id minted by the gateway. */
storedSessionId?: string | null
}