Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
105 changes: 105 additions & 0 deletions packages/app/scripts/sidebar-sessions-guard.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
11 changes: 11 additions & 0 deletions packages/app/src/app/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) => ({
Expand Down
47 changes: 47 additions & 0 deletions packages/app/src/app/lib/sidebar-sessions-guard.ts
Original file line number Diff line number Diff line change
@@ -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<T extends { directory: string }>(
sessions: T[],
workspaceRoot: string | null,
normalizeDir: (dir: string) => string,
): T[] {
if (!workspaceRoot) {
return sessions;
}
return sessions.filter(
(session) => normalizeDir(session.directory) === workspaceRoot,
);
}