From cb26d71483c5bbaa4c39151a262b2e5ede45dc5e Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 30 Mar 2026 16:41:05 -0400 Subject: [PATCH] fix: preserve active tab when switching projects (#3071) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #2711 Two changes fix the tab-reset-to-dashboard bug: 1. Remove the forced `gsd:navigate-view` dispatch to "dashboard" in ProjectsPanel.handleSelectProject — this was unconditionally resetting the view on every project switch. 2. Add a useEffect in WorkspaceChrome that resets `viewRestored` when `projectPath` changes, so the per-project sessionStorage view restore fires for the newly-selected project. Co-authored-by: Claude Opus 4.6 --- .../web-project-tab-preservation.test.ts | 243 ++++++++++++++++++ web/components/gsd/app-shell.tsx | 10 + web/components/gsd/projects-view.tsx | 1 - 3 files changed, 253 insertions(+), 1 deletion(-) create mode 100644 src/tests/integration/web-project-tab-preservation.test.ts diff --git a/src/tests/integration/web-project-tab-preservation.test.ts b/src/tests/integration/web-project-tab-preservation.test.ts new file mode 100644 index 000000000..4b7b5d2d1 --- /dev/null +++ b/src/tests/integration/web-project-tab-preservation.test.ts @@ -0,0 +1,243 @@ +import test, { describe } from "node:test"; +import assert from "node:assert/strict"; + +// --------------------------------------------------------------------------- +// Test: project switching preserves the active tab (view) instead of +// resetting to dashboard. +// +// Bug #2711: Switching projects always returns to dashboard. +// +// Root cause: handleSelectProject in ProjectsPanel dispatched +// gsd:navigate-view with { view: "dashboard" } on every switch. +// Additionally, the viewRestored flag in WorkspaceChrome was never +// reset when the project changed, so the per-project sessionStorage +// restore could not fire for the new project. +// +// These tests validate the corrected logic in isolation, without needing +// a full React DOM. +// --------------------------------------------------------------------------- + +// ── Simulated sessionStorage (mirrors browser sessionStorage API) ──────── + +class MockSessionStorage { + private store = new Map(); + + getItem(key: string): string | null { + return this.store.get(key) ?? null; + } + + setItem(key: string, value: string): void { + this.store.set(key, value); + } + + removeItem(key: string): void { + this.store.delete(key); + } + + clear(): void { + this.store.clear(); + } +} + +// ── Mirrors the KNOWN_VIEWS set and viewStorageKey from app-shell.tsx ───── + +const KNOWN_VIEWS = new Set([ + "dashboard", + "power", + "chat", + "roadmap", + "files", + "activity", + "visualize", +]); + +function viewStorageKey(projectCwd: string): string { + return `gsd-active-view:${projectCwd}`; +} + +// ── Simulated WorkspaceChrome view-restore logic ───────────────────────── +// This mirrors the useEffect in WorkspaceChrome that restores the persisted +// view when projectPath changes — with the fix applied. + +interface ChromeState { + activeView: string; + viewRestored: boolean; + projectPath: string | null; +} + +/** + * Simulates the view-restore effect. + * In the fixed code, viewRestored resets to false when projectPath changes, + * allowing the stored view to be read for the new project. + */ +function simulateViewRestoreEffect( + state: ChromeState, + storage: MockSessionStorage, +): ChromeState { + // The fix: if projectPath changed, reset viewRestored + // (In React this is a separate useEffect that depends on [projectPath]) + if (!state.viewRestored && state.projectPath) { + const stored = storage.getItem(viewStorageKey(state.projectPath)); + if (stored && KNOWN_VIEWS.has(stored)) { + return { ...state, activeView: stored, viewRestored: true }; + } + return { ...state, viewRestored: true }; + } + return state; +} + +/** + * Simulates switching to a new project path. + * The fix resets viewRestored so the restore effect can fire for the new project. + */ +function simulateProjectSwitch( + state: ChromeState, + newProjectPath: string, +): ChromeState { + return { + ...state, + projectPath: newProjectPath, + viewRestored: false, // <-- THE FIX: reset so restore runs for new project + }; +} + +// ── Simulated handleSelectProject (pre-fix vs post-fix) ────────────────── + +/** Pre-fix: always navigates to dashboard on project switch */ +function handleSelectProjectPreFix( + _state: ChromeState, + _projectPath: string, +): string { + // Bug: always forces dashboard + return "dashboard"; +} + +/** Post-fix: does NOT override the active view */ +function handleSelectProjectPostFix( + state: ChromeState, + _projectPath: string, +): string { + // Fix: preserve whatever view is active (restore logic handles per-project view) + return state.activeView; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("project switch tab preservation (#2711)", () => { + test("BUG: pre-fix handleSelectProject always resets to dashboard", () => { + const state: ChromeState = { + activeView: "roadmap", + viewRestored: true, + projectPath: "/projects/alpha", + }; + + const viewAfterSwitch = handleSelectProjectPreFix(state, "/projects/beta"); + // This demonstrates the bug: user was on "roadmap" but got sent to "dashboard" + assert.equal(viewAfterSwitch, "dashboard"); + }); + + test("FIX: post-fix handleSelectProject preserves current view", () => { + const state: ChromeState = { + activeView: "roadmap", + viewRestored: true, + projectPath: "/projects/alpha", + }; + + const viewAfterSwitch = handleSelectProjectPostFix(state, "/projects/beta"); + assert.equal(viewAfterSwitch, "roadmap", "Should preserve the current tab"); + }); + + test("FIX: viewRestored resets on project switch, enabling per-project view restore", () => { + const storage = new MockSessionStorage(); + storage.setItem(viewStorageKey("/projects/alpha"), "files"); + storage.setItem(viewStorageKey("/projects/beta"), "activity"); + + // Start on project alpha, viewing files + let state: ChromeState = { + activeView: "dashboard", + viewRestored: false, + projectPath: "/projects/alpha", + }; + + // Initial restore for alpha + state = simulateViewRestoreEffect(state, storage); + assert.equal(state.activeView, "files"); + assert.equal(state.viewRestored, true); + + // Switch to project beta + state = simulateProjectSwitch(state, "/projects/beta"); + assert.equal(state.viewRestored, false, "viewRestored should reset on project switch"); + + // Restore effect fires for beta + state = simulateViewRestoreEffect(state, storage); + assert.equal(state.activeView, "activity", "Should restore beta's persisted view"); + }); + + test("FIX: switching to project with no stored view keeps current view", () => { + const storage = new MockSessionStorage(); + // Only alpha has a stored view + storage.setItem(viewStorageKey("/projects/alpha"), "roadmap"); + + let state: ChromeState = { + activeView: "roadmap", + viewRestored: true, + projectPath: "/projects/alpha", + }; + + // Switch to gamma (no stored view) + state = simulateProjectSwitch(state, "/projects/gamma"); + state = simulateViewRestoreEffect(state, storage); + + // Should keep the current view since gamma has no stored preference + assert.equal(state.activeView, "roadmap", "Should keep current view when new project has no stored view"); + }); + + test("FIX: stored view for invalid view name is ignored", () => { + const storage = new MockSessionStorage(); + storage.setItem(viewStorageKey("/projects/alpha"), "nonexistent-view"); + + let state: ChromeState = { + activeView: "power", + viewRestored: false, + projectPath: "/projects/alpha", + }; + + state = simulateViewRestoreEffect(state, storage); + // Invalid stored view should be ignored, keeping current view + assert.equal(state.activeView, "power"); + }); + + test("FIX: rapid project switches each get a fresh restore", () => { + const storage = new MockSessionStorage(); + storage.setItem(viewStorageKey("/projects/a"), "chat"); + storage.setItem(viewStorageKey("/projects/b"), "visualize"); + storage.setItem(viewStorageKey("/projects/c"), "files"); + + let state: ChromeState = { + activeView: "dashboard", + viewRestored: false, + projectPath: "/projects/a", + }; + + // Restore for A + state = simulateViewRestoreEffect(state, storage); + assert.equal(state.activeView, "chat"); + + // Switch to B + state = simulateProjectSwitch(state, "/projects/b"); + state = simulateViewRestoreEffect(state, storage); + assert.equal(state.activeView, "visualize"); + + // Switch to C + state = simulateProjectSwitch(state, "/projects/c"); + state = simulateViewRestoreEffect(state, storage); + assert.equal(state.activeView, "files"); + + // Switch back to A + state = simulateProjectSwitch(state, "/projects/a"); + state = simulateViewRestoreEffect(state, storage); + assert.equal(state.activeView, "chat", "Should restore A's view again after switching away and back"); + }); +}); diff --git a/web/components/gsd/app-shell.tsx b/web/components/gsd/app-shell.tsx index 88442c53b..6c9c2e8a8 100644 --- a/web/components/gsd/app-shell.tsx +++ b/web/components/gsd/app-shell.tsx @@ -87,6 +87,16 @@ function WorkspaceChrome() { return () => window.clearTimeout(restoreTimer) }, [projectPath, viewRestored]) + // Reset viewRestored when projectPath changes so the restore effect can + // fire for the newly-selected project (fixes #2711: tab reset on switch). + const prevProjectPath = useRef(projectPath) + useEffect(() => { + if (prevProjectPath.current !== projectPath) { + prevProjectPath.current = projectPath + setViewRestored(false) + } + }, [projectPath]) + // Persist view changes to sessionStorage useEffect(() => { if (!projectPath) return diff --git a/web/components/gsd/projects-view.tsx b/web/components/gsd/projects-view.tsx index 7cb736940..83e906889 100644 --- a/web/components/gsd/projects-view.tsx +++ b/web/components/gsd/projects-view.tsx @@ -371,7 +371,6 @@ export function ProjectsPanel({ // loading toast managed by WorkspaceChrome onOpenChange(false) manager.switchProject(project.path) - window.dispatchEvent(new CustomEvent("gsd:navigate-view", { detail: { view: "dashboard" } })) } // Sort: active-gsd first, then by name