Merge pull request #81247 from NousResearch/bb/desktop-files-pane-cwd-ownership

fix(desktop): rebind the Files pane workspace when switching sessions
This commit is contained in:
brooklyn! 2026-08-07 12:18:39 -06:00 committed by GitHub
commit 10a2b3d7a2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 344 additions and 15 deletions

View File

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

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

@ -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', () => {

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 /

View File

@ -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<Record<string, HermesGitWorktree[]>>({})
// 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<HermesRepoStatus | null> = 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<HermesGitWorktree[]> = 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

View File

@ -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 | string>(null)
export const $newChatWorkspaceTarget = atom<NewChatWorkspaceTarget>(undefined)
export const $newChatWorkspaceTargetGeneration = atom(0)
export const $currentBranch = atom('')
@ -628,6 +650,49 @@ export const setCurrentCwd = (next: Updater<string>) => {
export const setCurrentCwdTransient = (next: Updater<string>) => 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)

View File

@ -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"] == ""

View File

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