From e00874ff1b94f621320bf923b60b0a543e5a9083 Mon Sep 17 00:00:00 2001 From: Ben Hermes Date: Thu, 16 Jul 2026 16:08:01 +0200 Subject: [PATCH] fix(desktop): preserve fresh-chat file submits --- .../hooks/use-prompt-actions/index.test.tsx | 72 +++++++++++++++++++ .../hooks/use-prompt-actions/submit.ts | 23 ++++-- 2 files changed, 88 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx index e35589010bd1..0570ff9e272b 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx @@ -1558,6 +1558,78 @@ describe('usePromptActions submit session-context isolation (#54527)', () => { }) }) + it('submits a non-default-profile file after its own delayed route commit', async () => { + $connection.set({ mode: 'remote' } as never) + Object.defineProperty(window, 'hermesDesktop', { + configurable: true, + value: { readFileDataUrl: vi.fn(async () => 'data:application/pdf;base64,JVBERi0=') } + }) + + const calls: { method: string; params?: Record }[] = [] + const selectedStoredSessionIdRef: MutableRefObject = { current: null } + const activeSessionIdRef: MutableRefObject = { current: null } + let routeToken = '/?profile=recruiter' + + const requestGateway = vi.fn(async (method: string, params?: Record) => { + calls.push({ method, params }) + + if (method === 'file.attach') { + // A background profile's route commits after its session + gateway are + // ready. Upload latency makes that create-owned navigation observable + // here; it is not the user switching to another chat. + routeToken = '/stored-recruiter?profile=recruiter' + + return { + attached: true, + ref_text: '@file:.hermes/desktop-attachments/candidate.pdf', + uploaded: true + } as never + } + + return {} as never + }) + + const createBackendSessionForSend = vi.fn(async () => { + activeSessionIdRef.current = 'rt-recruiter' + selectedStoredSessionIdRef.current = 'stored-recruiter' + + return 'rt-recruiter' + }) + + let handle: HarnessHandle | null = null + render( + routeToken} + onReady={h => (handle = h)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + selectedStoredSessionIdRef={selectedStoredSessionIdRef} + storedSessionId={null} + /> + ) + await waitFor(() => expect(handle).not.toBeNull()) + + expect( + await handle!.submitText('', { + attachments: [ + { + id: 'file:candidate.pdf', + kind: 'file', + label: 'candidate.pdf', + path: 'C:\\Users\\Admin\\Downloads\\candidate.pdf' + } + ] + }) + ).toBe(true) + expect(calls.find(call => call.method === 'prompt.submit')?.params).toMatchObject({ + session_id: 'rt-recruiter', + text: '@file:.hermes/desktop-attachments/candidate.pdf' + }) + }) + it('aborts when the user switches sessions during the tail of a successful create', async () => { // createBackendSessionForSend awaits once more (armed-YOLO apply) AFTER // committing the refs and returning a real id, so a switch in that window diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts index c53c7fe23095..cc9a4577cbb1 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts @@ -139,15 +139,22 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { // 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 — - // not const — because a new-chat submit legitimately re-homes to the - // session it creates (see the re-pin after createBackendSessionForSend). + // redirect the user's text into a different chat (#54527). const startingActiveSessionId = activeSessionIdRef.current let startingStoredSessionId = selectedStoredSessionIdRef.current - let startingRouteToken = getRouteToken() + const startingRouteToken = getRouteToken() + + // Before a runtime session exists, the route is part of the submit's + // identity. Once this submit creates and pins a real session, its own + // navigation may commit later (notably after a background-profile swap + // while file.attach awaits I/O). From then on the stable stored/runtime + // ids are authoritative; treating the delayed route commit as drift + // aborts the first file send and strands an empty session. + let guardRouteToken = true const sessionContextDrifted = (): boolean => - selectedStoredSessionIdRef.current !== startingStoredSessionId || getRouteToken() !== startingRouteToken + selectedStoredSessionIdRef.current !== startingStoredSessionId || + (guardRouteToken && getRouteToken() !== startingRouteToken) // 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. @@ -332,9 +339,11 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { } // Re-pin the baseline to the created chat for the rest of the - // pipeline; the closures (seedOptimistic et al) see the new value. + // pipeline; the closures (seedOptimistic et al) see the new value. The + // create's own route navigation can commit asynchronously, so stable + // session ids — not the still-settling route — guard this phase. startingStoredSessionId = selectedStoredSessionIdRef.current - startingRouteToken = getRouteToken() + guardRouteToken = false seedOptimistic(sessionId) }