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 9d54e17bd957..8912bab778af 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 @@ -34,6 +34,7 @@ interface UseComposerQueueArgs { onCancel: ChatBarProps['onCancel'] onSubmit: ChatBarProps['onSubmit'] queueEditRef: RefObject + queueProfile?: string | null queueSessionKey: ChatBarProps['queueSessionKey'] sessionId: string | null | undefined } @@ -58,6 +59,7 @@ export function useComposerQueue({ onCancel, onSubmit, queueEditRef, + queueProfile, queueSessionKey, sessionId }: UseComposerQueueArgs) { @@ -171,7 +173,7 @@ export function useComposerQueue({ return false } - if (!enqueueQueuedPrompt(activeQueueSessionKey, { text, attachments })) { + if (!enqueueQueuedPrompt(activeQueueSessionKey, { text, attachments, profile: queueProfile })) { return false } @@ -180,7 +182,7 @@ export function useComposerQueue({ triggerHaptic('selection') return true - }, [activeQueueSessionKey, attachments, clearDraft, draftRef, scope.attachments]) + }, [activeQueueSessionKey, attachments, clearDraft, draftRef, queueProfile, scope.attachments]) // All queue drain paths share one lock + send-then-remove sequence. // `pickEntry` lets each caller choose head, by-id, or skip-edited. @@ -205,6 +207,7 @@ export function useComposerQueue({ onSubmit(entry.text, { attachments: entry.attachments, fromQueue: true, + ...(entry.profile ? { profile: entry.profile } : {}), sessionId: drainRuntimeSessionId, storedSessionId: drainQueueSessionKey }) diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index ce0e43d7d1cc..79f9f29905d0 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -68,6 +68,7 @@ export function ChatBar({ focusKey, gateway, maxRecordingSeconds = 120, + queueProfile, queueSessionKey, sessionId, state, @@ -203,6 +204,7 @@ export function ChatBar({ onCancel, onSubmit, queueEditRef, + queueProfile, queueSessionKey, sessionId }) diff --git a/apps/desktop/src/app/chat/composer/types.ts b/apps/desktop/src/app/chat/composer/types.ts index 24abcf373697..3d70ed8a49d4 100644 --- a/apps/desktop/src/app/chat/composer/types.ts +++ b/apps/desktop/src/app/chat/composer/types.ts @@ -38,6 +38,7 @@ export interface ChatBarProps { maxRecordingSeconds?: number state: ChatBarState gateway?: HermesGateway | null + queueProfile?: string | null queueSessionKey?: string | null sessionId?: string | null cwd?: string | null diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index b47f0dfa9907..c9bc2676fd51 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -276,6 +276,11 @@ export function ChatView({ const messagesEmpty = useStore(view.$messagesEmpty) const lastVisibleIsUser = useStore(view.$lastVisibleIsUser) const selectedSessionId = useStore(view.$storedId) + + const queueProfile = selectedSessionId + ? ($sessions.get().find(session => sessionMatchesStoredId(session, selectedSessionId))?.profile ?? null) + : null + const resumeExhaustedSessionId = useStore($resumeExhaustedSessionId) // A tile IS its session — no route involved, never "mismatched". const routedSessionId = isPrimary ? routeSessionId(location.pathname) : selectedSessionId @@ -524,6 +529,7 @@ export function ChatView({ onSteer={onSteer} onSubmit={onSubmit} onTranscribeAudio={onTranscribeAudio} + queueProfile={queueProfile} queueSessionKey={selectedSessionId} sessionId={activeSessionId} state={chatBarState} diff --git a/apps/desktop/src/app/chat/session-tile-actions.ts b/apps/desktop/src/app/chat/session-tile-actions.ts index 4eca47f5c094..2d54a7f08675 100644 --- a/apps/desktop/src/app/chat/session-tile-actions.ts +++ b/apps/desktop/src/app/chat/session-tile-actions.ts @@ -41,7 +41,7 @@ import { runRewindSubmit } from '../session/hooks/use-prompt-actions/rewind' import { useSubmitPrompt } from '../session/hooks/use-prompt-actions/submit' -import { type SubmitTextOptions } from '../session/hooks/use-prompt-actions/utils' +import { type GatewayRequest, type SubmitTextOptions } from '../session/hooks/use-prompt-actions/utils' import type { ComposerScope } from './composer/scope' @@ -54,7 +54,7 @@ interface SessionTileActionsArgs { export function useSessionTileActions({ runtimeId, scope, storedSessionId }: SessionTileActionsArgs) { const { t } = useI18n() const copy = t.desktop - const { requestGateway } = useGatewayRequest() + const { requestGateway, requestGatewayForProfile } = useGatewayRequest() const runtimeIdRef = useRef(runtimeId) runtimeIdRef.current = runtimeId @@ -95,9 +95,15 @@ export function useSessionTileActions({ runtimeId, scope, storedSessionId }: Ses async ( sessionId: string, attachments: ComposerAttachment[], - options: { updateComposerAttachments?: boolean } = {} + options: { profile?: string | null; requestGateway?: GatewayRequest; updateComposerAttachments?: boolean } = {} ): Promise => { - const remote = $connection.get()?.mode === 'remote' + const attachmentGateway = options.requestGateway ?? requestGateway + + const profileConnection = options.profile + ? await window.hermesDesktop?.getConnection(options.profile).catch(() => null) + : null + + const remote = (profileConnection ?? $connection.get())?.mode === 'remote' const synced: ComposerAttachment[] = [] for (const attachment of attachments) { @@ -108,7 +114,11 @@ export function useSessionTileActions({ runtimeId, scope, storedSessionId }: Ses } if (attachment.kind === 'image' || attachment.kind === 'file') { - const next = await uploadComposerAttachment(attachment, { remote, requestGateway, sessionId }) + const next = await uploadComposerAttachment(attachment, { + remote, + requestGateway: attachmentGateway, + sessionId + }) if (options.updateComposerAttachments ?? true) { scope.attachments.update(next) @@ -140,6 +150,7 @@ export function useSessionTileActions({ runtimeId, scope, storedSessionId }: Ses // token is a stable constant (the guard never trips for a tile). getRouteToken: () => runtimeId, requestGateway, + requestGatewayForProfile, // Tile ids are always bound before this hook mounts, so routed recovery is // unreachable here; keep the shared submit contract explicit. resumeStoredSession: () => undefined, diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index f47a2301b2ac..193afd147112 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -197,7 +197,7 @@ export function ContribWiring({ children }: { children: ReactNode }) { setMessages }) - const { connectionRef, gatewayRef, requestGateway } = useGatewayRequest() + const { connectionRef, gatewayRef, requestGateway, requestGatewayForProfile } = useGatewayRequest() const { loadMoreMessagingForPlatform, @@ -530,6 +530,7 @@ export function ContribWiring({ children }: { children: ReactNode }) { openMemoryGraph: openStarmap, refreshSessions, requestGateway, + requestGatewayForProfile, resumeStoredSession: resumeSession, selectedStoredSessionIdRef, startFreshSessionDraft, @@ -539,8 +540,17 @@ export function ContribWiring({ children }: { children: ReactNode }) { // Runs outside the selected ChatBar so queues belonging to background // sessions continue once those sessions are idle. + const getProfileForStoredSession = useCallback((storedSessionId: string): null | string => { + const stored = [...$sessions.get(), ...$messagingSessions.get()].find(session => + sessionMatchesStoredId(session, storedSessionId) + ) + + return stored?.profile ?? null + }, []) + useBackgroundQueueDrain({ enabled: gatewayState === 'open', + getProfileForStoredSession, runtimeIdByStoredSessionIdRef, selectedStoredSessionId, submitText diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts b/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts index d6c9ab0e029b..c5290bd4d4d7 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts @@ -3,7 +3,7 @@ import { useStore } from '@nanostores/react' import { useCallback, useEffect, useRef } from 'react' import type { HermesGateway } from '@/hermes' -import { $gateway, ensureActiveGatewayOpen, isActivePrimary } from '@/store/gateway' +import { $gateway, ensureActiveGatewayOpen, ensureGatewayOpenForProfile, isActivePrimary } from '@/store/gateway' import { $activeGatewayProfile } from '@/store/profile' import { $gatewayState, setConnection } from '@/store/session' @@ -134,5 +134,18 @@ export function useGatewayRequest() { [ensureGatewayOpen] ) - return { connectionRef, gatewayRef, requestGateway } + const requestGatewayForProfile = useCallback( + async (profile: string, method: string, params: Record = {}, timeoutMs?: number) => { + const gateway = await ensureGatewayOpenForProfile(profile) + + if (!gateway) { + throw new Error(`Hermes gateway unavailable for profile: ${profile}`) + } + + return gateway.request(method, params, timeoutMs) + }, + [] + ) + + return { connectionRef, gatewayRef, requestGateway, requestGatewayForProfile } } 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 9f5589b816f4..f8ed7b9d4ed6 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 @@ -11,17 +11,20 @@ import type { SubmitTextOptions } from './use-prompt-actions/utils' function Harness({ enabled = true, + getProfileForStoredSession, runtimeMap, selectedStoredSessionId = 'stored-session-b', submitText }: { enabled?: boolean + getProfileForStoredSession?: (storedSessionId: string) => null | string runtimeMap: MutableRefObject> selectedStoredSessionId?: string | null submitText: (text: string, options?: SubmitTextOptions) => Promise | boolean }) { useBackgroundQueueDrain({ enabled, + getProfileForStoredSession, runtimeIdByStoredSessionIdRef: runtimeMap, selectedStoredSessionId, submitText @@ -100,14 +103,25 @@ describe('useBackgroundQueueDrain', () => { const runtimeMap = { current: new Map() } const submitText = vi.fn(async () => true) - enqueueQueuedPrompt('stored-session-a', { text: 'resume then send', attachments: [] }) + enqueueQueuedPrompt('stored-session-a', { + text: 'resume then send', + attachments: [], + profile: 'persisted-background-profile' + }) - render() + render( + 'stale-fallback-profile'} + runtimeMap={runtimeMap} + submitText={submitText} + /> + ) await waitFor(() => { expect(submitText).toHaveBeenCalledWith('resume then send', { attachments: [], fromQueue: true, + profile: 'persisted-background-profile', sessionId: null, storedSessionId: 'stored-session-a' }) 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 8d98df149313..c86c1ffe3707 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 @@ -20,6 +20,7 @@ type SubmitQueuedPrompt = (text: string, options?: SubmitTextOptions) => Promise interface BackgroundQueueDrainOptions { enabled: boolean + getProfileForStoredSession?: (storedSessionId: string) => null | string runtimeIdByStoredSessionIdRef: MutableRefObject> selectedStoredSessionId: string | null submitText: SubmitQueuedPrompt @@ -37,6 +38,7 @@ const BACKGROUND_DRAIN_RETRY_MS = 750 */ export function useBackgroundQueueDrain({ enabled, + getProfileForStoredSession, runtimeIdByStoredSessionIdRef, selectedStoredSessionId, submitText @@ -113,11 +115,13 @@ export function useBackgroundQueueDrain({ } const runtimeSessionId = runtimeIdByStoredSessionIdRef.current.get(sessionKey) ?? null + const profile = liveEntry.profile?.trim() || getProfileForStoredSession?.(sessionKey)?.trim() || null const accepted = await Promise.resolve( submitTextRef.current(liveEntry.text, { attachments: liveEntry.attachments, fromQueue: true, + ...(profile ? { profile } : {}), sessionId: runtimeSessionId, storedSessionId: sessionKey }) @@ -143,7 +147,7 @@ export function useBackgroundQueueDrain({ drainingSessionIdsRef.current.delete(sessionKey) }) }, - [runtimeIdByStoredSessionIdRef, scheduleRetry, t] + [getProfileForStoredSession, runtimeIdByStoredSessionIdRef, scheduleRetry, t] ) useEffect(() => { diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx index a06ee1294c08..ddc0001b3d44 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx @@ -77,6 +77,7 @@ function Harness({ openMemoryGraph, refreshSessions, requestGateway, + requestGatewayForProfile, resumeStoredSession, seedMessages, selectedStoredSessionIdRef: selectedStoredSessionIdRefProp, @@ -99,6 +100,12 @@ function Harness({ openMemoryGraph?: () => void refreshSessions: () => Promise requestGateway: (method: string, params?: Record) => Promise + requestGatewayForProfile?: ( + profile: string, + method: string, + params?: Record, + timeoutMs?: number + ) => Promise resumeStoredSession?: (storedSessionId: string) => Promise | void seedMessages?: unknown[] selectedStoredSessionIdRef?: MutableRefObject @@ -138,6 +145,7 @@ function Harness({ openMemoryGraph: openMemoryGraph ?? (() => undefined), refreshSessions, requestGateway, + requestGatewayForProfile: requestGatewayForProfile ?? ((_, method, params) => requestGateway(method, params)), resumeStoredSession: resumeStoredSession ?? (() => undefined), selectedStoredSessionIdRef, startFreshSessionDraft: () => undefined, @@ -601,6 +609,71 @@ describe('usePromptActions submit / queue drain semantics', () => { expect($busy.get()).toBe(false) }) + it('a background queue drain with no runtime id resumes its stored session instead of using the foreground runtime', async () => { + $busy.set(false) + + const calls: { method: string; params?: Record; profile: string }[] = [] + const recoveredBackgroundRuntimeId = 'rt-background-recovered' + const backgroundStoredSessionId = 'stored-background' + const backgroundProfile = 'background-profile' + const foregroundStoredSessionId = 'stored-foreground' + const resumeStoredSession = vi.fn() + + const requestGateway = vi.fn(async () => { + throw new Error('foreground gateway must not serve a background-profile queue') + }) + + const requestGatewayForProfile = vi.fn( + async (profile: string, method: string, params?: Record) => { + calls.push({ method, params, profile }) + + if (method === 'session.resume') { + return { session_id: recoveredBackgroundRuntimeId } as never + } + + return {} as never + } + ) + + let handle: HarnessHandle | null = null + await actRender( + foregroundStoredSessionId} + getRuntimeIdForStoredSession={() => null} + onReady={h => (handle = h)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + requestGatewayForProfile={requestGatewayForProfile} + resumeStoredSession={resumeStoredSession} + storedSessionId={foregroundStoredSessionId} + /> + ) + + const accepted = await handle!.submitText('resume queued background session', { + fromQueue: true, + profile: backgroundProfile, + sessionId: null, + storedSessionId: backgroundStoredSessionId + }) + + expect(accepted).toBe(true) + expect(calls).toEqual([ + { + method: 'session.resume', + params: { session_id: backgroundStoredSessionId, source: 'desktop' }, + profile: backgroundProfile + }, + { + method: 'prompt.submit', + params: { session_id: recoveredBackgroundRuntimeId, text: 'resume queued background session' }, + profile: backgroundProfile + } + ]) + expect(requestGateway).not.toHaveBeenCalled() + expect(resumeStoredSession).not.toHaveBeenCalled() + expect(handle!.activeSessionIdRef.current).toBe(RUNTIME_SESSION_ID) + }) + 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 diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts index 1fde9070d4a3..c5eb80b6641e 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts @@ -179,6 +179,12 @@ interface PromptActionsOptions { openMemoryGraph: () => void refreshSessions: () => Promise requestGateway: (method: string, params?: Record, timeoutMs?: number) => Promise + requestGatewayForProfile: ( + profile: string, + method: string, + params?: Record, + timeoutMs?: number + ) => Promise resumeStoredSession: (storedSessionId: string) => Promise | void selectedStoredSessionIdRef: MutableRefObject startFreshSessionDraft: () => void @@ -210,6 +216,7 @@ export function usePromptActions({ openMemoryGraph, refreshSessions, requestGateway, + requestGatewayForProfile, resumeStoredSession, selectedStoredSessionIdRef, startFreshSessionDraft, @@ -259,10 +266,16 @@ export function usePromptActions({ async ( sessionId: string, attachments: ComposerAttachment[], - options: { updateComposerAttachments?: boolean } = {} + options: { profile?: string | null; requestGateway?: GatewayRequest; updateComposerAttachments?: boolean } = {} ): Promise => { + const attachmentGateway = options.requestGateway ?? requestGateway const updateComposerAttachments = options.updateComposerAttachments ?? true - const remote = $connection.get()?.mode === 'remote' + + const profileConnection = options.profile + ? await window.hermesDesktop?.getConnection(options.profile).catch(() => null) + : null + + const remote = (profileConnection ?? $connection.get())?.mode === 'remote' const synced: ComposerAttachment[] = [] for (const original of attachments) { @@ -290,7 +303,11 @@ export function usePromptActions({ } if (attachment.kind === 'image' || attachment.kind === 'file') { - const nextAttachment = await uploadComposerAttachment(attachment, { remote, requestGateway, sessionId }) + const nextAttachment = await uploadComposerAttachment(attachment, { + remote, + requestGateway: attachmentGateway, + sessionId + }) // Update-only: never resurrect a chip the user removed mid-upload. if (updateComposerAttachments) { @@ -377,6 +394,7 @@ export function usePromptActions({ getRuntimeIdForStoredSession, getRouteToken, requestGateway, + requestGatewayForProfile, resumeStoredSession, selectedStoredSessionIdRef, syncAttachmentsForSubmit, diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts index 709652dce7bf..c5a3ea0af069 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts @@ -39,12 +39,18 @@ interface SubmitPromptDeps { getRuntimeIdForStoredSession: (storedSessionId: string) => null | string getRouteToken: () => string requestGateway: GatewayRequest + requestGatewayForProfile: ( + profile: string, + method: string, + params?: Record, + timeoutMs?: number + ) => Promise resumeStoredSession: (storedSessionId: string) => Promise | void selectedStoredSessionIdRef: MutableRefObject syncAttachmentsForSubmit: ( sessionId: string, attachments: ComposerAttachment[], - options?: { updateComposerAttachments?: boolean } + options?: { profile?: string | null; requestGateway?: GatewayRequest; updateComposerAttachments?: boolean } ) => Promise updateSessionState: ( sessionId: string, @@ -84,6 +90,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { getRuntimeIdForStoredSession, getRouteToken, requestGateway, + requestGatewayForProfile, resumeStoredSession, selectedStoredSessionIdRef, syncAttachmentsForSubmit, @@ -145,11 +152,23 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { // must never inherit the currently selected session after the user moves // to another chat. const targetStoredSessionId = options?.storedSessionId ?? selectedStoredSessionIdRef.current + const targetProfile = options?.profile?.trim() || null + + const targetRequestGateway: GatewayRequest = targetProfile + ? (method, params, timeoutMs) => requestGatewayForProfile(targetProfile, method, params, timeoutMs) + : requestGateway const targetStartedInCurrentView = !targetStoredSessionId || targetStoredSessionId === selectedStoredSessionIdRef.current - let sessionId: null | string = options?.sessionId ?? activeSessionIdRef.current + // An explicit null is authoritative: background drains use it to mean + // "no runtime mapping — resume storedSessionId". Only an omitted runtime + // target may inherit or recover the foreground session. + const inheritsForegroundRuntime = options?.sessionId === undefined + + let sessionId: null | string = inheritsForegroundRuntime + ? activeSessionIdRef.current + : (options?.sessionId ?? null) // Pin the foreground session context for the whole async submit pipeline. // Without this, a fast session switch during session.resume / file.attach @@ -295,8 +314,9 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { // A route whose selected/runtime binding is incomplete or cross-wired // 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) { + // runtime target — including null, which means resume its stored id — is + // authoritative and is left untouched. + if (inheritsForegroundRuntime && routedSessionNeedsResume) { sessionId = null } @@ -306,7 +326,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { scope.setMessages(current => [...current, buildUserMessage()]) } - if (!sessionId && routedStoredSessionId && routedSessionNeedsResume) { + if (inheritsForegroundRuntime && !sessionId && routedStoredSessionId && routedSessionNeedsResume) { // The URL still names a durable conversation, but a profile // swap/reconnect left its volatile session binding incomplete or // cross-wired. Run the full profile-aware resume path. Creating here @@ -346,7 +366,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { // 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', { + const resumed = await targetRequestGateway<{ session_id: string }>('session.resume', { session_id: targetStoredSessionId, source: 'desktop' }) @@ -436,6 +456,8 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { try { const syncedAttachments = await syncAttachmentsForSubmit(sessionId, attachments, { + profile: targetProfile, + requestGateway: targetRequestGateway, updateComposerAttachments: usingComposerAttachments }) @@ -457,7 +479,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { try { await withSessionBusyRetry(() => - requestGateway('prompt.submit', { session_id: sessionId, text }, PROMPT_SUBMIT_REQUEST_TIMEOUT_MS) + targetRequestGateway('prompt.submit', { session_id: sessionId, text }, PROMPT_SUBMIT_REQUEST_TIMEOUT_MS) ) } catch (firstErr) { const recoverStoredSessionId = targetStoredSessionId ?? selectedStoredSessionIdRef.current @@ -468,7 +490,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { // 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', { + const resumed = await targetRequestGateway<{ session_id: string }>('session.resume', { session_id: recoverStoredSessionId, source: 'desktop' }) @@ -485,7 +507,11 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { } await withSessionBusyRetry(() => - requestGateway('prompt.submit', { session_id: recoveredId, text }, PROMPT_SUBMIT_REQUEST_TIMEOUT_MS) + targetRequestGateway( + 'prompt.submit', + { session_id: recoveredId, text }, + PROMPT_SUBMIT_REQUEST_TIMEOUT_MS + ) ) } else { submitErr = firstErr @@ -564,6 +590,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { getRuntimeIdForStoredSession, getRouteToken, requestGateway, + requestGatewayForProfile, resumeStoredSession, scope, selectedStoredSessionIdRef, diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts index b1d41893cca6..6dae204b5634 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts @@ -226,6 +226,10 @@ export function visibleUserIndexAtOrdinal(messages: readonly ChatMessage[], targ export interface SubmitTextOptions { attachments?: ComposerAttachment[] fromQueue?: boolean + /** Owning profile for an offscreen queued session. When present, every RPC + * in the submit pipeline uses that profile's background gateway without + * activating it in the foreground. */ + profile?: string | null /** 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. */ diff --git a/apps/desktop/src/store/composer-queue.test.ts b/apps/desktop/src/store/composer-queue.test.ts index 8012e2870f06..0b2853a746b7 100644 --- a/apps/desktop/src/store/composer-queue.test.ts +++ b/apps/desktop/src/store/composer-queue.test.ts @@ -51,6 +51,19 @@ describe('composer queue store', () => { expect(getQueuedPrompts(SESSION_KEY)[0]?.attachments[0]).not.toBe(source[0]) }) + it('persists the owning profile with queued prompts', () => { + enqueueQueuedPrompt(SESSION_KEY, { attachments: [], profile: ' work ', text: 'profiled' }) + + expect(getQueuedPrompts(SESSION_KEY)[0]?.profile).toBe('work') + + const raw = JSON.parse(String(window.localStorage.getItem(QUEUE_STORAGE_KEY))) as Record< + string, + { profile?: string }[] + > + + expect(raw[SESSION_KEY]?.[0]?.profile).toBe('work') + }) + it('updates and removes queued entries by id', () => { const first = enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'draft one' }) const second = enqueueQueuedPrompt(SESSION_KEY, { attachments: [], text: 'draft two' }) diff --git a/apps/desktop/src/store/composer-queue.ts b/apps/desktop/src/store/composer-queue.ts index 922e990fdce7..ccd5bf6f79e2 100644 --- a/apps/desktop/src/store/composer-queue.ts +++ b/apps/desktop/src/store/composer-queue.ts @@ -6,6 +6,7 @@ export interface QueuedPromptEntry { id: string text: string attachments: ComposerAttachment[] + profile?: string queuedAt: number } @@ -80,7 +81,7 @@ export const getQueuedPrompts = (key: string | null | undefined): QueuedPromptEn export const enqueueQueuedPrompt = ( key: string | null | undefined, - payload: { text: string; attachments: ComposerAttachment[] } + payload: { text: string; attachments: ComposerAttachment[]; profile?: string | null } ): null | QueuedPromptEntry => { const sid = sidOf(key) @@ -92,6 +93,7 @@ export const enqueueQueuedPrompt = ( id: nextId(), text: payload.text, attachments: cloneAttachments(payload.attachments), + ...(payload.profile?.trim() ? { profile: payload.profile.trim() } : {}), queuedAt: Date.now() } diff --git a/apps/desktop/src/store/gateway.ts b/apps/desktop/src/store/gateway.ts index 9b0f037b2ac6..062809354589 100644 --- a/apps/desktop/src/store/gateway.ts +++ b/apps/desktop/src/store/gateway.ts @@ -202,6 +202,23 @@ export async function openGatewayForProfile(profile: string): Promise { } } +// Resolve a profile's live gateway WITHOUT making it the foreground gateway. +// Background queue drains use this so an offscreen profile can resume/submit +// work without changing the active profile, composer state, or route. +export async function ensureGatewayOpenForProfile(profile: string): Promise { + const key = normKey(profile) + + if (key === primaryProfile) { + return isOpen(primaryGateway) ? primaryGateway : null + } + + await openGatewayForProfile(key) + + const gateway = secondaries.get(key)?.gateway ?? null + + return isOpen(gateway) ? gateway : null +} + // Make `profile` the active gateway, lazily opening its socket if needed. The // primary is a no-op fast path. Background sockets are never closed here. export async function ensureGatewayForProfile(profile: string): Promise {