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:
@@ -1825,6 +1825,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
|||||||
openNewSessionDraft={openNewSessionDraft}
|
openNewSessionDraft={openNewSessionDraft}
|
||||||
setActiveMainTab={setActiveMainTab}
|
setActiveMainTab={setActiveMainTab}
|
||||||
setSessionSwitcherOpen={setSessionSwitcherOpen}
|
setSessionSwitcherOpen={setSessionSwitcherOpen}
|
||||||
|
sessionOwnerBySessionId={sessionOwnership.bySessionId}
|
||||||
/>
|
/>
|
||||||
<SessionPrefetchEffect
|
<SessionPrefetchEffect
|
||||||
enabled={isVisible}
|
enabled={isVisible}
|
||||||
|
|||||||
+347
-62
@@ -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 { Session } from '@opencode-ai/sdk/v2';
|
||||||
import type { SessionGroup, SessionNode } from '../types';
|
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
|
// 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 = {
|
type ProjectSection = {
|
||||||
@@ -182,24 +211,6 @@ describe('useProjectSessionSelection — worktree session click race', () => {
|
|||||||
expect(projectMap?.has('wt-session-1')).toBe(true);
|
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', () => {
|
test('second click works correctly when projectSections is updated', () => {
|
||||||
const { metaByProject } = computeProjectMeta(updatedSections);
|
const { metaByProject } = computeProjectMeta(updatedSections);
|
||||||
const projectMap = metaByProject.get('project-1')!;
|
const projectMap = metaByProject.get('project-1')!;
|
||||||
@@ -210,53 +221,327 @@ describe('useProjectSessionSelection — worktree session click race', () => {
|
|||||||
expect(pathAHit).toBe(true);
|
expect(pathAHit).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('project switch: guard does NOT fire when currentSessionId matches new project', () => {
|
test('project switch: Path A succeeds when currentSessionId matches the new project', () => {
|
||||||
// Simulates: user clicks a session in project-2 (normal click, not worktree)
|
|
||||||
const { metaByProject } = computeProjectMeta(project2Sections);
|
const { metaByProject } = computeProjectMeta(project2Sections);
|
||||||
const projectMap = metaByProject.get('project-2')!;
|
const projectMap = metaByProject.get('project-2')!;
|
||||||
const currentSessionId = 'project-2-session-1';
|
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));
|
const pathAHit = Boolean(currentSessionId && projectMap?.has(currentSessionId));
|
||||||
expect(pathAHit).toBe(true);
|
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);
|
describe('resolveMissingProjectSessionSelection', () => {
|
||||||
});
|
test('A → B selects B remembered session when the current session is owned by A', () => {
|
||||||
|
const projectBMap = new Map([
|
||||||
test('guard does NOT fire when currentSessionId is null (deleted/archived session)', () => {
|
['project-b-first-session', null],
|
||||||
const { metaByProject } = computeProjectMeta(staleSections);
|
['project-b-remembered-session', null],
|
||||||
const projectMap = metaByProject.get('project-1')!;
|
]);
|
||||||
const currentSessionId = null;
|
|
||||||
|
expect(resolveMissingProjectSessionSelection({
|
||||||
// Path A: currentSessionId is null → skipped
|
activeProjectId: 'project-b',
|
||||||
const pathAHit = Boolean(currentSessionId && projectMap?.has(currentSessionId));
|
currentSessionId: 'stale-worktree-session-a',
|
||||||
expect(pathAHit).toBe(false);
|
currentSessionOwnerProjectId: 'project-a',
|
||||||
|
projectMap: projectBMap,
|
||||||
// Guard: currentSessionId is null → skipped, falls through to Path B/C
|
metaByProject: new Map([['project-b', projectBMap]]),
|
||||||
const guardWouldFire = currentSessionId !== null && !pathAHit;
|
rememberedSessionId: 'project-b-remembered-session',
|
||||||
expect(guardWouldFire).toBe(false);
|
fallbackSessionId: 'project-b-first-session',
|
||||||
});
|
})).toEqual({ kind: 'select-session', sessionId: 'project-b-remembered-session' });
|
||||||
|
});
|
||||||
test('guard does NOT fire for empty projects — falls through to Path B (open draft)', () => {
|
|
||||||
// Empty project: no groups/sessions in projectSections
|
test('A → B falls back to B first session when none is remembered', () => {
|
||||||
const emptySections: ProjectSection[] = [
|
const projectAMap = new Map([['project-a-session', null]]);
|
||||||
{
|
const projectBMap = new Map([['project-b-first-session', null]]);
|
||||||
project: { id: 'empty-project', normalizedPath: '/workspace/empty' },
|
const metaByProject = new Map([
|
||||||
groups: [],
|
['project-a', projectAMap],
|
||||||
},
|
['project-b', projectBMap],
|
||||||
];
|
]);
|
||||||
const { metaByProject } = computeProjectMeta(emptySections);
|
|
||||||
const projectMap = metaByProject.get('empty-project');
|
expect(resolveMissingProjectSessionSelection({
|
||||||
const currentSessionId = 'some-session-id';
|
activeProjectId: 'project-b',
|
||||||
|
currentSessionId: 'project-a-session',
|
||||||
// projectMap is undefined for empty project
|
currentSessionOwnerProjectId: 'project-a',
|
||||||
expect(projectMap).toBe(undefined);
|
projectMap: projectBMap,
|
||||||
|
metaByProject,
|
||||||
// Guard: projectMap is undefined → skipped, falls through to Path B
|
rememberedSessionId: undefined,
|
||||||
// which opens a new session draft for the empty project
|
fallbackSessionId: 'project-b-first-session',
|
||||||
const guardWouldFire = Boolean(currentSessionId && projectMap);
|
})).toEqual({ kind: 'select-session', sessionId: 'project-b-first-session' });
|
||||||
expect(guardWouldFire).toBe(false);
|
});
|
||||||
|
|
||||||
|
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>;
|
activeSessionByProject: Map<string, string>;
|
||||||
setActiveSessionByProject: React.Dispatch<React.SetStateAction<Map<string, string>>>;
|
setActiveSessionByProject: React.Dispatch<React.SetStateAction<Map<string, string>>>;
|
||||||
currentSessionId: string | null;
|
currentSessionId: string | null;
|
||||||
|
currentSessionOwnerProjectId?: string | null;
|
||||||
handleSessionSelect: (sessionId: string, sessionDirectory: string | null) => void;
|
handleSessionSelect: (sessionId: string, sessionDirectory: string | null) => void;
|
||||||
newSessionDraftOpen: boolean;
|
newSessionDraftOpen: boolean;
|
||||||
mobileVariant: boolean;
|
mobileVariant: boolean;
|
||||||
@@ -25,6 +26,69 @@ type Args = {
|
|||||||
setSessionSwitcherOpen: (open: boolean) => void;
|
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 => {
|
export const useProjectSessionSelection = (args: Args): void => {
|
||||||
const {
|
const {
|
||||||
projectSections,
|
projectSections,
|
||||||
@@ -32,6 +96,7 @@ export const useProjectSessionSelection = (args: Args): void => {
|
|||||||
activeSessionByProject,
|
activeSessionByProject,
|
||||||
setActiveSessionByProject,
|
setActiveSessionByProject,
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
|
currentSessionOwnerProjectId,
|
||||||
handleSessionSelect,
|
handleSessionSelect,
|
||||||
newSessionDraftOpen,
|
newSessionDraftOpen,
|
||||||
mobileVariant,
|
mobileVariant,
|
||||||
@@ -103,10 +168,10 @@ export const useProjectSessionSelection = (args: Args): void => {
|
|||||||
if (!section) {
|
if (!section) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
previousActiveProjectRef.current = activeProjectId;
|
|
||||||
const projectMap = projectSessionMeta.metaByProject.get(activeProjectId);
|
const projectMap = projectSessionMeta.metaByProject.get(activeProjectId);
|
||||||
|
|
||||||
if (currentSessionId && projectMap && projectMap.has(currentSessionId)) {
|
if (currentSessionId && projectMap && projectMap.has(currentSessionId)) {
|
||||||
|
previousActiveProjectRef.current = activeProjectId;
|
||||||
setActiveSessionByProject((prev) => {
|
setActiveSessionByProject((prev) => {
|
||||||
if (prev.get(activeProjectId) === currentSessionId) {
|
if (prev.get(activeProjectId) === currentSessionId) {
|
||||||
return prev;
|
return prev;
|
||||||
@@ -118,16 +183,28 @@ export const useProjectSessionSelection = (args: Args): void => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path A' — currentSessionId is set but not in stale projectMap.
|
const selection = resolveMissingProjectSessionSelection({
|
||||||
// Preserve user's explicit selection when the projectMap exists but
|
activeProjectId,
|
||||||
// is missing the session (worktree data not yet loaded). For
|
currentSessionId,
|
||||||
// empty projects (projectMap is undefined), fall through to Path B
|
currentSessionOwnerProjectId,
|
||||||
// so a new session draft is opened.
|
projectMap,
|
||||||
if (currentSessionId && 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!projectMap || projectMap.size === 0) {
|
previousActiveProjectRef.current = activeProjectId;
|
||||||
|
|
||||||
|
if (selection.kind === 'open-draft') {
|
||||||
setActiveMainTab('chat');
|
setActiveMainTab('chat');
|
||||||
if (mobileVariant) {
|
if (mobileVariant) {
|
||||||
setSessionSwitcherOpen(false);
|
setSessionSwitcherOpen(false);
|
||||||
@@ -139,21 +216,16 @@ export const useProjectSessionSelection = (args: Args): void => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rememberedSessionId = activeSessionByProject.get(activeProjectId);
|
if (selection.kind !== 'select-session') {
|
||||||
const remembered = rememberedSessionId && projectMap.has(rememberedSessionId)
|
|
||||||
? rememberedSessionId
|
|
||||||
: null;
|
|
||||||
const fallback = projectSessionMeta.firstSessionByProject.get(activeProjectId)?.id ?? null;
|
|
||||||
const targetSessionId = remembered ?? fallback;
|
|
||||||
if (!targetSessionId || targetSessionId === currentSessionId) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const targetDirectory = projectMap.get(targetSessionId)?.directory ?? null;
|
const targetDirectory = projectMap?.get(selection.sessionId)?.directory ?? null;
|
||||||
handleSessionSelect(targetSessionId, targetDirectory);
|
handleSessionSelect(selection.sessionId, targetDirectory);
|
||||||
}, [
|
}, [
|
||||||
activeProjectId,
|
activeProjectId,
|
||||||
activeSessionByProject,
|
activeSessionByProject,
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
|
currentSessionOwnerProjectId,
|
||||||
handleSessionSelect,
|
handleSessionSelect,
|
||||||
newSessionDraftOpen,
|
newSessionDraftOpen,
|
||||||
mobileVariant,
|
mobileVariant,
|
||||||
@@ -187,19 +259,24 @@ export const useProjectSessionSelection = (args: Args): void => {
|
|||||||
|
|
||||||
type ProjectSessionSelectionEffectProps = Omit<
|
type ProjectSessionSelectionEffectProps = Omit<
|
||||||
Args,
|
Args,
|
||||||
'activeSessionByProject' | 'setActiveSessionByProject' | 'currentSessionId' | 'newSessionDraftOpen'
|
'activeSessionByProject' | 'setActiveSessionByProject' | 'currentSessionId' | 'newSessionDraftOpen' | 'currentSessionOwnerProjectId'
|
||||||
> & {
|
> & {
|
||||||
initialActiveSessionByProject: Map<string, string>;
|
initialActiveSessionByProject: Map<string, string>;
|
||||||
persistActiveSessionByProject: (value: Map<string, string>) => void;
|
persistActiveSessionByProject: (value: Map<string, string>) => void;
|
||||||
|
sessionOwnerBySessionId?: ReadonlyMap<string, { projectId: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ProjectSessionSelectionEffect: React.FC<ProjectSessionSelectionEffectProps> = ({
|
export const ProjectSessionSelectionEffect: React.FC<ProjectSessionSelectionEffectProps> = ({
|
||||||
initialActiveSessionByProject,
|
initialActiveSessionByProject,
|
||||||
persistActiveSessionByProject,
|
persistActiveSessionByProject,
|
||||||
|
sessionOwnerBySessionId,
|
||||||
...args
|
...args
|
||||||
}) => {
|
}) => {
|
||||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||||
const newSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open));
|
const newSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open));
|
||||||
|
const currentSessionOwnerProjectId = currentSessionId
|
||||||
|
? sessionOwnerBySessionId?.get(currentSessionId)?.projectId ?? null
|
||||||
|
: null;
|
||||||
const [activeSessionByProject, setActiveSessionByProject] = React.useState(
|
const [activeSessionByProject, setActiveSessionByProject] = React.useState(
|
||||||
() => new Map(initialActiveSessionByProject),
|
() => new Map(initialActiveSessionByProject),
|
||||||
);
|
);
|
||||||
@@ -208,6 +285,7 @@ export const ProjectSessionSelectionEffect: React.FC<ProjectSessionSelectionEffe
|
|||||||
activeSessionByProject,
|
activeSessionByProject,
|
||||||
setActiveSessionByProject,
|
setActiveSessionByProject,
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
|
currentSessionOwnerProjectId,
|
||||||
newSessionDraftOpen,
|
newSessionDraftOpen,
|
||||||
});
|
});
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user