{"repo":"NousResearch/hermes-agent","public_id":"54527","display_label":"Session isolation fixes for routing and drafts","summary":"Multiple fixes addressing message routing and composer draft state bleeding across sessions in the desktop client. Issues involve input being misdirected or lost when switching between sessions or sidebar contexts.","subsystem":"apps/desktop","counts":{"members":6,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":1,"attempt_prs":3},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":62413},"resolution":{"state":"partially_resolved","by":"pr-62413"},"freshness":{"state":"verified","last_verified_at":"2026-07-11T02:35:06+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.2906666666666667},"fix_edges":[{"issue_number":54527,"pr_number":62413,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":54527,"title":"Bug: Gateway message routing misdirects user input between TUI sessions -- prompt permanently lost","url":null,"state":"closed"},{"kind":"issue","number":68358,"title":"Bug: New desktop session message routed into stale TUI session after TTFB timeout","url":null,"state":"closed"},{"kind":"pr","number":62130,"title":"fix(desktop): isolate composer drafts across session switches (#54527)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#62134","number":62134}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":62134,"title":"fix(desktop): isolate session routing across sidebar switches (#54527)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#62413","number":62413}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":62144,"title":"fix(desktop): pin composer draft scope to the swap-effect owner, not the render ref","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#62413","number":62413},{"kind":"pr","value":"#62134","number":62134}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":62413,"title":"fix(desktop): keep draft and submit routing session-bound","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/composer-utils.test.ts","apps/desktop/src/app/chat/composer/composer-utils.ts","apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts","apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts","apps/desktop/src/app/desktop-controller.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"],"fix_hunks":[{"file":"apps/desktop/src/app/chat/composer/composer-utils.test.ts","hunks":["@@ -1,7 +1,14 @@\n import type { Unstable_TriggerItem } from '@assistant-ui/core'\n import { describe, expect, it } from 'vitest'\n \n-import { pickPlaceholder, slashArgStage, slashChipKindForItem, slashCommandToken } from './composer-utils'\n+import {\n+  isPendingDraftPersistCurrent,\n+  type PendingDraftPersist,\n+  pickPlaceholder,\n+  slashArgStage,\n+  slashChipKindForItem,\n+  slashCommandToken\n+} from './composer-utils'\n \n const item = (group: string): Unstable_TriggerItem =>\n   ({ id: 'x', type: 'slash', label: 'x', metadata: { group } }) as unknown as Unstable_TriggerItem","@@ -38,3 +45,36 @@ describe('pickPlaceholder', () => {\n     expect(pool).toContain(pickPlaceholder(pool))\n   })\n })\n+\n+describe('isPendingDraftPersistCurrent (#54527 integrity guard)', () => {\n+  it('accepts a write when the pending entry still matches what was captured', () => {\n+    const entry: PendingDraftPersist = { scope: 'session-a', text: 'hello' }\n+\n+    expect(isPendingDraftPersistCurrent(entry, entry)).toBe(true)\n+    expect(isPendingDraftPersistCurrent({ scope: 'session-a', text: 'hello' }, entry)).toBe(true)\n+  })\n+\n+  it('rejects when the pending slot was cleared (session swap / newer flush already committed)', () => {\n+    const entry: PendingDraftPersist = { scope: 'session-a', text: 'hello' }\n+\n+    expect(isPendingDraftPersistCurrent(null, entry)).toBe(false)\n+  })\n+\n+  it('rejects when the pending slot now belongs to a different session (the #54527 misroute shape)', () => {\n+    const captured: PendingDraftPersist = { scope: 'session-a', text: 'carefully composed prompt' }\n+    const supersededBy: PendingDraftPersist = { scope: 'session-b', text: 'different draft' }\n+\n+    expect(isPendingDraftPersistCurrent(supersededBy, captured)).toBe(false)\n+  })\n+\n+  it('rejects when the pending slot was replaced by a newer keystroke in the same session', () => {\n+    const captured: PendingDraftPersist = { scope: 'session-a', text: 'first draft' }\n+    const supersededBy: PendingDraftPersist = { scope: 'session-a', text: 'first draft continued' }\n+\n+    expect(isPendingDraftPersistCurrent(supersededBy, captured)).toBe(false)\n+  })\n+\n+  it('rejects when nothing was ever captured', () => {\n+    expect(isPendingDraftPersistCurrent(null, null)).toBe(false)\n+  })\n+})"]},{"file":"apps/desktop/src/app/chat/composer/composer-utils.ts","hunks":["@@ -58,3 +58,28 @@ export interface QueueEditState {\n }\n \n export const cloneAttachments = (attachments: ComposerAttachment[]) => attachments.map(a => ({ ...a }))\n+\n+export interface PendingDraftPersist {\n+  scope: string | null\n+  text: string\n+}\n+\n+/**\n+ * Defense-in-depth for #54527: the debounce timer and the `pagehide` flush\n+ * both write a captured `{ scope, text }` pair some time after it was\n+ * scheduled. Before either commits the write, this checks the pair is still\n+ * the one currently on file — i.e. nothing cleared or replaced it in the\n+ * meantime (a session swap, a newer keystroke). The scope-capture fix\n+ * upstream (`draftScopeRef`) already makes every captured pair correct by\n+ * construction; this guard exists so that if a future change reintroduces a\n+ * stale/live-ref read at one of these call sites, the write is dropped\n+ * instead of silently filing one session's text under another session's key.\n+ */\n+export function isPendingDraftPersistCurrent(\n+  pending: PendingDraftPersist | null,\n+  expected: PendingDraftPersist | null\n+): boolean {\n+  return (\n+    pending !== null && expected !== null && pending.scope === expected.scope && pending.text === expected.text\n+  )\n+}"]},{"file":"apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts","hunks":["@@ -5,7 +5,12 @@ import { SLASH_COMMAND_RE } from '@/lib/chat-runtime'\n import { $composerAttachments, type ComposerAttachment, stashSessionDraft, takeSessionDraft } from '@/store/composer'\n import { isBrowsingHistory } from '@/store/composer-input-history'\n \n-import { cloneAttachments, DRAFT_PERSIST_DEBOUNCE_MS, type QueueEditState } from '../composer-utils'\n+import {\n+  cloneAttachments,\n+  DRAFT_PERSIST_DEBOUNCE_MS,\n+  isPendingDraftPersistCurrent,\n+  type QueueEditState\n+} from '../composer-utils'\n import {\n   type ComposerInsertMode,\n   focusComposerInput,","@@ -77,6 +82,13 @@ export function useComposerDraft({\n   const draftPersistTimerRef = useRef<number | undefined>(undefined)\n   const activeQueueSessionKeyRef = useRef(activeQueueSessionKey)\n   activeQueueSessionKeyRef.current = activeQueueSessionKey\n+  // Owned only by the swap effect below — unlike activeQueueSessionKeyRef this\n+  // does NOT update on every render, so it always reflects the session whose\n+  // text is actually loaded in the editor. Async work (debounce timers,\n+  // pagehide flush) must persist against this, not the render-time ref, or a\n+  // session switch mid-flight files one session's draft under another's key\n+  // (#54527).\n+  const draftScopeRef = useRef(activeQueueSessionKey)\n   const sessionIdRef = useRef(sessionId)\n   sessionIdRef.current = sessionId\n   const queueEditStateRef = useRef<QueueEditState | null>(queueEditRef.current)","@@ -222,10 +234,20 @@ export function useComposerDraft({\n         return\n       }\n \n-      const scope = activeQueueSessionKeyRef.current\n-      pendingDraftPersistRef.current = { scope, text }\n+      const scope = draftScopeRef.current\n+      const entry = { scope, text }\n+      pendingDraftPersistRef.current = entry\n       window.clearTimeout(draftPersistTimerRef.current)\n       draftPersistTimerRef.current = window.setTimeout(() => {\n+        // Integrity guard (defense-in-depth, #54527): only commit if this is\n+        // still the pending write on file. A session swap or a newer\n+        // keystroke clears/replaces it before firing in the normal case; this\n+        // catches any future call site that skips that bookkeeping instead of\n+        // silently filing text under the wrong session.\n+        if (!isPendingDraftPersistCurrent(pendingDraftPersistRef.current, entry)) {\n+          return\n+        }\n+\n         pendingDraftPersistRef.current = null\n         stashAt(scope, text)\n       }, DRAFT_PERSIST_DEBOUNCE_MS)","@@ -285,6 +307,14 @@ export function useComposerDraft({\n   // never clears composer state; this effect alone stashes on leave, restores\n   // on enter. Keyed writes are idempotent, so no skip-sentinel.\n   useEffect(() => {\n+    // A pending debounce timer from the outgoing session is now stale — its\n+    // scope was correct when scheduled, but the authoritative stash below\n+    // (and the cleanup on the way out) already covers that text. Letting it\n+    // fire later would just clobber with an older snapshot.\n+    window.clearTimeout(draftPersistTimerRef.current)\n+    pendingDraftPersistRef.current = null\n+    draftScopeRef.current = activeQueueSessionKey\n+\n     const { attachments, text } = takeSessionDraft(activeQueueSessionKey)\n     loadIntoComposer(text, attachments)\n ","@@ -304,7 +334,7 @@ export function useComposerDraft({\n   // inside the debounce/rAF window would drop trailing keystrokes without this.\n   useEffect(() => {\n     const flushPendingDraftPersist = () => {\n-      const scope = activeQueueSessionKeyRef.current\n+      const scope = draftScopeRef.current\n       const editing = queueEditStateRef.current\n \n       if (editing?.sessionKey === scope || isBrowsingHistory(sessionIdRef.current)) {"]},{"file":"apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts","hunks":["@@ -79,7 +79,11 @@ export function useComposerSubmit({\n \n     const restore = () => {\n       loadIntoComposer(text, submittedAttachments)\n-      stashAt(activeQueueSessionKeyRef.current, text, submittedAttachments)\n+      // Use the scope captured at dispatch, not whatever session is focused\n+      // now — the gateway can reject well after the user has switched away,\n+      // and re-stashing into the currently-focused session would overwrite\n+      // its draft with the rejected text from a different session (#54527).\n+      stashAt(submittedScope, text, submittedAttachments)\n     }\n \n     void Promise.resolve(attachments ? onSubmit(text, { attachments }) : onSubmit(text))"]},{"file":"apps/desktop/src/app/desktop-controller.tsx","hunks":["@@ -812,6 +812,7 @@ export function DesktopController() {\n     branchCurrentSession: branchInNewChat,\n     busyRef,\n     createBackendSessionForSend,\n+    getRouteToken,\n     handleSkinCommand,\n     openMemoryGraph: openStarmap,\n     refreshSessions,"]},{"file":"apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx","hunks":["@@ -51,7 +51,9 @@ interface HarnessHandle {\n }\n \n function Harness({\n+  activeSessionIdRef: activeSessionIdRefProp,\n   busyRef,\n+  getRouteToken,\n   onReady,\n   onSeedState,\n   openMemoryGraph,","@@ -59,11 +61,14 @@ function Harness({\n   requestGateway,\n   resumeStoredSession,\n   seedMessages,\n+  selectedStoredSessionIdRef: selectedStoredSessionIdRefProp,\n   storedSessionId,\n   activeSessionId,\n   createBackendSessionForSend\n }: {\n+  activeSessionIdRef?: MutableRefObject<string | null>\n   busyRef?: MutableRefObject<boolean>\n+  getRouteToken?: () => string\n   onReady: (handle: HarnessHandle) => void\n   onSeedState?: (state: Record<string, unknown>) => void\n   openMemoryGraph?: () => void","@@ -71,15 +76,16 @@ function Harness({\n   requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>\n   resumeStoredSession?: (storedSessionId: string) => Promise<void> | void\n   seedMessages?: unknown[]\n+  selectedStoredSessionIdRef?: MutableRefObject<string | null>\n   storedSessionId?: null | string\n   activeSessionId?: null | string\n   createBackendSessionForSend?: () => Promise<null | string>\n }) {\n-  const activeSessionIdRef: MutableRefObject<string | null> = {\n+  const activeSessionIdRef: MutableRefObject<string | null> = activeSessionIdRefProp ?? {\n     current: activeSessionId === undefined ? RUNTIME_SESSION_ID : activeSessionId\n   }\n \n-  const selectedStoredSessionIdRef: MutableRefObject<string | null> = {\n+  const selectedStoredSessionIdRef: MutableRefObject<string | null> = selectedStoredSessionIdRefProp ?? {\n     current: storedSessionId === undefined ? RUNTIME_SESSION_ID : storedSessionId\n   }\n ","@@ -98,6 +104,7 @@ function Harness({\n     branchCurrentSession: async () => true,\n     busyRef: localBusyRef,\n     createBackendSessionForSend: createBackendSessionForSend ?? (async () => RUNTIME_SESSION_ID),\n+    getRouteToken: getRouteToken ?? (() => 'token'),\n     handleSkinCommand: () => '',\n     openMemoryGraph: openMemoryGraph ?? (() => undefined),\n     refreshSessions,","@@ -1239,7 +1246,7 @@ describe('usePromptActions sleep/wake session recovery', () => {\n \n     expect(ok).toBe(true)\n     expect(calls.map(c => c.method)).toEqual(['prompt.submit', 'session.resume', 'prompt.submit'])\n-    expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID })\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: 'message during starved loop'","@@ -1316,6 +1323,125 @@ describe('usePromptActions sleep/wake session recovery', () => {\n   })\n })\n \n+describe('usePromptActions submit session-context isolation (#54527)', () => {\n+  const STORED_SESSION_A = 'stored-project-a'\n+  const STORED_SESSION_B = 'stored-project-b'\n+  const RUNTIME_SESSION_B = 'rt-session-b-wrong'\n+\n+  afterEach(() => {\n+    cleanup()\n+    vi.restoreAllMocks()\n+  })\n+\n+  it('aborts submit when the user switches sessions during session.resume (no misroute)', async () => {\n+    // Exact #54527 failure: user submits in Session A while its runtime binding\n+    // is gone; before resume returns they switch to Session B. Without a pinned\n+    // context the resumed runtime id belongs to B and A's text lands in the\n+    // wrong chat — permanently lost from A.\n+    let releaseResume: () => void = () => {}\n+    const calls: { method: string; params?: Record<string, unknown> }[] = []\n+\n+    const selectedStoredSessionIdRef: MutableRefObject<string | null> = { current: STORED_SESSION_A }\n+    const activeSessionIdRef: MutableRefObject<string | null> = { current: null }\n+\n+    const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {\n+      calls.push({ method, params })\n+\n+      if (method === 'session.resume') {\n+        await new Promise<void>(resolve => {\n+          releaseResume = resolve\n+        })\n+\n+        // Simulate the user switching to Session B while resume is in flight.\n+        selectedStoredSessionIdRef.current = STORED_SESSION_B\n+        activeSessionIdRef.current = RUNTIME_SESSION_B\n+\n+        return { session_id: RUNTIME_SESSION_B } as never\n+      }\n+\n+      return {} as never\n+    })\n+\n+    let handle: HarnessHandle | null = null\n+    render(\n+      <Harness\n+        activeSessionId={null}\n+        activeSessionIdRef={activeSessionIdRef}\n+        onReady={h => (handle = h)}\n+        refreshSessions={async () => undefined}\n+        requestGateway={requestGateway}\n+        selectedStoredSessionIdRef={selectedStoredSessionIdRef}\n+        storedSessionId={STORED_SESSION_A}\n+      />\n+    )\n+    await waitFor(() => expect(handle).not.toBeNull())\n+\n+    const submitting = handle!.submitText('carefully composed prompt for project A')\n+    await waitFor(() => expect(calls.some(c => c.method === 'session.resume')).toBe(true))\n+    releaseResume()\n+\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+    })\n+  })\n+\n+  it('aborts recovery submit when the user switches sessions during timeout resume', async () => {\n+    const calls: { method: string; params?: Record<string, unknown> }[] = []\n+    let submitAttempts = 0\n+    let releaseResume: () => void = () => {}\n+\n+    const selectedStoredSessionIdRef: MutableRefObject<string | null> = { current: STORED_SESSION_A }\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('request timed out: prompt.submit')\n+        }\n+      }\n+\n+      if (method === 'session.resume') {\n+        await new Promise<void>(resolve => {\n+          releaseResume = resolve\n+        })\n+        selectedStoredSessionIdRef.current = STORED_SESSION_B\n+\n+        return { session_id: RUNTIME_SESSION_B } 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+        selectedStoredSessionIdRef={selectedStoredSessionIdRef}\n+        storedSessionId={STORED_SESSION_A}\n+      />\n+    )\n+    await waitFor(() => expect(handle).not.toBeNull())\n+\n+    const submitting = handle!.submitText('message that must not land in session B')\n+    await waitFor(() => expect(calls.some(c => c.method === 'session.resume')).toBe(true))\n+    releaseResume()\n+\n+    expect(await submitting).toBe(false)\n+    expect(submitAttempts).toBe(1)\n+    expect(calls.filter(c => c.method === 'prompt.submit')).toHaveLength(1)\n+    expect(calls.find(c => c.method === 'session.resume')?.params).toMatchObject({\n+      session_id: STORED_SESSION_A\n+    })\n+  })\n+})\n+\n describe('usePromptActions eager attachment upload (drop-time)', () => {\n   afterEach(() => {\n     cleanup()"]},{"file":"apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts","hunks":["@@ -158,6 +158,7 @@ interface PromptActionsOptions {\n   busyRef: MutableRefObject<boolean>\n   branchCurrentSession: () => Promise<boolean>\n   createBackendSessionForSend: (preview?: string | null) => Promise<string | null>\n+  getRouteToken: () => string\n   handleSkinCommand: (arg: string) => string\n   openMemoryGraph: () => void\n   refreshSessions: () => Promise<void>","@@ -186,6 +187,7 @@ export function usePromptActions({\n   busyRef,\n   branchCurrentSession,\n   createBackendSessionForSend,\n+  getRouteToken,\n   handleSkinCommand,\n   openMemoryGraph,\n   refreshSessions,","@@ -354,6 +356,7 @@ export function usePromptActions({\n     busyRef,\n     copy,\n     createBackendSessionForSend,\n+    getRouteToken,\n     requestGateway,\n     selectedStoredSessionIdRef,\n     syncAttachmentsForSubmit,"]},{"file":"apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts","hunks":["@@ -35,6 +35,7 @@ interface SubmitPromptDeps {\n   busyRef: MutableRefObject<boolean>\n   copy: Translations['desktop']\n   createBackendSessionForSend: (preview?: string | null) => Promise<string | null>\n+  getRouteToken: () => string\n   requestGateway: GatewayRequest\n   selectedStoredSessionIdRef: MutableRefObject<string | null>\n   syncAttachmentsForSubmit: (","@@ -57,6 +58,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {\n     busyRef,\n     copy,\n     createBackendSessionForSend,\n+    getRouteToken,\n     requestGateway,\n     selectedStoredSessionIdRef,\n     syncAttachmentsForSubmit,","@@ -113,9 +115,20 @@ 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).\n+      const startingActiveSessionId = activeSessionIdRef.current\n+      const startingStoredSessionId = selectedStoredSessionIdRef.current\n+      const startingRouteToken = [redacted:secret-assignment]\n+\n+      const sessionContextDrifted = (): boolean =>\n+        selectedStoredSessionIdRef.current !== startingStoredSessionId ||\n+        getRouteToken() !== startingRouteToken\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 = selectedStoredSessionIdRef.current || activeSessionId || '__pending_new__'\n+      const submitLockKey = startingStoredSessionId || startingActiveSessionId || '__pending_new__'\n \n       if (_submitInFlight.has(submitLockKey)) {\n         return false","@@ -166,7 +179,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {\n             // (what made drained-after-interrupt sends go silent).\n             interrupted: false\n           }),\n-          selectedStoredSessionIdRef.current\n+          startingStoredSessionId\n         )\n \n       // After sync rewrites refs, refresh the optimistic message in place so the","@@ -178,7 +191,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {\n             ...state,\n             messages: state.messages.map(message => (message.id === optimisticId ? buildUserMessage() : message))\n           }),\n-          selectedStoredSessionIdRef.current\n+          startingStoredSessionId\n         )\n \n       const dropOptimistic = (sid: null | string) => {","@@ -197,10 +210,17 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {\n             awaitingResponse: false,\n             pendingBranchGroup: null\n           }),\n-          selectedStoredSessionIdRef.current\n+          startingStoredSessionId\n         )\n       }\n \n+      const abortForSessionSwitch = (optimisticSessionId: null | string): false => {\n+        dropOptimistic(optimisticSessionId)\n+        releaseBusy()\n+\n+        return false\n+      }\n+\n       setMutableRef(busyRef, true)\n       setBusy(true)\n       setAwaitingResponse(true)","@@ -214,7 +234,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {\n         setMessages(current => [...current, buildUserMessage()])\n       }\n \n-      if (!sessionId && selectedStoredSessionIdRef.current) {\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","@@ -224,9 +244,13 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {\n         // new-chat draft).\n         try {\n           const resumed = await requestGateway<{ session_id: string }>('session.resume', {\n-            session_id: selectedStoredSessionIdRef.current\n+            session_id: startingStoredSessionId\n           })\n \n+          if (sessionContextDrifted()) {\n+            return abortForSessionSwitch(sessionId)\n+          }\n+\n           if (resumed?.session_id) {\n             sessionId = resumed.session_id\n             activeSessionIdRef.current = sessionId","@@ -237,6 +261,10 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {\n           // the user's message.\n         }\n \n+        if (sessionContextDrifted()) {\n+          return abortForSessionSwitch(sessionId)\n+        }\n+\n         if (sessionId) {\n           seedOptimistic(sessionId)\n         }","@@ -253,6 +281,10 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {\n           return false\n         }\n \n+        if (sessionContextDrifted()) {\n+          return abortForSessionSwitch(sessionId)\n+        }\n+\n         if (!sessionId) {\n           dropOptimistic(null)\n           releaseBusy()","@@ -269,6 +301,10 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {\n           updateComposerAttachments: usingComposerAttachments\n         })\n \n+        if (sessionContextDrifted()) {\n+          return abortForSessionSwitch(sessionId)\n+        }\n+\n         // Rewrite the optimistic message + prompt text with the synced refs so\n         // the gateway receives @file: paths that resolve in its workspace.\n         // (Images keep their inline base64 preview — see optimisticAttachmentRef.)","@@ -288,7 +324,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {\n         } catch (firstErr) {\n           if (\n             (isSessionNotFoundError(firstErr) || isGatewayTimeoutError(firstErr)) &&\n-            selectedStoredSessionIdRef.current\n+            startingStoredSessionId\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","@@ -296,10 +332,14 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {\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: selectedStoredSessionIdRef.current,\n+              session_id: startingStoredSessionId,\n               source: 'desktop'\n             })\n \n+            if (sessionContextDrifted()) {\n+              return abortForSessionSwitch(sessionId)\n+            }\n+\n             const recoveredId = resumed?.session_id\n \n             if (recoveredId) {","@@ -375,6 +415,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {\n       busyRef,\n       copy,\n       createBackendSessionForSend,\n+      getRouteToken,\n       requestGateway,\n       selectedStoredSessionIdRef,\n       syncAttachmentsForSubmit,\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}