From ae6eb578bbef6bb3204686e655cd2e596a2d8088 Mon Sep 17 00:00:00 2001 From: bb Date: Fri, 7 Aug 2026 13:06:35 -0500 Subject: [PATCH 1/5] fix(desktop): add workspace-cwd ownership so switches are atomic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `$currentCwd` is a global singleton, but a conversation switch publishes the new stored session id immediately while the new workspace only arrives when `session.resume` settles. For that whole window the path still names the PREVIOUS conversation, and every workspace-derived surface treats it as authoritative. Track WHICH conversation the live path describes instead of trying to keep the path itself in lockstep. Ownership — not emptiness — is what makes the switch atomic: clearing the path would collapse the workspace/review panes and drop file-tree state on every switch, so the path stays put and is simply marked not-yet-owned. The released marker is deliberately not `null`: `null` MATCHES a fresh draft (whose selected id is also null), so releasing to it would hand a leftover path to the draft as its own workspace. Co-authored-by: ZHJay --- apps/desktop/src/store/coding-status.ts | 22 ++++++--- apps/desktop/src/store/session.ts | 65 +++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 6 deletions(-) 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) From 416e025c46c6d778316f411616a2f76db01563b6 Mon Sep 17 00:00:00 2001 From: bb Date: Fri, 7 Aug 2026 13:06:47 -0500 Subject: [PATCH 2/5] fix(desktop): rebind the Files pane cwd when switching sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects left the Files pane showing the previous project's tree: - `applyStoredSessionPreviewRuntimeInfo` reset every composer atom EXCEPT cwd, and runs before the `session.resume` RPC. The sidebar row already knows the conversation's workspace (`cwd` is in the compact row projection), so mirror it on the same tick the selection changes. - `if (info.cwd)` was truthy-only, so a detached session reporting `cwd: ''` never cleared and the pane stayed pinned to the last project for the rest of the session — the "not always" in the report. Empty is now authoritative. Empty routes through ownership release rather than a persisted `''`: `setCurrentCwd` writes to localStorage and seeds `$currentCwd` on next boot, so blanking would also wipe the remembered workspace. Only `cwd` is consulted, never `git_repo_root` — the latter is documented null for non-git workspaces and not-yet-backfilled rows, so falling back to it reads as "no workspace" and blanks a pane that was correct. A session outside the loaded sidebar page (no row at all) releases ownership instead of blanking, for the same reason. Also claims ownership on the warm-cache path (its missing-RPC compat branch returns before `applyRuntimeInfo`) and for a center tile, whose Project "+" create left the right rail on the previous session's folder. Co-authored-by: xxxigm Co-authored-by: worlldz Co-authored-by: ZHJay --- .../hooks/use-session-actions/index.ts | 28 +++++++++- .../hooks/use-session-actions/utils.ts | 56 ++++++++++++++++++- 2 files changed, 78 insertions(+), 6 deletions(-) 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.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 / From 9cdbeceda4fd639584b3de7b893a28815447cab7 Mon Sep 17 00:00:00 2001 From: bb Date: Fri, 7 Aug 2026 13:06:55 -0500 Subject: [PATCH 3/5] fix(desktop): don't let a named session.info rehome a fresh draft `session.info` claimed the cwd for whatever the selection happened to be, so a background tile's payload could re-point a fresh draft at the tile's workspace. Treat a nonempty `stored_session_id` as non-matching when no primary session is selected; only an ABSENT id uses the selected-session fallback (the backend omits it on a lazy session, and refusing there would leave the workspace un-owned for the rest of the conversation). Matching goes through the lineage rather than raw string equality: the backend id is the live session_key, which auto-compression rotates to the continuation tip, while a selection made from a pinned row holds the stable lineage root. Comparing those literally reads one conversation as two. Co-authored-by: ZHJay --- .../hooks/use-message-stream/gateway-event.ts | 49 ++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) 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) } From 6ff052479bdf7d125cd07eebd205c82ef5c705bd Mon Sep 17 00:00:00 2001 From: bb Date: Fri, 7 Aug 2026 13:07:02 -0500 Subject: [PATCH 4/5] fix(tui_gateway): report a lazy session's own cwd, not the launch dir MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_fallback_session_info` returned `_default_session_cwd()` — the directory the gateway process happened to start in — so a session resumed without a built agent told its client the wrong workspace, and the desktop Files pane painted the wrong project even after the renderer rebound correctly. Return the session's own cwd and always emit `branch` ("" outside a git repo) so a client can clear a stale label instead of retaining it. This matches the contract `_lazy_session_info` already follows a few hundred lines above. Co-authored-by: ZHJay --- tests/test_tui_gateway_server.py | 35 ++++++++++++++++++++++++++++++++ tui_gateway/server.py | 10 ++++++++- 2 files changed, 44 insertions(+), 1 deletion(-) 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(), From cdc10cd78411f45a8bfcb562351dda78bd538c33 Mon Sep 17 00:00:00 2001 From: bb Date: Fri, 7 Aug 2026 13:07:11 -0500 Subject: [PATCH 5/5] test(desktop): cover the Files-pane cwd desync bug class Eight cases across the whole class, not just the reported path: - cold resume rebinds from the selected row before resume settles - an empty runtime cwd releases ownership (the permanent-staleness half) - releasing leaves the PATH intact, so panes don't collapse and the persisted workspace survives - a session row outside the loaded sidebar page doesn't blank the pane - a non-git workspace with a null git_repo_root still uses its row cwd - the branch label clears so the previous project doesn't leak Verified as a real barrier: reverting utils.ts fails 5 of these. Co-authored-by: xxxigm Co-authored-by: ZHJay Co-authored-by: worlldz --- .../hooks/use-session-actions/utils.test.ts | 94 ++++++++++++++++++- 1 file changed, 93 insertions(+), 1 deletion(-) 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', () => {