Skip to content

Commit 2d083be

Browse files
devswhacursoragent
andcommitted
feat(shell): restore the selected project after reload
Reloading `/` dropped the selected project: the sidebar highlight and the composer landing were gone, and only `/session/:id` came back with its context, because it reads it from the URL. The shell store now remembers the selected project's id in localStorage, alongside the active tab, and `useProjectsState` restores it once the project list has loaded - only on `/`, and only if the project is still in the list, so a remembered id for a deleted project leaves the choice to the user. Deleting the selected project forgets it. Routing is unchanged. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent e651d53 commit 2d083be

5 files changed

Lines changed: 207 additions & 5 deletions

src/hooks/useProjectsState.query.dom.bun.test.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,8 @@ const installFetch = (responses: Array<{ status?: number; body: unknown }>) => {
9999

100100
afterEach(() => {
101101
cleanup();
102+
// The lone project selects itself and is remembered; do not hand that to the next suite.
103+
localStorage.clear();
102104
resetAppShellStore();
103105
});
104106

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import assert from 'node:assert/strict';
2+
import { afterEach, beforeEach, test } from 'node:test';
3+
4+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
5+
import { act, cleanup, render, waitFor } from '@testing-library/react';
6+
import { createElement } from 'react';
7+
8+
import type { Project } from '../types/app';
9+
import { resetAppShellStore } from '../stores/useAppShellStore';
10+
11+
import { useProjectsState } from './useProjectsState';
12+
13+
/*
14+
* The selected project survives a reload of `/`.
15+
*
16+
* `/session/:id` restores its context from the URL, but `/` used to come back
17+
* to "pick a project" every time. The last selection is remembered and
18+
* restored when the list has loaded - and only if the project is still in it.
19+
*/
20+
21+
const project = (projectId: string, displayName: string): Project => ({
22+
projectId,
23+
path: `/workspace/${projectId}`,
24+
fullPath: `/workspace/${projectId}`,
25+
displayName,
26+
origin: 'explicit',
27+
isStarred: false,
28+
sessions: [],
29+
sessionMeta: { hasMore: false, total: 0 },
30+
});
31+
32+
// Two projects, so the "a lone project selects itself" rule stays out of the way.
33+
const twoProjects = [project('project-1', 'Project one'), project('project-2', 'Project two')];
34+
35+
type HookState = ReturnType<typeof useProjectsState>;
36+
37+
/** One page load: a fresh query cache and shell store, the same localStorage. */
38+
const mountApp = (route: { sessionId?: string | null } = {}) => {
39+
let state: HookState | null = null;
40+
const queryClient = new QueryClient({ defaultOptions: { queries: { refetchOnWindowFocus: false, retry: false } } });
41+
const Harness = () => {
42+
state = useProjectsState({
43+
sessionId: route.sessionId ?? null,
44+
navigate: (() => undefined) as never,
45+
subscribe: () => () => undefined,
46+
isMobile: false,
47+
activeSessions: new Map(),
48+
});
49+
return null;
50+
};
51+
const view = render(createElement(QueryClientProvider, { client: queryClient }, createElement(Harness)));
52+
return {
53+
getState: () => {
54+
assert.ok(state, 'hook state is available after rendering');
55+
return state;
56+
},
57+
reload: () => {
58+
view.unmount();
59+
resetAppShellStore();
60+
},
61+
};
62+
};
63+
64+
const serveProjects = (projects: Project[]) => {
65+
const originalFetch = globalThis.fetch;
66+
globalThis.fetch = async () => new Response(JSON.stringify(projects), { status: 200 });
67+
return () => { globalThis.fetch = originalFetch; };
68+
};
69+
70+
// Suites share one window here, so a remembered id from another file must not
71+
// leak into a "first visit".
72+
beforeEach(() => {
73+
localStorage.clear();
74+
resetAppShellStore();
75+
});
76+
77+
afterEach(() => {
78+
cleanup();
79+
localStorage.clear();
80+
resetAppShellStore();
81+
});
82+
83+
test('the project selected before a reload is selected again after it', async () => {
84+
const restore = serveProjects(twoProjects);
85+
try {
86+
const first = mountApp();
87+
await waitFor(() => assert.equal(first.getState().projects.length, 2));
88+
assert.equal(first.getState().selectedProject, null, 'nothing is chosen for the user on a first visit');
89+
90+
act(() => { first.getState().handleProjectSelect(twoProjects[1]); });
91+
assert.equal(first.getState().selectedProject?.projectId, 'project-2');
92+
93+
first.reload();
94+
const second = mountApp();
95+
await waitFor(() => assert.equal(second.getState().selectedProject?.projectId, 'project-2'));
96+
} finally {
97+
restore();
98+
}
99+
});
100+
101+
test('a remembered project that no longer exists is ignored', async () => {
102+
localStorage.setItem('selectedProjectId', 'project-deleted');
103+
const restore = serveProjects(twoProjects);
104+
try {
105+
const app = mountApp();
106+
await waitFor(() => assert.equal(app.getState().projects.length, 2));
107+
// Give the restore effect a turn; a stale id must not select anything.
108+
await act(async () => { await Promise.resolve(); });
109+
assert.equal(app.getState().selectedProject, null);
110+
} finally {
111+
restore();
112+
}
113+
});
114+
115+
test('deleting the selected project forgets it, so a reload does not bring it back', async () => {
116+
// Three, so that two remain and neither selects itself as a lone project.
117+
const restore = serveProjects([...twoProjects, project('project-3', 'Project three')]);
118+
try {
119+
const app = mountApp();
120+
await waitFor(() => assert.equal(app.getState().projects.length, 3));
121+
act(() => { app.getState().handleProjectSelect(twoProjects[0]); });
122+
assert.equal(localStorage.getItem('selectedProjectId'), 'project-1');
123+
124+
act(() => { app.getState().handleProjectDelete('project-1'); });
125+
await act(async () => { await Promise.resolve(); });
126+
assert.equal(localStorage.getItem('selectedProjectId'), null);
127+
assert.equal(app.getState().selectedProject, null);
128+
} finally {
129+
restore();
130+
}
131+
});
132+
133+
test('a session route restores from the URL and leaves the remembered project alone', async () => {
134+
localStorage.setItem('selectedProjectId', 'project-2');
135+
const restore = serveProjects(twoProjects);
136+
try {
137+
const app = mountApp({ sessionId: 'session-elsewhere' });
138+
await waitFor(() => assert.equal(app.getState().projects.length, 2));
139+
await act(async () => { await Promise.resolve(); });
140+
assert.equal(app.getState().selectedProject, null, 'the URL owns the context on /session/:id');
141+
} finally {
142+
restore();
143+
}
144+
});

src/hooks/useProjectsState.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { NavigateFunction } from 'react-router-dom';
44

55
import type { ServerEvent } from '../contexts/WebSocketContext';
66
import { api } from '../utils/api';
7-
import { useAppShellStore } from '../stores/useAppShellStore';
7+
import { readPersistedProjectId, useAppShellStore } from '../stores/useAppShellStore';
88
import { useSessionAttentionStore } from '../stores/useSessionAttentionStore';
99
import type { LLMProvider, LoadingProgress, Project, ProjectSession } from '../types/app';
1010

@@ -149,8 +149,19 @@ export function useProjectsState({ sessionId, navigate, subscribe, isMobile, act
149149
setSelectedProject((current) => reconcileSelectedProject(current, query.data ?? []));
150150
}, [query.data, setSelectedProject]);
151151

152+
// On `/` with nothing selected: a lone project selects itself, otherwise the
153+
// project the user last worked in comes back - but only if it still exists,
154+
// so a remembered id for a deleted project leaves the choice to the user.
155+
// `/session/:id` restores its context from the URL and is left alone.
152156
useEffect(() => {
153-
if (!query.isLoading && projects.length === 1 && !selectedProject && !sessionId) setSelectedProject(projects[0]);
157+
if (query.isLoading || selectedProject || sessionId) return;
158+
if (projects.length === 1) {
159+
setSelectedProject(projects[0]);
160+
return;
161+
}
162+
const rememberedId = readPersistedProjectId();
163+
const remembered = rememberedId ? projects.find((project) => project.projectId === rememberedId) : undefined;
164+
if (remembered) setSelectedProject(remembered);
154165
}, [projects, query.isLoading, selectedProject, sessionId, setSelectedProject]);
155166

156167
useEffect(() => {

src/stores/useAppShellStore.dom.bun.test.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,19 @@ test('invalid persisted tabs fall back to chat and tab updates persist', () => {
7070
assert.equal(localStorage.getItem('activeTab'), 'tasks');
7171
});
7272

73+
test('the selected project id is remembered, and forgotten when the selection clears', () => {
74+
const project = { projectId: 'project-1', displayName: 'One' } as Project;
75+
useAppShellStore.getState().setSelectedProject(project);
76+
assert.equal(localStorage.getItem('selectedProjectId'), 'project-1');
77+
78+
// Reconciling the same project with fresh data is not a new choice.
79+
useAppShellStore.getState().setSelectedProject((previous) => previous ? { ...previous, displayName: 'One (renamed)' } : previous);
80+
assert.equal(localStorage.getItem('selectedProjectId'), 'project-1');
81+
82+
useAppShellStore.getState().setSelectedProject(null);
83+
assert.equal(localStorage.getItem('selectedProjectId'), null);
84+
});
85+
7386
test('openSettings defaults to tools and shows the settings modal', () => {
7487
useAppShellStore.getState().openSettings();
7588

src/stores/useAppShellStore.ts

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,33 @@ const readPersistedTab = (): AppTab => {
4141
return 'chat';
4242
};
4343

44+
const SELECTED_PROJECT_KEY = 'selectedProjectId';
45+
46+
/**
47+
* The project the user last worked in. `/session/:id` restores its own
48+
* context from the URL; this is what lets `/` come back to the same project
49+
* after a reload instead of the empty "pick a project" state.
50+
*/
51+
export const readPersistedProjectId = (): string | null => {
52+
try {
53+
return localStorage.getItem(SELECTED_PROJECT_KEY);
54+
} catch {
55+
return null;
56+
}
57+
};
58+
59+
const persistProjectId = (projectId: string | null) => {
60+
try {
61+
if (projectId) {
62+
localStorage.setItem(SELECTED_PROJECT_KEY, projectId);
63+
} else {
64+
localStorage.removeItem(SELECTED_PROJECT_KEY);
65+
}
66+
} catch {
67+
// Silently ignore storage errors
68+
}
69+
};
70+
4471
const resolve = <T,>(next: T | ((prev: T) => T), prev: T): T =>
4572
typeof next === 'function' ? (next as (prev: T) => T)(prev) : next;
4673

@@ -63,9 +90,14 @@ const createInitialState = (): AppShellState => ({
6390

6491
export const useAppShellStore = create<AppShellState>()((set) => ({
6592
...createInitialState(),
66-
setSelectedProject: (next) => set((state) => ({
67-
selectedProject: resolve(next, state.selectedProject),
68-
})),
93+
setSelectedProject: (next) => set((state) => {
94+
const selectedProject = resolve(next, state.selectedProject);
95+
// Reconciliation re-sets the same project on every fetch; only a change
96+
// of project is worth a storage write.
97+
const projectId = selectedProject?.projectId ?? null;
98+
if (projectId !== (state.selectedProject?.projectId ?? null)) persistProjectId(projectId);
99+
return { selectedProject };
100+
}),
69101
setSelectedSession: (next) => set((state) => ({
70102
selectedSession: resolve(next, state.selectedSession),
71103
})),

0 commit comments

Comments
 (0)