fix: preserve active tab when switching projects (#3071)
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 <noreply@anthropic.com>
This commit is contained in:
parent
501fb83606
commit
cb26d71483
3 changed files with 253 additions and 1 deletions
243
src/tests/integration/web-project-tab-preservation.test.ts
Normal file
243
src/tests/integration/web-project-tab-preservation.test.ts
Normal file
|
|
@ -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<string, string>();
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue