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:
@@ -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
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user