From 8e25bc4ccebcad8af25b9d09490f3fdcd4ad9dfe Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 3 Jun 2026 13:02:18 +0300 Subject: [PATCH] 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 --- bun.lock | 10 ++-- package.json | 2 +- packages/ui/package.json | 2 +- packages/ui/src/apps/MobileSessionsSheet.tsx | 7 ++- .../chat/MobileSessionStatusBar.tsx | 7 ++- .../src/components/session/SessionSidebar.tsx | 7 ++- .../sidebar/hooks/useProjectSessionLists.ts | 4 +- .../sidebar/hooks/useSessionGrouping.ts | 6 +- .../src/stores/useGlobalSessionsStore.test.ts | 55 +++++++++++++++++- .../ui/src/stores/useGlobalSessionsStore.ts | 57 +++++++++++++++++-- packages/vscode/package.json | 2 +- packages/web/package.json | 2 +- 12 files changed, 132 insertions(+), 29 deletions(-) diff --git a/bun.lock b/bun.lock index 9ba24423..0826d487 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/package.json b/package.json index 2b6e11e6..38dd7020 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/ui/package.json b/packages/ui/package.json index 30040f8f..388e75a4 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -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", diff --git a/packages/ui/src/apps/MobileSessionsSheet.tsx b/packages/ui/src/apps/MobileSessionsSheet.tsx index 384888bd..736f6500 100644 --- a/packages/ui/src/apps/MobileSessionsSheet.tsx +++ b/packages/ui/src/apps/MobileSessionsSheet.tsx @@ -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 = ({ 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); diff --git a/packages/ui/src/components/chat/MobileSessionStatusBar.tsx b/packages/ui/src/components/chat/MobileSessionStatusBar.tsx index 630cfd86..8e543a4f 100644 --- a/packages/ui/src/components/chat/MobileSessionStatusBar.tsx +++ b/packages/ui/src/components/chat/MobileSessionStatusBar.tsx @@ -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); diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx index 9438bb57..8ebe4fde 100644 --- a/packages/ui/src/components/session/SessionSidebar.tsx +++ b/packages/ui/src/components/session/SessionSidebar.tsx @@ -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 = ({ 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) => { diff --git a/packages/ui/src/components/session/sidebar/hooks/useProjectSessionLists.ts b/packages/ui/src/components/session/sidebar/hooks/useProjectSessionLists.ts index 775c30cc..006d5735 100644 --- a/packages/ui/src/components/session/sidebar/hooks/useProjectSessionLists.ts +++ b/packages/ui/src/components/session/sidebar/hooks/useProjectSessionLists.ts @@ -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(); 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; } diff --git a/packages/ui/src/components/session/sidebar/hooks/useSessionGrouping.ts b/packages/ui/src/components/session/sidebar/hooks/useSessionGrouping.ts index 14fbfd0a..ddf675e2 100644 --- a/packages/ui/src/components/session/sidebar/hooks/useSessionGrouping.ts +++ b/packages/ui/src/components/session/sidebar/hooks/useSessionGrouping.ts @@ -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__'; diff --git a/packages/ui/src/stores/useGlobalSessionsStore.test.ts b/packages/ui/src/stores/useGlobalSessionsStore.test.ts index ee1696e0..9c829cbd 100644 --- a/packages/ui/src/stores/useGlobalSessionsStore.test.ts +++ b/packages/ui/src/stores/useGlobalSessionsStore.test.ts @@ -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 & { + 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'); + }); }); diff --git a/packages/ui/src/stores/useGlobalSessionsStore.ts b/packages/ui/src/stores/useGlobalSessionsStore.ts index e863ac67..e6d7a33c 100644 --- a/packages/ui/src/stores/useGlobalSessionsStore.ts +++ b/packages/ui/src/stores/useGlobalSessionsStore.ts @@ -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) | null; + }; + const existingRecord = existing as Session & { + directory?: string | null; + project?: ({ worktree?: string | null } & Record) | 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 => { const next = new Map(); 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((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 ( diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 384a8426..7977c239 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -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", diff --git a/packages/web/package.json b/packages/web/package.json index 0fc2911c..6731637e 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -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",