Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 34 additions & 2 deletions components/workbench/workspace/WorkspaceShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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) => {
Expand Down
27 changes: 26 additions & 1 deletion lib/workbench/workspace-session-memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
Expand All @@ -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;
Expand All @@ -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);
Expand Down
24 changes: 24 additions & 0 deletions tests/workbench/workspace-course-chat-bootstrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
53 changes: 53 additions & 0 deletions tests/workbench/workspace-session-memory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import {
forgetWorkspaceSession,
LAST_WORKSPACE_SESSION_STORAGE_KEY,
readLastWorkspaceSessionId,
rememberWorkspaceHome,
rememberWorkspaceSession,
validateRememberedWorkspaceSession,
workspaceResumeHref,
} from '@/lib/workbench/workspace-session-memory';

Expand Down Expand Up @@ -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');
Expand All @@ -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');
});
Expand Down
Loading