perf(sidebar): index session ownership and narrow live subscriptions

Replace repeated project-by-session directory matching across sidebar hooks with a shared ownership index that resolves each unique directory once and exposes direct project and folder-scope buckets.

Gate destructive folder reconciliation on authoritative session data and topology readiness, preserve last-known worktrees after discovery failures, and retain nested-project, VS Code, active/archive dedupe, and Windows drive-root semantics.

Narrow cross-directory subscriptions to session and status slices so streaming deltas no longer trigger global aggregation. Reuse a cached session ID index for permission lineage checks instead of rebuilding it on every session switch.

On the reported 15-project, 67-worktree, 14,561-session shape, ownership indexing averages 3.81 ms versus roughly 450 ms for the cache-only hotfix.

Validation: 28 targeted tests, UI type-check, UI lint, and dead-code analysis.
This commit is contained in:
Bohdan Triapitsyn
2026-07-13 23:18:12 +03:00
parent 799904f0f4
commit b36afbf5ee
19 changed files with 619 additions and 511 deletions
@@ -31,6 +31,7 @@ import { useSidebarPersistence } from './sidebar/hooks/useSidebarPersistence';
import { useProjectRepoStatus } from './sidebar/hooks/useProjectRepoStatus';
import { useProjectSessionLists } from './sidebar/hooks/useProjectSessionLists';
import { useSessionFolderCleanup } from './sidebar/hooks/useSessionFolderCleanup';
import { createSessionOwnershipIndex } from './sidebar/sessionOwnership';
import { useStickyProjectHeaders } from './sidebar/hooks/useStickyProjectHeaders';
import { getGitHubPrStatusKey, usePrVisualSummaryByKeys, useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore';
import { ProjectEditDialog } from '@/components/layout/ProjectEditDialog';
@@ -319,7 +320,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const sync = useSync();
const liveSessions = useAllLiveSessions();
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
const hasLoadedGlobalSessions = useGlobalSessionsStore((state) => state.hasLoaded);
const hasAuthoritativeGlobalSessions = useGlobalSessionsStore((state) => state.status === 'ready');
const globalActiveSessions = useGlobalSessionsStore((state) => state.activeSessions);
const archivedSessions = useGlobalSessionsStore((state) => state.archivedSessions);
@@ -418,6 +418,11 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
.join('|'),
[projects],
);
const [resolvedWorktreeTopologyKey, setResolvedWorktreeTopologyKey] = React.useState<string | null>(
isVSCode ? projectWorktreeDiscoveryKey : null,
);
const isWorktreeTopologyLoading = !isVSCode && resolvedWorktreeTopologyKey !== projectWorktreeDiscoveryKey;
const [unresolvedWorktreeProjectPaths, setUnresolvedWorktreeProjectPaths] = React.useState<ReadonlySet<string>>(new Set());
const initialGlobalSessionsRefreshStartedRef = React.useRef(false);
React.useEffect(() => {
@@ -428,19 +433,22 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
void refreshGlobalSessions(syncSessionsSnapshotRef.current);
}, []);
// Tracks the last project list we already kicked off discovery for.
// A re-mount with the same project set shouldn't fan out another
// burst of `checkIsGitRepository` / `listProjectWorktrees` calls.
const discoveredProjectsRef = React.useRef<string>('');
React.useEffect(() => {
let cancelled = false;
const discoverWorktrees = async () => {
const projectEntries = useProjectsStore.getState().projects;
if (projectEntries.length === 0) return;
if (projectEntries.length === 0 || isVSCode) {
if (!cancelled) {
setUnresolvedWorktreeProjectPaths(new Set());
setResolvedWorktreeTopologyKey(projectWorktreeDiscoveryKey);
}
return;
}
const worktreesByProject = new Map<string, WorktreeMetadata[]>();
const allWorktrees: WorktreeMetadata[] = [];
const currentByProject = useSessionUIStore.getState().availableWorktreesByProject;
const worktreesByProject = new Map(currentByProject);
const unresolvedProjectPaths = new Set<string>();
// Constrain fanout: previously `Promise.all(projects.map(...))` could
// spawn dozens of concurrent `git worktree list` and
@@ -464,13 +472,20 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
// PR/render paths downstream can read isGitRepo for free.
const cachedIsGitRepo = useGitStore.getState().directories.get(projectPath)?.isGitRepo;
const isGitRepo = cachedIsGitRepo ?? await checkIsGitRepository(projectPath);
if (!isGitRepo) continue;
if (!isGitRepo) {
worktreesByProject.delete(projectPath);
continue;
}
const worktrees = await listProjectWorktrees({ id: project.id, path: projectPath });
if (cancelled || worktrees.length === 0) continue;
worktreesByProject.set(projectPath, worktrees);
allWorktrees.push(...worktrees);
if (cancelled) return;
if (worktrees.length === 0) {
worktreesByProject.delete(projectPath);
} else {
worktreesByProject.set(projectPath, worktrees);
}
} catch {
// ignore discovery errors
// Keep last-known worktrees when a project is temporarily unavailable.
unresolvedProjectPaths.add(projectPath);
}
}
});
@@ -478,27 +493,31 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
if (cancelled) return;
const activeProjectPaths = new Set(projectEntries.map((project) => normalizePath(project.path)).filter(Boolean));
for (const projectPath of worktreesByProject.keys()) {
if (!activeProjectPaths.has(projectPath)) {
worktreesByProject.delete(projectPath);
}
}
const allWorktrees = [...worktreesByProject.values()].flat();
// Skip update if nothing changed — see worktreeMapsEqual JSDoc.
const currentByProject = useSessionUIStore.getState().availableWorktreesByProject;
if (!worktreeMapsEqual(worktreesByProject, currentByProject)) {
useSessionUIStore.setState({
availableWorktrees: allWorktrees,
availableWorktreesByProject: worktreesByProject,
});
}
setUnresolvedWorktreeProjectPaths(unresolvedProjectPaths);
setResolvedWorktreeTopologyKey(projectWorktreeDiscoveryKey);
};
// Skip if we already discovered worktrees for this exact project set.
if (discoveredProjectsRef.current === projectWorktreeDiscoveryKey) {
return;
}
discoveredProjectsRef.current = projectWorktreeDiscoveryKey;
void discoverWorktrees();
return () => {
cancelled = true;
};
}, [projectWorktreeDiscoveryKey]);
}, [isVSCode, projectWorktreeDiscoveryKey]);
React.useEffect(() => {
let refreshTimeout: ReturnType<typeof setTimeout> | null = null;
@@ -943,18 +962,15 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
);
const projectSessionDirectories = React.useMemo(() => {
const directories = new Set<string>();
normalizedProjects.forEach((project) => {
if (project.normalizedPath) directories.add(project.normalizedPath);
if (isVSCode) {
return;
const directories = new Set(normalizedProjects.map((project) => project.normalizedPath));
if (!isVSCode) {
for (const worktrees of availableWorktreesByProject.values()) {
for (const worktree of worktrees) {
const directory = normalizePath(worktree.path);
if (directory) directories.add(directory);
}
}
const worktrees = availableWorktreesByProject.get(project.normalizedPath) ?? [];
worktrees.forEach((worktree) => {
const directory = normalizePath(worktree.path);
if (directory) directories.add(directory);
});
});
}
return [...directories].sort();
}, [availableWorktreesByProject, isVSCode, normalizedProjects]);
@@ -994,32 +1010,32 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
});
const isSessionsLoading = useSessionUIStore((state) => state.isLoading);
const sessionOwnership = React.useMemo(
() => createSessionOwnershipIndex(sessions, normalizedProjects, availableWorktreesByProject, isVSCode, archivedSessions),
[archivedSessions, availableWorktreesByProject, isVSCode, normalizedProjects, sessions],
);
useSessionFolderCleanup({
isSessionsLoading,
hasLoadedGlobalSessions,
sessions,
archivedSessions,
hasAuthoritativeGlobalSessions,
isWorktreeTopologyLoading,
normalizedProjects,
isVSCode,
ownership: sessionOwnership,
availableWorktreesByProject,
unresolvedWorktreeProjectPaths,
cleanupSessions,
});
const { getSessionsForProject, getArchivedSessionsForProject } = useProjectSessionLists({
isVSCode,
sessions,
archivedSessions,
availableWorktreesByProject,
normalizedProjects,
ownership: sessionOwnership,
});
useArchivedAutoFolders({
normalizedProjects,
sessions,
archivedSessions,
availableWorktreesByProject,
isVSCode,
ownership: sessionOwnership,
isSessionsLoading,
hasAuthoritativeGlobalSessions,
isWorktreeTopologyLoading,
unresolvedWorktreeProjectPaths,
foldersMap,
createFolder,
addSessionToFolder,
@@ -33,6 +33,7 @@
- `ConfirmDialogs.tsx`: Shared confirm dialog wrappers for session delete and folder delete flows.
- `sortableItems.tsx`: DnD sortable wrappers for project and group ordering plus project-row action affordances.
- `sessionFolderDnd.tsx`: Folder/session DnD scope and wrappers for dropping/moving sessions into folders.
- `sessionOwnership.ts`: Resolves session directories once into shared project/worktree ownership and folder-scope indexes.
### Hooks
@@ -46,7 +47,7 @@
- `hooks/useArchivedAutoFolders.ts`: Maintains archived auto-folder structure and assignment behavior.
- `hooks/useSidebarPersistence.ts`: Persists sidebar UI state (expanded/collapsed/pinned/group order/active session) to storage + desktop settings.
- `hooks/useProjectRepoStatus.ts`: Tracks per-project git-repo state and root branch metadata.
- `hooks/useProjectSessionLists.ts`: Builds live and archived session lists for a given project (including worktrees + dedupe).
- `hooks/useProjectSessionLists.ts`: Reads live and archived project buckets from the shared ownership index.
- `hooks/useSessionFolderCleanup.ts`: Cleans stale folder session IDs by reconciling known sessions/archived scopes.
- `hooks/useStickyProjectHeaders.ts`: Tracks which project headers are sticky/stuck via `IntersectionObserver`.
@@ -1,16 +1,12 @@
import React from 'react';
import type { Session } from '@opencode-ai/sdk/v2';
import type { WorktreeMetadata } from '@/types/worktree';
import {
collectKnownProjectDirectories,
dedupeSessionsById,
getArchivedScopeKey,
isSessionRelatedToProject,
normalizePath,
resolveArchivedFolderName,
} from '../utils';
import type { SessionOwnershipIndex } from '../sessionOwnership';
type ProjectForArchivedFolders = {
id: string;
normalizedPath: string;
};
@@ -22,83 +18,42 @@ type FolderEntry = {
type Args = {
normalizedProjects: ProjectForArchivedFolders[];
sessions: Session[];
archivedSessions: Session[];
availableWorktreesByProject: Map<string, WorktreeMetadata[]>;
isVSCode: boolean;
ownership: SessionOwnershipIndex;
isSessionsLoading: boolean;
hasAuthoritativeGlobalSessions: boolean;
isWorktreeTopologyLoading: boolean;
unresolvedWorktreeProjectPaths: ReadonlySet<string>;
foldersMap: Record<string, FolderEntry[]>;
createFolder: (scopeKey: string, name: string, parentId?: string | null) => FolderEntry;
addSessionToFolder: (scopeKey: string, folderId: string, sessionId: string) => void;
cleanupSessions: (scopeKey: string, existingSessionIds: Set<string>) => void;
};
const getArchivedSessionsForProject = (
project: ProjectForArchivedFolders,
params: Pick<Args, 'sessions' | 'archivedSessions' | 'availableWorktreesByProject' | 'isVSCode'> & {
knownProjectDirectories: Set<string>;
},
): Session[] => {
const worktreesForProject = params.isVSCode ? [] : (params.availableWorktreesByProject.get(project.normalizedPath) ?? []);
const validDirectories = new Set<string>([
project.normalizedPath,
...worktreesForProject
.map((meta) => normalizePath(meta.path) ?? meta.path)
.filter((value): value is string => Boolean(value)),
]);
const collect = (input: Session[]): Session[] => input.filter((session) =>
isSessionRelatedToProject(session, project.normalizedPath, validDirectories, params.knownProjectDirectories),
);
const archived = collect(params.archivedSessions);
const unassignedLive = params.sessions.filter((session) => {
if (session.time?.archived) {
return false;
}
const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null);
if (sessionDirectory) {
return false;
}
return isSessionRelatedToProject(session, project.normalizedPath, validDirectories, params.knownProjectDirectories);
});
return dedupeSessionsById([...archived, ...unassignedLive]);
};
export const useArchivedAutoFolders = (args: Args): void => {
const {
normalizedProjects,
sessions,
archivedSessions,
availableWorktreesByProject,
isVSCode,
ownership,
isSessionsLoading,
hasAuthoritativeGlobalSessions,
isWorktreeTopologyLoading,
unresolvedWorktreeProjectPaths,
foldersMap,
createFolder,
addSessionToFolder,
cleanupSessions,
} = args;
const knownProjectDirectories = React.useMemo(
() => collectKnownProjectDirectories(normalizedProjects, availableWorktreesByProject, isVSCode),
[normalizedProjects, availableWorktreesByProject, isVSCode],
);
React.useEffect(() => {
if (isSessionsLoading) {
if (isSessionsLoading || !hasAuthoritativeGlobalSessions || isWorktreeTopologyLoading) {
return;
}
normalizedProjects.forEach((project) => {
if (unresolvedWorktreeProjectPaths.has(project.normalizedPath)) {
return;
}
const scopeKey = getArchivedScopeKey(project.normalizedPath);
const projectArchivedSessions = getArchivedSessionsForProject(project, {
sessions,
archivedSessions,
availableWorktreesByProject,
isVSCode,
knownProjectDirectories,
});
const projectArchivedSessions = ownership.archivedSessionsByProject.get(project.id) ?? [];
const sessionIds = new Set(projectArchivedSessions.map((session) => session.id));
const existingFolders = foldersMap[scopeKey] ?? [];
@@ -122,12 +77,11 @@ export const useArchivedAutoFolders = (args: Args): void => {
});
}, [
normalizedProjects,
sessions,
archivedSessions,
availableWorktreesByProject,
knownProjectDirectories,
isVSCode,
ownership,
isSessionsLoading,
hasAuthoritativeGlobalSessions,
isWorktreeTopologyLoading,
unresolvedWorktreeProjectPaths,
foldersMap,
createFolder,
addSessionToFolder,
@@ -1,159 +1,27 @@
import React from 'react';
import type { Session } from '@opencode-ai/sdk/v2';
import { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
import { collectKnownProjectDirectories, dedupeSessionsById, isSessionRelatedToProject, normalizePath } from '../utils';
type WorktreeMeta = { path: string };
type NormalizedProject = { id: string; normalizedPath: string };
import type { SessionOwnershipIndex } from '../sessionOwnership';
type Args = {
isVSCode: boolean;
sessions: Session[];
archivedSessions: Session[];
availableWorktreesByProject: Map<string, WorktreeMeta[]>;
/**
* The set of normalized projects the sidebar will render. Used in
* Layer 4.13 to precompute the allowed directory set so the per-row
* `sessionsByDirectory` Map only contains buckets the sidebar will
* actually consume. With 10 projects × 5 worktrees and 100 sessions
* per directory this drops the Map from N entries to the small
* subset the sidebar needs.
*/
normalizedProjects: NormalizedProject[];
ownership: SessionOwnershipIndex;
};
export const useProjectSessionLists = (args: Args) => {
const {
isVSCode,
sessions,
archivedSessions,
availableWorktreesByProject,
normalizedProjects,
ownership,
} = args;
// Precompute the set of directories the sidebar will ever ask about:
// every project's normalized path plus the path of each registered
// 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 knownProjectDirectories = React.useMemo(
() => collectKnownProjectDirectories(normalizedProjects, availableWorktreesByProject, isVSCode),
[normalizedProjects, availableWorktreesByProject, isVSCode],
);
const sessionsByDirectory = React.useMemo(() => {
const next = new Map<string, Session[]>();
sessions.forEach((session) => {
const directory = resolveGlobalSessionDirectory(session);
if (!directory) {
return;
}
// Skip sessions whose directory doesn't belong to any known
// project or worktree. Without this filter the Map grows with
// every session the server has ever seen, even ones for
// long-removed worktrees; the sidebar's downstream filters
// would then drop them anyway.
if (!knownProjectDirectories.has(directory)) {
return;
}
const collection = next.get(directory) ?? [];
collection.push(session);
next.set(directory, collection);
});
return next;
}, [sessions, knownProjectDirectories]);
const getSessionsForProject = React.useCallback(
(project: { normalizedPath: string }) => {
const worktreesForProject = isVSCode ? [] : (availableWorktreesByProject.get(project.normalizedPath) ?? []);
const directories = [
project.normalizedPath,
...worktreesForProject
.map((meta) => normalizePath(meta.path) ?? meta.path)
.filter((value): value is string => Boolean(value)),
];
const seen = new Set<string>();
const collected: Session[] = [];
directories.forEach((directory) => {
const sessionsForDirectory: Session[] = sessionsByDirectory.get(directory) ?? [];
sessionsForDirectory.forEach((session) => {
if (seen.has(session.id)) {
return;
}
seen.add(session.id);
collected.push(session);
});
});
return collected;
(projectId: string) => {
return ownership.sessionsByProject.get(projectId) ?? [];
},
[availableWorktreesByProject, isVSCode, sessionsByDirectory],
[ownership],
);
const getArchivedSessionsForProject = React.useCallback(
(project: { normalizedPath: string }) => {
if (isVSCode) {
const archived = archivedSessions.filter((session) => {
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);
if (sessionDirectory) {
return sessionDirectory === project.normalizedPath;
}
return projectWorktree === project.normalizedPath;
});
const unassignedLive = sessions.filter((session) => {
if (session.time?.archived) {
return false;
}
const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null);
if (sessionDirectory) {
return false;
}
const projectWorktree = normalizePath((session as Session & { project?: { worktree?: string | null } | null }).project?.worktree ?? null);
return projectWorktree === project.normalizedPath;
});
return dedupeSessionsById([...archived, ...unassignedLive]);
}
const worktreesForProject = isVSCode ? [] : (availableWorktreesByProject.get(project.normalizedPath) ?? []);
const validDirectories = new Set<string>([
project.normalizedPath,
...worktreesForProject
.map((meta) => normalizePath(meta.path) ?? meta.path)
.filter((value): value is string => Boolean(value)),
]);
const collect = (input: Session[]): Session[] => input.filter((session) =>
isSessionRelatedToProject(session, project.normalizedPath, validDirectories, knownProjectDirectories),
);
const archived = collect(archivedSessions);
const unassignedLive = sessions.filter((session) => {
if (session.time?.archived) {
return false;
}
const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null);
if (sessionDirectory) {
return false;
}
const projectWorktree = normalizePath((session as Session & { project?: { worktree?: string | null } | null }).project?.worktree ?? null);
if (!projectWorktree) {
return false;
}
return isSessionRelatedToProject(session, project.normalizedPath, validDirectories, knownProjectDirectories);
});
return dedupeSessionsById([...archived, ...unassignedLive]);
(projectId: string) => {
return ownership.archivedSessionsByProject.get(projectId) ?? [];
},
[archivedSessions, availableWorktreesByProject, isVSCode, knownProjectDirectories, sessions],
[ownership],
);
return {
@@ -1,113 +1,80 @@
import React from 'react';
import type { Session } from '@opencode-ai/sdk/v2';
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
import {
collectKnownProjectDirectories,
dedupeSessionsById,
getArchivedScopeKey,
isSessionRelatedToProject,
normalizePath,
} from '../utils';
import { getArchivedScopeKey, normalizePath } from '../utils';
import type { SessionOwnershipIndex } from '../sessionOwnership';
type WorktreeMeta = { path: string };
type NormalizedProject = {
id: string;
normalizedPath: string;
};
type WorktreeMeta = { path: string };
type Args = {
isSessionsLoading: boolean;
hasLoadedGlobalSessions: boolean;
sessions: Session[];
archivedSessions: Session[];
hasAuthoritativeGlobalSessions: boolean;
isWorktreeTopologyLoading: boolean;
normalizedProjects: NormalizedProject[];
isVSCode: boolean;
ownership: SessionOwnershipIndex;
availableWorktreesByProject: Map<string, WorktreeMeta[]>;
unresolvedWorktreeProjectPaths: ReadonlySet<string>;
cleanupSessions: (scopeKey: string, validSessionIds: Set<string>) => void;
};
export const useSessionFolderCleanup = (args: Args): void => {
const {
isSessionsLoading,
hasLoadedGlobalSessions,
sessions,
archivedSessions,
hasAuthoritativeGlobalSessions,
isWorktreeTopologyLoading,
normalizedProjects,
isVSCode,
ownership,
availableWorktreesByProject,
unresolvedWorktreeProjectPaths,
cleanupSessions,
} = args;
const knownProjectDirectories = React.useMemo(
() => collectKnownProjectDirectories(normalizedProjects, availableWorktreesByProject, isVSCode),
[normalizedProjects, availableWorktreesByProject, isVSCode],
);
React.useEffect(() => {
if (isSessionsLoading || !hasLoadedGlobalSessions) {
if (isSessionsLoading || !hasAuthoritativeGlobalSessions || isWorktreeTopologyLoading) {
return;
}
if (sessions.length === 0 && archivedSessions.length === 0) {
if (ownership.bySessionId.size === 0) {
return;
}
const idsByScope = new Map<string, Set<string>>();
sessions.forEach((session) => {
const directory = normalizePath((session as Session & { directory?: string | null }).directory ?? null);
if (!directory) {
return;
}
const existing = idsByScope.get(directory);
if (existing) {
existing.add(session.id);
return;
}
idsByScope.set(directory, new Set([session.id]));
ownership.sessionsByScope.forEach((sessionIds, scopeDirectory) => {
idsByScope.set(scopeDirectory, new Set(sessionIds));
});
normalizedProjects.forEach((project) => {
if (unresolvedWorktreeProjectPaths.has(project.normalizedPath)) {
return;
}
const scopeKey = getArchivedScopeKey(project.normalizedPath);
const worktreesForProject = isVSCode ? [] : (availableWorktreesByProject.get(project.normalizedPath) ?? []);
const validDirectories = new Set<string>([
project.normalizedPath,
...worktreesForProject
.map((meta) => normalizePath(meta.path) ?? meta.path)
.filter((value): value is string => Boolean(value)),
]);
const archivedForProject = dedupeSessionsById([
...archivedSessions,
...sessions.filter((session) => {
if (session.time?.archived) {
return false;
}
const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null);
if (sessionDirectory) {
return false;
}
return isSessionRelatedToProject(session, project.normalizedPath, validDirectories, knownProjectDirectories);
}),
]).filter((session) => isSessionRelatedToProject(session, project.normalizedPath, validDirectories, knownProjectDirectories));
idsByScope.set(scopeKey, new Set(archivedForProject.map((session) => session.id)));
const archivedSessions = ownership.archivedSessionsByProject.get(project.id) ?? [];
idsByScope.set(scopeKey, new Set(archivedSessions.map((session) => session.id)));
if (!idsByScope.has(project.normalizedPath)) {
idsByScope.set(project.normalizedPath, new Set());
}
for (const worktree of availableWorktreesByProject.get(project.normalizedPath) ?? []) {
const worktreePath = normalizePath(worktree.path);
if (worktreePath && !idsByScope.has(worktreePath)) {
idsByScope.set(worktreePath, new Set());
}
}
});
const currentFoldersMap = useSessionFoldersStore.getState().foldersMap;
const allScopeKeys = new Set([...Object.keys(currentFoldersMap), ...idsByScope.keys()]);
allScopeKeys.forEach((scopeKey) => {
cleanupSessions(scopeKey, idsByScope.get(scopeKey) ?? new Set<string>());
idsByScope.forEach((sessionIds, scopeKey) => {
cleanupSessions(scopeKey, sessionIds);
});
}, [
archivedSessions,
availableWorktreesByProject,
cleanupSessions,
hasLoadedGlobalSessions,
hasAuthoritativeGlobalSessions,
isWorktreeTopologyLoading,
isSessionsLoading,
isVSCode,
knownProjectDirectories,
normalizedProjects,
sessions,
ownership,
unresolvedWorktreeProjectPaths,
]);
};
@@ -23,8 +23,8 @@ type ProjectSection = {
type Args = {
normalizedProjects: ProjectItem[];
getSessionsForProject: (project: { normalizedPath: string }) => Session[];
getArchivedSessionsForProject: (project: { normalizedPath: string }) => Session[];
getSessionsForProject: (projectId: string) => Session[];
getArchivedSessionsForProject: (projectId: string) => Session[];
availableWorktreesByProject: Map<string, WorktreeMetadata[]>;
projectRepoStatus: Map<string, boolean | null>;
projectRootBranches: Map<string, string | null>;
@@ -63,8 +63,8 @@ export const useSessionSidebarSections = (args: Args) => {
const projectSections = React.useMemo<ProjectSection[]>(() => {
return normalizedProjects.map((project) => {
const projectSessions = dedupeSessionsById([
...getSessionsForProject(project),
...getArchivedSessionsForProject(project),
...getSessionsForProject(project.id),
...getArchivedSessionsForProject(project.id),
]);
const worktreesForProject = availableWorktreesByProject.get(project.normalizedPath) ?? [];
const isRepo = projectRepoStatus.has(project.id)
@@ -0,0 +1,129 @@
import { describe, expect, test } from 'bun:test';
import type { Session } from '@opencode-ai/sdk/v2';
import { createSessionOwnershipIndex } from './sessionOwnership';
describe('createSessionOwnershipIndex', () => {
test('assigns sessions to the deepest project and registered worktree', () => {
const sessions = [
{ id: 'nested', directory: '/projects/app/packages/admin/src' },
{ id: 'external-worktree', directory: '/worktrees/app-feature/src' },
{ id: 'worktree-fallback', project: { worktree: '/worktrees/app-feature/src' } },
{ id: 'directory-wins', directory: '/projects/app/packages/admin', project: { worktree: '/projects/app' } },
{ id: 'windows', directory: 'c:\\Projects\\App\\src' },
{ id: 'unassigned', directory: '/elsewhere' },
] as unknown as Session[];
const projects = [
{ id: 'app', normalizedPath: '/projects/app' },
{ id: 'admin', normalizedPath: '/projects/app/packages/admin' },
{ id: 'windows-app', normalizedPath: 'C:/Projects/App' },
];
const worktrees = new Map([
['/projects/app', [{ path: '/worktrees/app-feature' }]],
]);
const ownership = createSessionOwnershipIndex(sessions, projects, worktrees, false);
expect(ownership.bySessionId.get('nested')?.projectId).toBe('admin');
expect(ownership.bySessionId.get('external-worktree')).toEqual({
projectId: 'app',
projectRoot: '/projects/app',
scopeDirectory: '/worktrees/app-feature',
kind: 'worktree',
});
expect(ownership.bySessionId.get('worktree-fallback')?.scopeDirectory).toBe('/worktrees/app-feature');
expect(ownership.bySessionId.get('directory-wins')?.projectId).toBe('admin');
expect(ownership.bySessionId.get('windows')?.projectId).toBe('windows-app');
expect(ownership.bySessionId.has('unassigned')).toBe(false);
expect(ownership.sessionsByProject.get('admin')?.map((session) => session.id)).toEqual([
'nested',
'directory-wins',
]);
expect(ownership.sessionsByScope.get('/worktrees/app-feature')).toEqual(new Set([
'external-worktree',
'worktree-fallback',
]));
});
test('gives an exact project precedence over a colliding worktree', () => {
const ownership = createSessionOwnershipIndex(
[{ id: 'nested', directory: '/projects/app/packages/admin/src' } as Session],
[
{ id: 'app', normalizedPath: '/projects/app' },
{ id: 'admin', normalizedPath: '/projects/app/packages/admin' },
],
new Map([['/projects/app', [{ path: '/projects/app/packages/admin' }]]]),
false,
);
expect(ownership.bySessionId.get('nested')?.projectId).toBe('admin');
expect(ownership.bySessionId.get('nested')?.kind).toBe('project');
});
test('indexes archived sessions separately', () => {
const ownership = createSessionOwnershipIndex(
[],
[{ id: 'app', normalizedPath: '/projects/app' }],
new Map([['/projects/app', [{ path: '/worktrees/app-feature' }]]]),
false,
[
{ id: 'archived-child', directory: '/worktrees/app-feature/src', time: { archived: 1 } },
{ id: 'archived-fallback', project: { worktree: '/worktrees/app-feature' }, time: { archived: 1 } },
] as unknown as Session[],
);
expect(ownership.archivedSessionsByProject.get('app')?.map((session) => session.id)).toEqual([
'archived-child',
'archived-fallback',
]);
});
test('requires exact workspace directories in VS Code', () => {
const ownership = createSessionOwnershipIndex(
[
{ id: 'workspace', directory: '/projects/app' },
{ id: 'nested', directory: '/projects/app/packages/ui' },
] as Session[],
[{ id: 'app', normalizedPath: '/projects/app' }],
new Map(),
true,
);
expect(ownership.bySessionId.get('workspace')?.projectId).toBe('app');
expect(ownership.bySessionId.has('nested')).toBe(false);
});
test('supports a Windows drive root project', () => {
const ownership = createSessionOwnershipIndex(
[{ id: 'windows-root', directory: 'c:\\Users\\name\\project' } as Session],
[{ id: 'drive', normalizedPath: 'C:/' }],
new Map(),
false,
);
expect(ownership.bySessionId.get('windows-root')?.projectId).toBe('drive');
});
test('resolves report-sized data once instead of once per project consumer', () => {
const projects = Array.from({ length: 15 }, (_, index) => ({
id: `project-${index}`,
normalizedPath: `/projects/${index}`,
}));
const worktrees = new Map(projects.map((project, projectIndex) => [
project.normalizedPath,
Array.from({ length: projectIndex < 7 ? 5 : 4 }, (_, index) => ({
path: `/worktrees/${projectIndex}/${index}`,
})),
]));
const sessions = Array.from({ length: 14_561 }, (_, index) => ({
id: `session-${index}`,
directory: `/worktrees/${index % 15}/${index % 4}/session/${index}`,
})) as unknown as Session[];
const ownership = createSessionOwnershipIndex(sessions, projects, worktrees, false);
expect(ownership.bySessionId.size).toBe(14_561);
expect(ownership.directoryResolutions).toBeLessThan(14_561 * 2);
expect([...ownership.sessionsByProject.values()].reduce((total, bucket) => total + bucket.length, 0)).toBe(14_561);
});
});
@@ -0,0 +1,179 @@
import type { Session } from '@opencode-ai/sdk/v2';
import { normalizePath } from '@/lib/pathNormalization';
type Project = {
id: string;
normalizedPath: string;
};
type Worktree = {
path: string;
};
export type DirectoryOwner = {
projectId: string;
projectRoot: string;
scopeDirectory: string;
kind: 'project' | 'worktree';
};
export type SessionOwnershipIndex = {
bySessionId: Map<string, DirectoryOwner>;
sessionsByProject: Map<string, Session[]>;
archivedSessionsByProject: Map<string, Session[]>;
sessionsByScope: Map<string, Set<string>>;
directoryResolutions: number;
};
const shouldReplaceOwner = (existing: DirectoryOwner | undefined, candidate: DirectoryOwner): boolean => {
if (!existing) return true;
if (candidate.kind !== existing.kind) {
return candidate.kind === 'project';
}
if (candidate.projectRoot.length !== existing.projectRoot.length) {
return candidate.projectRoot.length > existing.projectRoot.length;
}
return candidate.projectId.localeCompare(existing.projectId) < 0;
};
const setOwner = (owners: Map<string, DirectoryOwner>, directory: string, candidate: DirectoryOwner): void => {
if (shouldReplaceOwner(owners.get(directory), candidate)) {
owners.set(directory, candidate);
}
};
const resolveSessionDirectory = (session: Session): string | null => {
const record = session as Session & {
directory?: string | null;
project?: { worktree?: string | null } | null;
};
return normalizePath(record.directory) ?? normalizePath(record.project?.worktree);
};
const getParentDirectory = (directory: string): string | null => {
if (directory === '/' || /^[A-Z]:$/.test(directory)) {
return null;
}
const separator = directory.lastIndexOf('/');
if (separator < 0) return null;
if (separator === 0) return '/';
if (separator === 2 && /^[A-Z]:\//.test(directory)) return directory.slice(0, 2);
return directory.slice(0, separator);
};
export const createSessionOwnershipIndex = (
sessions: Session[],
projects: Project[],
availableWorktreesByProject: Map<string, Worktree[]>,
isVSCode: boolean,
archivedSessions: Session[] = [],
): SessionOwnershipIndex => {
const ownerByDirectory = new Map<string, DirectoryOwner>();
const projectByRoot = new Map<string, Project>();
for (const project of projects) {
const projectRoot = normalizePath(project.normalizedPath);
if (!projectRoot) continue;
const existingProject = projectByRoot.get(projectRoot);
if (!existingProject || project.id.localeCompare(existingProject.id) < 0) {
projectByRoot.set(projectRoot, project);
}
setOwner(ownerByDirectory, projectRoot, {
projectId: project.id,
projectRoot,
scopeDirectory: projectRoot,
kind: 'project',
});
}
if (!isVSCode) {
for (const [projectPath, worktrees] of availableWorktreesByProject) {
const projectRoot = normalizePath(projectPath);
const project = projectRoot ? projectByRoot.get(projectRoot) : undefined;
if (!project || !projectRoot) continue;
for (const worktree of worktrees) {
const directory = normalizePath(worktree.path);
if (!directory) continue;
setOwner(ownerByDirectory, directory, {
projectId: project.id,
projectRoot,
scopeDirectory: directory,
kind: 'worktree',
});
}
}
}
const resolvedOwners = new Map<string, DirectoryOwner | null>();
const bySessionId = new Map<string, DirectoryOwner>();
const sessionsByProject = new Map<string, Session[]>();
const archivedSessionsByProject = new Map<string, Session[]>();
const sessionsByScope = new Map<string, Set<string>>();
const resolveOwner = (directory: string | null): DirectoryOwner | null => {
if (!directory) return null;
if (resolvedOwners.has(directory)) {
return resolvedOwners.get(directory) ?? null;
}
if (isVSCode) {
const owner = ownerByDirectory.get(directory) ?? null;
resolvedOwners.set(directory, owner);
return owner;
}
const visited: string[] = [];
let current: string | null = directory;
let owner: DirectoryOwner | null = null;
while (current) {
if (resolvedOwners.has(current)) {
owner = resolvedOwners.get(current) ?? null;
break;
}
visited.push(current);
owner = ownerByDirectory.get(current) ?? null;
if (owner) break;
current = getParentDirectory(current);
}
for (const visitedDirectory of visited) {
resolvedOwners.set(visitedDirectory, owner);
}
return owner;
};
const bucket = (
input: Session[],
target: Map<string, Session[]>,
scopeTarget?: Map<string, Set<string>>,
): void => {
for (const session of input) {
const owner = resolveOwner(resolveSessionDirectory(session));
if (!owner) continue;
bySessionId.set(session.id, owner);
const projectSessions = target.get(owner.projectId);
if (projectSessions) {
projectSessions.push(session);
} else {
target.set(owner.projectId, [session]);
}
if (!scopeTarget) continue;
const scopeSessions = scopeTarget.get(owner.scopeDirectory);
if (scopeSessions) {
scopeSessions.add(session.id);
} else {
scopeTarget.set(owner.scopeDirectory, new Set([session.id]));
}
}
};
bucket(sessions, sessionsByProject, sessionsByScope);
bucket(archivedSessions, archivedSessionsByProject);
return {
bySessionId,
sessionsByProject,
archivedSessionsByProject,
sessionsByScope,
directoryResolutions: resolvedOwners.size,
};
};
@@ -1,7 +1,5 @@
import { describe, expect, test } from 'bun:test';
import type { Session } from '@opencode-ai/sdk/v2';
import { isPathWithinProject, isSessionRelatedToProject } from './utils';
import { isPathWithinProject } from './utils';
describe('isPathWithinProject', () => {
test('matches child directories for root projects', () => {
@@ -28,86 +26,3 @@ 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);
});
});
@@ -83,67 +83,16 @@ export const formatSessionCompactDateLabel = (updatedMs: number): string => {
export const isPathWithinProject = (directory?: string | null, projectPath?: string | null): boolean => {
const normalizedDirectory = normalizePath(directory);
const normalizedProjectPath = normalizePath(projectPath);
return isNormalizedPathWithinProject(normalizedDirectory, normalizedProjectPath);
};
const isNormalizedPathWithinProject = (normalizedDirectory: string | null, normalizedProjectPath: string | null): boolean => {
if (!normalizedDirectory || !normalizedProjectPath) return false;
if (normalizedDirectory === normalizedProjectPath) return true;
if (normalizedProjectPath === '/') return normalizedDirectory.startsWith('/');
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 => {
return value
.toLowerCase()
@@ -223,33 +172,6 @@ export const resolveArchivedFolderName = (session: Session, projectRoot: string
return segments[segments.length - 1] ?? 'unassigned';
};
export const isSessionRelatedToProject = (
session: Session,
projectRoot: string,
validDirectories?: Set<string>,
knownDirectories?: Iterable<string>,
): 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 (resolvedDirectory && validDirectories?.has(resolvedDirectory)) {
return true;
}
if (!resolvedDirectory) {
return false;
}
const bestMatch = findBestProjectDirectoryMatch(resolvedDirectory, knownDirectories);
if (bestMatch) {
return validDirectories ? validDirectories.has(bestMatch) : bestMatch === projectRoot;
}
return resolvedDirectory === projectRoot || resolvedDirectory.startsWith(`${projectRoot}/`);
};
export const formatProjectLabel = (label: string): string => {
return label
.replace(/[-_]/g, ' ')