fix(desktop): prevent stale worktree status (#69781)

Clear the coding rail while a session's workspace changes and discard late
Git status results for the previous cwd. This prevents Ctrl+Shift+B from
opening a worktree dialog against an old branch.
This commit is contained in:
ethernet 2026-07-22 23:24:17 -04:00 committed by GitHub
parent 4baf2ed8ad
commit 9f5e568812
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 48 additions and 6 deletions

View file

@ -76,6 +76,31 @@ describe('refreshRepoStatus', () => {
expect($repoStatus.get()).toBeNull()
})
it('never publishes an old worktree status after the active cwd moves', async () => {
let resolveOld!: (status: HermesRepoStatus | null) => void
stubProbe(
() =>
new Promise(resolve => {
resolveOld = resolve
})
)
$currentCwd.set('/repo-a')
vi.advanceTimersByTime(200)
await vi.runAllTicks()
// The first probe is still in flight when the user switches sessions. The
// new cwd's probe is intentionally debounced, so this is the exact window
// where Ctrl+Shift+B used to see the old branch in the coding rail.
$currentCwd.set('/repo-b')
expect($repoStatus.get()).toBeNull()
resolveOld(sampleStatus)
await vi.runAllTicks()
expect($repoStatus.get()).toBeNull()
})
it('runs one probe at a time and coalesces overlap into one trailing refresh', async () => {
const resolvers: Array<(status: HermesRepoStatus | null) => void> = []
const calls: string[] = []

View file

@ -56,11 +56,11 @@ async function loadWorktrees(target: string): Promise<void> {
try {
const worktrees = await list(target)
if (inflightCwd === target) {
if (inflightCwd === target && statusStillBelongsToActiveCwd(target)) {
$repoWorktrees.set(worktrees)
}
} catch {
if (inflightCwd === target) {
if (inflightCwd === target && statusStillBelongsToActiveCwd(target)) {
$repoWorktrees.set([])
}
}
@ -84,6 +84,16 @@ let repoStatusRefreshTimer: ReturnType<typeof setTimeout> | null = null
const normalizeCwd = (cwd?: null | string): null | string => cwd?.trim() || null
// A result only belongs in the global rail while it still describes the active
// workspace. The debounce below deliberately delays the next probe; without
// this live check, an old probe can land during that gap and briefly make a new
// session look like it is on the previous worktree's branch.
const statusStillBelongsToActiveCwd = (target: string): boolean => {
const active = normalizeCwd($currentCwd.get())
return !active || active === target
}
/**
* Re-probe the working tree for `cwd` (defaults to the active session's cwd).
* Best-effort: a non-repo, a remote backend, or a missing probe clears the
@ -95,7 +105,7 @@ async function runRepoStatusRefresh({ probe, seq, target }: RepoStatusRefreshReq
// Drop the result if the cwd moved on while we were probing (a fast session
// switch) — the newer probe owns the atom.
if (seq === repoStatusRefreshSeq && inflightCwd === target) {
if (seq === repoStatusRefreshSeq && inflightCwd === target && statusStillBelongsToActiveCwd(target)) {
$repoStatus.set(status)
// Worktrees only matter inside a repo; clear them otherwise.
@ -106,7 +116,7 @@ async function runRepoStatusRefresh({ probe, seq, target }: RepoStatusRefreshReq
}
}
} catch {
if (seq === repoStatusRefreshSeq && inflightCwd === target) {
if (seq === repoStatusRefreshSeq && inflightCwd === target && statusStillBelongsToActiveCwd(target)) {
$repoStatus.set(null)
$repoWorktrees.set([])
}
@ -169,8 +179,15 @@ function scheduleRepoStatusRefresh(cwd?: null | string): void {
// Wired once at module load (mirrors projects.ts's module-scope subscriptions).
// Each is a structural edge where the working tree may have changed under us.
// The active session's cwd changed (session switch / new chat) → re-probe.
$currentCwd.subscribe(cwd => scheduleRepoStatusRefresh(cwd))
// The active session's cwd changed (session switch / new chat) → immediately
// hide the old repo's facts, then re-probe after the small debounce. This makes
// keyboard actions safe in the switch-to-probe gap: Ctrl+Shift+B cannot use a
// still-painted branch label from the previous worktree.
$currentCwd.subscribe(cwd => {
$repoStatus.set(null)
$repoWorktrees.set([])
scheduleRepoStatusRefresh(cwd)
})
// Switching sessions can land on the same cwd but a different checked-out
// branch (the agent ran `git checkout` in another session's terminal). The cwd