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 { useProjectRepoStatus } from './sidebar/hooks/useProjectRepoStatus';
import { useProjectSessionLists } from './sidebar/hooks/useProjectSessionLists'; import { useProjectSessionLists } from './sidebar/hooks/useProjectSessionLists';
import { useSessionFolderCleanup } from './sidebar/hooks/useSessionFolderCleanup'; import { useSessionFolderCleanup } from './sidebar/hooks/useSessionFolderCleanup';
import { createSessionOwnershipIndex } from './sidebar/sessionOwnership';
import { useStickyProjectHeaders } from './sidebar/hooks/useStickyProjectHeaders'; import { useStickyProjectHeaders } from './sidebar/hooks/useStickyProjectHeaders';
import { getGitHubPrStatusKey, usePrVisualSummaryByKeys, useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore'; import { getGitHubPrStatusKey, usePrVisualSummaryByKeys, useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore';
import { ProjectEditDialog } from '@/components/layout/ProjectEditDialog'; import { ProjectEditDialog } from '@/components/layout/ProjectEditDialog';
@@ -319,7 +320,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const sync = useSync(); const sync = useSync();
const liveSessions = useAllLiveSessions(); const liveSessions = useAllLiveSessions();
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []); const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
const hasLoadedGlobalSessions = useGlobalSessionsStore((state) => state.hasLoaded);
const hasAuthoritativeGlobalSessions = useGlobalSessionsStore((state) => state.status === 'ready'); const hasAuthoritativeGlobalSessions = useGlobalSessionsStore((state) => state.status === 'ready');
const globalActiveSessions = useGlobalSessionsStore((state) => state.activeSessions); const globalActiveSessions = useGlobalSessionsStore((state) => state.activeSessions);
const archivedSessions = useGlobalSessionsStore((state) => state.archivedSessions); const archivedSessions = useGlobalSessionsStore((state) => state.archivedSessions);
@@ -418,6 +418,11 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
.join('|'), .join('|'),
[projects], [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); const initialGlobalSessionsRefreshStartedRef = React.useRef(false);
React.useEffect(() => { React.useEffect(() => {
@@ -428,19 +433,22 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
void refreshGlobalSessions(syncSessionsSnapshotRef.current); 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(() => { React.useEffect(() => {
let cancelled = false; let cancelled = false;
const discoverWorktrees = async () => { const discoverWorktrees = async () => {
const projectEntries = useProjectsStore.getState().projects; 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 currentByProject = useSessionUIStore.getState().availableWorktreesByProject;
const allWorktrees: WorktreeMetadata[] = []; const worktreesByProject = new Map(currentByProject);
const unresolvedProjectPaths = new Set<string>();
// Constrain fanout: previously `Promise.all(projects.map(...))` could // Constrain fanout: previously `Promise.all(projects.map(...))` could
// spawn dozens of concurrent `git worktree list` and // 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. // PR/render paths downstream can read isGitRepo for free.
const cachedIsGitRepo = useGitStore.getState().directories.get(projectPath)?.isGitRepo; const cachedIsGitRepo = useGitStore.getState().directories.get(projectPath)?.isGitRepo;
const isGitRepo = cachedIsGitRepo ?? await checkIsGitRepository(projectPath); const isGitRepo = cachedIsGitRepo ?? await checkIsGitRepository(projectPath);
if (!isGitRepo) continue; if (!isGitRepo) {
worktreesByProject.delete(projectPath);
continue;
}
const worktrees = await listProjectWorktrees({ id: project.id, path: projectPath }); const worktrees = await listProjectWorktrees({ id: project.id, path: projectPath });
if (cancelled || worktrees.length === 0) continue; if (cancelled) return;
worktreesByProject.set(projectPath, worktrees); if (worktrees.length === 0) {
allWorktrees.push(...worktrees); worktreesByProject.delete(projectPath);
} else {
worktreesByProject.set(projectPath, worktrees);
}
} catch { } 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; 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. // Skip update if nothing changed — see worktreeMapsEqual JSDoc.
const currentByProject = useSessionUIStore.getState().availableWorktreesByProject;
if (!worktreeMapsEqual(worktreesByProject, currentByProject)) { if (!worktreeMapsEqual(worktreesByProject, currentByProject)) {
useSessionUIStore.setState({ useSessionUIStore.setState({
availableWorktrees: allWorktrees, availableWorktrees: allWorktrees,
availableWorktreesByProject: worktreesByProject, 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(); void discoverWorktrees();
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [projectWorktreeDiscoveryKey]); }, [isVSCode, projectWorktreeDiscoveryKey]);
React.useEffect(() => { React.useEffect(() => {
let refreshTimeout: ReturnType<typeof setTimeout> | null = null; let refreshTimeout: ReturnType<typeof setTimeout> | null = null;
@@ -943,18 +962,15 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
); );
const projectSessionDirectories = React.useMemo(() => { const projectSessionDirectories = React.useMemo(() => {
const directories = new Set<string>(); const directories = new Set(normalizedProjects.map((project) => project.normalizedPath));
normalizedProjects.forEach((project) => { if (!isVSCode) {
if (project.normalizedPath) directories.add(project.normalizedPath); for (const worktrees of availableWorktreesByProject.values()) {
if (isVSCode) { for (const worktree of worktrees) {
return; 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(); return [...directories].sort();
}, [availableWorktreesByProject, isVSCode, normalizedProjects]); }, [availableWorktreesByProject, isVSCode, normalizedProjects]);
@@ -994,32 +1010,32 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
}); });
const isSessionsLoading = useSessionUIStore((state) => state.isLoading); const isSessionsLoading = useSessionUIStore((state) => state.isLoading);
const sessionOwnership = React.useMemo(
() => createSessionOwnershipIndex(sessions, normalizedProjects, availableWorktreesByProject, isVSCode, archivedSessions),
[archivedSessions, availableWorktreesByProject, isVSCode, normalizedProjects, sessions],
);
useSessionFolderCleanup({ useSessionFolderCleanup({
isSessionsLoading, isSessionsLoading,
hasLoadedGlobalSessions, hasAuthoritativeGlobalSessions,
sessions, isWorktreeTopologyLoading,
archivedSessions,
normalizedProjects, normalizedProjects,
isVSCode, ownership: sessionOwnership,
availableWorktreesByProject, availableWorktreesByProject,
unresolvedWorktreeProjectPaths,
cleanupSessions, cleanupSessions,
}); });
const { getSessionsForProject, getArchivedSessionsForProject } = useProjectSessionLists({ const { getSessionsForProject, getArchivedSessionsForProject } = useProjectSessionLists({
isVSCode, ownership: sessionOwnership,
sessions,
archivedSessions,
availableWorktreesByProject,
normalizedProjects,
}); });
useArchivedAutoFolders({ useArchivedAutoFolders({
normalizedProjects, normalizedProjects,
sessions, ownership: sessionOwnership,
archivedSessions,
availableWorktreesByProject,
isVSCode,
isSessionsLoading, isSessionsLoading,
hasAuthoritativeGlobalSessions,
isWorktreeTopologyLoading,
unresolvedWorktreeProjectPaths,
foldersMap, foldersMap,
createFolder, createFolder,
addSessionToFolder, addSessionToFolder,
@@ -33,6 +33,7 @@
- `ConfirmDialogs.tsx`: Shared confirm dialog wrappers for session delete and folder delete flows. - `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. - `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. - `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 ### Hooks
@@ -46,7 +47,7 @@
- `hooks/useArchivedAutoFolders.ts`: Maintains archived auto-folder structure and assignment behavior. - `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/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/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/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`. - `hooks/useStickyProjectHeaders.ts`: Tracks which project headers are sticky/stuck via `IntersectionObserver`.
@@ -1,16 +1,12 @@
import React from 'react'; import React from 'react';
import type { Session } from '@opencode-ai/sdk/v2';
import type { WorktreeMetadata } from '@/types/worktree';
import { import {
collectKnownProjectDirectories,
dedupeSessionsById,
getArchivedScopeKey, getArchivedScopeKey,
isSessionRelatedToProject,
normalizePath,
resolveArchivedFolderName, resolveArchivedFolderName,
} from '../utils'; } from '../utils';
import type { SessionOwnershipIndex } from '../sessionOwnership';
type ProjectForArchivedFolders = { type ProjectForArchivedFolders = {
id: string;
normalizedPath: string; normalizedPath: string;
}; };
@@ -22,83 +18,42 @@ type FolderEntry = {
type Args = { type Args = {
normalizedProjects: ProjectForArchivedFolders[]; normalizedProjects: ProjectForArchivedFolders[];
sessions: Session[]; ownership: SessionOwnershipIndex;
archivedSessions: Session[];
availableWorktreesByProject: Map<string, WorktreeMetadata[]>;
isVSCode: boolean;
isSessionsLoading: boolean; isSessionsLoading: boolean;
hasAuthoritativeGlobalSessions: boolean;
isWorktreeTopologyLoading: boolean;
unresolvedWorktreeProjectPaths: ReadonlySet<string>;
foldersMap: Record<string, FolderEntry[]>; foldersMap: Record<string, FolderEntry[]>;
createFolder: (scopeKey: string, name: string, parentId?: string | null) => FolderEntry; createFolder: (scopeKey: string, name: string, parentId?: string | null) => FolderEntry;
addSessionToFolder: (scopeKey: string, folderId: string, sessionId: string) => void; addSessionToFolder: (scopeKey: string, folderId: string, sessionId: string) => void;
cleanupSessions: (scopeKey: string, existingSessionIds: Set<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 => { export const useArchivedAutoFolders = (args: Args): void => {
const { const {
normalizedProjects, normalizedProjects,
sessions, ownership,
archivedSessions,
availableWorktreesByProject,
isVSCode,
isSessionsLoading, isSessionsLoading,
hasAuthoritativeGlobalSessions,
isWorktreeTopologyLoading,
unresolvedWorktreeProjectPaths,
foldersMap, foldersMap,
createFolder, createFolder,
addSessionToFolder, addSessionToFolder,
cleanupSessions, cleanupSessions,
} = args; } = args;
const knownProjectDirectories = React.useMemo(
() => collectKnownProjectDirectories(normalizedProjects, availableWorktreesByProject, isVSCode),
[normalizedProjects, availableWorktreesByProject, isVSCode],
);
React.useEffect(() => { React.useEffect(() => {
if (isSessionsLoading) { if (isSessionsLoading || !hasAuthoritativeGlobalSessions || isWorktreeTopologyLoading) {
return; return;
} }
normalizedProjects.forEach((project) => { normalizedProjects.forEach((project) => {
if (unresolvedWorktreeProjectPaths.has(project.normalizedPath)) {
return;
}
const scopeKey = getArchivedScopeKey(project.normalizedPath); const scopeKey = getArchivedScopeKey(project.normalizedPath);
const projectArchivedSessions = getArchivedSessionsForProject(project, { const projectArchivedSessions = ownership.archivedSessionsByProject.get(project.id) ?? [];
sessions,
archivedSessions,
availableWorktreesByProject,
isVSCode,
knownProjectDirectories,
});
const sessionIds = new Set(projectArchivedSessions.map((session) => session.id)); const sessionIds = new Set(projectArchivedSessions.map((session) => session.id));
const existingFolders = foldersMap[scopeKey] ?? []; const existingFolders = foldersMap[scopeKey] ?? [];
@@ -122,12 +77,11 @@ export const useArchivedAutoFolders = (args: Args): void => {
}); });
}, [ }, [
normalizedProjects, normalizedProjects,
sessions, ownership,
archivedSessions,
availableWorktreesByProject,
knownProjectDirectories,
isVSCode,
isSessionsLoading, isSessionsLoading,
hasAuthoritativeGlobalSessions,
isWorktreeTopologyLoading,
unresolvedWorktreeProjectPaths,
foldersMap, foldersMap,
createFolder, createFolder,
addSessionToFolder, addSessionToFolder,
@@ -1,159 +1,27 @@
import React from 'react'; import React from 'react';
import type { Session } from '@opencode-ai/sdk/v2'; import type { SessionOwnershipIndex } from '../sessionOwnership';
import { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
import { collectKnownProjectDirectories, dedupeSessionsById, isSessionRelatedToProject, normalizePath } from '../utils';
type WorktreeMeta = { path: string };
type NormalizedProject = { id: string; normalizedPath: string };
type Args = { type Args = {
isVSCode: boolean; ownership: SessionOwnershipIndex;
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[];
}; };
export const useProjectSessionLists = (args: Args) => { export const useProjectSessionLists = (args: Args) => {
const { const {
isVSCode, ownership,
sessions,
archivedSessions,
availableWorktreesByProject,
normalizedProjects,
} = args; } = 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( const getSessionsForProject = React.useCallback(
(project: { normalizedPath: string }) => { (projectId: string) => {
const worktreesForProject = isVSCode ? [] : (availableWorktreesByProject.get(project.normalizedPath) ?? []); return ownership.sessionsByProject.get(projectId) ?? [];
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;
}, },
[availableWorktreesByProject, isVSCode, sessionsByDirectory], [ownership],
); );
const getArchivedSessionsForProject = React.useCallback( const getArchivedSessionsForProject = React.useCallback(
(project: { normalizedPath: string }) => { (projectId: string) => {
if (isVSCode) { return ownership.archivedSessionsByProject.get(projectId) ?? [];
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]);
}, },
[archivedSessions, availableWorktreesByProject, isVSCode, knownProjectDirectories, sessions], [ownership],
); );
return { return {
@@ -1,113 +1,80 @@
import React from 'react'; import React from 'react';
import type { Session } from '@opencode-ai/sdk/v2'; import { getArchivedScopeKey, normalizePath } from '../utils';
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore'; import type { SessionOwnershipIndex } from '../sessionOwnership';
import {
collectKnownProjectDirectories, type WorktreeMeta = { path: string };
dedupeSessionsById,
getArchivedScopeKey,
isSessionRelatedToProject,
normalizePath,
} from '../utils';
type NormalizedProject = { type NormalizedProject = {
id: string; id: string;
normalizedPath: string; normalizedPath: string;
}; };
type WorktreeMeta = { path: string };
type Args = { type Args = {
isSessionsLoading: boolean; isSessionsLoading: boolean;
hasLoadedGlobalSessions: boolean; hasAuthoritativeGlobalSessions: boolean;
sessions: Session[]; isWorktreeTopologyLoading: boolean;
archivedSessions: Session[];
normalizedProjects: NormalizedProject[]; normalizedProjects: NormalizedProject[];
isVSCode: boolean; ownership: SessionOwnershipIndex;
availableWorktreesByProject: Map<string, WorktreeMeta[]>; availableWorktreesByProject: Map<string, WorktreeMeta[]>;
unresolvedWorktreeProjectPaths: ReadonlySet<string>;
cleanupSessions: (scopeKey: string, validSessionIds: Set<string>) => void; cleanupSessions: (scopeKey: string, validSessionIds: Set<string>) => void;
}; };
export const useSessionFolderCleanup = (args: Args): void => { export const useSessionFolderCleanup = (args: Args): void => {
const { const {
isSessionsLoading, isSessionsLoading,
hasLoadedGlobalSessions, hasAuthoritativeGlobalSessions,
sessions, isWorktreeTopologyLoading,
archivedSessions,
normalizedProjects, normalizedProjects,
isVSCode, ownership,
availableWorktreesByProject, availableWorktreesByProject,
unresolvedWorktreeProjectPaths,
cleanupSessions, cleanupSessions,
} = args; } = args;
const knownProjectDirectories = React.useMemo(
() => collectKnownProjectDirectories(normalizedProjects, availableWorktreesByProject, isVSCode),
[normalizedProjects, availableWorktreesByProject, isVSCode],
);
React.useEffect(() => { React.useEffect(() => {
if (isSessionsLoading || !hasLoadedGlobalSessions) { if (isSessionsLoading || !hasAuthoritativeGlobalSessions || isWorktreeTopologyLoading) {
return; return;
} }
if (sessions.length === 0 && archivedSessions.length === 0) { if (ownership.bySessionId.size === 0) {
return; return;
} }
const idsByScope = new Map<string, Set<string>>(); const idsByScope = new Map<string, Set<string>>();
sessions.forEach((session) => { ownership.sessionsByScope.forEach((sessionIds, scopeDirectory) => {
const directory = normalizePath((session as Session & { directory?: string | null }).directory ?? null); idsByScope.set(scopeDirectory, new Set(sessionIds));
if (!directory) {
return;
}
const existing = idsByScope.get(directory);
if (existing) {
existing.add(session.id);
return;
}
idsByScope.set(directory, new Set([session.id]));
}); });
normalizedProjects.forEach((project) => { normalizedProjects.forEach((project) => {
if (unresolvedWorktreeProjectPaths.has(project.normalizedPath)) {
return;
}
const scopeKey = getArchivedScopeKey(project.normalizedPath); const scopeKey = getArchivedScopeKey(project.normalizedPath);
const worktreesForProject = isVSCode ? [] : (availableWorktreesByProject.get(project.normalizedPath) ?? []); const archivedSessions = ownership.archivedSessionsByProject.get(project.id) ?? [];
const validDirectories = new Set<string>([ idsByScope.set(scopeKey, new Set(archivedSessions.map((session) => session.id)));
project.normalizedPath, if (!idsByScope.has(project.normalizedPath)) {
...worktreesForProject idsByScope.set(project.normalizedPath, new Set());
.map((meta) => normalizePath(meta.path) ?? meta.path) }
.filter((value): value is string => Boolean(value)), for (const worktree of availableWorktreesByProject.get(project.normalizedPath) ?? []) {
]); const worktreePath = normalizePath(worktree.path);
if (worktreePath && !idsByScope.has(worktreePath)) {
const archivedForProject = dedupeSessionsById([ idsByScope.set(worktreePath, new Set());
...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 currentFoldersMap = useSessionFoldersStore.getState().foldersMap; idsByScope.forEach((sessionIds, scopeKey) => {
const allScopeKeys = new Set([...Object.keys(currentFoldersMap), ...idsByScope.keys()]); cleanupSessions(scopeKey, sessionIds);
allScopeKeys.forEach((scopeKey) => {
cleanupSessions(scopeKey, idsByScope.get(scopeKey) ?? new Set<string>());
}); });
}, [ }, [
archivedSessions,
availableWorktreesByProject, availableWorktreesByProject,
cleanupSessions, cleanupSessions,
hasLoadedGlobalSessions, hasAuthoritativeGlobalSessions,
isWorktreeTopologyLoading,
isSessionsLoading, isSessionsLoading,
isVSCode,
knownProjectDirectories,
normalizedProjects, normalizedProjects,
sessions, ownership,
unresolvedWorktreeProjectPaths,
]); ]);
}; };
@@ -23,8 +23,8 @@ type ProjectSection = {
type Args = { type Args = {
normalizedProjects: ProjectItem[]; normalizedProjects: ProjectItem[];
getSessionsForProject: (project: { normalizedPath: string }) => Session[]; getSessionsForProject: (projectId: string) => Session[];
getArchivedSessionsForProject: (project: { normalizedPath: string }) => Session[]; getArchivedSessionsForProject: (projectId: string) => Session[];
availableWorktreesByProject: Map<string, WorktreeMetadata[]>; availableWorktreesByProject: Map<string, WorktreeMetadata[]>;
projectRepoStatus: Map<string, boolean | null>; projectRepoStatus: Map<string, boolean | null>;
projectRootBranches: Map<string, string | null>; projectRootBranches: Map<string, string | null>;
@@ -63,8 +63,8 @@ export const useSessionSidebarSections = (args: Args) => {
const projectSections = React.useMemo<ProjectSection[]>(() => { const projectSections = React.useMemo<ProjectSection[]>(() => {
return normalizedProjects.map((project) => { return normalizedProjects.map((project) => {
const projectSessions = dedupeSessionsById([ const projectSessions = dedupeSessionsById([
...getSessionsForProject(project), ...getSessionsForProject(project.id),
...getArchivedSessionsForProject(project), ...getArchivedSessionsForProject(project.id),
]); ]);
const worktreesForProject = availableWorktreesByProject.get(project.normalizedPath) ?? []; const worktreesForProject = availableWorktreesByProject.get(project.normalizedPath) ?? [];
const isRepo = projectRepoStatus.has(project.id) 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 { 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', () => {
@@ -28,86 +26,3 @@ 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);
});
});
@@ -83,67 +83,16 @@ export const formatSessionCompactDateLabel = (updatedMs: number): string => {
export const isPathWithinProject = (directory?: string | null, projectPath?: string | null): boolean => { export const isPathWithinProject = (directory?: string | null, projectPath?: string | null): boolean => {
const normalizedDirectory = normalizePath(directory); const normalizedDirectory = normalizePath(directory);
const normalizedProjectPath = normalizePath(projectPath); 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 false;
if (normalizedDirectory === normalizedProjectPath) return true; if (normalizedDirectory === normalizedProjectPath) return true;
if (normalizedProjectPath === '/') return normalizedDirectory.startsWith('/'); if (normalizedProjectPath === '/') return normalizedDirectory.startsWith('/');
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()
@@ -223,33 +172,6 @@ export const resolveArchivedFolderName = (session: Session, projectRoot: string
return segments[segments.length - 1] ?? 'unassigned'; 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 => { export const formatProjectLabel = (label: string): string => {
return label return label
.replace(/[-_]/g, ' ') .replace(/[-_]/g, ' ')
@@ -4,7 +4,7 @@ let fetchImpl: (input: string, init?: RequestInit) => Promise<Response>;
mock.module('@/lib/runtime-fetch', () => ({ mock.module('@/lib/runtime-fetch', () => ({
runtimeFetch: (input: string, init?: RequestInit) => fetchImpl(input, init), runtimeFetch: (input: string, init?: RequestInit) => fetchImpl(input, init),
})); }));
mock.module('@/sync/sync-refs', () => ({ getAllSyncSessions: () => [] })); mock.module('@/sync/sync-refs', () => ({ getAllSyncSessionMap: () => new Map() }));
mock.module('@/sync/session-ui-store', () => ({ mock.module('@/sync/session-ui-store', () => ({
useSessionUIStore: { getState: () => ({ getDirectoryForSession: () => '/project' }) }, useSessionUIStore: { getState: () => ({ getDirectoryForSession: () => '/project' }) },
})); }));
+9 -4
View File
@@ -2,7 +2,7 @@ import { create } from "zustand";
import { persist } from "zustand/middleware"; import { persist } from "zustand/middleware";
import type { Session } from "@opencode-ai/sdk/v2/client"; import type { Session } from "@opencode-ai/sdk/v2/client";
import { autoRespondsPermission, type PermissionAutoAcceptMap } from "./utils/permissionAutoAccept"; import { autoRespondsPermission, type PermissionAutoAcceptMap } from "./utils/permissionAutoAccept";
import { getAllSyncSessions } from "@/sync/sync-refs"; import { getAllSyncSessionMap } from "@/sync/sync-refs";
import { runtimeFetch } from "@/lib/runtime-fetch"; import { runtimeFetch } from "@/lib/runtime-fetch";
import { isVSCodeRuntime } from "@/lib/desktop"; import { isVSCodeRuntime } from "@/lib/desktop";
import { createDeferredSafeJSONStorage } from "./utils/safeStorage"; import { createDeferredSafeJSONStorage } from "./utils/safeStorage";
@@ -39,8 +39,11 @@ const readSnapshot = async (response: Response): Promise<PermissionPolicySnapsho
const requestSnapshot = async (path: string, init?: RequestInit) => readSnapshot(await runtimeFetch(path, init)); const requestSnapshot = async (path: string, init?: RequestInit) => readSnapshot(await runtimeFetch(path, init));
const isAutoAccepting = (autoAccept: PermissionAutoAcceptMap, sessions: Session[], sessionId: string) => const isAutoAccepting = (
autoRespondsPermission({ autoAccept, sessions, sessionID: sessionId }); autoAccept: PermissionAutoAcceptMap,
sessionById: ReadonlyMap<string, Session>,
sessionId: string,
) => autoRespondsPermission({ autoAccept, sessions: [], sessionById, sessionID: sessionId });
export const usePermissionStore = create<PermissionStore>()(persist((set, get) => ({ export const usePermissionStore = create<PermissionStore>()(persist((set, get) => ({
autoAccept: {}, autoAccept: {},
@@ -79,7 +82,9 @@ export const usePermissionStore = create<PermissionStore>()(persist((set, get) =
isSessionAutoAccepting: (sessionId) => { isSessionAutoAccepting: (sessionId) => {
if (!sessionId) return false; if (!sessionId) return false;
return isAutoAccepting(get().autoAccept, getAllSyncSessions(), sessionId); const autoAccept = get().autoAccept;
if (Object.keys(autoAccept).length === 0) return false;
return isAutoAccepting(autoAccept, getAllSyncSessionMap(), sessionId);
}, },
setSessionAutoAccept: async (sessionId, enabled) => { setSessionAutoAccept: async (sessionId, enabled) => {
@@ -60,6 +60,17 @@ describe("autoRespondsPermission", () => {
})).toBe(true) })).toBe(true)
}) })
test("uses a prebuilt session index for lineage lookup", () => {
const parent = makeSession("parent")
const child = makeSession("child", "parent")
expect(autoRespondsPermission({
autoAccept: { parent: true },
sessions: [],
sessionById: new Map([[parent.id, parent], [child.id, child]]),
sessionID: "child",
})).toBe(true)
})
test("returns false when only sibling has autoAccept enabled", () => { test("returns false when only sibling has autoAccept enabled", () => {
const autoAccept: PermissionAutoAcceptMap = { sibling: true } const autoAccept: PermissionAutoAcceptMap = { sibling: true }
const sessions = [ const sessions = [
@@ -10,8 +10,12 @@ const buildSessionMap = (sessions: Session[]): Map<string, Session> => {
return map; return map;
}; };
const resolveLineage = (sessionID: string, sessions: Session[]): string[] => { const resolveLineage = (
const map = buildSessionMap(sessions); sessionID: string,
sessions: Session[],
sessionById?: ReadonlyMap<string, Session>,
): string[] => {
const map = sessionById ?? buildSessionMap(sessions);
const result: string[] = []; const result: string[] = [];
const seen = new Set<string>(); const seen = new Set<string>();
let current: string | undefined = sessionID; let current: string | undefined = sessionID;
@@ -28,10 +32,12 @@ const resolveLineage = (sessionID: string, sessions: Session[]): string[] => {
export const autoRespondsPermission = (input: { export const autoRespondsPermission = (input: {
autoAccept: PermissionAutoAcceptMap; autoAccept: PermissionAutoAcceptMap;
sessions: Session[]; sessions: Session[];
sessionById?: ReadonlyMap<string, Session>;
sessionID: string; sessionID: string;
}): boolean => { }): boolean => {
const { autoAccept, sessions, sessionID } = input; const { autoAccept, sessions, sessionById, sessionID } = input;
const lineage = resolveLineage(sessionID, sessions); if (Object.keys(autoAccept).length === 0) return false;
const lineage = resolveLineage(sessionID, sessions, sessionById);
for (const id of lineage) { for (const id of lineage) {
if (!Object.prototype.hasOwnProperty.call(autoAccept, id)) { if (!Object.prototype.hasOwnProperty.call(autoAccept, id)) {
+4
View File
@@ -80,6 +80,10 @@ Current consumers:
- `Header.tsx` - `Header.tsx`
- agent/session activity surfaces using `useGlobalSessionStatus()` / `useAllSessionStatuses()` - agent/session activity surfaces using `useGlobalSessionStatus()` / `useAllSessionStatuses()`
Cross-directory selectors subscribe to the narrow child-store field they aggregate. Session aggregation listens to `state.session`; per-session status listens only to that session's `state.session_status` entry. Unrelated streaming events such as `message.part.delta` must not trigger global session/status scans.
Imperative cross-directory session lookups use the cached ID index from `getAllSyncSessionMap()`. The index is rebuilt only when a child store's `state.session` reference changes; permission lineage checks must reuse it instead of rebuilding a full session map per call.
### Mutation responsibility ### Mutation responsibility
`useGlobalSessionsStore` is not maintained by SSE directly. It is kept correct by: `useGlobalSessionsStore` is not maintained by SSE directly. It is kept correct by:
+37
View File
@@ -0,0 +1,37 @@
import { describe, expect, test } from 'bun:test';
import { ChildStoreManager } from './child-store';
describe('ChildStoreManager.subscribeAllSelected', () => {
test('ignores unrelated child-store updates', () => {
const manager = new ChildStoreManager();
const child = manager.ensureChild('/workspace', { bootstrap: false });
let notifications = 0;
const unsubscribe = manager.subscribeAllSelected((state) => state.session, () => {
notifications += 1;
});
child.setState({ session_status: { session: { type: 'busy' } } });
expect(notifications).toBe(0);
child.setState({ session: [...child.getState().session] });
expect(notifications).toBe(1);
unsubscribe();
manager.disposeAll();
});
test('notifies when the child-store registry changes', () => {
const manager = new ChildStoreManager();
let notifications = 0;
const unsubscribe = manager.subscribeAllSelected((state) => state.session, () => {
notifications += 1;
});
manager.ensureChild('/workspace', { bootstrap: false });
expect(notifications).toBe(1);
unsubscribe();
manager.disposeAll();
});
});
+37
View File
@@ -238,4 +238,41 @@ export class ChildStoreManager {
storeUnsubscribers.clear() storeUnsubscribers.clear()
} }
} }
subscribeAllSelected<T>(selector: (state: DirectoryStore) => T, listener: () => void): () => void {
const storeUnsubscribers = new Map<string, () => void>()
const syncStoreSubscriptions = () => {
const activeDirectories = new Set(this.children.keys())
for (const [directory, unsubscribe] of storeUnsubscribers.entries()) {
if (activeDirectories.has(directory)) continue
unsubscribe()
storeUnsubscribers.delete(directory)
}
for (const [directory, store] of this.children.entries()) {
if (storeUnsubscribers.has(directory)) continue
storeUnsubscribers.set(directory, store.subscribe((state, previous) => {
if (!Object.is(selector(state), selector(previous))) {
listener()
}
}))
}
}
syncStoreSubscriptions()
const unsubscribeRegistry = this.subscribeRegistry(() => {
syncStoreSubscriptions()
listener()
})
return () => {
unsubscribeRegistry()
for (const unsubscribe of storeUnsubscribers.values()) {
unsubscribe()
}
storeUnsubscribers.clear()
}
}
} }
+31 -2
View File
@@ -109,7 +109,11 @@ function getLiveStates(childStores: ChildStoreManager): State[] {
return Array.from(childStores.children.values(), (store) => store.getState()) return Array.from(childStores.children.values(), (store) => store.getState())
} }
function useLiveSyncSelector<T>(selector: (states: State[]) => T, isEqual: (left: T, right: T) => boolean = Object.is): T { function useLiveSyncSelector<T>(
selector: (states: State[]) => T,
isEqual: (left: T, right: T) => boolean = Object.is,
subscribe?: (childStores: ChildStoreManager, notify: () => void) => () => void,
): T {
const { childStores } = useSyncSystem() const { childStores } = useSyncSystem()
const cacheRef = useRef<T | undefined>(undefined) const cacheRef = useRef<T | undefined>(undefined)
const initializedRef = useRef(false) const initializedRef = useRef(false)
@@ -126,7 +130,10 @@ function useLiveSyncSelector<T>(selector: (states: State[]) => T, isEqual: (left
}, [childStores, isEqual, selector]) }, [childStores, isEqual, selector])
return React.useSyncExternalStore( return React.useSyncExternalStore(
useCallback((notify) => childStores.subscribeAll(notify), [childStores]), useCallback(
(notify) => subscribe ? subscribe(childStores, notify) : childStores.subscribeAll(notify),
[childStores, subscribe],
),
getSnapshot, getSnapshot,
getSnapshot, getSnapshot,
) )
@@ -142,6 +149,14 @@ function useLiveSyncSelector<T>(selector: (states: State[]) => T, isEqual: (left
export function useGlobalSessionStatus(sessionId: string): SessionStatus | undefined { export function useGlobalSessionStatus(sessionId: string): SessionStatus | undefined {
return useLiveSyncSelector( return useLiveSyncSelector(
useCallback((states) => findLiveSessionStatus(states, sessionId), [sessionId]), useCallback((states) => findLiveSessionStatus(states, sessionId), [sessionId]),
Object.is,
useCallback(
(childStores: ChildStoreManager, notify: () => void) => childStores.subscribeAllSelected(
(state: State) => state.session_status?.[sessionId],
notify,
),
[sessionId],
),
) )
} }
@@ -150,6 +165,13 @@ export function useAllSessionStatuses(): Record<string, SessionStatus> {
return useLiveSyncSelector( return useLiveSyncSelector(
useCallback((states) => aggregateLiveSessionStatuses(states), []), useCallback((states) => aggregateLiveSessionStatuses(states), []),
areStatusMapsEquivalent, areStatusMapsEquivalent,
useCallback(
(childStores: ChildStoreManager, notify: () => void) => childStores.subscribeAllSelected(
(state: State) => state.session_status,
notify,
),
[],
),
) )
} }
@@ -157,6 +179,13 @@ export function useAllLiveSessions(): Session[] {
return useLiveSyncSelector( return useLiveSyncSelector(
useCallback((states) => aggregateLiveSessions(states), []), useCallback((states) => aggregateLiveSessions(states), []),
areSessionListsEquivalent, areSessionListsEquivalent,
useCallback(
(childStores: ChildStoreManager, notify: () => void) => childStores.subscribeAllSelected(
(state: State) => state.session,
notify,
),
[],
),
) )
} }
+36 -8
View File
@@ -14,6 +14,9 @@ let _childStores: ChildStoreManager | null = null
let _directory: string = "" let _directory: string = ""
let _registerSessionDirectory: ((sessionID: string, directory: string) => void) | null = null let _registerSessionDirectory: ((sessionID: string, directory: string) => void) | null = null
const configListeners = new Set<(directory: string, config: Config) => void>() const configListeners = new Set<(directory: string, config: Config) => void>()
let cachedSessionManager: ChildStoreManager | null = null
let cachedSessionSlices = new Map<string, State["session"]>()
let cachedSessionsById = new Map<string, State["session"][number]>()
export function setSyncRefs( export function setSyncRefs(
_sdk: OpencodeClient, _sdk: OpencodeClient,
@@ -22,6 +25,11 @@ export function setSyncRefs(
registerSessionDirectory?: (sessionID: string, directory: string) => void, registerSessionDirectory?: (sessionID: string, directory: string) => void,
) { ) {
_childStores = childStores _childStores = childStores
if (cachedSessionManager !== childStores) {
cachedSessionManager = null
cachedSessionSlices = new Map()
cachedSessionsById = new Map()
}
_directory = directory _directory = directory
if (registerSessionDirectory) { if (registerSessionDirectory) {
_registerSessionDirectory = registerSessionDirectory _registerSessionDirectory = registerSessionDirectory
@@ -76,17 +84,37 @@ export function getSyncSessions(directory?: string) {
/** Read sessions across all initialized child stores */ /** Read sessions across all initialized child stores */
export function getAllSyncSessions() { export function getAllSyncSessions() {
const stores = _childStores return Array.from(getAllSyncSessionMap().values())
if (!stores) return [] }
const deduped = new Map<string, State["session"][number]>() /** Read the cached cross-directory session index, rebuilding only when a session slice changes. */
for (const store of stores.children.values()) { export function getAllSyncSessionMap(): ReadonlyMap<string, State["session"][number]> {
for (const session of store.getState().session) { const stores = _childStores
if (!session?.id) continue if (!stores) return cachedSessionsById
deduped.set(session.id, session)
let changed = cachedSessionManager !== stores || cachedSessionSlices.size !== stores.children.size
for (const [directory, store] of stores.children) {
if (cachedSessionSlices.get(directory) !== store.getState().session) {
changed = true
break
} }
} }
return Array.from(deduped.values()) if (!changed) return cachedSessionsById
const nextSlices = new Map<string, State["session"]>()
const nextSessionsById = new Map<string, State["session"][number]>()
for (const [directory, store] of stores.children) {
const sessions = store.getState().session
nextSlices.set(directory, sessions)
for (const session of sessions) {
if (!session?.id) continue
nextSessionsById.set(session.id, session)
}
}
cachedSessionManager = stores
cachedSessionSlices = nextSlices
cachedSessionsById = nextSessionsById
return cachedSessionsById
} }
/** Read messages for a session from current directory's child store */ /** Read messages for a session from current directory's child store */