# NousResearch/hermes-agent · Session isolation and cross-contamination bugs in desktop

## Open now

- 2 issues, 3 PRs open on GitHub.
- #63840: Bug: new desktop session auto-resumes stale content despite clean state - regression
- #65601: \[Bug\]: Desktop new session window reuses existing agent context — no session isolation
- PR #39211: fix(desktop): clear stale session bubbles and busy state on new chat
- PR #67049: fix(desktop): isolate new-session windows
- PR #69815: fix(desktop): isolate New Window from remembered sessions

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.

## TL;DR

- 44 member(s): 2 open issue(s), 24 closed issue(s), 3 open PR(s), 5 merged PR(s), 10 PR(s) closed without merging.
- A fix was merged to main in PR #63948. Verify it addresses your case.
- **Recommended:** read PR #63948 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-63948,pr-122781,pr-122953`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.04`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Open related issues

- #63840: Bug: new desktop session auto-resumes stale content despite clean state - regression
- #65601: \[Bug\]: Desktop new session window reuses existing agent context — no session isolation

## Closed / duplicate issues

- #39086: Desktop: stale user bubbles/session busy rows can still render in a new chat after #37975
- #40394: \[Feature\]: Support opening multiple independent desktop windows
- #44187: bug: error messages from previous session bleed into current conversation
- #45738: Desktop can show another session’s transcript/status above the composer after switching sessions
- #46194: \[Bug\]: Desktop session switch leaks queued follow-up state and can show wrong chat contents
- #46732: Desktop: failed/unsent messages leak across multiple windows even after /new session
- #47340: Desktop: Messages sent in Window A appear in Window B (session routing broken after WebSocket reconnect)
- #47475: Bug: Messages leaking between conversation sessions
- #47709: \[Bug\]: Desktop shows newer session output in an older session
- #49106: Web/WeChat sessions leak history: turns from one Hermes session appear in another
- #54320: Desktop sidebar can highlight one session while chat pane sends to another
- #54929: Cross-session message leak: messages from one conversation appearing in another
- #56390: \[Bug\]: Queued messages can fire into the wrong session when switching between concurrent sessions (Desktop only)
- #58105: \[Bug\]: Message routing bug - User input sent to wrong session
- #61573: Desktop: message queued in a busy session is delivered to an unrelated idle session
- #62652: Desktop: complete previous session history renders in new chat — full transcript bleed
- #65005: \[Bug\]: Session-switch data merging / cross-contamination — can't switch chats cleanly
- #65039: Queued message leaks to wrong session on session switch
- #65328: \[Bug\]: New desktop session routes user input to existing old session instead (regression after 7/16 update)
- #65596: Desktop: new session window reuses existing agent context (no session isolation)
- #65597: Desktop: new session window reuses existing agent context (no session isolation)
- #65598: \[Bug\]: Desktop new session window reuses existing agent context — no session isolation
- #65749: Desktop: user input routed to wrong session in multi-session scenario
- #65750: Desktop: user input occasionally routed to wrong session (multi-session)

## Prior & competing PRs

- **#63948: fix(desktop): pin unscoped streams + clear view sync on switch (supersedes #48281)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `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`
- **#66001: fix(desktop): drain queued prompts to their originating session (supersedes #57849)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `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:
  - `apps/desktop/src/app/chat/composer/hooks/use-composer-queue.ts`

    ```diff
    @@ -8,6 +8,7 @@ import { resetBrowseState } from '@/store/composer-input-history'
     import {
       $queuedPromptsBySession,
       enqueueQueuedPrompt,
    +  getQueuedPrompts,
       MAX_AUTO_DRAIN_ATTEMPTS,
       migrateQueuedPrompts,
       promoteQueuedPrompt,
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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) {
    ```

    ```diff
    @@ -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(
    ```
  - `apps/desktop/src/app/chat/composer/types.ts`

    ```diff
    @@ -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'
     
    ```

    ```diff
    @@ -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>
     }
     
    ```
  - `apps/desktop/src/app/chat/index.tsx`

    ```diff
    @@ -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'
    ```

    ```diff
    @@ -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'
    ```

    ```diff
    @@ -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>
    ```
  - `apps/desktop/src/app/contrib/wiring.tsx`

    ```diff
    @@ -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'
    ```

    ```diff
    @@ -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({
    ```
  - `apps/desktop/src/app/session/hooks/use-background-queue-drain.test.tsx`

    ```diff
    @@ -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)
    +  })
    +})
    ```
  - `apps/desktop/src/app/session/hooks/use-background-queue-drain.ts`

    ```diff
    @@ -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])
    +}
    ```
  - `apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx`

    ```diff
    @@ -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', () => ({
    ```

    ```diff
    @@ -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({
    ```

    ```diff
    @@ -68,6 +71,7 @@ function Harness({
       getRoutedStoredSessionId,
       getRuntimeIdForStoredSession,
       getRouteToken,
    +  onUpdateState,
       onReady,
       onSeedState,
       openMemoryGraph,
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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
         }
    ```

    ```diff
    @@ -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>) =>
    ```

    ```diff
    @@ -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
     }
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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 })
       })
     
    ```

    ```diff
    @@ -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'
         })
       })
     
    ```
  - `apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts`

    ```diff
    @@ -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'
    ```

    ```diff
    @@ -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 {
    ```

    ```diff
    @@ -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. */
    ```
  - `apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts`

    ```diff
    @@ -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
    ```

    ```diff
    @@ -166,11 +176,16 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
           let startingRouteToken = [redacted:secret-assignment]
     
           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
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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
             }
    ```

    ```diff
    @@ -252,7 +272,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
                 awaitingResponse: false,
                 pendingBranchGroup: null
               }),
    -          startingStoredSessionId
    +          targetStoredSessionId
             )
           }
     
    ```

    ```diff
    @@ -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()])
           }
     
    ```

    ```diff
    @@ -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()) {
    ```

    ```diff
    @@ -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)
             }
     
    ```

    ```diff
    @@ -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
             }
    ```

    ```diff
    @@ -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
             }
    ```

    ```diff
    @@ -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'
                 })
     
    ```

    ```diff
    @@ -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)
                   )
    ```

    ```diff
    @@ -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
    -        }))
    -
    -        if (isProviderSetupError(err)) {
    +        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 (targetIsCurrentView() && isProviderSetupError(err)) {
               requestDesktopOnboarding(copy.providerCredentialRequired)
     
               return false
             }
     
    -        notifyError(err, copy.promptFailed)
    +        if (targetIsCurrentView()) {
    +          notifyError(err, copy.promptFailed)
    +        }
     
             return false
           }
    ```
  - `apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts`

    ```diff
    @@ -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
     }
    ```
- **#86594: fix(desktop): salvage session-race cluster — Stop→edit/resend busy race + submit ownership proof (#83855, #65328)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `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]`
- **#122781: fix(tui): never auto-resume a source='unknown' guard placeholder**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `tests/tui_gateway/test_tui_gateway_server.py`, `tui_gateway/methods_session.py`
- **#122953: fix(desktop): sync the composer queue across windows**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `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`
- **#39211: fix(desktop): clear stale session bubbles and busy state on new chat**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `3c163cb0353c6dc5e0b5278998e721368ed71a68`
  - files: `apps/desktop/src/app/session/hooks/use-session-actions.ts`, `apps/desktop/src/app/session/hooks/use-session-state-cache.ts`
- **#40499: feat(desktop): support opening sessions in multiple windows**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `39d09453f95e8aefc0c97e5d9b30ff341cae9ed8`
- **#45841: fix(desktop): flip the foreground view to the warm session synchronously on resume**. closed without merge
  - maintainer direction: category `uncategorized`; implication `needs_llm`
- **#46743: fix: prevent guardrail and desktop duplicate messages**. closed without merge
  - maintainer direction: category `uncategorized`; implication `needs_llm`
- **#48281: fix(desktop): pin unscoped stream events**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`
- **#49659: fix(cli): prevent desktop session transcript bleed from unscoped events**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `28f1590b7`, `#42359`
- **#51729: fix(dashboard): scope ChatSidebar tool feed to the live session**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `28f1590b7`, `#49077`, `#49106`
- **#52025: fix(desktop): require session_id for all session-scoped gateway events**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#49659`
- **#56444: fix(desktop): bind queued sends to their source session**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#66001`, `#57849`, `#56390`, `#57850`, `#61573`
- **#57516: fix(desktop): sync the composer queue across windows (#46732)**. closed without merge
  - maintainer direction: category `silent_close`; implication `active`
- **#65381: fix(desktop): prove submit target belongs to selected session from both directions (#65328)**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#86594`
- **#67049: fix(desktop): isolate new-session windows**. open
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#66270`
  - files: `apps/desktop/src/app/contrib/hooks/use-desktop-integrations.test.tsx`, `apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts`
- **#69815: fix(desktop): isolate New Window from remembered sessions**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`
  - files: `apps/desktop/electron/main.ts`, `apps/desktop/electron/session-windows.test.ts`, `apps/desktop/electron/session-windows.ts`, `apps/desktop/src/app/contrib/hooks/use-desktop-integrations.test.ts`, `apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts`, `apps/desktop/src/store/windows.ts`

## Fix status

- Merged to main in PR #63948 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-09-25`.
- Subsystem: `agent`
