Skip to content

Commit 64cfb3c

Browse files
authored
fix(workbench): handle stale workspace resume memory (#1271)
Remember explicit workspace home visits and validate remembered session entries against the existing workspace session list before resuming. Stale entries now fall back to the workspace home and are forgotten. Closes #1270
1 parent 9a28ca3 commit 64cfb3c

4 files changed

Lines changed: 137 additions & 3 deletions

File tree

components/workbench/workspace/WorkspaceShell.tsx

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,10 @@ import { useWorkspacePaneNavigation } from '@/lib/workbench/use-workspace-pane-n
9696
import { readCourseTabsMemory, writeCourseTabsMemory } from '@/lib/workbench/workspace-course-tabs';
9797
import {
9898
forgetWorkspaceSession,
99+
readLastWorkspaceSessionId,
100+
rememberWorkspaceHome,
99101
rememberWorkspaceSession,
102+
validateRememberedWorkspaceSession,
100103
} from '@/lib/workbench/workspace-session-memory';
101104
import type { WorkbenchCourseSummary } from '@/lib/workbench/panel-context';
102105
import { useWorkbenchStore, type WorkbenchMaterial } from '@/lib/workbench/session-store';
@@ -190,9 +193,19 @@ function WorkspaceShellController({ initialPanes }: { readonly initialPanes: Wor
190193
const chatWidth = useChatWidth();
191194

192195
const panes = navigation.panes;
196+
const [rememberedResumeSessionId] = useState(() => {
197+
const remembered = readLastWorkspaceSessionId();
198+
return initialPanes.courseId === null && initialPanes.sessionId === remembered
199+
? remembered
200+
: null;
201+
});
193202
useEffect(() => {
194-
if (panes.sessionId) rememberWorkspaceSession(panes.sessionId);
195-
}, [panes.sessionId]);
203+
if (panes.sessionId) {
204+
rememberWorkspaceSession(panes.sessionId);
205+
} else if (!panes.courseId) {
206+
rememberWorkspaceHome();
207+
}
208+
}, [panes.courseId, panes.sessionId]);
196209
const collapse = usePaneCollapse();
197210
const [courseTabs, setCourseTabs] = useState(() => restoreCourseTabs(null, panes.courseId));
198211
const narrow = useNarrowViewport();
@@ -333,6 +346,25 @@ function WorkspaceShellController({ initialPanes }: { readonly initialPanes: Wor
333346
};
334347
}, []);
335348

349+
useEffect(() => {
350+
if (
351+
sessionState !== 'ready' ||
352+
!rememberedResumeSessionId ||
353+
panes.sessionId !== rememberedResumeSessionId
354+
) {
355+
return;
356+
}
357+
if (
358+
validateRememberedWorkspaceSession(
359+
rememberedResumeSessionId,
360+
sessions.map((session) => session.id),
361+
)
362+
) {
363+
return;
364+
}
365+
navigation.replace({ sessionId: null, courseId: null });
366+
}, [navigation, panes.sessionId, rememberedResumeSessionId, sessions, sessionState]);
367+
336368
// ── Navigation ────────────────────────────────────────────────────────
337369
const goTo = useCallback(
338370
(next: WorkspacePanes) => {

lib/workbench/workspace-session-memory.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
*/
88
export const LAST_WORKSPACE_SESSION_STORAGE_KEY = 'openmaic:workspace:last-session';
99

10+
const WORKSPACE_HOME_SENTINEL = 'openmaic:workspace:home';
11+
1012
interface SessionMemoryStorage {
1113
getItem(key: string): string | null;
1214
setItem(key: string, value: string): void;
@@ -26,9 +28,10 @@ function normalizeSessionId(value: string | null | undefined): string | null {
2628

2729
export function readLastWorkspaceSessionId(storage?: SessionMemoryStorage): string | null {
2830
try {
29-
return normalizeSessionId(
31+
const remembered = normalizeSessionId(
3032
availableStorage(storage)?.getItem(LAST_WORKSPACE_SESSION_STORAGE_KEY),
3133
);
34+
return remembered === WORKSPACE_HOME_SENTINEL ? null : remembered;
3235
} catch {
3336
return null;
3437
}
@@ -44,6 +47,15 @@ export function rememberWorkspaceSession(sessionId: string, storage?: SessionMem
4447
}
4548
}
4649

50+
/** Remember that the clean workspace home, rather than a conversation, was last open. */
51+
export function rememberWorkspaceHome(storage?: SessionMemoryStorage): void {
52+
try {
53+
availableStorage(storage)?.setItem(LAST_WORKSPACE_SESSION_STORAGE_KEY, WORKSPACE_HOME_SENTINEL);
54+
} catch {
55+
// Storage can be unavailable in private or restricted browser contexts.
56+
}
57+
}
58+
4759
export function forgetWorkspaceSession(sessionId: string, storage?: SessionMemoryStorage): void {
4860
const normalized = normalizeSessionId(sessionId);
4961
if (!normalized) return;
@@ -57,6 +69,19 @@ export function forgetWorkspaceSession(sessionId: string, storage?: SessionMemor
5769
}
5870
}
5971

72+
/** Forget a remembered conversation when the workspace's existing list no longer contains it. */
73+
export function validateRememberedWorkspaceSession(
74+
sessionId: string,
75+
existingSessionIds: readonly string[],
76+
storage?: SessionMemoryStorage,
77+
): boolean {
78+
const normalized = normalizeSessionId(sessionId);
79+
if (!normalized) return false;
80+
const exists = existingSessionIds.some((existing) => normalizeSessionId(existing) === normalized);
81+
if (!exists) forgetWorkspaceSession(normalized, storage);
82+
return exists;
83+
}
84+
6085
/** The classic-mode entry target; missing memory keeps the clean Pro home. */
6186
export function workspaceResumeHref(sessionId: string | null): string {
6287
const normalized = normalizeSessionId(sessionId);

tests/workbench/workspace-course-chat-bootstrap.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,10 @@ import {
151151
CLASSROOM_COLLAPSED_STORAGE_KEY,
152152
} from '@/lib/workbench/workspace-panes';
153153
import { COURSE_TABS_STORAGE_KEY } from '@/lib/workbench/workspace-course-tabs';
154+
import {
155+
LAST_WORKSPACE_SESSION_STORAGE_KEY,
156+
readLastWorkspaceSessionId,
157+
} from '@/lib/workbench/workspace-session-memory';
154158

155159
let root: Root | null = null;
156160
let container: HTMLDivElement | null = null;
@@ -244,6 +248,26 @@ afterEach(async () => {
244248
vi.restoreAllMocks();
245249
});
246250

251+
describe('a remembered workspace entry', () => {
252+
it('forgets a missing session and replaces it with the workspace home', async () => {
253+
mocks.searchParams = new URLSearchParams('session=session-stale');
254+
localStorage.setItem(LAST_WORKSPACE_SESSION_STORAGE_KEY, 'session-stale');
255+
256+
await render();
257+
258+
expect(mocks.routerReplace).toHaveBeenCalledWith('/workspace');
259+
expect(readLastWorkspaceSessionId()).toBeNull();
260+
});
261+
262+
it('does not treat an unremembered deep link as a stale resume', async () => {
263+
mocks.searchParams = new URLSearchParams('session=session-shared');
264+
265+
await render();
266+
267+
expect(mocks.routerReplace).not.toHaveBeenCalled();
268+
});
269+
});
270+
247271
describe('a course opened without a conversation', () => {
248272
it('creates NOTHING on arrival, however many times the course is opened', async () => {
249273
await render();

tests/workbench/workspace-session-memory.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ import {
33
forgetWorkspaceSession,
44
LAST_WORKSPACE_SESSION_STORAGE_KEY,
55
readLastWorkspaceSessionId,
6+
rememberWorkspaceHome,
67
rememberWorkspaceSession,
8+
validateRememberedWorkspaceSession,
79
workspaceResumeHref,
810
} from '@/lib/workbench/workspace-session-memory';
911

@@ -38,6 +40,17 @@ describe('workspace session entry memory', () => {
3840
expect(workspaceResumeHref(' ')).toBe('/workspace');
3941
});
4042

43+
it('remembers an explicit workspace home over the previously opened chat', () => {
44+
const storage = new MemoryStorage();
45+
rememberWorkspaceSession('session-old', storage);
46+
47+
rememberWorkspaceHome(storage);
48+
49+
expect(readLastWorkspaceSessionId(storage)).toBeNull();
50+
expect(workspaceResumeHref(readLastWorkspaceSessionId(storage))).toBe('/workspace');
51+
expect(storage.getItem(LAST_WORKSPACE_SESSION_STORAGE_KEY)).not.toBeNull();
52+
});
53+
4154
it('forgets only the matching deleted conversation', () => {
4255
const storage = new MemoryStorage();
4356
storage.setItem(LAST_WORKSPACE_SESSION_STORAGE_KEY, 'session-kept');
@@ -49,6 +62,46 @@ describe('workspace session entry memory', () => {
4962
expect(readLastWorkspaceSessionId(storage)).toBeNull();
5063
});
5164

65+
it('forgets a remembered session that is missing from the loaded session list', () => {
66+
const storage = new MemoryStorage();
67+
rememberWorkspaceSession('session-stale', storage);
68+
69+
expect(validateRememberedWorkspaceSession('session-stale', ['session-current'], storage)).toBe(
70+
false,
71+
);
72+
expect(readLastWorkspaceSessionId(storage)).toBeNull();
73+
});
74+
75+
it('keeps a remembered session that still exists', () => {
76+
const storage = new MemoryStorage();
77+
rememberWorkspaceSession('session-current', storage);
78+
79+
expect(
80+
validateRememberedWorkspaceSession('session-current', ['session-current'], storage),
81+
).toBe(true);
82+
expect(readLastWorkspaceSessionId(storage)).toBe('session-current');
83+
});
84+
85+
it('stays safe when storage is unavailable', () => {
86+
const storage = {
87+
getItem: () => {
88+
throw new Error('unavailable');
89+
},
90+
setItem: () => {
91+
throw new Error('unavailable');
92+
},
93+
removeItem: () => {
94+
throw new Error('unavailable');
95+
},
96+
};
97+
98+
expect(() => rememberWorkspaceSession('session-a', storage)).not.toThrow();
99+
expect(() => rememberWorkspaceHome(storage)).not.toThrow();
100+
expect(readLastWorkspaceSessionId(storage)).toBeNull();
101+
expect(() => forgetWorkspaceSession('session-a', storage)).not.toThrow();
102+
expect(() => validateRememberedWorkspaceSession('session-a', [], storage)).not.toThrow();
103+
});
104+
52105
it('encodes opaque session ids in the entry URL', () => {
53106
expect(workspaceResumeHref('session / ?')).toBe('/workspace?session=session%20%2F%20%3F');
54107
});

0 commit comments

Comments
 (0)