fix(sessions): select the active project using session ownership (#2865)

* fix(sessions): select the active project using session ownership

Keep a same-project worktree session while its rendered map is stale, but switch to the remembered or fallback session when the current session is known to belong to another project.

Fixes #2317

* test(sessions): pin project-switch ownership recovery in the selection hook
This commit is contained in:
Serhii Dziupin
2026-08-13 10:51:40 +03:00
committed by GitHub
parent e7b864e9ae
commit 8a6eca5597
3 changed files with 444 additions and 80 deletions
@@ -1825,6 +1825,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
openNewSessionDraft={openNewSessionDraft}
setActiveMainTab={setActiveMainTab}
setSessionSwitcherOpen={setSessionSwitcherOpen}
sessionOwnerBySessionId={sessionOwnership.bySessionId}
/>
<SessionPrefetchEffect
enabled={isVisible}
@@ -1,10 +1,39 @@
import { describe, expect, test } from 'bun:test';
import React, { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, describe, expect, mock, test } from 'bun:test';
import type { Session } from '@opencode-ai/sdk/v2';
import type { SessionGroup, SessionNode } from '../types';
let currentSessionId: string | null = null;
let newSessionDraftOpen = false;
let isNewWorktreeDialogOpen = false;
mock.module('@/stores/useUIStore', () => ({
useUIStore: Object.assign(
(selector: (state: { isNewWorktreeDialogOpen: boolean }) => unknown) =>
selector({ isNewWorktreeDialogOpen }),
{ getState: () => ({ isNewWorktreeDialogOpen }) },
),
}));
mock.module('@/sync/session-ui-store', () => ({
useSessionUIStore: (selector: (state: {
currentSessionId: string | null;
newSessionDraft: { open: boolean };
}) => unknown) => selector({
currentSessionId,
newSessionDraft: { open: newSessionDraftOpen },
}),
}));
const {
resolveMissingProjectSessionSelection,
ProjectSessionSelectionEffect,
} = await import('./useProjectSessionSelection');
// ---------------------------------------------------------------------------
// Helper: simulate the projectSessionMeta computation from the hook
// (same visitNodes logic as useProjectSessionSelection.ts lines 46-71)
// (same visitNodes logic as useProjectSessionSelection.ts)
// ---------------------------------------------------------------------------
type ProjectSection = {
@@ -182,24 +211,6 @@ describe('useProjectSessionSelection — worktree session click race', () => {
expect(projectMap?.has('wt-session-1')).toBe(true);
});
test('guard preserves currentSessionId when projectMap is stale (the bug fix)', () => {
const { metaByProject, firstSessionByProject } = computeProjectMeta(staleSections);
const projectMap = metaByProject.get('project-1')!;
const currentSessionId = 'wt-session-1';
// Path A fails: currentSessionId is set but not in stale projectMap
const pathAHit = Boolean(currentSessionId && projectMap?.has(currentSessionId));
expect(pathAHit).toBe(false);
// Guard: if (currentSessionId) return;
// This is what prevents the fallthrough to Path C (auto-select wrong session)
// Without the guard, Path C would select firstSessionByProject = root-session-1
// instead of preserving the user's wt-session-1 selection
const fallback = firstSessionByProject.get('project-1')?.id ?? null;
expect(fallback).toBe('root-session-1');
expect(fallback).not.toBe(currentSessionId);
});
test('second click works correctly when projectSections is updated', () => {
const { metaByProject } = computeProjectMeta(updatedSections);
const projectMap = metaByProject.get('project-1')!;
@@ -210,53 +221,327 @@ describe('useProjectSessionSelection — worktree session click race', () => {
expect(pathAHit).toBe(true);
});
test('project switch: guard does NOT fire when currentSessionId matches new project', () => {
// Simulates: user clicks a session in project-2 (normal click, not worktree)
test('project switch: Path A succeeds when currentSessionId matches the new project', () => {
const { metaByProject } = computeProjectMeta(project2Sections);
const projectMap = metaByProject.get('project-2')!;
const currentSessionId = 'project-2-session-1';
// Path A succeeds — the session is in the new project's projectMap
const pathAHit = Boolean(currentSessionId && projectMap?.has(currentSessionId));
expect(pathAHit).toBe(true);
// Guard condition only fires when Path A fails — should not fire here
const guardWouldFire = Boolean(currentSessionId && !(projectMap?.has(currentSessionId)));
expect(guardWouldFire).toBe(false);
});
test('guard does NOT fire when currentSessionId is null (deleted/archived session)', () => {
const { metaByProject } = computeProjectMeta(staleSections);
const projectMap = metaByProject.get('project-1')!;
const currentSessionId = null;
// Path A: currentSessionId is null → skipped
const pathAHit = Boolean(currentSessionId && projectMap?.has(currentSessionId));
expect(pathAHit).toBe(false);
// Guard: currentSessionId is null → skipped, falls through to Path B/C
const guardWouldFire = currentSessionId !== null && !pathAHit;
expect(guardWouldFire).toBe(false);
});
test('guard does NOT fire for empty projects — falls through to Path B (open draft)', () => {
// Empty project: no groups/sessions in projectSections
const emptySections: ProjectSection[] = [
{
project: { id: 'empty-project', normalizedPath: '/workspace/empty' },
groups: [],
},
];
const { metaByProject } = computeProjectMeta(emptySections);
const projectMap = metaByProject.get('empty-project');
const currentSessionId = 'some-session-id';
// projectMap is undefined for empty project
expect(projectMap).toBe(undefined);
// Guard: projectMap is undefined → skipped, falls through to Path B
// which opens a new session draft for the empty project
const guardWouldFire = Boolean(currentSessionId && projectMap);
expect(guardWouldFire).toBe(false);
});
});
describe('resolveMissingProjectSessionSelection', () => {
test('A → B selects B remembered session when the current session is owned by A', () => {
const projectBMap = new Map([
['project-b-first-session', null],
['project-b-remembered-session', null],
]);
expect(resolveMissingProjectSessionSelection({
activeProjectId: 'project-b',
currentSessionId: 'stale-worktree-session-a',
currentSessionOwnerProjectId: 'project-a',
projectMap: projectBMap,
metaByProject: new Map([['project-b', projectBMap]]),
rememberedSessionId: 'project-b-remembered-session',
fallbackSessionId: 'project-b-first-session',
})).toEqual({ kind: 'select-session', sessionId: 'project-b-remembered-session' });
});
test('A → B falls back to B first session when none is remembered', () => {
const projectAMap = new Map([['project-a-session', null]]);
const projectBMap = new Map([['project-b-first-session', null]]);
const metaByProject = new Map([
['project-a', projectAMap],
['project-b', projectBMap],
]);
expect(resolveMissingProjectSessionSelection({
activeProjectId: 'project-b',
currentSessionId: 'project-a-session',
currentSessionOwnerProjectId: 'project-a',
projectMap: projectBMap,
metaByProject,
rememberedSessionId: undefined,
fallbackSessionId: 'project-b-first-session',
})).toEqual({ kind: 'select-session', sessionId: 'project-b-first-session' });
});
test('A → B opens a B-scoped draft when B is empty', () => {
expect(resolveMissingProjectSessionSelection({
activeProjectId: 'project-b',
currentSessionId: 'project-a-session',
currentSessionOwnerProjectId: 'project-a',
projectMap: undefined,
metaByProject: new Map([['project-a', new Map([['project-a-session', null]])]]),
rememberedSessionId: undefined,
fallbackSessionId: null,
})).toEqual({ kind: 'open-draft' });
});
test('preserves a same-project worktree session missing from a stale projectMap', () => {
const projectMap = new Map([['root-session-1', null]]);
const metaByProject = new Map([['project-1', projectMap]]);
expect(resolveMissingProjectSessionSelection({
activeProjectId: 'project-1',
currentSessionId: 'wt-session-1',
currentSessionOwnerProjectId: 'project-1',
projectMap,
metaByProject,
rememberedSessionId: undefined,
fallbackSessionId: 'root-session-1',
})).toEqual({ kind: 'preserve-current' });
});
test('preserves an unknown session while worktree metadata may still be loading', () => {
const projectMap = new Map([['root-session-1', null]]);
const metaByProject = new Map([['project-1', projectMap]]);
expect(resolveMissingProjectSessionSelection({
activeProjectId: 'project-1',
currentSessionId: 'wt-session-1',
currentSessionOwnerProjectId: null,
projectMap,
metaByProject,
rememberedSessionId: undefined,
fallbackSessionId: 'root-session-1',
})).toEqual({ kind: 'preserve-current' });
});
test('unknown ownership still switches when the session already appears under another project', () => {
const projectAMap = new Map([['project-a-session', null]]);
const projectBMap = new Map([
['project-b-first-session', null],
['project-b-remembered-session', null],
]);
const metaByProject = new Map([
['project-a', projectAMap],
['project-b', projectBMap],
]);
expect(resolveMissingProjectSessionSelection({
activeProjectId: 'project-b',
currentSessionId: 'project-a-session',
currentSessionOwnerProjectId: null,
projectMap: projectBMap,
metaByProject,
rememberedSessionId: 'project-b-remembered-session',
fallbackSessionId: 'project-b-first-session',
})).toEqual({ kind: 'select-session', sessionId: 'project-b-remembered-session' });
});
test('deleted or missing currentSessionId falls through to remembered/fallback selection', () => {
const projectMap = new Map([['root-session-1', null]]);
expect(resolveMissingProjectSessionSelection({
activeProjectId: 'project-1',
currentSessionId: null,
currentSessionOwnerProjectId: null,
projectMap,
metaByProject: new Map([['project-1', projectMap]]),
rememberedSessionId: undefined,
fallbackSessionId: 'root-session-1',
})).toEqual({ kind: 'select-session', sessionId: 'root-session-1' });
});
test('empty projects resolve to opening a draft', () => {
expect(resolveMissingProjectSessionSelection({
activeProjectId: 'empty-project',
currentSessionId: 'some-session-id',
currentSessionOwnerProjectId: null,
projectMap: undefined,
metaByProject: new Map<string, Map<string, null>>(),
rememberedSessionId: undefined,
fallbackSessionId: null,
})).toEqual({ kind: 'open-draft' });
});
});
// ---------------------------------------------------------------------------
// Hook-level: ProjectSessionSelectionEffect recovery / preserve
// ---------------------------------------------------------------------------
const installMinimalDom = () => {
const descriptors = new Map<string, PropertyDescriptor | undefined>();
const setGlobal = (name: string, value: unknown) => {
descriptors.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
Object.defineProperty(globalThis, name, { configurable: true, writable: true, value });
};
class ElementStub {}
const documentStub: Record<string, unknown> = {
nodeType: 9,
defaultView: globalThis,
activeElement: null,
addEventListener: () => undefined,
removeEventListener: () => undefined,
};
const container = {
nodeType: 1,
tagName: 'DIV',
nodeName: 'DIV',
namespaceURI: 'http://www.w3.org/1999/xhtml',
ownerDocument: documentStub,
addEventListener: () => undefined,
removeEventListener: () => undefined,
};
documentStub.documentElement = container;
documentStub.body = container;
setGlobal('document', documentStub);
setGlobal('window', globalThis);
setGlobal('location', { search: '', protocol: 'http:', hostname: 'localhost' });
setGlobal('Element', ElementStub);
setGlobal('HTMLElement', ElementStub);
setGlobal('HTMLIFrameElement', ElementStub);
setGlobal('IS_REACT_ACT_ENVIRONMENT', true);
setGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => setTimeout(() => callback(Date.now()), 0));
setGlobal('cancelAnimationFrame', (id: ReturnType<typeof setTimeout>) => clearTimeout(id));
return {
container: container as unknown as Element,
restore: () => {
for (const [name, descriptor] of descriptors) {
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
else Reflect.deleteProperty(globalThis, name);
}
},
};
};
type SelectionEffectProps = React.ComponentProps<typeof ProjectSessionSelectionEffect>;
const bothProjectSections: ProjectSection[] = [staleSections[0]!, project2Sections[0]!];
function mountSelectionEffect(initial: {
activeProjectId: string;
projectSections: ProjectSection[];
sessionId: string | null;
sessionOwnerBySessionId?: ReadonlyMap<string, { projectId: string }>;
rememberedByProject?: Map<string, string>;
}) {
currentSessionId = initial.sessionId;
newSessionDraftOpen = false;
isNewWorktreeDialogOpen = false;
const sessionSelectCalls: Array<[string, string | null]> = [];
const draftCalls: Array<{ selectedProjectId?: string | null; directoryOverride?: string | null } | undefined> = [];
const dom = installMinimalDom();
const root: Root = createRoot(dom.container);
const props: SelectionEffectProps = {
projectSections: initial.projectSections,
activeProjectId: initial.activeProjectId,
initialActiveSessionByProject: initial.rememberedByProject ?? new Map(),
persistActiveSessionByProject: () => undefined,
handleSessionSelect: (sessionId, sessionDirectory) => {
sessionSelectCalls.push([sessionId, sessionDirectory]);
},
mobileVariant: false,
openNewSessionDraft: (options) => {
draftCalls.push(options);
},
setActiveMainTab: () => undefined,
setSessionSwitcherOpen: () => undefined,
sessionOwnerBySessionId: initial.sessionOwnerBySessionId,
};
act(() => {
root.render(React.createElement(ProjectSessionSelectionEffect, props));
});
return {
sessionSelectCalls,
draftCalls,
rerender: (next: Partial<SelectionEffectProps> & { sessionId?: string | null }) => {
const { sessionId, ...effectProps } = next;
if (sessionId !== undefined) currentSessionId = sessionId;
Object.assign(props, effectProps);
act(() => {
root.render(React.createElement(ProjectSessionSelectionEffect, props));
});
},
teardown: () => {
act(() => {
root.unmount();
});
dom.restore();
},
};
}
describe('ProjectSessionSelectionEffect — ownership recovery', () => {
let teardown: (() => void) | null = null;
afterEach(() => {
teardown?.();
teardown = null;
currentSessionId = null;
newSessionDraftOpen = false;
isNewWorktreeDialogOpen = false;
});
test('A → B with later foreign ownership selects B remembered session', () => {
const missingASessionId = 'session-a-missing-from-maps';
const mounted = mountSelectionEffect({
activeProjectId: 'project-1',
projectSections: bothProjectSections,
sessionId: missingASessionId,
sessionOwnerBySessionId: new Map([[missingASessionId, { projectId: 'project-1' }]]),
rememberedByProject: new Map([['project-2', 'project-2-session-2']]),
});
teardown = mounted.teardown;
expect(mounted.sessionSelectCalls).toEqual([]);
mounted.rerender({
activeProjectId: 'project-2',
sessionOwnerBySessionId: new Map(),
});
expect(mounted.sessionSelectCalls).toEqual([]);
mounted.rerender({
sessionOwnerBySessionId: new Map([[missingASessionId, { projectId: 'project-1' }]]),
});
expect(mounted.sessionSelectCalls).toEqual([
['project-2-session-2', '/workspace/project-2'],
]);
});
test('A → B with known foreign ownership selects B remembered session', () => {
const mounted = mountSelectionEffect({
activeProjectId: 'project-1',
projectSections: bothProjectSections,
sessionId: 'root-session-1',
sessionOwnerBySessionId: new Map([['root-session-1', { projectId: 'project-1' }]]),
rememberedByProject: new Map([['project-2', 'project-2-session-2']]),
});
teardown = mounted.teardown;
expect(mounted.sessionSelectCalls).toEqual([]);
mounted.rerender({ activeProjectId: 'project-2' });
expect(mounted.sessionSelectCalls).toEqual([
['project-2-session-2', '/workspace/project-2'],
]);
});
test('stale same-project worktree selection stays put when ownership arrives', () => {
const mounted = mountSelectionEffect({
activeProjectId: 'project-1',
projectSections: staleSections,
sessionId: 'wt-session-1',
sessionOwnerBySessionId: new Map(),
rememberedByProject: new Map([['project-1', 'root-session-1']]),
});
teardown = mounted.teardown;
expect(mounted.sessionSelectCalls).toEqual([]);
expect(mounted.draftCalls).toEqual([]);
mounted.rerender({
sessionOwnerBySessionId: new Map([['wt-session-1', { projectId: 'project-1' }]]),
});
expect(mounted.sessionSelectCalls).toEqual([]);
expect(mounted.draftCalls).toEqual([]);
});
});
@@ -17,6 +17,7 @@ type Args = {
activeSessionByProject: Map<string, string>;
setActiveSessionByProject: React.Dispatch<React.SetStateAction<Map<string, string>>>;
currentSessionId: string | null;
currentSessionOwnerProjectId?: string | null;
handleSessionSelect: (sessionId: string, sessionDirectory: string | null) => void;
newSessionDraftOpen: boolean;
mobileVariant: boolean;
@@ -25,6 +26,69 @@ type Args = {
setSessionSwitcherOpen: (open: boolean) => void;
};
export type MissingProjectSessionSelection =
| { kind: 'preserve-current' }
| { kind: 'open-draft' }
| { kind: 'select-session'; sessionId: string }
| { kind: 'none' };
/**
* Resolves the active-project action after its rendered session map does not
* contain the current session.
*
* Authoritative ownership wins. If ownership is still unknown, a session that
* already appears under another project's rendered map is treated as foreign,
* while a session missing from every rendered map is preserved so stale
* worktree metadata can catch up.
*/
export function resolveMissingProjectSessionSelection<T>({
activeProjectId,
currentSessionId,
currentSessionOwnerProjectId,
projectMap,
metaByProject,
rememberedSessionId,
fallbackSessionId,
}: {
activeProjectId: string;
currentSessionId: string | null;
currentSessionOwnerProjectId?: string | null;
projectMap: ReadonlyMap<string, T> | undefined;
metaByProject: ReadonlyMap<string, ReadonlyMap<string, T>>;
rememberedSessionId: string | undefined;
fallbackSessionId: string | null;
}): MissingProjectSessionSelection {
if (currentSessionId && currentSessionOwnerProjectId === activeProjectId) {
return { kind: 'preserve-current' };
}
if (currentSessionOwnerProjectId == null) {
const currentSessionBelongsToAnotherProject = Boolean(
currentSessionId
&& Array.from(metaByProject.entries()).some(
([projectId, sessions]) => projectId !== activeProjectId && sessions.has(currentSessionId),
),
);
if (currentSessionId && projectMap && !currentSessionBelongsToAnotherProject) {
return { kind: 'preserve-current' };
}
}
if (!projectMap || projectMap.size === 0) {
return { kind: 'open-draft' };
}
const remembered = rememberedSessionId && projectMap.has(rememberedSessionId)
? rememberedSessionId
: null;
const targetSessionId = remembered ?? fallbackSessionId;
if (!targetSessionId || targetSessionId === currentSessionId) {
return { kind: 'none' };
}
return { kind: 'select-session', sessionId: targetSessionId };
}
export const useProjectSessionSelection = (args: Args): void => {
const {
projectSections,
@@ -32,6 +96,7 @@ export const useProjectSessionSelection = (args: Args): void => {
activeSessionByProject,
setActiveSessionByProject,
currentSessionId,
currentSessionOwnerProjectId,
handleSessionSelect,
newSessionDraftOpen,
mobileVariant,
@@ -103,10 +168,10 @@ export const useProjectSessionSelection = (args: Args): void => {
if (!section) {
return;
}
previousActiveProjectRef.current = activeProjectId;
const projectMap = projectSessionMeta.metaByProject.get(activeProjectId);
if (currentSessionId && projectMap && projectMap.has(currentSessionId)) {
previousActiveProjectRef.current = activeProjectId;
setActiveSessionByProject((prev) => {
if (prev.get(activeProjectId) === currentSessionId) {
return prev;
@@ -118,16 +183,28 @@ export const useProjectSessionSelection = (args: Args): void => {
return;
}
// Path A' — currentSessionId is set but not in stale projectMap.
// Preserve user's explicit selection when the projectMap exists but
// is missing the session (worktree data not yet loaded). For
// empty projects (projectMap is undefined), fall through to Path B
// so a new session draft is opened.
if (currentSessionId && projectMap) {
const selection = resolveMissingProjectSessionSelection({
activeProjectId,
currentSessionId,
currentSessionOwnerProjectId,
projectMap,
metaByProject: projectSessionMeta.metaByProject,
rememberedSessionId: activeSessionByProject.get(activeProjectId),
fallbackSessionId: projectSessionMeta.firstSessionByProject.get(activeProjectId)?.id ?? null,
});
// Keep the project unprocessed while ownership/maps may still catch up,
// so a later owner of another project can still select B.
if (selection.kind === 'preserve-current') {
if (currentSessionOwnerProjectId === activeProjectId) {
previousActiveProjectRef.current = activeProjectId;
}
return;
}
if (!projectMap || projectMap.size === 0) {
previousActiveProjectRef.current = activeProjectId;
if (selection.kind === 'open-draft') {
setActiveMainTab('chat');
if (mobileVariant) {
setSessionSwitcherOpen(false);
@@ -139,21 +216,16 @@ export const useProjectSessionSelection = (args: Args): void => {
return;
}
const rememberedSessionId = activeSessionByProject.get(activeProjectId);
const remembered = rememberedSessionId && projectMap.has(rememberedSessionId)
? rememberedSessionId
: null;
const fallback = projectSessionMeta.firstSessionByProject.get(activeProjectId)?.id ?? null;
const targetSessionId = remembered ?? fallback;
if (!targetSessionId || targetSessionId === currentSessionId) {
if (selection.kind !== 'select-session') {
return;
}
const targetDirectory = projectMap.get(targetSessionId)?.directory ?? null;
handleSessionSelect(targetSessionId, targetDirectory);
const targetDirectory = projectMap?.get(selection.sessionId)?.directory ?? null;
handleSessionSelect(selection.sessionId, targetDirectory);
}, [
activeProjectId,
activeSessionByProject,
currentSessionId,
currentSessionOwnerProjectId,
handleSessionSelect,
newSessionDraftOpen,
mobileVariant,
@@ -187,19 +259,24 @@ export const useProjectSessionSelection = (args: Args): void => {
type ProjectSessionSelectionEffectProps = Omit<
Args,
'activeSessionByProject' | 'setActiveSessionByProject' | 'currentSessionId' | 'newSessionDraftOpen'
'activeSessionByProject' | 'setActiveSessionByProject' | 'currentSessionId' | 'newSessionDraftOpen' | 'currentSessionOwnerProjectId'
> & {
initialActiveSessionByProject: Map<string, string>;
persistActiveSessionByProject: (value: Map<string, string>) => void;
sessionOwnerBySessionId?: ReadonlyMap<string, { projectId: string }>;
};
export const ProjectSessionSelectionEffect: React.FC<ProjectSessionSelectionEffectProps> = ({
initialActiveSessionByProject,
persistActiveSessionByProject,
sessionOwnerBySessionId,
...args
}) => {
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const newSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open));
const currentSessionOwnerProjectId = currentSessionId
? sessionOwnerBySessionId?.get(currentSessionId)?.projectId ?? null
: null;
const [activeSessionByProject, setActiveSessionByProject] = React.useState(
() => new Map(initialActiveSessionByProject),
);
@@ -208,6 +285,7 @@ export const ProjectSessionSelectionEffect: React.FC<ProjectSessionSelectionEffe
activeSessionByProject,
setActiveSessionByProject,
currentSessionId,
currentSessionOwnerProjectId,
newSessionDraftOpen,
});
React.useEffect(() => {