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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user