From fbcf4ea2b90ae1c12bca7a69136aa2c7e437fca9 Mon Sep 17 00:00:00 2001 From: Leonid <127580858+bashrusakh@users.noreply.github.com> Date: Sun, 12 Jul 2026 00:03:37 +1100 Subject: [PATCH] fix(sidebar): prevent home-project archived session overlap crash (#2017) * fix(sidebar): scope archived sessions to deepest project * fix(sidebar): prefer session directory over worktree --------- Co-authored-by: bashrusakh --- .../sidebar/hooks/useArchivedAutoFolders.ts | 24 +++++- .../sidebar/hooks/useProjectSessionLists.ts | 35 +++----- .../sidebar/hooks/useSessionFolderCleanup.ts | 18 +++- .../components/session/sidebar/utils.test.ts | 86 ++++++++++++++++++- .../src/components/session/sidebar/utils.tsx | 70 +++++++++++++-- 5 files changed, 196 insertions(+), 37 deletions(-) diff --git a/packages/ui/src/components/session/sidebar/hooks/useArchivedAutoFolders.ts b/packages/ui/src/components/session/sidebar/hooks/useArchivedAutoFolders.ts index 54f7c3cf..6dc5f4e2 100644 --- a/packages/ui/src/components/session/sidebar/hooks/useArchivedAutoFolders.ts +++ b/packages/ui/src/components/session/sidebar/hooks/useArchivedAutoFolders.ts @@ -1,7 +1,14 @@ import React from 'react'; import type { Session } from '@opencode-ai/sdk/v2'; import type { WorktreeMetadata } from '@/types/worktree'; -import { dedupeSessionsById, getArchivedScopeKey, isSessionRelatedToProject, normalizePath, resolveArchivedFolderName } from '../utils'; +import { + collectKnownProjectDirectories, + dedupeSessionsById, + getArchivedScopeKey, + isSessionRelatedToProject, + normalizePath, + resolveArchivedFolderName, +} from '../utils'; type ProjectForArchivedFolders = { normalizedPath: string; @@ -28,7 +35,9 @@ type Args = { const getArchivedSessionsForProject = ( project: ProjectForArchivedFolders, - params: Pick, + params: Pick & { + knownProjectDirectories: Set; + }, ): Session[] => { const worktreesForProject = params.isVSCode ? [] : (params.availableWorktreesByProject.get(project.normalizedPath) ?? []); const validDirectories = new Set([ @@ -39,7 +48,7 @@ const getArchivedSessionsForProject = ( ]); const collect = (input: Session[]): Session[] => input.filter((session) => - isSessionRelatedToProject(session, project.normalizedPath, validDirectories), + isSessionRelatedToProject(session, project.normalizedPath, validDirectories, params.knownProjectDirectories), ); const archived = collect(params.archivedSessions); @@ -51,7 +60,7 @@ const getArchivedSessionsForProject = ( if (sessionDirectory) { return false; } - return isSessionRelatedToProject(session, project.normalizedPath, validDirectories); + return isSessionRelatedToProject(session, project.normalizedPath, validDirectories, params.knownProjectDirectories); }); return dedupeSessionsById([...archived, ...unassignedLive]); @@ -71,6 +80,11 @@ export const useArchivedAutoFolders = (args: Args): void => { cleanupSessions, } = args; + const knownProjectDirectories = React.useMemo( + () => collectKnownProjectDirectories(normalizedProjects, availableWorktreesByProject, isVSCode), + [normalizedProjects, availableWorktreesByProject, isVSCode], + ); + React.useEffect(() => { if (isSessionsLoading) { return; @@ -83,6 +97,7 @@ export const useArchivedAutoFolders = (args: Args): void => { archivedSessions, availableWorktreesByProject, isVSCode, + knownProjectDirectories, }); const sessionIds = new Set(projectArchivedSessions.map((session) => session.id)); @@ -110,6 +125,7 @@ export const useArchivedAutoFolders = (args: Args): void => { sessions, archivedSessions, availableWorktreesByProject, + knownProjectDirectories, isVSCode, isSessionsLoading, foldersMap, diff --git a/packages/ui/src/components/session/sidebar/hooks/useProjectSessionLists.ts b/packages/ui/src/components/session/sidebar/hooks/useProjectSessionLists.ts index 81c9a8c1..3ce8ba3d 100644 --- a/packages/ui/src/components/session/sidebar/hooks/useProjectSessionLists.ts +++ b/packages/ui/src/components/session/sidebar/hooks/useProjectSessionLists.ts @@ -1,7 +1,7 @@ import React from 'react'; import type { Session } from '@opencode-ai/sdk/v2'; import { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore'; -import { dedupeSessionsById, isSessionRelatedToProject, normalizePath } from '../utils'; +import { collectKnownProjectDirectories, dedupeSessionsById, isSessionRelatedToProject, normalizePath } from '../utils'; type WorktreeMeta = { path: string }; @@ -37,23 +37,10 @@ export const useProjectSessionLists = (args: Args) => { // worktree. Walking this set is O(P + W) per Sidebar render and lets // us skip the bulk of `sessions` (whose directory is not associated // with a known project) when building `sessionsByDirectory`. - const allowedDirectories = React.useMemo(() => { - const set = new Set(); - normalizedProjects.forEach((project) => { - if (project.normalizedPath) { - set.add(project.normalizedPath); - } - }); - if (!isVSCode) { - for (const worktrees of availableWorktreesByProject.values()) { - for (const worktree of worktrees) { - const normalized = normalizePath(worktree.path); - if (normalized) set.add(normalized); - } - } - } - return set; - }, [normalizedProjects, availableWorktreesByProject, isVSCode]); + const knownProjectDirectories = React.useMemo( + () => collectKnownProjectDirectories(normalizedProjects, availableWorktreesByProject, isVSCode), + [normalizedProjects, availableWorktreesByProject, isVSCode], + ); const sessionsByDirectory = React.useMemo(() => { const next = new Map(); @@ -67,7 +54,7 @@ export const useProjectSessionLists = (args: Args) => { // every session the server has ever seen, even ones for // long-removed worktrees; the sidebar's downstream filters // would then drop them anyway. - if (!allowedDirectories.has(directory)) { + if (!knownProjectDirectories.has(directory)) { return; } @@ -76,7 +63,7 @@ export const useProjectSessionLists = (args: Args) => { next.set(directory, collection); }); return next; - }, [sessions, allowedDirectories]); + }, [sessions, knownProjectDirectories]); const getSessionsForProject = React.useCallback( (project: { normalizedPath: string }) => { @@ -92,7 +79,7 @@ export const useProjectSessionLists = (args: Args) => { const collected: Session[] = []; directories.forEach((directory) => { - const sessionsForDirectory = sessionsByDirectory.get(directory) ?? []; + const sessionsForDirectory: Session[] = sessionsByDirectory.get(directory) ?? []; sessionsForDirectory.forEach((session) => { if (seen.has(session.id)) { return; @@ -145,7 +132,7 @@ export const useProjectSessionLists = (args: Args) => { ]); const collect = (input: Session[]): Session[] => input.filter((session) => - isSessionRelatedToProject(session, project.normalizedPath, validDirectories), + isSessionRelatedToProject(session, project.normalizedPath, validDirectories, knownProjectDirectories), ); const archived = collect(archivedSessions); @@ -161,12 +148,12 @@ export const useProjectSessionLists = (args: Args) => { if (!projectWorktree) { return false; } - return projectWorktree === project.normalizedPath || projectWorktree.startsWith(`${project.normalizedPath}/`); + return isSessionRelatedToProject(session, project.normalizedPath, validDirectories, knownProjectDirectories); }); return dedupeSessionsById([...archived, ...unassignedLive]); }, - [archivedSessions, availableWorktreesByProject, isVSCode, sessions], + [archivedSessions, availableWorktreesByProject, isVSCode, knownProjectDirectories, sessions], ); return { diff --git a/packages/ui/src/components/session/sidebar/hooks/useSessionFolderCleanup.ts b/packages/ui/src/components/session/sidebar/hooks/useSessionFolderCleanup.ts index 66948c92..a0090803 100644 --- a/packages/ui/src/components/session/sidebar/hooks/useSessionFolderCleanup.ts +++ b/packages/ui/src/components/session/sidebar/hooks/useSessionFolderCleanup.ts @@ -1,7 +1,13 @@ import React from 'react'; import type { Session } from '@opencode-ai/sdk/v2'; import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore'; -import { dedupeSessionsById, getArchivedScopeKey, isSessionRelatedToProject, normalizePath } from '../utils'; +import { + collectKnownProjectDirectories, + dedupeSessionsById, + getArchivedScopeKey, + isSessionRelatedToProject, + normalizePath, +} from '../utils'; type NormalizedProject = { id: string; @@ -33,6 +39,11 @@ export const useSessionFolderCleanup = (args: Args): void => { cleanupSessions, } = args; + const knownProjectDirectories = React.useMemo( + () => collectKnownProjectDirectories(normalizedProjects, availableWorktreesByProject, isVSCode), + [normalizedProjects, availableWorktreesByProject, isVSCode], + ); + React.useEffect(() => { if (isSessionsLoading || !hasLoadedGlobalSessions) { return; @@ -76,9 +87,9 @@ export const useSessionFolderCleanup = (args: Args): void => { if (sessionDirectory) { return false; } - return isSessionRelatedToProject(session, project.normalizedPath, validDirectories); + return isSessionRelatedToProject(session, project.normalizedPath, validDirectories, knownProjectDirectories); }), - ]).filter((session) => isSessionRelatedToProject(session, project.normalizedPath, validDirectories)); + ]).filter((session) => isSessionRelatedToProject(session, project.normalizedPath, validDirectories, knownProjectDirectories)); idsByScope.set(scopeKey, new Set(archivedForProject.map((session) => session.id))); }); @@ -95,6 +106,7 @@ export const useSessionFolderCleanup = (args: Args): void => { hasLoadedGlobalSessions, isSessionsLoading, isVSCode, + knownProjectDirectories, normalizedProjects, sessions, ]); diff --git a/packages/ui/src/components/session/sidebar/utils.test.ts b/packages/ui/src/components/session/sidebar/utils.test.ts index 31a17ad0..707bbcac 100644 --- a/packages/ui/src/components/session/sidebar/utils.test.ts +++ b/packages/ui/src/components/session/sidebar/utils.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'bun:test'; +import type { Session } from '@opencode-ai/sdk/v2'; -import { isPathWithinProject } from './utils'; +import { isPathWithinProject, isSessionRelatedToProject } from './utils'; describe('isPathWithinProject', () => { test('matches child directories for root projects', () => { @@ -27,3 +28,86 @@ describe('isPathWithinProject', () => { expect(isPathWithinProject('/workspace/app/sub/dir', '/workspace/app')).toBe(true); }); }); + +describe('isSessionRelatedToProject', () => { + test('prefers the most specific project root for archived session directories', () => { + const session = { + id: 'ses_parent_child', + directory: '/home/user/proj/foo/src', + } as unknown as Session; + + const knownProjectDirectories = new Set(['/home/user', '/home/user/proj/foo']); + + expect( + isSessionRelatedToProject(session, '/home/user', new Set(['/home/user']), knownProjectDirectories), + ).toBe(false); + expect( + isSessionRelatedToProject( + session, + '/home/user/proj/foo', + new Set(['/home/user/proj/foo']), + knownProjectDirectories, + ), + ).toBe(true); + }); + + test('prefers the most specific project worktree when session directory is missing', () => { + const session = { + id: 'ses_project_worktree', + project: { + worktree: '/home/user/proj/foo', + }, + } as unknown as Session; + + const knownProjectDirectories = new Set(['/home/user', '/home/user/proj/foo']); + + expect( + isSessionRelatedToProject(session, '/home/user', new Set(['/home/user']), knownProjectDirectories), + ).toBe(false); + expect( + isSessionRelatedToProject( + session, + '/home/user/proj/foo', + new Set(['/home/user/proj/foo']), + knownProjectDirectories, + ), + ).toBe(true); + }); + + test('prefers explicit session directory over broader project worktree metadata', () => { + const session = { + id: 'ses_directory_beats_worktree', + directory: '/home/user/proj/foo/src', + project: { + worktree: '/home/user', + }, + } as unknown as Session; + + const knownProjectDirectories = new Set(['/home/user', '/home/user/proj/foo']); + + expect( + isSessionRelatedToProject(session, '/home/user', new Set(['/home/user']), knownProjectDirectories), + ).toBe(false); + expect( + isSessionRelatedToProject( + session, + '/home/user/proj/foo', + new Set(['/home/user/proj/foo']), + knownProjectDirectories, + ), + ).toBe(true); + }); + + test('keeps descendant sessions on the broad project when no child project matches', () => { + const session = { + id: 'ses_home_misc', + directory: '/home/user/misc/sandbox', + } as unknown as Session; + + const knownProjectDirectories = new Set(['/home/user', '/home/user/proj/foo']); + + expect( + isSessionRelatedToProject(session, '/home/user', new Set(['/home/user']), knownProjectDirectories), + ).toBe(true); + }); +}); diff --git a/packages/ui/src/components/session/sidebar/utils.tsx b/packages/ui/src/components/session/sidebar/utils.tsx index e908d731..7fe94a9a 100644 --- a/packages/ui/src/components/session/sidebar/utils.tsx +++ b/packages/ui/src/components/session/sidebar/utils.tsx @@ -94,6 +94,61 @@ export const isPathWithinProject = (directory?: string | null, projectPath?: str return normalizedDirectory.startsWith(`${normalizedProjectPath}/`); }; +type NormalizedProjectPath = { normalizedPath: string }; +type WorktreePath = { path: string }; + +export const collectKnownProjectDirectories = ( + normalizedProjects: NormalizedProjectPath[], + availableWorktreesByProject: Map, + isVSCode: boolean, +): Set => { + const knownDirectories = new Set(); + + normalizedProjects.forEach((project) => { + if (project.normalizedPath) { + knownDirectories.add(project.normalizedPath); + } + }); + + if (isVSCode) { + return knownDirectories; + } + + for (const worktrees of availableWorktreesByProject.values()) { + for (const worktree of worktrees) { + const normalized = normalizePath(worktree.path); + if (normalized) { + knownDirectories.add(normalized); + } + } + } + + return knownDirectories; +}; + +const findBestProjectDirectoryMatch = ( + value: string | null, + knownDirectories?: Iterable, +): string | null => { + if (!value || !knownDirectories) { + return null; + } + + let bestMatch: string | null = null; + for (const candidate of knownDirectories) { + const normalizedCandidate = normalizePath(candidate); + if (!normalizedCandidate || !isPathWithinProject(value, normalizedCandidate)) { + continue; + } + + if (!bestMatch || normalizedCandidate.length > bestMatch.length) { + bestMatch = normalizedCandidate; + } + } + + return bestMatch; +}; + export const normalizeForBranchComparison = (value: string): string => { return value .toLowerCase() @@ -177,21 +232,26 @@ export const isSessionRelatedToProject = ( session: Session, projectRoot: string, validDirectories?: Set, + knownDirectories?: Iterable, ): boolean => { const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null); const projectWorktree = normalizePath((session as Session & { project?: { worktree?: string | null } | null }).project?.worktree ?? null); + const resolvedDirectory = sessionDirectory ?? projectWorktree; - if (projectWorktree && (projectWorktree === projectRoot || projectWorktree.startsWith(`${projectRoot}/`))) { + if (resolvedDirectory && validDirectories?.has(resolvedDirectory)) { return true; } - if (!sessionDirectory) { + if (!resolvedDirectory) { return false; } - if (validDirectories && validDirectories.has(sessionDirectory)) { - return true; + + const bestMatch = findBestProjectDirectoryMatch(resolvedDirectory, knownDirectories); + if (bestMatch) { + return validDirectories ? validDirectories.has(bestMatch) : bestMatch === projectRoot; } - return sessionDirectory === projectRoot || sessionDirectory.startsWith(`${projectRoot}/`); + + return resolvedDirectory === projectRoot || resolvedDirectory.startsWith(`${projectRoot}/`); };