From bc4e66c7994a6c7305231fdba547f02c7de38fa7 Mon Sep 17 00:00:00 2001 From: Yunqian Fan Date: Tue, 27 Oct 2026 02:41:28 +0800 Subject: [PATCH] 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 --- .../store-active-worktree-selection.test.ts | 2 ++ .../store/slices/terminals-hydration.test.ts | 23 +++++++++++++++++++ ...orktrees-workspace-selection-state.test.ts | 18 +++++++++++++++ .../worktrees/session/set-active-worktree.ts | 8 ++++--- .../terminals/workspace-terminal-hydration.ts | 16 ++++++------- 5 files changed, 55 insertions(+), 12 deletions(-) diff --git a/src/renderer/src/store/slices/store-active-worktree-selection.test.ts b/src/renderer/src/store/slices/store-active-worktree-selection.test.ts index a0c73b617e7..4318feec163 100644 --- a/src/renderer/src/store/slices/store-active-worktree-selection.test.ts +++ b/src/renderer/src/store/slices/store-active-worktree-selection.test.ts @@ -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' }, diff --git a/src/renderer/src/store/slices/terminals-hydration.test.ts b/src/renderer/src/store/slices/terminals-hydration.test.ts index 6f4a343f24b..6dcffc66f1a 100644 --- a/src/renderer/src/store/slices/terminals-hydration.test.ts +++ b/src/renderer/src/store/slices/terminals-hydration.test.ts @@ -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: {} }) diff --git a/src/renderer/src/store/slices/worktrees-workspace-selection-state.test.ts b/src/renderer/src/store/slices/worktrees-workspace-selection-state.test.ts index e9687f3bf44..2d0e0131bd3 100644 --- a/src/renderer/src/store/slices/worktrees-workspace-selection-state.test.ts +++ b/src/renderer/src/store/slices/worktrees-workspace-selection-state.test.ts @@ -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', () => { diff --git a/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts b/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts index 0c0e2b79ae6..ae948e0ded6 100644 --- a/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts +++ b/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts @@ -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, diff --git a/src/renderer/src/store/terminals/workspace-terminal-hydration.ts b/src/renderer/src/store/terminals/workspace-terminal-hydration.ts index d8105efd859..6e47769f3b2 100644 --- a/src/renderer/src/store/terminals/workspace-terminal-hydration.ts +++ b/src/renderer/src/store/terminals/workspace-terminal-hydration.ts @@ -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