diff --git a/apps/desktop/src/store/coding-status.ts b/apps/desktop/src/store/coding-status.ts index fa52453023..9c725f963a 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 b21fa6875e..24a0b8beff 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)