fix(desktop): rebind the Files pane cwd when switching sessions

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 <tuancanhnguyen706@gmail.com>
Co-authored-by: worlldz <worlldz@users.noreply.github.com>
Co-authored-by: ZHJay <ZHJay@users.noreply.github.com>
This commit is contained in:
bb 2026-08-07 13:06:47 -05:00
parent ae6eb578bb
commit 416e025c46
2 changed files with 78 additions and 6 deletions

View File

@ -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)

View File

@ -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 /