fix(desktop): add workspace-cwd ownership so switches are atomic
`$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 <ZHJay@users.noreply.github.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user