{"repo":"NousResearch/hermes-agent","public_id":"39086","display_label":"Session isolation and cross-contamination bugs in desktop","summary":"A cluster of bugs and fixes centered on desktop session isolation: messages, events, history, and UI state leaking or routing incorrectly across sessions when switching or creating new chats. Accompanying fixes attempt to scope streams, sync view state, and enforce correct session targeting.","subsystem":"agent","counts":{"members":44,"open_issues":2,"open_issues_distinct":2,"open_prs":3,"closed_issues":24,"merged_prs":5,"attempt_prs":13},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":63948},"resolution":{"state":"partially_resolved","by":"pr-63948,pr-122781,pr-122953"},"freshness":{"state":"verified","last_verified_at":"2026-09-25T18:04:15+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.03826638477801267},"fix_edges":[{"issue_number":46194,"pr_number":122953,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":46732,"pr_number":122953,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":47709,"pr_number":63948,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":54320,"pr_number":122781,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":56390,"pr_number":66001,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":57850,"pr_number":66001,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":65328,"pr_number":86594,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":83855,"pr_number":86594,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":39086,"title":"Desktop: stale user bubbles/session busy rows can still render in a new chat after #37975","url":null,"state":"closed"},{"kind":"issue","number":40394,"title":"[Feature]: Support opening multiple independent desktop windows","url":null,"state":"closed"},{"kind":"issue","number":44187,"title":"bug: error messages from previous session bleed into current conversation","url":null,"state":"closed"},{"kind":"issue","number":45738,"title":"Desktop can show another session’s transcript/status above the composer after switching sessions","url":null,"state":"closed"},{"kind":"issue","number":46194,"title":"[Bug]: Desktop session switch leaks queued follow-up state and can show wrong chat contents","url":null,"state":"closed"},{"kind":"issue","number":46732,"title":"Desktop: failed/unsent messages leak across multiple windows even after /new session","url":null,"state":"closed"},{"kind":"issue","number":47340,"title":"Desktop: Messages sent in Window A appear in Window B (session routing broken after WebSocket reconnect)","url":null,"state":"closed"},{"kind":"issue","number":47475,"title":"Bug: Messages leaking between conversation sessions","url":null,"state":"closed"},{"kind":"issue","number":47709,"title":"[Bug]: Desktop shows newer session output in an older session","url":null,"state":"closed"},{"kind":"issue","number":49106,"title":"Web/WeChat sessions leak history: turns from one Hermes session appear in another","url":null,"state":"closed"},{"kind":"issue","number":54320,"title":"Desktop sidebar can highlight one session while chat pane sends to another","url":null,"state":"closed"},{"kind":"issue","number":54929,"title":"Cross-session message leak: messages from one conversation appearing in another","url":null,"state":"closed"},{"kind":"issue","number":56390,"title":"[Bug]: Queued messages can fire into the wrong session when switching between concurrent sessions (Desktop only)","url":null,"state":"closed"},{"kind":"issue","number":58105,"title":"[Bug]: Message routing bug - User input sent to wrong session","url":null,"state":"closed"},{"kind":"issue","number":61573,"title":"Desktop: message queued in a busy session is delivered to an unrelated idle session","url":null,"state":"closed"},{"kind":"issue","number":62652,"title":"Desktop: complete previous session history renders in new chat — full transcript bleed","url":null,"state":"closed"},{"kind":"issue","number":63840,"title":"Bug: new desktop session auto-resumes stale content despite clean state - regression","url":null,"state":"open"},{"kind":"issue","number":65005,"title":"[Bug]: Session-switch data merging / cross-contamination — can't switch chats cleanly","url":null,"state":"closed"},{"kind":"issue","number":65039,"title":"Queued message leaks to wrong session on session switch","url":null,"state":"closed"},{"kind":"issue","number":65328,"title":"[Bug]: New desktop session routes user input to existing old session instead (regression after 7/16 update)","url":null,"state":"closed"},{"kind":"issue","number":65596,"title":"Desktop: new session window reuses existing agent context (no session isolation)","url":null,"state":"closed"},{"kind":"issue","number":65597,"title":"Desktop: new session window reuses existing agent context (no session isolation)","url":null,"state":"closed"},{"kind":"issue","number":65598,"title":"[Bug]: Desktop new session window reuses existing agent context — no session isolation","url":null,"state":"closed"},{"kind":"issue","number":65601,"title":"[Bug]: Desktop new session window reuses existing agent context — no session isolation","url":null,"state":"open"},{"kind":"issue","number":65749,"title":"Desktop: user input routed to wrong session in multi-session scenario","url":null,"state":"closed"},{"kind":"issue","number":65750,"title":"Desktop: user input occasionally routed to wrong session (multi-session)","url":null,"state":"closed"},{"kind":"pr","number":39211,"title":"fix(desktop): clear stale session bubbles and busy state on new chat","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"3c163cb0353c6dc5e0b5278998e721368ed71a68","number":0}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":40499,"title":"feat(desktop): support opening sessions in multiple windows","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"39d09453f95e8aefc0c97e5d9b30ff341cae9ed8","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":45841,"title":"fix(desktop): flip the foreground view to the warm session synchronously on resume","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":46743,"title":"fix: prevent guardrail and desktop duplicate messages","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":48281,"title":"fix(desktop): pin unscoped stream events","url":null,"state":"closed","close_reason":{"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":49659,"title":"fix(cli): prevent desktop session transcript bleed from unscoped events","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"28f1590b7","number":0},{"kind":"pr","value":"#42359","number":42359}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":51729,"title":"fix(dashboard): scope ChatSidebar tool feed to the live session","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"28f1590b7","number":0},{"kind":"pr","value":"#49077","number":49077},{"kind":"pr","value":"#49106","number":49106}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":52025,"title":"fix(desktop): require session_id for all session-scoped gateway events","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#49659","number":49659}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":56444,"title":"fix(desktop): bind queued sends to their source session","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#66001","number":66001},{"kind":"pr","value":"#57849","number":57849},{"kind":"pr","value":"#56390","number":56390},{"kind":"pr","value":"#57850","number":57850},{"kind":"pr","value":"#61573","number":61573}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":57516,"title":"fix(desktop): sync the composer queue across windows (#46732)","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":63948,"title":"fix(desktop): pin unscoped streams + clear view sync on switch (supersedes #48281)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["apps/desktop/src/app/desktop-controller.tsx","apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts","apps/desktop/src/app/session/hooks/use-session-actions.test.tsx","apps/desktop/src/app/session/hooks/use-session-actions/index.ts","apps/desktop/src/app/session/hooks/use-session-state-cache.ts","apps/desktop/src/lib/gateway-events.test.ts","apps/desktop/src/lib/gateway-events.ts"]},{"kind":"pr","number":65381,"title":"fix(desktop): prove submit target belongs to selected session from both directions (#65328)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#86594","number":86594}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":66001,"title":"fix(desktop): drain queued prompts to their originating session (supersedes #57849)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["apps/desktop/src/app/chat/composer/hooks/use-composer-queue.ts","apps/desktop/src/app/chat/composer/types.ts","apps/desktop/src/app/chat/index.tsx","apps/desktop/src/app/contrib/wiring.tsx","apps/desktop/src/app/session/hooks/use-background-queue-drain.test.tsx","apps/desktop/src/app/session/hooks/use-background-queue-drain.ts","apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx","apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts","apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts","apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts"],"fix_hunks":[{"file":"apps/desktop/src/app/chat/composer/hooks/use-composer-queue.ts","hunks":["@@ -8,6 +8,7 @@ import { resetBrowseState } from '@/store/composer-input-history'\n import {\n   $queuedPromptsBySession,\n   enqueueQueuedPrompt,\n+  getQueuedPrompts,\n   MAX_AUTO_DRAIN_ATTEMPTS,\n   migrateQueuedPrompts,\n   promoteQueuedPrompt,","@@ -189,7 +190,9 @@ export function useComposerQueue({\n         return false\n       }\n \n-      const entry = pickEntry(queuedPrompts)\n+      const drainQueueSessionKey = activeQueueSessionKey\n+      const drainRuntimeSessionId = sessionId ?? null\n+      const entry = pickEntry(getQueuedPrompts(drainQueueSessionKey))\n \n       if (!entry) {\n         return false","@@ -199,7 +202,12 @@ export function useComposerQueue({\n \n       try {\n         const accepted = await Promise.resolve(\n-          onSubmit(entry.text, { attachments: entry.attachments, fromQueue: true })\n+          onSubmit(entry.text, {\n+            attachments: entry.attachments,\n+            fromQueue: true,\n+            sessionId: drainRuntimeSessionId,\n+            storedSessionId: drainQueueSessionKey\n+          })\n         )\n \n         if (accepted === false) {","@@ -207,15 +215,15 @@ export function useComposerQueue({\n         }\n \n         drainFailuresRef.current.delete(entry.id)\n-        removeQueuedPrompt(activeQueueSessionKey, entry.id)\n-        resetBrowseState(sessionId)\n+        removeQueuedPrompt(drainQueueSessionKey, entry.id)\n+        resetBrowseState(drainRuntimeSessionId)\n \n         return true\n       } finally {\n         drainingQueueRef.current = false\n       }\n     },\n-    [activeQueueSessionKey, onSubmit, queuedPrompts, sessionId]\n+    [activeQueueSessionKey, onSubmit, sessionId]\n   )\n \n   const pickDrainHead = useCallback("]},{"file":"apps/desktop/src/app/chat/composer/types.ts","hunks":["@@ -1,7 +1,7 @@\n import type { ReactNode } from 'react'\n \n+import type { SubmitTextOptions } from '@/app/session/hooks/use-prompt-actions/utils'\n import type { HermesGateway } from '@/hermes'\n-import type { ComposerAttachment } from '@/store/composer'\n \n import type { DroppedFile } from '../hooks/use-composer-actions'\n ","@@ -52,10 +52,7 @@ export interface ChatBarProps {\n   onPickImages?: () => void\n   onRemoveAttachment?: (id: string) => void\n   onSteer?: (text: string) => Promise<boolean> | boolean\n-  onSubmit: (\n-    value: string,\n-    options?: { attachments?: ComposerAttachment[]; fromQueue?: boolean }\n-  ) => Promise<boolean> | boolean\n+  onSubmit: (value: string, options?: SubmitTextOptions) => Promise<boolean> | boolean\n   onTranscribeAudio?: (audio: Blob) => Promise<string>\n }\n "]},{"file":"apps/desktop/src/app/chat/index.tsx","hunks":["@@ -5,6 +5,7 @@ import type * as React from 'react'\n import { Suspense, useCallback, useMemo } from 'react'\n import { useLocation } from 'react-router-dom'\n \n+import type { SubmitTextOptions } from '@/app/session/hooks/use-prompt-actions/utils'\n import { Thread } from '@/components/assistant-ui/thread'\n import { Backdrop } from '@/components/Backdrop'\n import { COMPOSER_HEART_CONFIG, HeartField } from '@/components/chat/vibe-hearts'","@@ -19,7 +20,6 @@ import type { ChatMessage } from '@/lib/chat-messages'\n import { quickModelOptions, sessionTitle } from '@/lib/chat-runtime'\n import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime'\n import { cn } from '@/lib/utils'\n-import type { ComposerAttachment } from '@/store/composer'\n import { $pinnedSessionIds } from '@/store/layout'\n import { $petActive } from '@/store/pet'\n import { $petOverlayActive } from '@/store/pet-overlay'","@@ -74,10 +74,7 @@ interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {\n   onPickImages: () => void\n   onRemoveAttachment: (id: string) => void\n   onSteer: (text: string) => Promise<boolean> | boolean\n-  onSubmit: (\n-    text: string,\n-    options?: { attachments?: ComposerAttachment[]; fromQueue?: boolean }\n-  ) => Promise<boolean> | boolean\n+  onSubmit: (text: string, options?: SubmitTextOptions) => Promise<boolean> | boolean\n   onThreadMessagesChange: (messages: readonly ThreadMessage[]) => void\n   onEdit: (message: AppendMessage) => Promise<void>\n   onReload: (parentId: string | null) => Promise<void>"]},{"file":"apps/desktop/src/app/contrib/wiring.tsx","hunks":["@@ -75,6 +75,7 @@ import { PersistentTerminal } from '../right-sidebar/terminal/persistent'\n import { CRON_ROUTE, routeSessionId, sessionRoute, SETTINGS_ROUTE, syncWorkspaceIsPage } from '../routes'\n import { SessionPickerOverlay } from '../session-picker-overlay'\n import { SessionSwitcher } from '../session-switcher'\n+import { useBackgroundQueueDrain } from '../session/hooks/use-background-queue-drain'\n import { useContextSuggestions } from '../session/hooks/use-context-suggestions'\n import { useCwdActions } from '../session/hooks/use-cwd-actions'\n import { useHermesConfig } from '../session/hooks/use-hermes-config'","@@ -536,6 +537,15 @@ export function ContribWiring({ children }: { children: ReactNode }) {\n     updateSessionState\n   })\n \n+  // Runs outside the selected ChatBar so queues belonging to background\n+  // sessions continue once those sessions are idle.\n+  useBackgroundQueueDrain({\n+    enabled: gatewayState === 'open',\n+    runtimeIdByStoredSessionIdRef,\n+    selectedStoredSessionId,\n+    submitText\n+  })\n+\n   // Session-tile delegate (resume/submit/interrupt/slash + the session verbs\n   // the tile TAB menu needs, without touching the primary view).\n   useSessionTileDelegate({"]},{"file":"apps/desktop/src/app/session/hooks/use-background-queue-drain.test.tsx","hunks":["@@ -0,0 +1,139 @@\n+import { act, cleanup, render, waitFor } from '@testing-library/react'\n+import type { MutableRefObject } from 'react'\n+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'\n+\n+import { $queuedPromptsBySession, enqueueQueuedPrompt, getQueuedPrompts } from '@/store/composer-queue'\n+import { $workingSessionIds } from '@/store/session'\n+\n+import { useBackgroundQueueDrain } from './use-background-queue-drain'\n+import type { SubmitTextOptions } from './use-prompt-actions/utils'\n+\n+function Harness({\n+  enabled = true,\n+  runtimeMap,\n+  selectedStoredSessionId = 'stored-session-b',\n+  submitText\n+}: {\n+  enabled?: boolean\n+  runtimeMap: MutableRefObject<Map<string, string>>\n+  selectedStoredSessionId?: string | null\n+  submitText: (text: string, options?: SubmitTextOptions) => Promise<boolean> | boolean\n+}) {\n+  useBackgroundQueueDrain({\n+    enabled,\n+    runtimeIdByStoredSessionIdRef: runtimeMap,\n+    selectedStoredSessionId,\n+    submitText\n+  })\n+\n+  return null\n+}\n+\n+describe('useBackgroundQueueDrain', () => {\n+  beforeEach(() => {\n+    vi.useRealTimers()\n+  })\n+\n+  afterEach(() => {\n+    cleanup()\n+    vi.restoreAllMocks()\n+    vi.useRealTimers()\n+    $queuedPromptsBySession.set({})\n+    $workingSessionIds.set([])\n+  })\n+\n+  it('drains an idle queued prompt for a non-selected background session', async () => {\n+    const runtimeMap = { current: new Map([['stored-session-a', 'rt-session-a']]) }\n+    const submitText = vi.fn(async () => true)\n+\n+    enqueueQueuedPrompt('stored-session-a', { text: 'continue in the background', attachments: [] })\n+    $workingSessionIds.set([])\n+\n+    render(<Harness runtimeMap={runtimeMap} submitText={submitText} />)\n+\n+    await waitFor(() => {\n+      expect(submitText).toHaveBeenCalledWith('continue in the background', {\n+        attachments: [],\n+        fromQueue: true,\n+        sessionId: 'rt-session-a',\n+        storedSessionId: 'stored-session-a'\n+      })\n+    })\n+\n+    await waitFor(() => expect(getQueuedPrompts('stored-session-a')).toHaveLength(0))\n+  })\n+\n+  it('leaves the selected session queue to the mounted ChatBar drainer', async () => {\n+    const runtimeMap = { current: new Map([['stored-session-a', 'rt-session-a']]) }\n+    const submitText = vi.fn(async () => true)\n+\n+    enqueueQueuedPrompt('stored-session-a', { text: 'visible queue entry', attachments: [] })\n+    $workingSessionIds.set([])\n+\n+    render(<Harness runtimeMap={runtimeMap} selectedStoredSessionId=\"stored-session-a\" submitText={submitText} />)\n+\n+    await new Promise(resolve => window.setTimeout(resolve, 0))\n+\n+    expect(submitText).not.toHaveBeenCalled()\n+    expect(getQueuedPrompts('stored-session-a')).toHaveLength(1)\n+  })\n+\n+  it('does not drain a background session that is still marked working', async () => {\n+    const runtimeMap = { current: new Map([['stored-session-a', 'rt-session-a']]) }\n+    const submitText = vi.fn(async () => true)\n+\n+    enqueueQueuedPrompt('stored-session-a', { text: 'wait for current turn', attachments: [] })\n+    $workingSessionIds.set(['stored-session-a'])\n+\n+    render(<Harness runtimeMap={runtimeMap} submitText={submitText} />)\n+\n+    await new Promise(resolve => window.setTimeout(resolve, 0))\n+\n+    expect(submitText).not.toHaveBeenCalled()\n+    expect(getQueuedPrompts('stored-session-a')).toHaveLength(1)\n+  })\n+\n+  it('passes a null runtime id so submitText can resume stale background sessions by stored id', async () => {\n+    const runtimeMap = { current: new Map<string, string>() }\n+    const submitText = vi.fn(async () => true)\n+\n+    enqueueQueuedPrompt('stored-session-a', { text: 'resume then send', attachments: [] })\n+\n+    render(<Harness runtimeMap={runtimeMap} submitText={submitText} />)\n+\n+    await waitFor(() => {\n+      expect(submitText).toHaveBeenCalledWith('resume then send', {\n+        attachments: [],\n+        fromQueue: true,\n+        sessionId: null,\n+        storedSessionId: 'stored-session-a'\n+      })\n+    })\n+  })\n+\n+  it('retries a rejected background drain without waiting for another queue or busy-state change', async () => {\n+    vi.useFakeTimers()\n+\n+    const runtimeMap = { current: new Map([['stored-session-a', 'rt-session-a']]) }\n+    const submitText = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true)\n+\n+    enqueueQueuedPrompt('stored-session-a', { text: 'retry me', attachments: [] })\n+\n+    render(<Harness runtimeMap={runtimeMap} submitText={submitText} />)\n+\n+    await act(async () => {\n+      await Promise.resolve()\n+    })\n+\n+    expect(submitText).toHaveBeenCalledTimes(1)\n+    expect(getQueuedPrompts('stored-session-a')).toHaveLength(1)\n+\n+    await act(async () => {\n+      await vi.advanceTimersByTimeAsync(750)\n+      await Promise.resolve()\n+    })\n+\n+    expect(submitText).toHaveBeenCalledTimes(2)\n+    expect(getQueuedPrompts('stored-session-a')).toHaveLength(0)\n+  })\n+})"]},{"file":"apps/desktop/src/app/session/hooks/use-background-queue-drain.ts","hunks":["@@ -0,0 +1,174 @@\n+import { useStore } from '@nanostores/react'\n+import { type MutableRefObject, useCallback, useEffect, useRef, useState } from 'react'\n+\n+import { useI18n } from '@/i18n'\n+import { resetBrowseState } from '@/store/composer-input-history'\n+import {\n+  $queuedPromptsBySession,\n+  getQueuedPrompts,\n+  MAX_AUTO_DRAIN_ATTEMPTS,\n+  type QueuedPromptEntry,\n+  removeQueuedPrompt,\n+  shouldAutoDrain\n+} from '@/store/composer-queue'\n+import { notify } from '@/store/notifications'\n+import { $workingSessionIds } from '@/store/session'\n+\n+import type { SubmitTextOptions } from './use-prompt-actions/utils'\n+\n+type SubmitQueuedPrompt = (text: string, options?: SubmitTextOptions) => Promise<boolean> | boolean\n+\n+interface BackgroundQueueDrainOptions {\n+  enabled: boolean\n+  runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>>\n+  selectedStoredSessionId: string | null\n+  submitText: SubmitQueuedPrompt\n+}\n+\n+const BACKGROUND_DRAIN_RETRY_MS = 750\n+\n+/**\n+ * Drain queued prompts for sessions that are not currently rendered by ChatBar.\n+ *\n+ * The visible ChatBar owns the interactive queue panel for the selected session.\n+ * Without this background drain, a prompt queued in Session A can sit forever\n+ * after the user switches to Session B: the only auto-drain effect lives inside\n+ * the mounted ChatBar, so Session A's queue is not observed when A is offscreen.\n+ */\n+export function useBackgroundQueueDrain({\n+  enabled,\n+  runtimeIdByStoredSessionIdRef,\n+  selectedStoredSessionId,\n+  submitText\n+}: BackgroundQueueDrainOptions) {\n+  const { t } = useI18n()\n+  const queuedPromptsBySession = useStore($queuedPromptsBySession)\n+  const workingSessionIds = useStore($workingSessionIds)\n+  const submitTextRef = useRef(submitText)\n+  const drainingSessionIdsRef = useRef(new Set<string>())\n+  const drainFailuresRef = useRef(new Map<string, number>())\n+  const retryTimersRef = useRef<number[]>([])\n+  const [retryTick, setRetryTick] = useState(0)\n+\n+  useEffect(() => {\n+    submitTextRef.current = submitText\n+  }, [submitText])\n+\n+  const scheduleRetry = useCallback(() => {\n+    if (typeof window === 'undefined') {\n+      return\n+    }\n+\n+    const timer = window.setTimeout(() => {\n+      retryTimersRef.current = retryTimersRef.current.filter(id => id !== timer)\n+      setRetryTick(tick => tick + 1)\n+    }, BACKGROUND_DRAIN_RETRY_MS)\n+\n+    retryTimersRef.current.push(timer)\n+  }, [])\n+\n+  useEffect(\n+    () => () => {\n+      for (const timer of retryTimersRef.current) {\n+        window.clearTimeout(timer)\n+      }\n+\n+      retryTimersRef.current = []\n+    },\n+    []\n+  )\n+\n+  const drainSessionQueue = useCallback(\n+    (sessionKey: string, entry: QueuedPromptEntry) => {\n+      if (drainingSessionIdsRef.current.has(sessionKey)) {\n+        return\n+      }\n+\n+      drainingSessionIdsRef.current.add(sessionKey)\n+\n+      const onFail = () => {\n+        const failures = (drainFailuresRef.current.get(entry.id) ?? 0) + 1\n+        drainFailuresRef.current.set(entry.id, failures)\n+\n+        if (failures >= MAX_AUTO_DRAIN_ATTEMPTS) {\n+          notify({\n+            id: `composer-background-queue-stuck-${sessionKey}`,\n+            kind: 'error',\n+            title: t.composer.queueStuckTitle,\n+            message: t.composer.queueStuckBody\n+          })\n+\n+          return\n+        }\n+\n+        scheduleRetry()\n+      }\n+\n+      void Promise.resolve()\n+        .then(async () => {\n+          const liveEntry = getQueuedPrompts(sessionKey).find(candidate => candidate.id === entry.id)\n+\n+          if (!liveEntry) {\n+            return true\n+          }\n+\n+          const runtimeSessionId = runtimeIdByStoredSessionIdRef.current.get(sessionKey) ?? null\n+\n+          const accepted = await Promise.resolve(\n+            submitTextRef.current(liveEntry.text, {\n+              attachments: liveEntry.attachments,\n+              fromQueue: true,\n+              sessionId: runtimeSessionId,\n+              storedSessionId: sessionKey\n+            })\n+          )\n+\n+          if (accepted === false) {\n+            return false\n+          }\n+\n+          drainFailuresRef.current.delete(liveEntry.id)\n+          removeQueuedPrompt(sessionKey, liveEntry.id)\n+          resetBrowseState(runtimeSessionId)\n+\n+          return true\n+        })\n+        .then(accepted => {\n+          if (!accepted) {\n+            onFail()\n+          }\n+        })\n+        .catch(onFail)\n+        .finally(() => {\n+          drainingSessionIdsRef.current.delete(sessionKey)\n+        })\n+    },\n+    [runtimeIdByStoredSessionIdRef, scheduleRetry, t]\n+  )\n+\n+  useEffect(() => {\n+    if (!enabled) {\n+      return\n+    }\n+\n+    const working = new Set(workingSessionIds)\n+\n+    for (const [sessionKey, entries] of Object.entries(queuedPromptsBySession)) {\n+      if (\n+        sessionKey === selectedStoredSessionId ||\n+        drainingSessionIdsRef.current.has(sessionKey) ||\n+        !shouldAutoDrain({ isBusy: working.has(sessionKey), queueLength: entries.length })\n+      ) {\n+        continue\n+      }\n+\n+      const entry = entries[0]\n+\n+      if (!entry || (drainFailuresRef.current.get(entry.id) ?? 0) >= MAX_AUTO_DRAIN_ATTEMPTS) {\n+        continue\n+      }\n+\n+      drainSessionQueue(sessionKey, entry)\n+    }\n+  }, [drainSessionQueue, enabled, queuedPromptsBySession, retryTick, selectedStoredSessionId, workingSessionIds])\n+}"]},{"file":"apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx","hunks":["@@ -8,6 +8,8 @@ import { $composerAttachments, $composerDraft, type ComposerAttachment, setCompo\n import { $busy, $connection, $messages, $sessions, $turnStartedAt, setSessions } from '@/store/session'\n import type { SessionInfo } from '@/types/hermes'\n \n+import type { SubmitTextOptions } from './utils'\n+\n import { uploadComposerAttachment, usePromptActions } from '.'\n \n vi.mock('@/hermes', () => ({","@@ -56,10 +58,11 @@ async function actRender(ui: React.ReactElement) {\n }\n \n interface HarnessHandle {\n+  activeSessionIdRef: MutableRefObject<string | null>\n   cancelRun: () => Promise<void>\n   restoreToMessage: (messageId: string, target?: { text?: string; userOrdinal?: number | null }) => Promise<void>\n   steerPrompt: (text: string) => Promise<boolean>\n-  submitText: (text: string, options?: { attachments?: ComposerAttachment[]; fromQueue?: boolean }) => Promise<boolean>\n+  submitText: (text: string, options?: SubmitTextOptions) => Promise<boolean>\n }\n \n function Harness({","@@ -68,6 +71,7 @@ function Harness({\n   getRoutedStoredSessionId,\n   getRuntimeIdForStoredSession,\n   getRouteToken,\n+  onUpdateState,\n   onReady,\n   onSeedState,\n   openMemoryGraph,","@@ -85,6 +89,11 @@ function Harness({\n   getRoutedStoredSessionId?: () => null | string\n   getRuntimeIdForStoredSession?: (storedSessionId: string) => null | string\n   getRouteToken?: () => string\n+  onUpdateState?: (\n+    sessionId: string,\n+    storedSessionId: null | string | undefined,\n+    state: Record<string, unknown>\n+  ) => void\n   onReady: (handle: HarnessHandle) => void\n   onSeedState?: (state: Record<string, unknown>) => void\n   openMemoryGraph?: () => void","@@ -97,9 +106,8 @@ function Harness({\n   activeSessionId?: null | string\n   createBackendSessionForSend?: () => Promise<null | string>\n }) {\n-  const activeSessionIdRef: MutableRefObject<string | null> = activeSessionIdRefProp ?? {\n-    current: activeSessionId === undefined ? RUNTIME_SESSION_ID : activeSessionId\n-  }\n+  const localActiveSessionIdRef = useRef<string | null>(activeSessionId === undefined ? RUNTIME_SESSION_ID : activeSessionId)\n+  const activeSessionIdRef = activeSessionIdRefProp ?? localActiveSessionIdRef\n \n   const selectedStoredSessionIdRef: MutableRefObject<string | null> = selectedStoredSessionIdRefProp ?? {\n     current: storedSessionId === undefined ? RUNTIME_SESSION_ID : storedSessionId","@@ -131,11 +139,12 @@ function Harness({\n     selectedStoredSessionIdRef,\n     startFreshSessionDraft: () => undefined,\n     sttEnabled: false,\n-    updateSessionState: (_sessionId, updater) => {\n+    updateSessionState: (sessionId, updater, storedSessionId) => {\n       // Seed with interrupted:true so we can prove a fresh submit clears it.\n       const next = updater(stateRef.current) as unknown as Record<string, unknown>\n       stateRef.current = next as never\n       onSeedState?.(next)\n+      onUpdateState?.(sessionId, storedSessionId, next)\n \n       return next as never\n     }","@@ -143,6 +152,7 @@ function Harness({\n \n   useEffect(() => {\n     onReady({\n+      activeSessionIdRef,\n       cancelRun: (...args: Parameters<typeof actions.cancelRun>) =>\n         act(async () => actions.cancelRun(...args)) as Promise<void>,\n       restoreToMessage: (...args: Parameters<typeof actions.restoreToMessage>) =>","@@ -152,7 +162,7 @@ function Harness({\n       submitText: (...args: Parameters<typeof actions.submitText>) =>\n         act(async () => actions.submitText(...args)) as Promise<boolean>\n     })\n-  }, [actions.cancelRun, actions.restoreToMessage, actions.steerPrompt, actions.submitText, onReady])\n+  }, [actions.cancelRun, actions.restoreToMessage, actions.steerPrompt, actions.submitText, activeSessionIdRef, onReady])\n \n   return null\n }","@@ -540,6 +550,45 @@ describe('usePromptActions submit / queue drain semantics', () => {\n     )\n   })\n \n+  it('a fromQueue drain sends to its queued session even after the active session changes', async () => {\n+    $busy.set(false)\n+\n+    const updates: { sessionId: string; state: Record<string, unknown>; storedSessionId: null | string | undefined }[] = []\n+    const requestGateway = vi.fn(async () => ({}) as never)\n+\n+    let handle: HarnessHandle | null = null\n+    render(\n+      <Harness\n+        onReady={h => (handle = h)}\n+        onUpdateState={(sessionId, storedSessionId, state) => updates.push({ sessionId, state, storedSessionId })}\n+        refreshSessions={async () => undefined}\n+        requestGateway={requestGateway}\n+      />\n+    )\n+\n+    const accepted = await handle!.submitText('queued for background session', {\n+      fromQueue: true,\n+      sessionId: 'rt-session-a',\n+      storedSessionId: 'stored-session-a'\n+    })\n+\n+    expect(accepted).toBe(true)\n+    expect(requestGateway).toHaveBeenCalledWith(\n+      'prompt.submit',\n+      {\n+        session_id: 'rt-session-a',\n+        text: 'queued for background session'\n+      },\n+      1_800_000\n+    )\n+    expect(requestGateway).not.toHaveBeenCalledWith('session.resume', expect.anything())\n+    expect(\n+      updates.some(update => update.sessionId === 'rt-session-a' && update.storedSessionId === 'stored-session-a')\n+    ).toBe(true)\n+    // Offscreen queue drains must not flip the foreground composer into Thinking.\n+    expect($busy.get()).toBe(false)\n+  })\n+\n   it('a rejected fromQueue drain returns false (entry stays queued) and a later retry sends it', async () => {\n     // A stale-session 404 must not strand the queued entry: submitPrompt returns\n     // false on failure so the composer keeps it, and the edge-independent","@@ -1132,6 +1181,58 @@ describe('usePromptActions sleep/wake session recovery', () => {\n     expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID, text: 'message after wake' })\n   })\n \n+  it('background queue resume uses the queued stored id and leaves foreground runtime selected', async () => {\n+    const calls: { method: string; params?: Record<string, unknown> }[] = []\n+    let submitAttempts = 0\n+\n+    const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {\n+      calls.push({ method, params })\n+\n+      if (method === 'prompt.submit') {\n+        submitAttempts += 1\n+\n+        if (submitAttempts === 1) {\n+          throw new Error('session not found')\n+        }\n+\n+        return {} as never\n+      }\n+\n+      if (method === 'session.resume') {\n+        return { session_id: RECOVERED_SESSION_ID } as never\n+      }\n+\n+      return {} as never\n+    })\n+\n+    let handle: HarnessHandle | null = null\n+    render(\n+      <Harness\n+        onReady={h => (handle = h)}\n+        refreshSessions={async () => undefined}\n+        requestGateway={requestGateway}\n+        storedSessionId=\"stored-foreground\"\n+      />\n+    )\n+    await waitFor(() => expect(handle).not.toBeNull())\n+\n+    const ok = await handle!.submitText('queued background message after wake', {\n+      fromQueue: true,\n+      sessionId: 'rt-background-stale',\n+      storedSessionId: STORED_SESSION_ID\n+    })\n+\n+    expect(ok).toBe(true)\n+    expect(calls.map(c => c.method)).toEqual(['prompt.submit', 'session.resume', 'prompt.submit'])\n+    expect(calls[0]?.params).toEqual({ session_id: 'rt-background-stale', text: 'queued background message after wake' })\n+    expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop' })\n+    expect(calls[2]?.params).toEqual({\n+      session_id: RECOVERED_SESSION_ID,\n+      text: 'queued background message after wake'\n+    })\n+    expect(handle!.activeSessionIdRef.current).toBe(RUNTIME_SESSION_ID)\n+  })\n+\n   it('resumes the stored session and retries once when session.interrupt reports \"session not found\"', async () => {\n     const calls: { method: string; params?: Record<string, unknown> }[] = []\n     let interruptAttempts = 0","@@ -1346,7 +1447,7 @@ describe('usePromptActions sleep/wake session recovery', () => {\n     expect(ok).toBe(true)\n     expect(createBackendSessionForSend).not.toHaveBeenCalled()\n     expect(calls.map(c => c.method)).toEqual(['session.resume', 'prompt.submit'])\n-    expect(calls[0]?.params).toEqual({ session_id: STORED_SESSION_ID })\n+    expect(calls[0]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop' })\n     expect(calls[1]?.params).toMatchObject({ session_id: RECOVERED_SESSION_ID })\n   })\n ","@@ -1655,7 +1756,8 @@ describe('usePromptActions submit session-context isolation (#54527)', () => {\n     expect(await submitting).toBe(false)\n     expect(calls.some(c => c.method === 'prompt.submit')).toBe(false)\n     expect(calls.find(c => c.method === 'session.resume')?.params).toEqual({\n-      session_id: STORED_SESSION_A\n+      session_id: STORED_SESSION_A,\n+      source: 'desktop'\n     })\n   })\n "]},{"file":"apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts","hunks":["@@ -14,7 +14,7 @@ import {\n } from '@/lib/desktop-slash-commands'\n import { setSessionYolo } from '@/lib/yolo-session'\n import { openCommandPalettePage } from '@/store/command-palette'\n-import { type ComposerAttachment, setComposerDraft } from '@/store/composer'\n+import { setComposerDraft } from '@/store/composer'\n import { notify, notifyError } from '@/store/notifications'\n import { setPetScale } from '@/store/pet-gallery'\n import { $petGenInput, openPetGenerate } from '@/store/pet-generate'","@@ -31,7 +31,13 @@ import {\n \n import type { BrowserManageResponse, SessionTitleResponse, SlashExecResponse } from '../../../types'\n \n-import { type GatewayRequest, isSessionIdCandidate, renderCommandsCatalog, slashStatusText } from './utils'\n+import {\n+  type GatewayRequest,\n+  isSessionIdCandidate,\n+  renderCommandsCatalog,\n+  slashStatusText,\n+  type SubmitTextOptions\n+} from './utils'\n \n /** Everything a slash handler needs about the invocation it's serving. */\n interface SlashActionCtx {","@@ -59,10 +65,7 @@ interface SlashCommandDeps {\n   requestGateway: GatewayRequest\n   resumeStoredSession: (storedSessionId: string) => Promise<void> | void\n   startFreshSessionDraft: () => void\n-  submitPromptText: (\n-    rawText: string,\n-    options?: { attachments?: ComposerAttachment[]; fromQueue?: boolean }\n-  ) => Promise<boolean>\n+  submitPromptText: (rawText: string, options?: SubmitTextOptions) => Promise<boolean>\n }\n \n /** The /slash command dispatcher, extracted from usePromptActions. */"]},{"file":"apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts","hunks":["@@ -141,9 +141,19 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {\n         return false\n       }\n \n-      // Pin the session context for the whole async submit pipeline. Without\n-      // this, a fast session switch during session.resume / file.attach can\n-      // redirect the user's text into a different chat (#54527). Mutable —\n+      // Queue drains carry their source session explicitly. A background drain\n+      // must never inherit the currently selected session after the user moves\n+      // to another chat.\n+      const targetStoredSessionId = options?.storedSessionId ?? selectedStoredSessionIdRef.current\n+\n+      const targetStartedInCurrentView =\n+        !targetStoredSessionId || targetStoredSessionId === selectedStoredSessionIdRef.current\n+\n+      let sessionId: null | string = options?.sessionId ?? activeSessionIdRef.current\n+\n+      // Pin the foreground session context for the whole async submit pipeline.\n+      // Without this, a fast session switch during session.resume / file.attach\n+      // can redirect the user's text into a different chat (#54527). Mutable —\n       // not const — because a new-chat submit legitimately re-homes to the\n       // session it creates (see the re-pin after createBackendSessionForSend).\n       const startingActiveSessionId = activeSessionIdRef.current","@@ -166,11 +176,16 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {\n       let startingRouteToken = [redacted:secret-assignment]\n \n       const sessionContextDrifted = (): boolean =>\n-        selectedStoredSessionIdRef.current !== startingStoredSessionId || getRouteToken() !== startingRouteToken\n+        targetStartedInCurrentView &&\n+        (selectedStoredSessionIdRef.current !== startingStoredSessionId || getRouteToken() !== startingRouteToken)\n+\n+      const targetIsCurrentView = (): boolean => targetStartedInCurrentView && !sessionContextDrifted()\n \n       // One submit in flight per session — drop any concurrent re-fire so a\n-      // stalled turn can't stack the same prompt into multiple real turns.\n-      const submitLockKey = startingStoredSessionId || startingActiveSessionId || '__pending_new__'\n+      // stalled turn can't stack the same prompt into multiple real turns. The\n+      // foreground ChatBar and background drainers can briefly overlap during a\n+      // session switch; this per-session lock makes that safe.\n+      const submitLockKey = targetStoredSessionId || sessionId || startingActiveSessionId || '__pending_new__'\n \n       if (_submitInFlight.has(submitLockKey)) {\n         return false","@@ -197,9 +212,12 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {\n \n       const releaseBusy = () => {\n         releaseSubmitLock()\n-        setMutableRef(busyRef, false)\n-        scope.setBusy(false)\n-        scope.setAwaitingResponse(false)\n+\n+        if (targetIsCurrentView()) {\n+          setMutableRef(busyRef, false)\n+          scope.setBusy(false)\n+          scope.setAwaitingResponse(false)\n+        }\n       }\n \n       // Idempotent optimistic insert — re-running with the resolved sessionId","@@ -221,7 +239,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {\n             // (what made drained-after-interrupt sends go silent).\n             interrupted: false\n           }),\n-          startingStoredSessionId\n+          targetStoredSessionId\n         )\n \n       // After sync rewrites refs, refresh the optimistic message in place so the","@@ -233,12 +251,14 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {\n             ...state,\n             messages: state.messages.map(message => (message.id === optimisticId ? buildUserMessage() : message))\n           }),\n-          startingStoredSessionId\n+          targetStoredSessionId\n         )\n \n       const dropOptimistic = (sid: null | string) => {\n         if (!sid) {\n-          scope.setMessages(current => current.filter(m => m.id !== optimisticId))\n+          if (targetIsCurrentView()) {\n+            scope.setMessages(current => current.filter(m => m.id !== optimisticId))\n+          }\n \n           return\n         }","@@ -252,7 +272,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {\n             awaitingResponse: false,\n             pendingBranchGroup: null\n           }),\n-          startingStoredSessionId\n+          targetStoredSessionId\n         )\n       }\n ","@@ -263,19 +283,26 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {\n         return false\n       }\n \n-      setMutableRef(busyRef, true)\n-      scope.setBusy(true)\n-      scope.setAwaitingResponse(true)\n-      clearNotifications()\n+      // Foreground-only state: a background queue drain must never write the\n+      // selected view's busy/awaiting flags or clear its notifications.\n+      if (targetIsCurrentView()) {\n+        setMutableRef(busyRef, true)\n+        scope.setBusy(true)\n+        scope.setAwaitingResponse(true)\n+        clearNotifications()\n+      }\n \n       // A route whose selected/runtime binding is incomplete or cross-wired\n-      // outranks any stale render-time runtime id (often from the previous\n-      // profile). Force the full routed resume path below in that case.\n-      let sessionId: null | string = routedSessionNeedsResume ? null : activeSessionIdRef.current\n+      // outranks a stale render-time runtime id (often from the previous\n+      // profile): force the full routed resume path below. An explicit queued\n+      // runtime id (background drain) is authoritative and is left untouched.\n+      if (!options?.sessionId && routedSessionNeedsResume) {\n+        sessionId = null\n+      }\n \n       if (sessionId) {\n         seedOptimistic(sessionId)\n-      } else {\n+      } else if (targetIsCurrentView()) {\n         scope.setMessages(current => [...current, buildUserMessage()])\n       }\n ","@@ -313,17 +340,15 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {\n         seedOptimistic(sessionId)\n       }\n \n-      if (!sessionId && startingStoredSessionId) {\n-        // A stored session is SELECTED but its runtime binding is gone (the\n-        // live session was orphan-reaped, or a timeout/reconnect cleared\n-        // activeSessionId). Continuing the selected conversation must mean\n-        // resuming it — minting a brand-new backend session here silently\n-        // splits the user's chat in two (#55578 symptom b). Only fall through\n-        // to session creation when NO stored session is selected (a genuine\n-        // new-chat draft).\n+      if (!sessionId && targetStoredSessionId) {\n+        // A target stored session exists but its runtime binding is gone (the\n+        // live session was orphan-reaped, a timeout/reconnect cleared it, or a\n+        // background queue drain only has the durable id). Continue that target\n+        // conversation; only a genuine new-chat draft may create a new session.\n         try {\n           const resumed = await requestGateway<{ session_id: string }>('session.resume', {\n-            session_id: startingStoredSessionId\n+            session_id: targetStoredSessionId,\n+            source: 'desktop'\n           })\n \n           if (sessionContextDrifted()) {","@@ -332,12 +357,17 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {\n \n           if (resumed?.session_id) {\n             sessionId = resumed.session_id\n-            activeSessionIdRef.current = sessionId\n+\n+            if (targetIsCurrentView()) {\n+              activeSessionIdRef.current = sessionId\n+            }\n           }\n         } catch {\n-          // A selected stored conversation is not a new-chat draft. If its\n+          // A target stored conversation is not a new-chat draft. If its\n           // runtime cannot be rebound, stop here rather than silently replacing\n-          // it with a contextless session.\n+          // it with a contextless session (#55578). For a background/queued\n+          // drain this abort is a no-op on foreground state (both helpers are\n+          // targetIsCurrentView-guarded) and simply drops the queued send.\n           return abortForSessionSwitch(null)\n         }\n ","@@ -358,7 +388,10 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {\n         } catch (err) {\n           dropOptimistic(null)\n           releaseBusy()\n-          notifyError(err, copy.sessionUnavailable)\n+\n+          if (targetIsCurrentView()) {\n+            notifyError(err, copy.sessionUnavailable)\n+          }\n \n           return false\n         }","@@ -373,7 +406,10 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {\n \n           dropOptimistic(null)\n           releaseBusy()\n-          notify({ kind: 'error', title: copy.sessionUnavailable, message: copy.createSessionFailed })\n+\n+          if (targetIsCurrentView()) {\n+            notify({ kind: 'error', title: copy.sessionUnavailable, message: copy.createSessionFailed })\n+          }\n \n           return false\n         }","@@ -424,14 +460,19 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {\n             requestGateway('prompt.submit', { session_id: sessionId, text }, PROMPT_SUBMIT_REQUEST_TIMEOUT_MS)\n           )\n         } catch (firstErr) {\n-          if ((isSessionNotFoundError(firstErr) || isGatewayTimeoutError(firstErr)) && startingStoredSessionId) {\n+          const recoverStoredSessionId = targetStoredSessionId ?? selectedStoredSessionIdRef.current\n+\n+          if (\n+            (isSessionNotFoundError(firstErr) || isGatewayTimeoutError(firstErr)) &&\n+            recoverStoredSessionId\n+          ) {\n             // Re-register the session in the gateway and get a fresh live ID.\n             // Timeouts recover the same way as \"session not found\": a starved\n             // backend loop (#55578 symptom d) rejects the submit even though\n             // the stored session is fine — resume + retry instead of erroring\n             // out and losing the session binding.\n             const resumed = await requestGateway<{ session_id: string }>('session.resume', {\n-              session_id: startingStoredSessionId,\n+              session_id: recoverStoredSessionId,\n               source: 'desktop'\n             })\n ","@@ -442,7 +483,10 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {\n             const recoveredId = resumed?.session_id\n \n             if (recoveredId) {\n-              activeSessionIdRef.current = recoveredId\n+              if (targetIsCurrentView()) {\n+                activeSessionIdRef.current = recoveredId\n+              }\n+\n               await withSessionBusyRetry(() =>\n                 requestGateway('prompt.submit', { session_id: recoveredId, text }, PROMPT_SUBMIT_REQUEST_TIMEOUT_MS)\n               )","@@ -479,31 +523,37 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {\n \n         const message = inlineErrorMessage(err, copy.promptFailed)\n \n-        updateSessionState(sessionId, state => ({\n-          ...state,\n-          messages: [\n-            ...state.messages,\n-            {\n-              id: `assistant-error-${Date.now()}`,\n-              role: 'assistant',\n-              parts: [],\n-              error: message || copy.promptFailed,\n-              branchGroupId: state.pendingBranchGroup ?? undefined\n-            }\n-          ],\n-          busy: false,\n-          awaitingResponse: false,\n-          pendingBranchGroup: null,\n-          sawAssistantPayload: true\n-        }))\n-\n-        if (isProviderSetupError(err)) {\n+        updateSessionState(\n+          sessionId,\n+          state => ({\n+            ...state,\n+            messages: [\n+              ...state.messages,\n+              {\n+                id: `assistant-error-${Date.now()}`,\n+                role: 'assistant',\n+                parts: [],\n+                error: message || copy.promptFailed,\n+                branchGroupId: state.pendingBranchGroup ?? undefined\n+              }\n+            ],\n+            busy: false,\n+            awaitingResponse: false,\n+            pendingBranchGroup: null,\n+            sawAssistantPayload: true\n+          }),\n+          targetStoredSessionId\n+        )\n+\n+        if (targetIsCurrentView() && isProviderSetupError(err)) {\n           requestDesktopOnboarding(copy.providerCredentialRequired)\n \n           return false\n         }\n \n-        notifyError(err, copy.promptFailed)\n+        if (targetIsCurrentView()) {\n+          notifyError(err, copy.promptFailed)\n+        }\n \n         return false\n       }"]},{"file":"apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts","hunks":["@@ -226,4 +226,11 @@ export function visibleUserIndexAtOrdinal(messages: readonly ChatMessage[], targ\n export interface SubmitTextOptions {\n   attachments?: ComposerAttachment[]\n   fromQueue?: boolean\n+  /** Runtime session id to submit into. Queue drains pass this so a\n+   *  backgrounded/source session cannot be replaced by the current foreground\n+   *  session between enqueue and drain. */\n+  sessionId?: string | null\n+  /** Stable stored session id for optimistic/cache updates and stale-runtime\n+   *  recovery. Distinct from the runtime session id minted by the gateway. */\n+  storedSessionId?: string | null\n }\n"]}]},{"kind":"pr","number":67049,"title":"fix(desktop): isolate new-session windows","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#66270","number":66270}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":69815,"title":"fix(desktop): isolate New Window from remembered sessions","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":86594,"title":"fix(desktop): salvage session-race cluster — Stop→edit/resend busy race + submit ownership proof (#83855, #65328)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["apps/desktop/src/app/chat/session-tile-actions.ts","apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.test.ts","apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.ts","apps/desktop/src/app/contrib/wiring.tsx","apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx","apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts","apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts","apps/desktop/src/app/session/hooks/use-prompt-actions/utils.test.ts","apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts","contributors/emails/[redacted:email]"]},{"kind":"pr","number":122781,"title":"fix(tui): never auto-resume a source='unknown' guard placeholder","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["tests/tui_gateway/test_tui_gateway_server.py","tui_gateway/methods_session.py"]},{"kind":"pr","number":122953,"title":"fix(desktop): sync the composer queue across windows","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["apps/desktop/src/app/session/hooks/use-background-queue-drain.test.tsx","apps/desktop/src/store/composer-queue.test.ts","apps/desktop/src/store/composer-queue.ts"]}],"trust":{"default_tier":"verified","includes_suggested":false}}