diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/index.ts b/apps/desktop/src/app/session/hooks/use-message-stream/index.ts index a73852f6e923f..979bbc1e2f5e2 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/index.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/index.ts @@ -656,12 +656,21 @@ export function useMessageStream({ const hasInlineError = nextMessages.some(m => m.role === 'assistant' && m.error && !m.hidden) const lastVisible = [...nextMessages].reverse().find(m => !m.hidden) const unresolvedUserTail = lastVisible?.role === 'user' + // Having streamed the reply normally means this window owns the whole + // turn and re-reading stored history would be wasted work. That only + // holds for a turn it STARTED: an adopted one (resumed onto a session + // already running elsewhere) arrives reply-first, with no prompt row, + // so it has to hydrate or the user's own message never shows up. shouldHydrate = - !completionError && !hasInlineError && !unresolvedUserTail && (!state.sawAssistantPayload || !finalText) + !completionError && + !hasInlineError && + !unresolvedUserTail && + (state.adoptedRunningTurn || !state.sawAssistantPayload || !finalText) return { ...state, messages: nextMessages, + adoptedRunningTurn: false, streamId: null, pendingBranchGroup: null, awaitingResponse: false, diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx index 1bdb521efabcb..cb3f6df916d2b 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx @@ -937,6 +937,7 @@ describe('resumeSession failure recovery', () => { interimBoundaryPending: false, interrupted: false, messages: [], + adoptedRunningTurn: false, model: '', needsInput: false, pendingBranchGroup: null, diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts index 0f13a375c0837..b09db251ccae7 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -735,8 +735,17 @@ export function useSessionActions({ } else { const runtimeInfo = applyRuntimeInfo(activated.info) - let activatedMessages = - activated.messages.length || activated.inflight || activated.queued + // `omit_messages` means the response carries NO transcript, not + // an empty one — the cache is the base and the live projection is + // a tail to graft onto it. Reconciling against the empty list + // instead rebuilds the thread out of the projection alone, so + // activating a session that is mid-turn somewhere else (leaving + // HUD mode is exactly that) collapsed the whole conversation down + // to the in-flight prompt until the turn finished and the + // post-turn hydrate restored it. + let activatedMessages = activated.messages_omitted + ? appendLiveSessionProjection(cachedViewState.messages, activated) + : activated.messages.length || activated.inflight || activated.queued ? reconcileAuthoritativeMessages(activated.messages, cachedViewState.messages, activated) : cachedViewState.messages @@ -773,7 +782,11 @@ export function useSessionActions({ ...(runtimeInfo ?? {}), messages: activatedMessages, busy: running, - awaitingResponse: running + awaitingResponse: running, + // Adopting someone else's turn: we'll stream its reply + // without ever having received its prompt, so the settle + // path must not take the "I saw it all" shortcut. + adoptedRunningTurn: state.adoptedRunningTurn || running }), storedSessionId ) @@ -933,7 +946,15 @@ export function useSessionActions({ ? preserveLocalPendingTurnMessages(currentMessages, resumeStartMessages) : currentMessages - const resumedMessages = reconcileAuthoritativeMessages(resumed.messages, previousMessages, resumed) + // Omitted, not empty — same trap as the activate path above. + // The REST prefetch IS the transcript here; the resume payload + // only contributes the live tail, so graft rather than rebuild. + // (Without a usable prefetch there is nothing better to stand + // on, so the projection alone remains the degraded fallback.) + const resumedMessages = + resumed.messages_omitted && prefetchApplied && prefetchMatchesResumedSession + ? appendLiveSessionProjection(localSnapshot, resumed) + : reconcileAuthoritativeMessages(resumed.messages, previousMessages, resumed) return chatMessageArraysEquivalent(currentMessages, resumedMessages) ? currentMessages : resumedMessages })() @@ -990,6 +1011,7 @@ export function useSessionActions({ messages: messagesForView, busy: resumedRunning, awaitingResponse: resumedRunning && !recoveredInFlightTail, + adoptedRunningTurn: state.adoptedRunningTurn || resumedRunning, ...(inFlightRecovery.applied ? { sawAssistantPayload: true, diff --git a/apps/desktop/src/app/types.ts b/apps/desktop/src/app/types.ts index f7452607f7c83..743c2b95be019 100644 --- a/apps/desktop/src/app/types.ts +++ b/apps/desktop/src/app/types.ts @@ -187,6 +187,12 @@ export interface ClientSessionState { awaitingResponse: boolean streamId: string | null sawAssistantPayload: boolean + /** This window picked up a turn it did not start — it resumed onto a session + * that was already running somewhere else (leaving HUD mode, opening a + * pop-out mid-turn). It therefore holds the reply but never received the + * prompt, so the usual "I streamed it, my transcript is complete" shortcut + * is false and the turn must hydrate from stored history when it settles. */ + adoptedRunningTurn: boolean pendingBranchGroup: string | null interrupted: boolean /** True after message.interim finalized a bubble in the still-running turn. */ diff --git a/apps/desktop/src/lib/chat-runtime.ts b/apps/desktop/src/lib/chat-runtime.ts index 99585a3c7642f..c581daaea9c7e 100644 --- a/apps/desktop/src/lib/chat-runtime.ts +++ b/apps/desktop/src/lib/chat-runtime.ts @@ -52,6 +52,7 @@ export function createClientSessionState( awaitingResponse: false, streamId: null, sawAssistantPayload: false, + adoptedRunningTurn: false, pendingBranchGroup: null, interrupted: false, interimBoundaryPending: false,