diff --git a/packages/app/package.json b/packages/app/package.json index 99eb13b26..dd78c4602 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -20,6 +20,7 @@ "test:session-switch": "node scripts/session-switch.mjs", "test:fs-engine": "node scripts/fs-engine.mjs", "test:local-file-path": "node scripts/local-file-path.mjs", + "test:sidebar-sessions-guard": "node scripts/sidebar-sessions-guard.mjs", "test:browser-entry": "node scripts/browser-entry.mjs", "test:e2e": "pnpm test:local-file-path && node scripts/e2e.mjs && node scripts/session-switch.mjs && node scripts/fs-engine.mjs && node scripts/browser-entry.mjs", "bump:patch": "node scripts/bump-version.mjs patch", diff --git a/packages/app/scripts/sidebar-sessions-guard.mjs b/packages/app/scripts/sidebar-sessions-guard.mjs new file mode 100644 index 000000000..d74133b3d --- /dev/null +++ b/packages/app/scripts/sidebar-sessions-guard.mjs @@ -0,0 +1,105 @@ +/** + * Test script for sidebar session guard logic. + * + * Verifies the fix for Issue #750: Session loss when switching workspaces. + * + * Run with: node packages/app/scripts/sidebar-sessions-guard.mjs + */ + +import assert from "node:assert/strict"; + +/** + * Determines whether the sidebar sessions should be updated. + * This is the pure function extracted from app.tsx for testability. + * + * @param {Array<{id: string}>} currentSessions - Current sidebar sessions + * @param {Array<{id: string}>} newSessions - New sessions from server + * @returns {boolean} true if should update, false to preserve existing + */ +function shouldUpdateSidebarSessions(currentSessions, newSessions) { + // Don't clear existing sidebar sessions if the session store is empty. + // This can happen during worker restart/reconnect when the server hasn't fully loaded + // its session database yet. Preserving existing sessions prevents the sidebar from + // flickering empty during the reconnection window. + if (currentSessions.length > 0 && newSessions.length === 0) { + return false; + } + return true; +} + +const results = { + ok: true, + tests: [], +}; + +function test(name, fn) { + results.tests.push({ name, status: "running" }); + const idx = results.tests.length - 1; + + try { + fn(); + results.tests[idx] = { name, status: "ok" }; + } catch (e) { + results.ok = false; + results.tests[idx] = { + name, + status: "error", + error: e instanceof Error ? e.message : String(e), + }; + } +} + +// Test 1: Empty current, empty new -> should update (no-op, but allowed) +test("empty current, empty new -> should update", () => { + const current = []; + const newSessions = []; + assert.equal(shouldUpdateSidebarSessions(current, newSessions), true); +}); + +// Test 2: Empty current, non-empty new -> should update (normal load) +test("empty current, non-empty new -> should update", () => { + const current = []; + const newSessions = [{ id: "session-1" }]; + assert.equal(shouldUpdateSidebarSessions(current, newSessions), true); +}); + +// Test 3: Non-empty current, empty new -> should NOT update (guard triggers) +test("non-empty current, empty new -> should NOT update", () => { + const current = [{ id: "session-1" }]; + const newSessions = []; + assert.equal(shouldUpdateSidebarSessions(current, newSessions), false); +}); + +// Test 4: Non-empty current, non-empty new -> should update (normal refresh) +test("non-empty current, non-empty new -> should update", () => { + const current = [{ id: "session-1" }]; + const newSessions = [{ id: "session-1" }, { id: "session-2" }]; + assert.equal(shouldUpdateSidebarSessions(current, newSessions), true); +}); + +// Test 5: Multiple current, empty new -> should NOT update (guard triggers) +test("multiple current, empty new -> should NOT update", () => { + const current = [{ id: "session-1" }, { id: "session-2" }, { id: "session-3" }]; + const newSessions = []; + assert.equal(shouldUpdateSidebarSessions(current, newSessions), false); +}); + +// Test 6: Multiple current, fewer new -> should update (deletion allowed) +test("multiple current, fewer new -> should update", () => { + const current = [{ id: "session-1" }, { id: "session-2" }]; + const newSessions = [{ id: "session-1" }]; + assert.equal(shouldUpdateSidebarSessions(current, newSessions), true); +}); + +// Test 7: Single current, empty new -> should NOT update +test("single current, empty new -> should NOT update", () => { + const current = [{ id: "only-session" }]; + const newSessions = []; + assert.equal(shouldUpdateSidebarSessions(current, newSessions), false); +}); + +console.log(JSON.stringify(results, null, 2)); + +if (!results.ok) { + process.exitCode = 1; +} diff --git a/packages/app/src/app/app.tsx b/packages/app/src/app/app.tsx index b08430f4a..af20492b6 100644 --- a/packages/app/src/app/app.tsx +++ b/packages/app/src/app/app.tsx @@ -50,6 +50,7 @@ import { listCommands as listCommandsTyped, } from "./lib/opencode-session"; import { clearPerfLogs, finishPerf, perfNow, recordPerfLog } from "./lib/perf-log"; +import { shouldUpdateSidebarSessions } from "./lib/sidebar-sessions-guard"; import { AUTO_COMPACT_CONTEXT_PREF_KEY, DEFAULT_MODEL, @@ -2676,6 +2677,16 @@ export default function App() { ? allSessions.filter((session) => normalizeDirectoryPath(session.directory) === activeWorkspaceRoot) : allSessions; const sorted = sortSessionsByActivity(scopedSessions); + + // Don't clear existing sidebar sessions if the session store is empty for this workspace. + // This can happen during worker restart/reconnect when the server hasn't fully loaded + // its session database yet. Preserving existing sessions prevents the sidebar from + // flickering empty during the reconnection window. + const currentSidebarSessions = sidebarSessionsByWorkspaceId()[wsId] || []; + if (!shouldUpdateSidebarSessions(currentSidebarSessions, sorted)) { + return; + } + setSidebarSessionsByWorkspaceId((prev) => ({ ...prev, [wsId]: sorted.map((s) => ({ diff --git a/packages/app/src/app/lib/sidebar-sessions-guard.ts b/packages/app/src/app/lib/sidebar-sessions-guard.ts new file mode 100644 index 000000000..31d41befd --- /dev/null +++ b/packages/app/src/app/lib/sidebar-sessions-guard.ts @@ -0,0 +1,47 @@ +/** + * Determines whether the sidebar sessions should be updated based on the current + * and new session lists. + * + * This guard prevents the sidebar from being cleared when the server temporarily + * returns an empty list (e.g., during worker restart/reconnect before the session + * database is fully loaded). + * + * @param currentSessions - The sessions currently displayed in the sidebar + * @param newSessions - The new sessions from the server + * @returns true if the sidebar should be updated, false to preserve existing sessions + */ +export function shouldUpdateSidebarSessions( + currentSessions: Array<{ id: string }>, + newSessions: Array<{ id: string }>, +): boolean { + // Don't clear existing sidebar sessions if the session store is empty. + // This can happen during worker restart/reconnect when the server hasn't fully loaded + // its session database yet. Preserving existing sessions prevents the sidebar from + // flickering empty during the reconnection window. + if (currentSessions.length > 0 && newSessions.length === 0) { + return false; + } + + return true; +} + +/** + * Filters sessions to only those belonging to a specific workspace root directory. + * + * @param sessions - All sessions + * @param workspaceRoot - The normalized workspace root directory + * @param normalizeDir - Function to normalize directory paths for comparison + * @returns Sessions scoped to the workspace + */ +export function scopeSessionsToWorkspace( + sessions: T[], + workspaceRoot: string | null, + normalizeDir: (dir: string) => string, +): T[] { + if (!workspaceRoot) { + return sessions; + } + return sessions.filter( + (session) => normalizeDir(session.directory) === workspaceRoot, + ); +}