fix(workspace): keep the active workspace key in sync with the active worktree (#14475)

setActiveWorktree's no-op guard now compares activeWorkspaceKey with the key
derived from the target worktree, so reselecting the current worktree repairs
a stale key instead of returning early.

Session hydration always derives activeWorkspaceKey from the restored
activeWorktreeId instead of trusting a persisted key that may name another
workspace, which kept the wrong sidebar row highlighted after restart.

Fixes #14474

Co-authored-by: Neil <neil@stably.ai>
This commit is contained in:
Yunqian Fan
2026-10-26 11:41:28 -07:00
committed by GitHub
co-authored by Neil
parent 8a1213920e
commit bc4e66c799
5 changed files with 55 additions and 12 deletions
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { installAttentionScreen } from './workspace-attention/workspace-unread-characterization-fixture'
import type * as AgentStatusModule from '@/lib/agent-status'
import { buildWorktreeComparator } from '../../../../shared/sidebar-list/smart-sort'
import { worktreeWorkspaceKey } from '../../../../shared/workspace-scope'
import {
createTestStore,
makeOpenFile,
@@ -230,6 +231,7 @@ describe('setActiveWorktree', () => {
repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })]
},
activeWorktreeId: wt,
activeWorkspaceKey: worktreeWorkspaceKey(wt),
activeTabId: tabId,
activeTabType: 'terminal',
activeTabTypeByWorktree: { [wt]: 'terminal' },
@@ -629,6 +629,29 @@ describe('hydrateWorkspaceSession', () => {
expect(store.getState().worktreeNavHistory).toEqual([worktreeId])
})
it('ignores a persisted workspace key that disagrees with the restored active worktree', () => {
const store = createTestStore()
const worktreeId = 'repo1::/wt-current'
const staleFolderKey = folderWorkspaceKey('folder-stale')
seedStore(store, {
worktreesByRepo: {
repo1: [makeWorktree({ id: worktreeId, repoId: 'repo1', path: '/wt-current' })]
}
})
store.getState().hydrateWorkspaceSession(
{
...getDefaultWorkspaceSession(),
activeRepoId: 'repo1',
activeWorktreeId: worktreeId,
activeWorkspaceKey: staleFolderKey
},
{ additionalValidWorkspaceKeys: [staleFolderKey] }
)
expect(store.getState().activeWorkspaceKey).toBe(worktreeWorkspaceKey(worktreeId))
})
it('leaves nav history empty when no active worktree is restored', () => {
const store = createTestStore()
seedStore(store, { worktreesByRepo: {} })
@@ -112,6 +112,24 @@ describe('setActiveWorktree focus handling', () => {
}
}
})
it('repairs a stale workspace key when reactivating the current worktree', () => {
const store = createTestStore()
const worktree = makeWorktree({ id: 'repo1::/path/current', repoId: 'repo1' })
store.setState({
worktreesByRepo: { repo1: [worktree] },
activeWorktreeId: worktree.id,
activeWorkspaceKey: worktreeWorkspaceKey('repo1::/path/stale'),
activeWorkspaceExecutionHostId: null,
activeTabTypeByWorktree: { [worktree.id]: 'terminal' },
rightSidebarExplorerView: 'files',
everActivatedWorktreeIds: new Set([worktree.id])
})
store.getState().setActiveWorktree(worktree.id)
expect(store.getState().activeWorkspaceKey).toBe(worktreeWorkspaceKey(worktree.id))
})
})
describe('markWorktreeVisited', () => {
@@ -76,6 +76,9 @@ export function createSetActiveWorktree(
}
}
const nextActiveWorkspaceKey = isWorkspaceKey(worktreeId)
? worktreeId
: worktreeWorkspaceKey(worktreeId)
const worktree = findKnownWorktreeById(s, worktreeId, executionHostId)
const {
restoredRightSidebarExplorerView,
@@ -124,6 +127,7 @@ export function createSetActiveWorktree(
: { ...s.activeTabTypeByWorktree, [worktreeId]: activeTabType }
const hasStateChange =
s.activeWorktreeId !== worktreeId ||
s.activeWorkspaceKey !== nextActiveWorkspaceKey ||
s.activeWorkspaceExecutionHostId !== (executionHostId ?? null) ||
// Why: a pending-creation panel can show over the prior worktree; a non-null activePendingCreationId counts as a change.
s.activePendingCreationId !== null ||
@@ -148,9 +152,7 @@ export function createSetActiveWorktree(
...reconciliation?.patch,
activeRepoId: nextActiveRepoId,
activeWorktreeId: worktreeId,
activeWorkspaceKey: isWorkspaceKey(worktreeId)
? worktreeId
: worktreeWorkspaceKey(worktreeId),
activeWorkspaceKey: nextActiveWorkspaceKey,
activeWorkspaceExecutionHostId: executionHostId ?? null,
activePendingCreationId: null,
activeFileId,
@@ -2,7 +2,7 @@ import type { WorkspaceKey } from '../../../../shared/folder-workspace-types'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
import {
folderWorkspaceKey,
parseWorkspaceKey,
isWorkspaceKey,
worktreeWorkspaceKey
} from '../../../../shared/workspace-scope'
import { buildByIdIndex } from '../slices/worktree-by-id-index'
@@ -87,14 +87,12 @@ export function createWorkspaceTerminalHydrationActions(
// An empty workspace is valid; fall back from active repo to avoid a blank landing screen.
return fallbackActiveWorktreeId
})()
const activeWorkspaceKey: WorkspaceKey | null =
session.activeWorkspaceKey && validWorktreeIds.has(session.activeWorkspaceKey)
? session.activeWorkspaceKey
: activeWorktreeId
? parseWorkspaceKey(activeWorktreeId)
? (activeWorktreeId as WorkspaceKey)
: worktreeWorkspaceKey(activeWorktreeId)
: null
// Derive from the restored worktree; older builds could persist a key naming another workspace.
const activeWorkspaceKey: WorkspaceKey | null = activeWorktreeId
? isWorkspaceKey(activeWorktreeId)
? activeWorktreeId
: worktreeWorkspaceKey(activeWorktreeId)
: null
const activeWorkspaceExecutionHostId =
activeWorkspaceKey && session.activeWorkspaceExecutionHostId
? session.activeWorkspaceExecutionHostId