fix: keep new sessions grouped by project

Preserves session directory metadata across live updates
Keeps desktop and mobile session lists in project groups
Adds regression coverage for session grouping
This commit is contained in:
Bohdan Triapitsyn
2026-06-03 14:51:17 +03:00
parent e120a379f9
commit 8e25bc4cce
12 changed files with 132 additions and 29 deletions
+5 -5
View File
@@ -33,7 +33,7 @@
"@ibm/plex": "^6.4.1",
"@lezer/highlight": "^1.2.3",
"@octokit/rest": "^22.0.1",
"@opencode-ai/sdk": "^1.15.10",
"@opencode-ai/sdk": "^1.15.13",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -144,7 +144,7 @@
"@fontsource/ibm-plex-sans": "^5.1.1",
"@ibm/plex": "^6.4.1",
"@lezer/highlight": "^1.2.3",
"@opencode-ai/sdk": "^1.15.10",
"@opencode-ai/sdk": "^1.15.13",
"@pierre/diffs": "1.1.0-beta.13",
"@simplewebauthn/browser": "13.3.0",
"@tanstack/react-virtual": "^3.13.18",
@@ -216,7 +216,7 @@
"version": "1.12.0",
"dependencies": {
"@openchamber/ui": "workspace:*",
"@opencode-ai/sdk": "^1.15.10",
"@opencode-ai/sdk": "^1.15.13",
"adm-zip": "^0.5.16",
"jsonc-parser": "^3.3.1",
"react": "^19.1.1",
@@ -243,7 +243,7 @@
"dependencies": {
"@clack/prompts": "^1.1.0",
"@octokit/rest": "^22.0.1",
"@opencode-ai/sdk": "^1.15.10",
"@opencode-ai/sdk": "^1.15.13",
"@simplewebauthn/server": "13.3.0",
"adm-zip": "^0.5.16",
"better-sqlite3": "^11.7.0",
@@ -938,7 +938,7 @@
"@openchamber/web": ["@openchamber/web@workspace:packages/web"],
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.15.10", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-CUhpmMGGOqzvPnNNjjWmEIodAfP6Qnuki2ChIUKWYF7UImZ4zUcMZnzO5BtUxu/Ni1P8qzWxDioXs+7aIZQEhA=="],
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.15.13", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-4TwojIoQ8EG6/mVBuUVYZXiFcwNmiiytEnjnvyuvSJjGwFIlw2YIBFxtSVC3FbwwbwHT63teh1RHiQUUC4U5xw=="],
"@paralleldrive/cuid2": ["@paralleldrive/cuid2@2.3.1", "", { "dependencies": { "@noble/hashes": "^1.1.5" } }, "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw=="],
+1 -1
View File
@@ -90,7 +90,7 @@
"@ibm/plex": "^6.4.1",
"@lezer/highlight": "^1.2.3",
"@octokit/rest": "^22.0.1",
"@opencode-ai/sdk": "^1.15.10",
"@opencode-ai/sdk": "^1.15.13",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
+1 -1
View File
@@ -40,7 +40,7 @@
"@fontsource/ibm-plex-sans": "^5.1.1",
"@ibm/plex": "^6.4.1",
"@lezer/highlight": "^1.2.3",
"@opencode-ai/sdk": "^1.15.10",
"@opencode-ai/sdk": "^1.15.13",
"@pierre/diffs": "1.1.0-beta.13",
"@simplewebauthn/browser": "13.3.0",
"@tanstack/react-virtual": "^3.13.18",
+5 -2
View File
@@ -45,7 +45,7 @@ import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, ProjectIconImage } from '@/lib/pro
import { cn } from '@/lib/utils';
import { listProjectWorktrees } from '@/lib/worktrees/worktreeManager';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { refreshGlobalSessions, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
import { mergeSessionDirectoryMetadata, refreshGlobalSessions, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
import { useMobileSessionExpansionStore } from '@/stores/useMobileSessionExpansionStore';
import { useMobileSessionTreeStore } from '@/stores/useMobileSessionTreeStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
@@ -616,7 +616,10 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
*/
const sessions = React.useMemo(() => {
const liveById = new Map(liveSessions.map((session) => [session.id, session]));
const merged = globalActiveSessions.map((session) => liveById.get(session.id) ?? session);
const merged = globalActiveSessions.map((session) => {
const liveSession = liveById.get(session.id);
return liveSession ? mergeSessionDirectoryMetadata(liveSession, session) : session;
});
const seenIds = new Set(merged.map((session) => session.id));
for (const session of liveSessions) {
if (!seenIds.has(session.id)) merged.push(session);
@@ -1,7 +1,7 @@
import React from 'react';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useAllSessionStatuses, useAllLiveSessions } from '@/sync/sync-context';
import { useGlobalSessionsStore, ensureGlobalSessionsLoaded, refreshGlobalSessions } from '@/stores/useGlobalSessionsStore';
import { mergeSessionDirectoryMetadata, useGlobalSessionsStore, ensureGlobalSessionsLoaded, refreshGlobalSessions } from '@/stores/useGlobalSessionsStore';
import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore } from '@/stores/useUIStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
@@ -37,7 +37,10 @@ function useAllProjectSessions(): Session[] {
const globalActiveSessions = useGlobalSessionsStore((state) => state.activeSessions);
return React.useMemo(() => {
const liveById = new Map(liveSessions.map((session) => [session.id, session]));
const merged = globalActiveSessions.map((session) => liveById.get(session.id) ?? session);
const merged = globalActiveSessions.map((session) => {
const liveSession = liveById.get(session.id);
return liveSession ? mergeSessionDirectoryMetadata(liveSession, session) : session;
});
const seen = new Set(merged.map((session) => session.id));
for (const session of liveSessions) {
if (!seen.has(session.id)) merged.push(session);
@@ -69,7 +69,7 @@ import {
formatProjectLabel,
normalizePath,
} from './sidebar/utils';
import { refreshGlobalSessions, resolveGlobalSessionDirectory, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
import { mergeSessionDirectoryMetadata, refreshGlobalSessions, resolveGlobalSessionDirectory, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
import { subscribeOpenchamberEvents } from '@/lib/openchamberEvents';
@@ -332,7 +332,10 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const sessions = React.useMemo(() => {
const liveById = new Map(liveSessions.map((session) => [session.id, session]));
const merged = globalActiveSessions.map((session) => liveById.get(session.id) ?? session);
const merged = globalActiveSessions.map((session) => {
const liveSession = liveById.get(session.id);
return liveSession ? mergeSessionDirectoryMetadata(liveSession, session) : session;
});
const seenIds = new Set(merged.map((session) => session.id));
liveSessions.forEach((session) => {
@@ -1,5 +1,6 @@
import React from 'react';
import type { Session } from '@opencode-ai/sdk/v2';
import { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
import { dedupeSessionsById, isSessionRelatedToProject, normalizePath } from '../utils';
type WorktreeMeta = { path: string };
@@ -22,8 +23,7 @@ export const useProjectSessionLists = (args: Args) => {
const sessionsByDirectory = React.useMemo(() => {
const next = new Map<string, Session[]>();
sessions.forEach((session) => {
const directory = normalizePath((session as Session & { directory?: string | null }).directory ?? null)
?? normalizePath((session as Session & { project?: { worktree?: string | null } | null }).project?.worktree ?? null);
const directory = resolveGlobalSessionDirectory(session);
if (!directory) {
return;
}
@@ -11,6 +11,7 @@ import {
} from '../utils';
import { formatDirectoryName, formatPathForDisplay } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
type Args = {
homeDirectory: string | null;
@@ -124,10 +125,7 @@ export const useSessionGrouping = (args: Args) => {
const getGroupKey = (session: Session) => {
if (session.time?.archived) return archivedKey;
const metadataPath = normalizePath(args.worktreeMetadata.get(session.id)?.path ?? null);
const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null);
if (!metadataPath && !sessionDirectory) return archivedKey;
const fallbackDirectory = normalizePath((session as Session & { project?: { worktree?: string | null } | null }).project?.worktree ?? null);
const normalizedDir = metadataPath ?? sessionDirectory ?? fallbackDirectory;
const normalizedDir = metadataPath ?? resolveGlobalSessionDirectory(session);
if (!normalizedDir) return archivedKey;
if (normalizedDir !== normalizedProjectRoot && worktreeByPath.has(normalizedDir)) return normalizedDir;
if (normalizedDir === normalizedProjectRoot) return normalizedProjectRoot ?? '__project_root__';
@@ -1,13 +1,19 @@
import { beforeEach, describe, expect, test } from 'bun:test';
import type { Session } from '@opencode-ai/sdk/v2';
import { useGlobalSessionsStore } from './useGlobalSessionsStore';
import { resolveGlobalSessionDirectory, useGlobalSessionsStore } from './useGlobalSessionsStore';
const buildSession = (shareUrl: string): Session => ({
type SessionExtra = Partial<Session> & {
directory?: string | null;
project?: { worktree?: string | null } | null;
};
const buildSession = (shareUrl: string, extra: SessionExtra = {}): Session => ({
id: 'ses_1',
title: 'Shared session',
time: { created: 1, updated: 2 },
share: { url: shareUrl },
...extra,
} as Session);
describe('useGlobalSessionsStore', () => {
@@ -27,4 +33,49 @@ describe('useGlobalSessionsStore', () => {
expect(useGlobalSessionsStore.getState().activeSessions[0]?.share?.url).toBe('https://share.example/b');
});
test('preserves directory metadata when a live update omits it', () => {
useGlobalSessionsStore.getState().upsertSession(buildSession('https://share.example/a', { directory: '/repo/app' }));
useGlobalSessionsStore.getState().upsertSession(buildSession('https://share.example/b', {
time: { created: 1, updated: 3 },
}));
const session = useGlobalSessionsStore.getState().activeSessions[0];
expect(resolveGlobalSessionDirectory(session)).toBe('/repo/app');
expect(useGlobalSessionsStore.getState().sessionsByDirectory.get('/repo/app')?.[0]?.id).toBe('ses_1');
});
test('preserves raw directory metadata when a live update only has project worktree', () => {
useGlobalSessionsStore.getState().upsertSession(buildSession('https://share.example/a', { directory: '/repo/app' }));
useGlobalSessionsStore.getState().upsertSession(buildSession('https://share.example/b', {
project: { worktree: '/repo/app' },
time: { created: 1, updated: 3 },
}));
const session = useGlobalSessionsStore.getState().activeSessions[0] as Session & { directory?: string | null };
expect(session.directory).toBe('/repo/app');
expect(resolveGlobalSessionDirectory(session)).toBe('/repo/app');
});
test('trusts explicit incoming raw directory metadata', () => {
useGlobalSessionsStore.getState().upsertSession(buildSession('https://share.example/a', { directory: '/repo/app' }));
useGlobalSessionsStore.getState().upsertSession(buildSession('https://share.example/b', {
directory: '/repo/app-worktree',
time: { created: 1, updated: 3 },
}));
expect(resolveGlobalSessionDirectory(useGlobalSessionsStore.getState().activeSessions[0])).toBe('/repo/app-worktree');
expect(useGlobalSessionsStore.getState().sessionsByDirectory.get('/repo/app')).toBe(undefined);
expect(useGlobalSessionsStore.getState().sessionsByDirectory.get('/repo/app-worktree')?.[0]?.id).toBe('ses_1');
});
test('preserves directory metadata when moving a session to archived', () => {
useGlobalSessionsStore.getState().upsertSession(buildSession('https://share.example/a', { directory: '/repo/app' }));
useGlobalSessionsStore.getState().upsertSession(buildSession('https://share.example/b', {
time: { created: 1, updated: 3, archived: 4 },
}));
expect(useGlobalSessionsStore.getState().activeSessions).toEqual([]);
expect(resolveGlobalSessionDirectory(useGlobalSessionsStore.getState().archivedSessions[0])).toBe('/repo/app');
});
});
@@ -52,6 +52,46 @@ export const resolveGlobalSessionDirectory = (session: Session): string | null =
?? normalizePath(record.project?.worktree ?? null);
};
export const mergeSessionDirectoryMetadata = (incoming: Session, existing?: Session | null): Session => {
if (!existing) {
return incoming;
}
const incomingRecord = incoming as Session & {
directory?: string | null;
project?: ({ worktree?: string | null } & Record<string, unknown>) | null;
};
const existingRecord = existing as Session & {
directory?: string | null;
project?: ({ worktree?: string | null } & Record<string, unknown>) | null;
};
const incomingDirectory = normalizePath(incomingRecord.directory ?? null);
const incomingWorktree = normalizePath(incomingRecord.project?.worktree ?? null);
const existingDirectory = normalizePath(existingRecord.directory ?? null);
const existingWorktree = normalizePath(existingRecord.project?.worktree ?? null);
let changed = false;
const next: typeof incomingRecord = { ...incomingRecord };
// Some live session updates omit stable raw directory metadata; keep the
// cached value so project grouping does not temporarily lose the session.
if (!incomingDirectory && existingDirectory) {
next.directory = existingRecord.directory;
changed = true;
}
if (!incomingWorktree && existingWorktree) {
next.project = {
...(incomingRecord.project ?? {}),
worktree: existingRecord.project?.worktree,
};
changed = true;
}
return changed ? next : incoming;
};
const buildSessionsByDirectory = (sessions: Session[]): Map<string, Session[]> => {
const next = new Map<string, Session[]>();
for (const session of sessions) {
@@ -101,11 +141,12 @@ const upsertSessionIntoList = (sessions: Session[], session: Session): Session[]
if (index === -1) {
return [session, ...sessions];
}
if (getSessionSignature(sessions[index]) === getSessionSignature(session)) {
const mergedSession = mergeSessionDirectoryMetadata(session, sessions[index]);
if (getSessionSignature(sessions[index]) === getSessionSignature(mergedSession)) {
return sessions;
}
const next = [...sessions];
next[index] = session;
next[index] = mergedSession;
return next;
};
@@ -120,7 +161,7 @@ const mergeSessionLists = (existing: Session[], incoming?: Session[]): Session[]
const byId = new Map(existing.map((session) => [session.id, session]));
incoming.forEach((session) => {
byId.set(session.id, session);
byId.set(session.id, mergeSessionDirectoryMetadata(session, byId.get(session.id)));
});
const ordered: Session[] = [];
@@ -245,12 +286,16 @@ export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) =>
upsertSession: (session) => {
set((state) => {
const isArchived = Boolean(session.time?.archived);
const existingSession = state.activeSessions.find((candidate) => candidate.id === session.id)
?? state.archivedSessions.find((candidate) => candidate.id === session.id)
?? null;
const sessionWithMetadata = mergeSessionDirectoryMetadata(session, existingSession);
const isArchived = Boolean(sessionWithMetadata.time?.archived);
const nextActiveSessions = isArchived
? state.activeSessions.filter((candidate) => candidate.id !== session.id)
: upsertSessionIntoList(state.activeSessions, session);
: upsertSessionIntoList(state.activeSessions, sessionWithMetadata);
const nextArchivedSessions = isArchived
? upsertSessionIntoList(state.archivedSessions, session)
? upsertSessionIntoList(state.archivedSessions, sessionWithMetadata)
: state.archivedSessions.filter((candidate) => candidate.id !== session.id);
if (
+1 -1
View File
@@ -243,7 +243,7 @@
},
"dependencies": {
"@openchamber/ui": "workspace:*",
"@opencode-ai/sdk": "^1.15.10",
"@opencode-ai/sdk": "^1.15.13",
"adm-zip": "^0.5.16",
"jsonc-parser": "^3.3.1",
"react": "^19.1.1",
+1 -1
View File
@@ -25,7 +25,7 @@
"dependencies": {
"@clack/prompts": "^1.1.0",
"@octokit/rest": "^22.0.1",
"@opencode-ai/sdk": "^1.15.10",
"@opencode-ai/sdk": "^1.15.13",
"@simplewebauthn/server": "13.3.0",
"adm-zip": "^0.5.16",
"better-sqlite3": "^11.7.0",