From 143d2232bbb53393f003b9bb7ddd2b24cedc79fb Mon Sep 17 00:00:00 2001 From: Yunqian Fan Date: Fri, 24 Jul 2026 15:25:49 +0800 Subject: [PATCH] fix: reconcile a stale activeWorktreeId against live worktrees on hydration (#9344) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The web client persists its workspace session (activeWorktreeId, lastVisitedAtByWorktreeId, ...) in localStorage and, unlike the main-process Store, gets no load-time orphan GC. `pruneLastVisitedTimestamps` already drops stale focus-recency entries on hydration, but the persisted `activeWorktreeId` pointer is not reconciled — so a pointer to a worktree the server no longer reports lingers (the main-process path clears it via removeWorkspaceSessionOwner when a repo is removed; the web has no equivalent), and can surface a phantom / duplicate workspace that survives reloads. Extend the hydration reconcile to also clear `activeWorktreeId` once its repo is hydrated and the worktree is confirmed gone, mirroring the existing per-repo defer rule (a not-yet-hydrated repo, e.g. SSH pre-connect, keeps its pointer). Co-authored-by: fanyunqian.1 Co-authored-by: Claude Opus 4.8 (1M context) --- .../src/store/slices/worktrees.test.ts | 28 +++++++++++++++++++ src/renderer/src/store/slices/worktrees.ts | 22 ++++++++++++++- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index 9c03c0ca5..4f8587db4 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -7156,6 +7156,34 @@ describe('markWorktreeVisited', () => { }) }) + it('pruneLastVisitedTimestamps clears a stale activeWorktreeId gone from a hydrated repo', () => { + const store = createTestStore() + const wt = makeWorktree({ id: 'repo1::/a', repoId: 'repo1', path: '/a' }) + store.setState({ + worktreesByRepo: { repo1: [wt] }, + activeWorktreeId: 'repo1::/gone', + lastVisitedAtByWorktreeId: {} + } as Partial) + store.getState().pruneLastVisitedTimestamps() + expect(store.getState().activeWorktreeId).toBeNull() + }) + + it('pruneLastVisitedTimestamps keeps a live activeWorktreeId and defers unhydrated repos', () => { + const store = createTestStore() + const wt = makeWorktree({ id: 'repo1::/a', repoId: 'repo1', path: '/a' }) + store.setState({ + worktreesByRepo: { repo1: [wt] }, + activeWorktreeId: 'repo1::/a' + } as Partial) + store.getState().pruneLastVisitedTimestamps() + expect(store.getState().activeWorktreeId).toBe('repo1::/a') + + // A pointer into a not-yet-hydrated (e.g. SSH pre-connect) repo is deferred. + store.setState({ activeWorktreeId: 'ssh-repo::/b' } as Partial) + store.getState().pruneLastVisitedTimestamps() + expect(store.getState().activeWorktreeId).toBe('ssh-repo::/b') + }) + it('pruneLastVisitedTimestamps defers when the detected list is non-authoritative', () => { const store = createTestStore() store.setState({ diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index ca00a12e5..b10674821 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -4428,7 +4428,27 @@ export const createWorktreeSlice: StateCreator changed = true } } - return changed ? { lastVisitedAtByWorktreeId: next } : {} + const patch: { lastVisitedAtByWorktreeId?: Record; activeWorktreeId?: null } = + {} + if (changed) { + patch.lastVisitedAtByWorktreeId = next + } + // Why: the persisted active-worktree pointer is a `${repoId}::${path}` id + // that nothing else reconciles here. The main-process Store clears a stale + // pointer when a repo is removed (removeWorkspaceSessionOwner nulls + // activeWorktreeId), but the web client keeps it in localStorage and gets + // no such load-time GC — so a pointer to a worktree the server no longer + // reports lingers and can surface a phantom/duplicate workspace. Clear it + // once its repo is hydrated and the worktree is confirmed gone (defer while + // the repo is unhydrated, mirroring the timestamp rule above). + const activeId = s.activeWorktreeId + if (activeId) { + const activeRepoWorktreeIds = validIdsByRepo.get(getRepoIdFromWorktreeId(activeId)) + if (activeRepoWorktreeIds && !activeRepoWorktreeIds.has(activeId)) { + patch.activeWorktreeId = null + } + } + return Object.keys(patch).length > 0 ? patch : {} }) },