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) } 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 543074f0265f1..8bb8029caac31 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 @@ -54,6 +54,7 @@ import { setSessions, setSessionStartedAt, setTurnStartedAt, + setWorkspaceCwdOwner, setYoloActive } from '@/store/session' import { @@ -350,6 +351,11 @@ export function useSessionActions({ setCurrentCwd(workspaceTarget) } + // A fresh draft resolves its own workspace right here, so it owns it. The + // selected stored id is null for a draft, and so is the owner — they match, + // which keeps workspace surfaces live on a new chat instead of treating the + // draft as an un-re-homed switch (#71254). + setWorkspaceCwdOwner(null) setCurrentBranch('') // Never clear the composer here — ChatBar's per-thread draft swap owns it. setFreshDraftReady(true) @@ -514,13 +520,23 @@ export function useSessionActions({ upsertOptimisticSession(created, stored, null, null) } - // A tile lives in its OWN worktree — it must not publish its cwd/branch - // into the composer atoms the main pane renders from. + // A tile lives in its OWN worktree, so it must not run the full + // foreground composer publish. A CENTER tile is the focused surface, + // though, and the Files pane still keys off the global `$currentCwd` — + // so the right rail kept showing the previous session's tree when a + // Project "+" created a session while the main chat was occupied + // (#76696). Split/side tiles deliberately stay isolated. const runtimeInfo = applyRuntimeInfo(created.info, { foreground: false }) updateSessionState(created.session_id, state => (runtimeInfo ? { ...state, ...runtimeInfo } : state), stored) openSessionTile(stored, dir) patchSessionTile(stored, { runtimeId: created.session_id }) + + if (dir === 'center' && runtimeInfo?.cwd) { + setCurrentCwd(runtimeInfo.cwd) + setWorkspaceCwdOwner(stored) + } + revealTreePane(`session-tile:${stored}`) if (listed) { @@ -701,6 +717,12 @@ export function useSessionActions({ activeSessionIdRef.current = cachedRuntimeId syncSessionStateToView(cachedRuntimeId, cachedViewState) setCurrentCwd(cachedViewState.cwd) + // The warm cache IS this conversation's own workspace truth, so the + // switch is already re-homed here. This claim cannot wait for + // `session.activate`: its missing-RPC compat branch returns before + // `applyRuntimeInfo` runs, which would leave the workspace marked + // un-owned for the life of the session (#71254). + setWorkspaceCwdOwner(storedSessionId) setCurrentBranch(cachedViewState.branch) setSessionStartedAt(Date.now()) @@ -844,7 +866,7 @@ export function useSessionActions({ const stored = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId)) ?? storedForProfile - applyStoredSessionPreviewRuntimeInfo(stored) + applyStoredSessionPreviewRuntimeInfo(stored, storedSessionId) if (stored) { applyStoredUsage(stored) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts b/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts index ed61eda70de0c..741267548b2b6 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts @@ -5,12 +5,20 @@ import { type ChatMessage, type ChatMessagePart, chatMessageText } from '@/lib/c import { $approvalModes, approvalModeForProfile } from '@/store/approval-mode' import { $desktopOnboarding } from '@/store/onboarding' import { $activeGatewayProfile } from '@/store/profile' -import { $currentBranch, $currentCwd, setCurrentBranch, setCurrentCwd } from '@/store/session' +import { + $currentBranch, + $currentCwd, + setCurrentBranch, + setCurrentCwd, + setSelectedStoredSessionId, + workspaceCwdBelongsToSelectedSession +} from '@/store/session' import type { SessionInfo } from '@/types/hermes' import { appendLiveSessionProjection, applyRuntimeInfo, + applyStoredSessionPreviewRuntimeInfo, chatMessageArraysEquivalent, chatMessagesEquivalent, chatPartsEquivalent, @@ -110,6 +118,90 @@ describe('applyRuntimeInfo foreground scoping', () => { // ...while the caller still gets everything it needs for its own session. expect(patch).toMatchObject({ branch: 'bb/tile', cwd: '/other-worktree' }) }) + + // #71254: `if (info.cwd)` treated '' as "no opinion", so a detached session + // never released the previous project and the Files pane stayed on it forever. + it('treats an empty runtime cwd as authoritative and releases ownership', () => { + setSelectedStoredSessionId('session-detached') + const patch = applyRuntimeInfo({ cwd: '' }) + + expect(patch).toMatchObject({ cwd: '' }) + expect(workspaceCwdBelongsToSelectedSession()).toBe(false) + }) + + // The release must NOT blank the path: setCurrentCwd persists, so writing '' + // would also wipe the remembered workspace that seeds $currentCwd on boot. + it('leaves the path in place when releasing, so panes do not collapse', () => { + setSelectedStoredSessionId('session-detached') + applyRuntimeInfo({ cwd: '' }) + + expect($currentCwd.get()).toBe('/main-repo') + }) + + it('claims ownership for the selected session when a real cwd arrives', () => { + setSelectedStoredSessionId('session-b') + applyRuntimeInfo({ cwd: '/project-b' }) + + expect($currentCwd.get()).toBe('/project-b') + expect(workspaceCwdBelongsToSelectedSession()).toBe(true) + }) +}) + +describe('applyStoredSessionPreviewRuntimeInfo workspace paint', () => { + beforeEach(() => { + setCurrentCwd('/previous-project') + setSelectedStoredSessionId(null) + }) + + afterEach(() => { + setCurrentCwd('') + setSelectedStoredSessionId(null) + }) + + // The core of the report: cold resume paints before session.resume returns. + it('rebinds the workspace from the selected session row before resume settles', () => { + applyStoredSessionPreviewRuntimeInfo({ cwd: '/next-project', model: 'gpt' }, 'session-next') + setSelectedStoredSessionId('session-next') + + expect($currentCwd.get()).toBe('/next-project') + expect(workspaceCwdBelongsToSelectedSession()).toBe(true) + }) + + it('releases ownership when the selected session row reports no workspace', () => { + applyStoredSessionPreviewRuntimeInfo({ cwd: '', model: 'gpt' }, 'session-detached') + setSelectedStoredSessionId('session-detached') + + expect(workspaceCwdBelongsToSelectedSession()).toBe(false) + }) + + // Regression guard: a session outside the loaded sidebar page has no row at + // all. Blanking $currentCwd here would drop file-tree state on every switch + // into older history, so the path must survive and ownership carry the signal. + it('does not blank the pane when the session row is not loaded', () => { + applyStoredSessionPreviewRuntimeInfo(undefined, 'session-off-page') + setSelectedStoredSessionId('session-off-page') + + expect($currentCwd.get()).toBe('/previous-project') + expect(workspaceCwdBelongsToSelectedSession()).toBe(false) + }) + + // Regression guard: git_repo_root is documented null for non-git workspaces + // and not-yet-backfilled rows, so it must never stand in for a real cwd — + // doing so reads as "no workspace" and blanks a pane that was correct. + it('uses the row cwd for a non-git workspace with no repo root', () => { + applyStoredSessionPreviewRuntimeInfo({ cwd: '/plain/folder', git_repo_root: null, model: 'gpt' } as never, 'session-nongit') + setSelectedStoredSessionId('session-nongit') + + expect($currentCwd.get()).toBe('/plain/folder') + expect(workspaceCwdBelongsToSelectedSession()).toBe(true) + }) + + it('clears the branch label so the previous project does not leak across a switch', () => { + setCurrentBranch('bb/previous') + applyStoredSessionPreviewRuntimeInfo({ cwd: '/next-project', model: 'gpt' }, 'session-next') + + expect($currentBranch.get()).toBe('') + }) }) describe('isSessionGoneError', () => { diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts index b6bc4ae6d5b2f..4648ca5ff1155 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts @@ -9,6 +9,8 @@ import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/p import { $currentCwd, $sessions, + commitWorkspaceCwdForSelectedSession, + releaseWorkspaceCwdOwner, sessionMatchesStoredId, setCurrentBranch, setCurrentCwd, @@ -20,6 +22,7 @@ import { setCurrentServiceTier, setCurrentUsage, setSessions, + setWorkspaceCwdOwner, setYoloActive } from '@/store/session' @@ -959,7 +962,17 @@ function publishRuntimeToComposer(state: SessionRuntimeStatePatch): void { } if (state.cwd !== undefined) { - setCurrentCwd(state.cwd) + if (state.cwd) { + // The runtime named a real folder for the session in the main pane, so + // that conversation owns the path. + commitWorkspaceCwdForSelectedSession(state.cwd) + } else { + // A detached session: the path on screen is provably still the previous + // conversation's. Release rather than write `''` — `setCurrentCwd` + // persists, so blanking here would also wipe the remembered workspace + // that seeds `$currentCwd` on next boot. + releaseWorkspaceCwdOwner() + } } if (state.branch !== undefined) { @@ -1017,7 +1030,12 @@ export function applyRuntimeInfo( sessionState.provider = info.provider } - if (info.cwd) { + // Empty string is authoritative, not "no opinion": a detached/bare session + // reports `cwd: ''`, and the truthy-only test left `$currentCwd` — and so the + // Files pane — pinned to the PREVIOUS project for the rest of the session + // (#71254). Empty is routed through ownership release below rather than + // persisted, so the pane hides a path it no longer owns instead of blanking. + if (typeof info.cwd === 'string') { sessionState.cwd = info.cwd } @@ -1056,7 +1074,10 @@ export function applyRuntimeInfo( return sessionState } -export function applyStoredSessionPreviewRuntimeInfo(stored: { model?: null | string } | undefined) { +export function applyStoredSessionPreviewRuntimeInfo( + stored: { cwd?: null | string; model?: null | string } | undefined, + storedSessionId: null | string +) { setCurrentModel(stored?.model || '') setCurrentProvider('') setCurrentReasoningEffort('') @@ -1064,6 +1085,35 @@ export function applyStoredSessionPreviewRuntimeInfo(stored: { model?: null | st setCurrentFastMode(false) setYoloActive(false) setCurrentPersonality('') + + // Cold resume paints the transcript before `session.resume` returns, so + // without this the Files pane shows the PREVIOUS project's tree for the whole + // round-trip (#71254 / #76696). The sidebar row already knows this + // conversation's workspace — `cwd` is part of the compact row projection — so + // mirror it on the same tick the selection changes. + // + // Only `cwd` is consulted. `git_repo_root` is documented as null for non-git + // workspaces and not-yet-backfilled history rows, so falling back to it would + // read as "no workspace" for those sessions and blank a pane that was correct. + const storedCwd = stored?.cwd?.trim() || '' + + if (storedCwd) { + setCurrentCwd(storedCwd) + setWorkspaceCwdOwner(storedSessionId) + } else { + // Either a genuinely detached session, or a row outside the loaded sidebar + // page (`stored` is undefined) — neither says anything about the workspace, + // while `$currentCwd` still holds the previous conversation's folder. + // Release so workspace-derived surfaces stop trusting it; `applyRuntimeInfo` + // publishes the truth a moment later. The path is deliberately left in place + // — clearing it collapses the workspace/review panes and drops file-tree + // state on every switch. + releaseWorkspaceCwdOwner() + } + + // Same window, same reasoning: the branch is derived from the workspace, so + // carrying the previous conversation's label across a switch is never right. + setCurrentBranch('') } // A "session genuinely doesn't exist" failure (deleted, or an id from a wiped / diff --git a/apps/desktop/src/store/coding-status.ts b/apps/desktop/src/store/coding-status.ts index fa5245302341e..9c725f963a94a 100644 --- a/apps/desktop/src/store/coding-status.ts +++ b/apps/desktop/src/store/coding-status.ts @@ -11,7 +11,13 @@ import { ALL_PROJECTS, projectRootCwd } from './projects' -import { $busy, $currentCwd, $selectedStoredSessionId } from './session' +import { + $busy, + $currentCwd, + $selectedStoredSessionId, + $workspaceCwdOwner, + workspaceCwdBelongsToSelectedSession +} from './session' import { $focusedRuntimeId, $sessionStates } from './session-states' import { $workspaceChangeTick } from './workspace-events' @@ -40,10 +46,13 @@ export const $repoWorktreesByCwd = atom>({}) // The PRIMARY (main pane) view — the active session's slice of the per-cwd // truth. Existing consumers (keybind gate, base-branch picker, file tree) keep // reading these; only surfaces that can live in ANOTHER worktree (tile rails) -// need the per-cwd accessors below. +// need the per-cwd accessors below. During a conversation switch `$currentCwd` +// can still name the previous conversation's path, so ownership hides only this +// primary slice; the per-cwd cache stays available to any tile that genuinely +// owns that worktree (#71254). export const $repoStatus: ReadableAtom = computed( - [$repoStatusByCwd, $currentCwd], - (byCwd, cwd) => byCwd[normalizeCwd(cwd) ?? ''] ?? null + [$repoStatusByCwd, $currentCwd, $selectedStoredSessionId, $workspaceCwdOwner], + (byCwd, cwd) => (workspaceCwdBelongsToSelectedSession() ? (byCwd[normalizeCwd(cwd) ?? ''] ?? null) : null) ) export const $repoStatusLoading = atom(false) @@ -51,8 +60,9 @@ export const $repoStatusLoading = atom(false) // The repo's real worktrees (for the coding rail's "jump to a worktree" menu). // Refreshed on the same edges as the status probe; empty off a repo. export const $repoWorktrees: ReadableAtom = computed( - [$repoWorktreesByCwd, $currentCwd], - (byCwd, cwd) => byCwd[normalizeCwd(cwd) ?? ''] ?? EMPTY_WORKTREES + [$repoWorktreesByCwd, $currentCwd, $selectedStoredSessionId, $workspaceCwdOwner], + (byCwd, cwd) => + workspaceCwdBelongsToSelectedSession() ? (byCwd[normalizeCwd(cwd) ?? ''] ?? EMPTY_WORKTREES) : EMPTY_WORKTREES ) // Reference-stable per-cwd slices, so any number of rails can each subscribe diff --git a/apps/desktop/src/store/session.ts b/apps/desktop/src/store/session.ts index b21fa6875e7a4..24a0b8beff619 100644 --- a/apps/desktop/src/store/session.ts +++ b/apps/desktop/src/store/session.ts @@ -505,6 +505,28 @@ export const $currentFastMode = atom(storedBoolean(COMPOSER_FAST_KEY, false)) // reflection of the truth the gateway reports rather than its own store. export const $yoloActive = atom(false) export const $currentCwd = atom(getRememberedWorkspaceCwd()) + +// Which conversation the live `$currentCwd` is known to describe. Three +// inhabitants, and the difference between the last two is load-bearing: +// a stored-session id (that conversation owns the path), `null` (the fresh-draft +// state, which MATCHES a null selection and therefore reads as OWNED — a draft's +// workspace is immediately usable), and the released marker +// `WORKSPACE_CWD_UNOWNED` below, which matches no selection and so reads as +// owned by nobody. `null` cannot double as the release value precisely because +// it matches: releasing to `null` while a draft is selected would hand the +// leftover path to the draft as its own workspace. +// +// A conversation switch publishes the new stored id immediately, but the new +// workspace only arrives when the resume settles, so for that whole window +// `$currentCwd` still holds the PREVIOUS conversation's folder. Without a way to +// say "this path is not this conversation's yet", workspace-derived surfaces +// treat the leftover path as authoritative and show the old repo's cached Git +// facts under the newly selected chat (#71254). +// +// Ownership, not emptiness, is what makes the switch atomic: clearing the path +// would collapse the workspace panes and drop file-tree state on every switch, +// so the path stays put and is simply marked as not-yet-owned. +export const $workspaceCwdOwner = atom(null) export const $newChatWorkspaceTarget = atom(undefined) export const $newChatWorkspaceTargetGeneration = atom(0) export const $currentBranch = atom('') @@ -628,6 +650,49 @@ export const setCurrentCwd = (next: Updater) => { export const setCurrentCwdTransient = (next: Updater) => updateAtom($currentCwd, next) +// Released-ownership marker: the live path belongs to no conversation. `null` +// cannot serve as the release value because it MATCHES a fresh draft (whose +// selected id is also null), which would declare a leftover path to be the +// draft's own workspace — #71254, one selection over. Kept here beside the atom +// and the comparison so a release site cannot reinvent a subtly different value. +const WORKSPACE_CWD_UNOWNED = 'desktop:workspace-cwd-unowned' + +/** Mark the live workspace as belonging to `storedSessionId`. + * + * Call this wherever a cwd is established for a conversation (resume settling, + * a warm switch, an explicit folder pick). Until it is called for the newly + * selected conversation, primary workspace-derived selectors hide the previous + * conversation's cached facts rather than publishing them (#71254). + */ +export const setWorkspaceCwdOwner = (storedSessionId: null | string) => updateAtom($workspaceCwdOwner, storedSessionId) + +/** Declare that no conversation owns the live workspace path. + * + * For a conversation whose workspace is not known yet: the path on screen is + * provably still the previous conversation's, so workspace-derived surfaces must + * hide it rather than adopt it. The path itself is deliberately left alone — + * clearing it would collapse the workspace/review panes and drop file-tree + * state on every switch. + */ +export const releaseWorkspaceCwdOwner = () => updateAtom($workspaceCwdOwner, WORKSPACE_CWD_UNOWNED) + +/** Commit `cwd` as the workspace of the conversation the user is looking at. + * + * The single primitive for "this path IS the selected conversation's" — a folder + * pick, a project entry, the agent relocating itself. Prefer it over a bare + * `setCurrentCwd`, which moves the path while leaving ownership naming whatever + * held it before; workspace-derived slices then stay hidden even though the + * path is correct (#71254). + */ +export const commitWorkspaceCwdForSelectedSession = (cwd: string) => { + setCurrentCwd(cwd) + setWorkspaceCwdOwner($selectedStoredSessionId.get()) +} + +/** True when `$currentCwd` is known to describe the selected conversation. */ +export const workspaceCwdBelongsToSelectedSession = (): boolean => + ($workspaceCwdOwner.get() ?? null) === ($selectedStoredSessionId.get() ?? null) + export const setNewChatWorkspaceTarget = (next: NewChatWorkspaceTarget): number => { const generation = $newChatWorkspaceTargetGeneration.get() + 1 $newChatWorkspaceTarget.set(next) diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index c8e6679aa509d..cc7b94b61c8c5 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -16375,3 +16375,38 @@ def test_prompt_submit_releases_old_history_before_heap_trim(monkeypatch): assert cleanup_order == ["trim", "reset_home"] finally: server._sessions.pop("sid_trim", None) + + +def test_fallback_session_info_reports_session_cwd_not_launch_dir(monkeypatch): + """A lazily-resumed session must report ITS workspace, not the gateway's. + + ``_fallback_session_info`` used ``_default_session_cwd()`` — the directory the + gateway process happened to start in — so the desktop Files pane painted the + wrong project for any session resumed without a built agent (#71254). + """ + monkeypatch.setattr(server, "_default_session_cwd", lambda: "/gateway/launch/dir") + monkeypatch.setattr(server, "_git_branch_for_cwd", lambda cwd: "bb/feature") + monkeypatch.setattr(server, "_project_info_for_cwd", lambda cwd: None) + monkeypatch.setattr(server, "_resolve_model", lambda: "test-model") + + info = server._fallback_session_info({"cwd": "/projects/session-own-repo"}) + + assert info["cwd"] == "/projects/session-own-repo" + assert info["branch"] == "bb/feature" + + +def test_fallback_session_info_always_emits_branch(monkeypatch): + """``branch`` is always present so a client can CLEAR a stale label. + + Omitting the key left the desktop showing the previous conversation's branch + after switching into a non-git session. + """ + monkeypatch.setattr(server, "_default_session_cwd", lambda: "/gateway/launch/dir") + monkeypatch.setattr(server, "_git_branch_for_cwd", lambda cwd: "") + monkeypatch.setattr(server, "_project_info_for_cwd", lambda cwd: None) + monkeypatch.setattr(server, "_resolve_model", lambda: "test-model") + + info = server._fallback_session_info({"cwd": "/plain/folder"}) + + assert "branch" in info + assert info["branch"] == "" diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 462b89ff249c2..a146c3ebcb765 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -7968,9 +7968,17 @@ def _fallback_session_info(session: dict) -> dict: agent = session.get("agent") if agent is not None: return _session_info(agent) - cwd = _default_session_cwd() + # The SESSION's own workspace, not the gateway's launch directory. Reporting + # `_default_session_cwd()` here told a lazily-resumed session's client that + # its workspace was wherever the gateway process happened to start, so the + # desktop Files pane painted the wrong project even after the renderer + # rebound correctly (#71254). `branch` is always emitted ("" outside a git + # repo) so a client can clear a stale label instead of retaining it — the + # same contract `_lazy_session_info` above already follows. + cwd = _session_cwd(session) return { "cwd": cwd, + "branch": _git_branch_for_cwd(cwd), "project": _project_info_for_cwd(cwd), "lazy": True, "model": _resolve_model(),