From 48c2baf7de3e4e01e9ccbc068d27722a361ca4da Mon Sep 17 00:00:00 2001 From: wyuc Date: Fri, 28 Aug 2026 04:41:52 -0400 Subject: [PATCH] fix(workbench): handle stale workspace resume memory 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 --- .../workbench/workspace/WorkspaceShell.tsx | 36 ++++++++++++- lib/workbench/workspace-session-memory.ts | 27 +++++++++- .../workspace-course-chat-bootstrap.test.ts | 24 +++++++++ .../workspace-session-memory.test.ts | 53 +++++++++++++++++++ 4 files changed, 137 insertions(+), 3 deletions(-) diff --git a/components/workbench/workspace/WorkspaceShell.tsx b/components/workbench/workspace/WorkspaceShell.tsx index 320a45a8ba..ad6af7f136 100644 --- a/components/workbench/workspace/WorkspaceShell.tsx +++ b/components/workbench/workspace/WorkspaceShell.tsx @@ -96,7 +96,10 @@ import { useWorkspacePaneNavigation } from '@/lib/workbench/use-workspace-pane-n import { readCourseTabsMemory, writeCourseTabsMemory } from '@/lib/workbench/workspace-course-tabs'; import { forgetWorkspaceSession, + readLastWorkspaceSessionId, + rememberWorkspaceHome, rememberWorkspaceSession, + validateRememberedWorkspaceSession, } from '@/lib/workbench/workspace-session-memory'; import type { WorkbenchCourseSummary } from '@/lib/workbench/panel-context'; import { useWorkbenchStore, type WorkbenchMaterial } from '@/lib/workbench/session-store'; @@ -190,9 +193,19 @@ function WorkspaceShellController({ initialPanes }: { readonly initialPanes: Wor const chatWidth = useChatWidth(); const panes = navigation.panes; + const [rememberedResumeSessionId] = useState(() => { + const remembered = readLastWorkspaceSessionId(); + return initialPanes.courseId === null && initialPanes.sessionId === remembered + ? remembered + : null; + }); useEffect(() => { - if (panes.sessionId) rememberWorkspaceSession(panes.sessionId); - }, [panes.sessionId]); + if (panes.sessionId) { + rememberWorkspaceSession(panes.sessionId); + } else if (!panes.courseId) { + rememberWorkspaceHome(); + } + }, [panes.courseId, panes.sessionId]); const collapse = usePaneCollapse(); const [courseTabs, setCourseTabs] = useState(() => restoreCourseTabs(null, panes.courseId)); const narrow = useNarrowViewport(); @@ -333,6 +346,25 @@ function WorkspaceShellController({ initialPanes }: { readonly initialPanes: Wor }; }, []); + useEffect(() => { + if ( + sessionState !== 'ready' || + !rememberedResumeSessionId || + panes.sessionId !== rememberedResumeSessionId + ) { + return; + } + if ( + validateRememberedWorkspaceSession( + rememberedResumeSessionId, + sessions.map((session) => session.id), + ) + ) { + return; + } + navigation.replace({ sessionId: null, courseId: null }); + }, [navigation, panes.sessionId, rememberedResumeSessionId, sessions, sessionState]); + // ── Navigation ──────────────────────────────────────────────────────── const goTo = useCallback( (next: WorkspacePanes) => { diff --git a/lib/workbench/workspace-session-memory.ts b/lib/workbench/workspace-session-memory.ts index d609e6ff60..ce382b8741 100644 --- a/lib/workbench/workspace-session-memory.ts +++ b/lib/workbench/workspace-session-memory.ts @@ -7,6 +7,8 @@ */ export const LAST_WORKSPACE_SESSION_STORAGE_KEY = 'openmaic:workspace:last-session'; +const WORKSPACE_HOME_SENTINEL = 'openmaic:workspace:home'; + interface SessionMemoryStorage { getItem(key: string): string | null; setItem(key: string, value: string): void; @@ -26,9 +28,10 @@ function normalizeSessionId(value: string | null | undefined): string | null { export function readLastWorkspaceSessionId(storage?: SessionMemoryStorage): string | null { try { - return normalizeSessionId( + const remembered = normalizeSessionId( availableStorage(storage)?.getItem(LAST_WORKSPACE_SESSION_STORAGE_KEY), ); + return remembered === WORKSPACE_HOME_SENTINEL ? null : remembered; } catch { return null; } @@ -44,6 +47,15 @@ export function rememberWorkspaceSession(sessionId: string, storage?: SessionMem } } +/** Remember that the clean workspace home, rather than a conversation, was last open. */ +export function rememberWorkspaceHome(storage?: SessionMemoryStorage): void { + try { + availableStorage(storage)?.setItem(LAST_WORKSPACE_SESSION_STORAGE_KEY, WORKSPACE_HOME_SENTINEL); + } catch { + // Storage can be unavailable in private or restricted browser contexts. + } +} + export function forgetWorkspaceSession(sessionId: string, storage?: SessionMemoryStorage): void { const normalized = normalizeSessionId(sessionId); if (!normalized) return; @@ -57,6 +69,19 @@ export function forgetWorkspaceSession(sessionId: string, storage?: SessionMemor } } +/** Forget a remembered conversation when the workspace's existing list no longer contains it. */ +export function validateRememberedWorkspaceSession( + sessionId: string, + existingSessionIds: readonly string[], + storage?: SessionMemoryStorage, +): boolean { + const normalized = normalizeSessionId(sessionId); + if (!normalized) return false; + const exists = existingSessionIds.some((existing) => normalizeSessionId(existing) === normalized); + if (!exists) forgetWorkspaceSession(normalized, storage); + return exists; +} + /** The classic-mode entry target; missing memory keeps the clean Pro home. */ export function workspaceResumeHref(sessionId: string | null): string { const normalized = normalizeSessionId(sessionId); diff --git a/tests/workbench/workspace-course-chat-bootstrap.test.ts b/tests/workbench/workspace-course-chat-bootstrap.test.ts index 7b9d6e59e2..738d574c69 100644 --- a/tests/workbench/workspace-course-chat-bootstrap.test.ts +++ b/tests/workbench/workspace-course-chat-bootstrap.test.ts @@ -151,6 +151,10 @@ import { CLASSROOM_COLLAPSED_STORAGE_KEY, } from '@/lib/workbench/workspace-panes'; import { COURSE_TABS_STORAGE_KEY } from '@/lib/workbench/workspace-course-tabs'; +import { + LAST_WORKSPACE_SESSION_STORAGE_KEY, + readLastWorkspaceSessionId, +} from '@/lib/workbench/workspace-session-memory'; let root: Root | null = null; let container: HTMLDivElement | null = null; @@ -244,6 +248,26 @@ afterEach(async () => { vi.restoreAllMocks(); }); +describe('a remembered workspace entry', () => { + it('forgets a missing session and replaces it with the workspace home', async () => { + mocks.searchParams = new URLSearchParams('session=session-stale'); + localStorage.setItem(LAST_WORKSPACE_SESSION_STORAGE_KEY, 'session-stale'); + + await render(); + + expect(mocks.routerReplace).toHaveBeenCalledWith('/workspace'); + expect(readLastWorkspaceSessionId()).toBeNull(); + }); + + it('does not treat an unremembered deep link as a stale resume', async () => { + mocks.searchParams = new URLSearchParams('session=session-shared'); + + await render(); + + expect(mocks.routerReplace).not.toHaveBeenCalled(); + }); +}); + describe('a course opened without a conversation', () => { it('creates NOTHING on arrival, however many times the course is opened', async () => { await render(); diff --git a/tests/workbench/workspace-session-memory.test.ts b/tests/workbench/workspace-session-memory.test.ts index 38341053dd..6b2d73bbad 100644 --- a/tests/workbench/workspace-session-memory.test.ts +++ b/tests/workbench/workspace-session-memory.test.ts @@ -3,7 +3,9 @@ import { forgetWorkspaceSession, LAST_WORKSPACE_SESSION_STORAGE_KEY, readLastWorkspaceSessionId, + rememberWorkspaceHome, rememberWorkspaceSession, + validateRememberedWorkspaceSession, workspaceResumeHref, } from '@/lib/workbench/workspace-session-memory'; @@ -38,6 +40,17 @@ describe('workspace session entry memory', () => { expect(workspaceResumeHref(' ')).toBe('/workspace'); }); + it('remembers an explicit workspace home over the previously opened chat', () => { + const storage = new MemoryStorage(); + rememberWorkspaceSession('session-old', storage); + + rememberWorkspaceHome(storage); + + expect(readLastWorkspaceSessionId(storage)).toBeNull(); + expect(workspaceResumeHref(readLastWorkspaceSessionId(storage))).toBe('/workspace'); + expect(storage.getItem(LAST_WORKSPACE_SESSION_STORAGE_KEY)).not.toBeNull(); + }); + it('forgets only the matching deleted conversation', () => { const storage = new MemoryStorage(); storage.setItem(LAST_WORKSPACE_SESSION_STORAGE_KEY, 'session-kept'); @@ -49,6 +62,46 @@ describe('workspace session entry memory', () => { expect(readLastWorkspaceSessionId(storage)).toBeNull(); }); + it('forgets a remembered session that is missing from the loaded session list', () => { + const storage = new MemoryStorage(); + rememberWorkspaceSession('session-stale', storage); + + expect(validateRememberedWorkspaceSession('session-stale', ['session-current'], storage)).toBe( + false, + ); + expect(readLastWorkspaceSessionId(storage)).toBeNull(); + }); + + it('keeps a remembered session that still exists', () => { + const storage = new MemoryStorage(); + rememberWorkspaceSession('session-current', storage); + + expect( + validateRememberedWorkspaceSession('session-current', ['session-current'], storage), + ).toBe(true); + expect(readLastWorkspaceSessionId(storage)).toBe('session-current'); + }); + + it('stays safe when storage is unavailable', () => { + const storage = { + getItem: () => { + throw new Error('unavailable'); + }, + setItem: () => { + throw new Error('unavailable'); + }, + removeItem: () => { + throw new Error('unavailable'); + }, + }; + + expect(() => rememberWorkspaceSession('session-a', storage)).not.toThrow(); + expect(() => rememberWorkspaceHome(storage)).not.toThrow(); + expect(readLastWorkspaceSessionId(storage)).toBeNull(); + expect(() => forgetWorkspaceSession('session-a', storage)).not.toThrow(); + expect(() => validateRememberedWorkspaceSession('session-a', [], storage)).not.toThrow(); + }); + it('encodes opaque session ids in the entry URL', () => { expect(workspaceResumeHref('session / ?')).toBe('/workspace?session=session%20%2F%20%3F'); });