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 <bashrusakh@users.noreply.github.com>
This commit is contained in:
Leonid
2026-07-11 16:03:37 +03:00
committed by GitHub
co-authored by bashrusakh
parent e5bba59a75
commit fbcf4ea2b9
5 changed files with 196 additions and 37 deletions
@@ -1,7 +1,14 @@
import React from 'react'; import React from 'react';
import type { Session } from '@opencode-ai/sdk/v2'; import type { Session } from '@opencode-ai/sdk/v2';
import type { WorktreeMetadata } from '@/types/worktree'; 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 = { type ProjectForArchivedFolders = {
normalizedPath: string; normalizedPath: string;
@@ -28,7 +35,9 @@ type Args = {
const getArchivedSessionsForProject = ( const getArchivedSessionsForProject = (
project: ProjectForArchivedFolders, project: ProjectForArchivedFolders,
params: Pick<Args, 'sessions' | 'archivedSessions' | 'availableWorktreesByProject' | 'isVSCode'>, params: Pick<Args, 'sessions' | 'archivedSessions' | 'availableWorktreesByProject' | 'isVSCode'> & {
knownProjectDirectories: Set<string>;
},
): Session[] => { ): Session[] => {
const worktreesForProject = params.isVSCode ? [] : (params.availableWorktreesByProject.get(project.normalizedPath) ?? []); const worktreesForProject = params.isVSCode ? [] : (params.availableWorktreesByProject.get(project.normalizedPath) ?? []);
const validDirectories = new Set<string>([ const validDirectories = new Set<string>([
@@ -39,7 +48,7 @@ const getArchivedSessionsForProject = (
]); ]);
const collect = (input: Session[]): Session[] => input.filter((session) => 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); const archived = collect(params.archivedSessions);
@@ -51,7 +60,7 @@ const getArchivedSessionsForProject = (
if (sessionDirectory) { if (sessionDirectory) {
return false; return false;
} }
return isSessionRelatedToProject(session, project.normalizedPath, validDirectories); return isSessionRelatedToProject(session, project.normalizedPath, validDirectories, params.knownProjectDirectories);
}); });
return dedupeSessionsById([...archived, ...unassignedLive]); return dedupeSessionsById([...archived, ...unassignedLive]);
@@ -71,6 +80,11 @@ export const useArchivedAutoFolders = (args: Args): void => {
cleanupSessions, cleanupSessions,
} = args; } = args;
const knownProjectDirectories = React.useMemo(
() => collectKnownProjectDirectories(normalizedProjects, availableWorktreesByProject, isVSCode),
[normalizedProjects, availableWorktreesByProject, isVSCode],
);
React.useEffect(() => { React.useEffect(() => {
if (isSessionsLoading) { if (isSessionsLoading) {
return; return;
@@ -83,6 +97,7 @@ export const useArchivedAutoFolders = (args: Args): void => {
archivedSessions, archivedSessions,
availableWorktreesByProject, availableWorktreesByProject,
isVSCode, isVSCode,
knownProjectDirectories,
}); });
const sessionIds = new Set(projectArchivedSessions.map((session) => session.id)); const sessionIds = new Set(projectArchivedSessions.map((session) => session.id));
@@ -110,6 +125,7 @@ export const useArchivedAutoFolders = (args: Args): void => {
sessions, sessions,
archivedSessions, archivedSessions,
availableWorktreesByProject, availableWorktreesByProject,
knownProjectDirectories,
isVSCode, isVSCode,
isSessionsLoading, isSessionsLoading,
foldersMap, foldersMap,
@@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import type { Session } from '@opencode-ai/sdk/v2'; import type { Session } from '@opencode-ai/sdk/v2';
import { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore'; import { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
import { dedupeSessionsById, isSessionRelatedToProject, normalizePath } from '../utils'; import { collectKnownProjectDirectories, dedupeSessionsById, isSessionRelatedToProject, normalizePath } from '../utils';
type WorktreeMeta = { path: string }; 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 // worktree. Walking this set is O(P + W) per Sidebar render and lets
// us skip the bulk of `sessions` (whose directory is not associated // us skip the bulk of `sessions` (whose directory is not associated
// with a known project) when building `sessionsByDirectory`. // with a known project) when building `sessionsByDirectory`.
const allowedDirectories = React.useMemo(() => { const knownProjectDirectories = React.useMemo(
const set = new Set<string>(); () => collectKnownProjectDirectories(normalizedProjects, availableWorktreesByProject, isVSCode),
normalizedProjects.forEach((project) => { [normalizedProjects, availableWorktreesByProject, isVSCode],
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 sessionsByDirectory = React.useMemo(() => { const sessionsByDirectory = React.useMemo(() => {
const next = new Map<string, Session[]>(); const next = new Map<string, Session[]>();
@@ -67,7 +54,7 @@ export const useProjectSessionLists = (args: Args) => {
// every session the server has ever seen, even ones for // every session the server has ever seen, even ones for
// long-removed worktrees; the sidebar's downstream filters // long-removed worktrees; the sidebar's downstream filters
// would then drop them anyway. // would then drop them anyway.
if (!allowedDirectories.has(directory)) { if (!knownProjectDirectories.has(directory)) {
return; return;
} }
@@ -76,7 +63,7 @@ export const useProjectSessionLists = (args: Args) => {
next.set(directory, collection); next.set(directory, collection);
}); });
return next; return next;
}, [sessions, allowedDirectories]); }, [sessions, knownProjectDirectories]);
const getSessionsForProject = React.useCallback( const getSessionsForProject = React.useCallback(
(project: { normalizedPath: string }) => { (project: { normalizedPath: string }) => {
@@ -92,7 +79,7 @@ export const useProjectSessionLists = (args: Args) => {
const collected: Session[] = []; const collected: Session[] = [];
directories.forEach((directory) => { directories.forEach((directory) => {
const sessionsForDirectory = sessionsByDirectory.get(directory) ?? []; const sessionsForDirectory: Session[] = sessionsByDirectory.get(directory) ?? [];
sessionsForDirectory.forEach((session) => { sessionsForDirectory.forEach((session) => {
if (seen.has(session.id)) { if (seen.has(session.id)) {
return; return;
@@ -145,7 +132,7 @@ export const useProjectSessionLists = (args: Args) => {
]); ]);
const collect = (input: Session[]): Session[] => input.filter((session) => const collect = (input: Session[]): Session[] => input.filter((session) =>
isSessionRelatedToProject(session, project.normalizedPath, validDirectories), isSessionRelatedToProject(session, project.normalizedPath, validDirectories, knownProjectDirectories),
); );
const archived = collect(archivedSessions); const archived = collect(archivedSessions);
@@ -161,12 +148,12 @@ export const useProjectSessionLists = (args: Args) => {
if (!projectWorktree) { if (!projectWorktree) {
return false; return false;
} }
return projectWorktree === project.normalizedPath || projectWorktree.startsWith(`${project.normalizedPath}/`); return isSessionRelatedToProject(session, project.normalizedPath, validDirectories, knownProjectDirectories);
}); });
return dedupeSessionsById([...archived, ...unassignedLive]); return dedupeSessionsById([...archived, ...unassignedLive]);
}, },
[archivedSessions, availableWorktreesByProject, isVSCode, sessions], [archivedSessions, availableWorktreesByProject, isVSCode, knownProjectDirectories, sessions],
); );
return { return {
@@ -1,7 +1,13 @@
import React from 'react'; import React from 'react';
import type { Session } from '@opencode-ai/sdk/v2'; import type { Session } from '@opencode-ai/sdk/v2';
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore'; import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
import { dedupeSessionsById, getArchivedScopeKey, isSessionRelatedToProject, normalizePath } from '../utils'; import {
collectKnownProjectDirectories,
dedupeSessionsById,
getArchivedScopeKey,
isSessionRelatedToProject,
normalizePath,
} from '../utils';
type NormalizedProject = { type NormalizedProject = {
id: string; id: string;
@@ -33,6 +39,11 @@ export const useSessionFolderCleanup = (args: Args): void => {
cleanupSessions, cleanupSessions,
} = args; } = args;
const knownProjectDirectories = React.useMemo(
() => collectKnownProjectDirectories(normalizedProjects, availableWorktreesByProject, isVSCode),
[normalizedProjects, availableWorktreesByProject, isVSCode],
);
React.useEffect(() => { React.useEffect(() => {
if (isSessionsLoading || !hasLoadedGlobalSessions) { if (isSessionsLoading || !hasLoadedGlobalSessions) {
return; return;
@@ -76,9 +87,9 @@ export const useSessionFolderCleanup = (args: Args): void => {
if (sessionDirectory) { if (sessionDirectory) {
return false; 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))); idsByScope.set(scopeKey, new Set(archivedForProject.map((session) => session.id)));
}); });
@@ -95,6 +106,7 @@ export const useSessionFolderCleanup = (args: Args): void => {
hasLoadedGlobalSessions, hasLoadedGlobalSessions,
isSessionsLoading, isSessionsLoading,
isVSCode, isVSCode,
knownProjectDirectories,
normalizedProjects, normalizedProjects,
sessions, sessions,
]); ]);
@@ -1,6 +1,7 @@
import { describe, expect, test } from 'bun:test'; 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', () => { describe('isPathWithinProject', () => {
test('matches child directories for root projects', () => { test('matches child directories for root projects', () => {
@@ -27,3 +28,86 @@ describe('isPathWithinProject', () => {
expect(isPathWithinProject('/workspace/app/sub/dir', '/workspace/app')).toBe(true); 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);
});
});
@@ -94,6 +94,61 @@ export const isPathWithinProject = (directory?: string | null, projectPath?: str
return normalizedDirectory.startsWith(`${normalizedProjectPath}/`); return normalizedDirectory.startsWith(`${normalizedProjectPath}/`);
}; };
type NormalizedProjectPath = { normalizedPath: string };
type WorktreePath = { path: string };
export const collectKnownProjectDirectories = (
normalizedProjects: NormalizedProjectPath[],
availableWorktreesByProject: Map<string, WorktreePath[]>,
isVSCode: boolean,
): Set<string> => {
const knownDirectories = new Set<string>();
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>,
): 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 => { export const normalizeForBranchComparison = (value: string): string => {
return value return value
.toLowerCase() .toLowerCase()
@@ -177,21 +232,26 @@ export const isSessionRelatedToProject = (
session: Session, session: Session,
projectRoot: string, projectRoot: string,
validDirectories?: Set<string>, validDirectories?: Set<string>,
knownDirectories?: Iterable<string>,
): boolean => { ): boolean => {
const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null); 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 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; return true;
} }
if (!sessionDirectory) { if (!resolvedDirectory) {
return false; 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}/`);
}; };