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
@@ -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)