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,