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:
@@ -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' }) },
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
Reference in New Issue
Block a user