diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts index 35bf230381a26..7ae6943cbc974 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts @@ -47,6 +47,8 @@ import { $currentCwd, $currentModel, $currentProvider, + $selectedStoredSessionId, + $sessions, sessionMatchesStoredId, setCurrentBranch, setCurrentCwd, @@ -58,6 +60,7 @@ import { setMessages, setSessions, setTurnStartedAt, + setWorkspaceCwdOwner, setYoloActive } from '@/store/session' import { dropSessionState } from '@/store/session-states' @@ -81,6 +84,42 @@ function firstBillingLine(text: string): string { return (text || '').split('\n')[0]?.trim() ?? '' } +/** + * Whether a `session.info` payload's `stored_session_id` may be treated as the + * selected conversation's, so its cwd can be claimed for it (#71254). + * + * Absent is not the same as different: the backend omits the id on a + * not-yet-built (`lazy`) session, and refusing there would leave the workspace + * marked un-owned for the rest of the conversation. Matching goes through the + * lineage (`sessionMatchesStoredId`) so a compression-rotated tip and the root + * a pinned-row selection may hold still read as one conversation. + */ +function sessionInfoDescribesSelectedSession(storedSessionId: string | undefined): boolean { + const infoStoredSessionId = storedSessionId?.trim() || null + const selected = $selectedStoredSessionId.get() ?? null + + if (!infoStoredSessionId) { + return true + } + + // A named session cannot describe a fresh draft. Treating a null selection as + // a wildcard let a background tile's `session.info` rehome the draft to the + // tile's workspace. + if (!selected) { + return false + } + + if (infoStoredSessionId === selected) { + return true + } + + // Either id may be the live tip or the lineage root, so ask whether ONE row + // answers to both rather than assuming which side rotated. + return $sessions + .get() + .some(session => sessionMatchesStoredId(session, infoStoredSessionId) && sessionMatchesStoredId(session, selected)) +} + /** * A turn failed on a billing wall (out of credits / payment required). The * gateway forwards the structured descriptor built by `agent/billing_links.py`; @@ -396,7 +435,7 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { // Active-session model/provider still flows through the session state // cache via updateSessionState → syncRuntimeMetadataToView below. - if (typeof payload?.cwd === 'string') { + if (typeof payload?.cwd === 'string' && sessionInfoDescribesSelectedSession(payload.stored_session_id)) { // The active session's agent can relocate itself (new repo/worktree // via the terminal). When the SAME active session's cwd actually // moves, follow it — refresh the project tree + scope so the sidebar @@ -408,6 +447,14 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { lastCwdInfoSessionRef.current = sessionId setCurrentCwd(payload.cwd) + // The backend just confirmed the selected conversation's real + // workspace, so it owns the path we wrote. Without the claim the + // marker keeps naming whoever held it before — including the + // released state a detached resume leaves behind — and the primary + // workspace-derived surfaces stay hidden against a folder the + // backend has confirmed (#71254). + setWorkspaceCwdOwner($selectedStoredSessionId.get()) + if (cwdMoved && sameSession) { void followActiveSessionCwd(payload.cwd) }