From b36afbf5eea9202988dcb5af13586b57b35f01cb Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 13 Jul 2026 23:18:12 +0300 Subject: [PATCH] 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. --- .../src/components/session/SessionSidebar.tsx | 104 +++++----- .../session/sidebar/DOCUMENTATION.md | 3 +- .../sidebar/hooks/useArchivedAutoFolders.ts | 84 ++------ .../sidebar/hooks/useProjectSessionLists.ts | 150 +-------------- .../sidebar/hooks/useSessionFolderCleanup.ts | 105 ++++------ .../hooks/useSessionSidebarSections.ts | 8 +- .../session/sidebar/sessionOwnership.test.ts | 129 +++++++++++++ .../session/sidebar/sessionOwnership.ts | 179 ++++++++++++++++++ .../components/session/sidebar/utils.test.ts | 87 +-------- .../src/components/session/sidebar/utils.tsx | 86 +-------- .../ui/src/stores/permissionStore.test.ts | 2 +- packages/ui/src/stores/permissionStore.ts | 13 +- .../stores/utils/permissionAutoAccept.test.ts | 11 ++ .../src/stores/utils/permissionAutoAccept.ts | 14 +- packages/ui/src/sync/DOCUMENTATION.md | 4 + packages/ui/src/sync/child-store.test.ts | 37 ++++ packages/ui/src/sync/child-store.ts | 37 ++++ packages/ui/src/sync/sync-context.tsx | 33 +++- packages/ui/src/sync/sync-refs.ts | 44 ++++- 19 files changed, 619 insertions(+), 511 deletions(-) create mode 100644 packages/ui/src/components/session/sidebar/sessionOwnership.test.ts create mode 100644 packages/ui/src/components/session/sidebar/sessionOwnership.ts create mode 100644 packages/ui/src/sync/child-store.test.ts diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx index 7606193f..85e58718 100644 --- a/packages/ui/src/components/session/SessionSidebar.tsx +++ b/packages/ui/src/components/session/SessionSidebar.tsx @@ -31,6 +31,7 @@ import { useSidebarPersistence } from './sidebar/hooks/useSidebarPersistence'; import { useProjectRepoStatus } from './sidebar/hooks/useProjectRepoStatus'; import { useProjectSessionLists } from './sidebar/hooks/useProjectSessionLists'; import { useSessionFolderCleanup } from './sidebar/hooks/useSessionFolderCleanup'; +import { createSessionOwnershipIndex } from './sidebar/sessionOwnership'; import { useStickyProjectHeaders } from './sidebar/hooks/useStickyProjectHeaders'; import { getGitHubPrStatusKey, usePrVisualSummaryByKeys, useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore'; import { ProjectEditDialog } from '@/components/layout/ProjectEditDialog'; @@ -319,7 +320,6 @@ export const SessionSidebar: React.FC = ({ const sync = useSync(); const liveSessions = useAllLiveSessions(); const isVSCode = React.useMemo(() => isVSCodeRuntime(), []); - const hasLoadedGlobalSessions = useGlobalSessionsStore((state) => state.hasLoaded); const hasAuthoritativeGlobalSessions = useGlobalSessionsStore((state) => state.status === 'ready'); const globalActiveSessions = useGlobalSessionsStore((state) => state.activeSessions); const archivedSessions = useGlobalSessionsStore((state) => state.archivedSessions); @@ -418,6 +418,11 @@ export const SessionSidebar: React.FC = ({ .join('|'), [projects], ); + const [resolvedWorktreeTopologyKey, setResolvedWorktreeTopologyKey] = React.useState( + isVSCode ? projectWorktreeDiscoveryKey : null, + ); + const isWorktreeTopologyLoading = !isVSCode && resolvedWorktreeTopologyKey !== projectWorktreeDiscoveryKey; + const [unresolvedWorktreeProjectPaths, setUnresolvedWorktreeProjectPaths] = React.useState>(new Set()); const initialGlobalSessionsRefreshStartedRef = React.useRef(false); React.useEffect(() => { @@ -428,19 +433,22 @@ export const SessionSidebar: React.FC = ({ 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(''); React.useEffect(() => { let cancelled = false; const discoverWorktrees = async () => { const projectEntries = useProjectsStore.getState().projects; - if (projectEntries.length === 0) return; + if (projectEntries.length === 0 || isVSCode) { + if (!cancelled) { + setUnresolvedWorktreeProjectPaths(new Set()); + setResolvedWorktreeTopologyKey(projectWorktreeDiscoveryKey); + } + return; + } - const worktreesByProject = new Map(); - const allWorktrees: WorktreeMetadata[] = []; + const currentByProject = useSessionUIStore.getState().availableWorktreesByProject; + const worktreesByProject = new Map(currentByProject); + const unresolvedProjectPaths = new Set(); // Constrain fanout: previously `Promise.all(projects.map(...))` could // spawn dozens of concurrent `git worktree list` and @@ -464,13 +472,20 @@ export const SessionSidebar: React.FC = ({ // PR/render paths downstream can read isGitRepo for free. const cachedIsGitRepo = useGitStore.getState().directories.get(projectPath)?.isGitRepo; const isGitRepo = cachedIsGitRepo ?? await checkIsGitRepository(projectPath); - if (!isGitRepo) continue; + if (!isGitRepo) { + worktreesByProject.delete(projectPath); + continue; + } const worktrees = await listProjectWorktrees({ id: project.id, path: projectPath }); - if (cancelled || worktrees.length === 0) continue; - worktreesByProject.set(projectPath, worktrees); - allWorktrees.push(...worktrees); + if (cancelled) return; + if (worktrees.length === 0) { + worktreesByProject.delete(projectPath); + } else { + worktreesByProject.set(projectPath, worktrees); + } } catch { - // ignore discovery errors + // Keep last-known worktrees when a project is temporarily unavailable. + unresolvedProjectPaths.add(projectPath); } } }); @@ -478,27 +493,31 @@ export const SessionSidebar: React.FC = ({ if (cancelled) return; + const activeProjectPaths = new Set(projectEntries.map((project) => normalizePath(project.path)).filter(Boolean)); + for (const projectPath of worktreesByProject.keys()) { + if (!activeProjectPaths.has(projectPath)) { + worktreesByProject.delete(projectPath); + } + } + const allWorktrees = [...worktreesByProject.values()].flat(); + // Skip update if nothing changed — see worktreeMapsEqual JSDoc. - const currentByProject = useSessionUIStore.getState().availableWorktreesByProject; if (!worktreeMapsEqual(worktreesByProject, currentByProject)) { useSessionUIStore.setState({ availableWorktrees: allWorktrees, availableWorktreesByProject: worktreesByProject, }); } + setUnresolvedWorktreeProjectPaths(unresolvedProjectPaths); + setResolvedWorktreeTopologyKey(projectWorktreeDiscoveryKey); }; - // Skip if we already discovered worktrees for this exact project set. - if (discoveredProjectsRef.current === projectWorktreeDiscoveryKey) { - return; - } - discoveredProjectsRef.current = projectWorktreeDiscoveryKey; void discoverWorktrees(); return () => { cancelled = true; }; - }, [projectWorktreeDiscoveryKey]); + }, [isVSCode, projectWorktreeDiscoveryKey]); React.useEffect(() => { let refreshTimeout: ReturnType | null = null; @@ -943,18 +962,15 @@ export const SessionSidebar: React.FC = ({ ); const projectSessionDirectories = React.useMemo(() => { - const directories = new Set(); - normalizedProjects.forEach((project) => { - if (project.normalizedPath) directories.add(project.normalizedPath); - if (isVSCode) { - return; + const directories = new Set(normalizedProjects.map((project) => project.normalizedPath)); + if (!isVSCode) { + for (const worktrees of availableWorktreesByProject.values()) { + for (const worktree of worktrees) { + const directory = normalizePath(worktree.path); + if (directory) directories.add(directory); + } } - const worktrees = availableWorktreesByProject.get(project.normalizedPath) ?? []; - worktrees.forEach((worktree) => { - const directory = normalizePath(worktree.path); - if (directory) directories.add(directory); - }); - }); + } return [...directories].sort(); }, [availableWorktreesByProject, isVSCode, normalizedProjects]); @@ -994,32 +1010,32 @@ export const SessionSidebar: React.FC = ({ }); const isSessionsLoading = useSessionUIStore((state) => state.isLoading); + const sessionOwnership = React.useMemo( + () => createSessionOwnershipIndex(sessions, normalizedProjects, availableWorktreesByProject, isVSCode, archivedSessions), + [archivedSessions, availableWorktreesByProject, isVSCode, normalizedProjects, sessions], + ); useSessionFolderCleanup({ isSessionsLoading, - hasLoadedGlobalSessions, - sessions, - archivedSessions, + hasAuthoritativeGlobalSessions, + isWorktreeTopologyLoading, normalizedProjects, - isVSCode, + ownership: sessionOwnership, availableWorktreesByProject, + unresolvedWorktreeProjectPaths, cleanupSessions, }); const { getSessionsForProject, getArchivedSessionsForProject } = useProjectSessionLists({ - isVSCode, - sessions, - archivedSessions, - availableWorktreesByProject, - normalizedProjects, + ownership: sessionOwnership, }); useArchivedAutoFolders({ normalizedProjects, - sessions, - archivedSessions, - availableWorktreesByProject, - isVSCode, + ownership: sessionOwnership, isSessionsLoading, + hasAuthoritativeGlobalSessions, + isWorktreeTopologyLoading, + unresolvedWorktreeProjectPaths, foldersMap, createFolder, addSessionToFolder, diff --git a/packages/ui/src/components/session/sidebar/DOCUMENTATION.md b/packages/ui/src/components/session/sidebar/DOCUMENTATION.md index c699c331..ce8c411c 100644 --- a/packages/ui/src/components/session/sidebar/DOCUMENTATION.md +++ b/packages/ui/src/components/session/sidebar/DOCUMENTATION.md @@ -33,6 +33,7 @@ - `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. - `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 @@ -46,7 +47,7 @@ - `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/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/useStickyProjectHeaders.ts`: Tracks which project headers are sticky/stuck via `IntersectionObserver`. diff --git a/packages/ui/src/components/session/sidebar/hooks/useArchivedAutoFolders.ts b/packages/ui/src/components/session/sidebar/hooks/useArchivedAutoFolders.ts index 6dc5f4e2..5bb33e8d 100644 --- a/packages/ui/src/components/session/sidebar/hooks/useArchivedAutoFolders.ts +++ b/packages/ui/src/components/session/sidebar/hooks/useArchivedAutoFolders.ts @@ -1,16 +1,12 @@ import React from 'react'; -import type { Session } from '@opencode-ai/sdk/v2'; -import type { WorktreeMetadata } from '@/types/worktree'; import { - collectKnownProjectDirectories, - dedupeSessionsById, getArchivedScopeKey, - isSessionRelatedToProject, - normalizePath, resolveArchivedFolderName, } from '../utils'; +import type { SessionOwnershipIndex } from '../sessionOwnership'; type ProjectForArchivedFolders = { + id: string; normalizedPath: string; }; @@ -22,83 +18,42 @@ type FolderEntry = { type Args = { normalizedProjects: ProjectForArchivedFolders[]; - sessions: Session[]; - archivedSessions: Session[]; - availableWorktreesByProject: Map; - isVSCode: boolean; + ownership: SessionOwnershipIndex; isSessionsLoading: boolean; + hasAuthoritativeGlobalSessions: boolean; + isWorktreeTopologyLoading: boolean; + unresolvedWorktreeProjectPaths: ReadonlySet; foldersMap: Record; createFolder: (scopeKey: string, name: string, parentId?: string | null) => FolderEntry; addSessionToFolder: (scopeKey: string, folderId: string, sessionId: string) => void; cleanupSessions: (scopeKey: string, existingSessionIds: Set) => void; }; -const getArchivedSessionsForProject = ( - project: ProjectForArchivedFolders, - params: Pick & { - knownProjectDirectories: Set; - }, -): Session[] => { - const worktreesForProject = params.isVSCode ? [] : (params.availableWorktreesByProject.get(project.normalizedPath) ?? []); - const validDirectories = new Set([ - project.normalizedPath, - ...worktreesForProject - .map((meta) => normalizePath(meta.path) ?? meta.path) - .filter((value): value is string => Boolean(value)), - ]); - - const collect = (input: Session[]): Session[] => input.filter((session) => - isSessionRelatedToProject(session, project.normalizedPath, validDirectories, params.knownProjectDirectories), - ); - - const archived = collect(params.archivedSessions); - const unassignedLive = params.sessions.filter((session) => { - if (session.time?.archived) { - return false; - } - const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null); - if (sessionDirectory) { - return false; - } - return isSessionRelatedToProject(session, project.normalizedPath, validDirectories, params.knownProjectDirectories); - }); - - return dedupeSessionsById([...archived, ...unassignedLive]); -}; - export const useArchivedAutoFolders = (args: Args): void => { const { normalizedProjects, - sessions, - archivedSessions, - availableWorktreesByProject, - isVSCode, + ownership, isSessionsLoading, + hasAuthoritativeGlobalSessions, + isWorktreeTopologyLoading, + unresolvedWorktreeProjectPaths, foldersMap, createFolder, addSessionToFolder, cleanupSessions, } = args; - const knownProjectDirectories = React.useMemo( - () => collectKnownProjectDirectories(normalizedProjects, availableWorktreesByProject, isVSCode), - [normalizedProjects, availableWorktreesByProject, isVSCode], - ); - React.useEffect(() => { - if (isSessionsLoading) { + if (isSessionsLoading || !hasAuthoritativeGlobalSessions || isWorktreeTopologyLoading) { return; } normalizedProjects.forEach((project) => { + if (unresolvedWorktreeProjectPaths.has(project.normalizedPath)) { + return; + } const scopeKey = getArchivedScopeKey(project.normalizedPath); - const projectArchivedSessions = getArchivedSessionsForProject(project, { - sessions, - archivedSessions, - availableWorktreesByProject, - isVSCode, - knownProjectDirectories, - }); + const projectArchivedSessions = ownership.archivedSessionsByProject.get(project.id) ?? []; const sessionIds = new Set(projectArchivedSessions.map((session) => session.id)); const existingFolders = foldersMap[scopeKey] ?? []; @@ -122,12 +77,11 @@ export const useArchivedAutoFolders = (args: Args): void => { }); }, [ normalizedProjects, - sessions, - archivedSessions, - availableWorktreesByProject, - knownProjectDirectories, - isVSCode, + ownership, isSessionsLoading, + hasAuthoritativeGlobalSessions, + isWorktreeTopologyLoading, + unresolvedWorktreeProjectPaths, foldersMap, createFolder, addSessionToFolder, diff --git a/packages/ui/src/components/session/sidebar/hooks/useProjectSessionLists.ts b/packages/ui/src/components/session/sidebar/hooks/useProjectSessionLists.ts index 3ce8ba3d..9f65de00 100644 --- a/packages/ui/src/components/session/sidebar/hooks/useProjectSessionLists.ts +++ b/packages/ui/src/components/session/sidebar/hooks/useProjectSessionLists.ts @@ -1,159 +1,27 @@ import React from 'react'; -import type { Session } from '@opencode-ai/sdk/v2'; -import { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore'; -import { collectKnownProjectDirectories, dedupeSessionsById, isSessionRelatedToProject, normalizePath } from '../utils'; - -type WorktreeMeta = { path: string }; - -type NormalizedProject = { id: string; normalizedPath: string }; +import type { SessionOwnershipIndex } from '../sessionOwnership'; type Args = { - isVSCode: boolean; - sessions: Session[]; - archivedSessions: Session[]; - availableWorktreesByProject: Map; - /** - * The set of normalized projects the sidebar will render. Used in - * Layer 4.13 to precompute the allowed directory set so the per-row - * `sessionsByDirectory` Map only contains buckets the sidebar will - * actually consume. With 10 projects × 5 worktrees and 100 sessions - * per directory this drops the Map from N entries to the small - * subset the sidebar needs. - */ - normalizedProjects: NormalizedProject[]; + ownership: SessionOwnershipIndex; }; export const useProjectSessionLists = (args: Args) => { const { - isVSCode, - sessions, - archivedSessions, - availableWorktreesByProject, - normalizedProjects, + ownership, } = args; - // Precompute the set of directories the sidebar will ever ask about: - // every project's normalized path plus the path of each registered - // worktree. Walking this set is O(P + W) per Sidebar render and lets - // us skip the bulk of `sessions` (whose directory is not associated - // with a known project) when building `sessionsByDirectory`. - const knownProjectDirectories = React.useMemo( - () => collectKnownProjectDirectories(normalizedProjects, availableWorktreesByProject, isVSCode), - [normalizedProjects, availableWorktreesByProject, isVSCode], - ); - - const sessionsByDirectory = React.useMemo(() => { - const next = new Map(); - sessions.forEach((session) => { - const directory = resolveGlobalSessionDirectory(session); - if (!directory) { - return; - } - // Skip sessions whose directory doesn't belong to any known - // project or worktree. Without this filter the Map grows with - // every session the server has ever seen, even ones for - // long-removed worktrees; the sidebar's downstream filters - // would then drop them anyway. - if (!knownProjectDirectories.has(directory)) { - return; - } - - const collection = next.get(directory) ?? []; - collection.push(session); - next.set(directory, collection); - }); - return next; - }, [sessions, knownProjectDirectories]); - const getSessionsForProject = React.useCallback( - (project: { normalizedPath: string }) => { - const worktreesForProject = isVSCode ? [] : (availableWorktreesByProject.get(project.normalizedPath) ?? []); - const directories = [ - project.normalizedPath, - ...worktreesForProject - .map((meta) => normalizePath(meta.path) ?? meta.path) - .filter((value): value is string => Boolean(value)), - ]; - - const seen = new Set(); - const collected: Session[] = []; - - directories.forEach((directory) => { - const sessionsForDirectory: Session[] = sessionsByDirectory.get(directory) ?? []; - sessionsForDirectory.forEach((session) => { - if (seen.has(session.id)) { - return; - } - seen.add(session.id); - collected.push(session); - }); - }); - - return collected; + (projectId: string) => { + return ownership.sessionsByProject.get(projectId) ?? []; }, - [availableWorktreesByProject, isVSCode, sessionsByDirectory], + [ownership], ); const getArchivedSessionsForProject = React.useCallback( - (project: { normalizedPath: string }) => { - if (isVSCode) { - const archived = archivedSessions.filter((session) => { - const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null); - const projectWorktree = normalizePath((session as Session & { project?: { worktree?: string | null } | null }).project?.worktree ?? null); - - if (sessionDirectory) { - return sessionDirectory === project.normalizedPath; - } - - return projectWorktree === project.normalizedPath; - }); - - const unassignedLive = sessions.filter((session) => { - if (session.time?.archived) { - return false; - } - const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null); - if (sessionDirectory) { - return false; - } - const projectWorktree = normalizePath((session as Session & { project?: { worktree?: string | null } | null }).project?.worktree ?? null); - return projectWorktree === project.normalizedPath; - }); - - return dedupeSessionsById([...archived, ...unassignedLive]); - } - - const worktreesForProject = isVSCode ? [] : (availableWorktreesByProject.get(project.normalizedPath) ?? []); - const validDirectories = new Set([ - project.normalizedPath, - ...worktreesForProject - .map((meta) => normalizePath(meta.path) ?? meta.path) - .filter((value): value is string => Boolean(value)), - ]); - - const collect = (input: Session[]): Session[] => input.filter((session) => - isSessionRelatedToProject(session, project.normalizedPath, validDirectories, knownProjectDirectories), - ); - - const archived = collect(archivedSessions); - const unassignedLive = sessions.filter((session) => { - if (session.time?.archived) { - return false; - } - const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null); - if (sessionDirectory) { - return false; - } - const projectWorktree = normalizePath((session as Session & { project?: { worktree?: string | null } | null }).project?.worktree ?? null); - if (!projectWorktree) { - return false; - } - return isSessionRelatedToProject(session, project.normalizedPath, validDirectories, knownProjectDirectories); - }); - - return dedupeSessionsById([...archived, ...unassignedLive]); + (projectId: string) => { + return ownership.archivedSessionsByProject.get(projectId) ?? []; }, - [archivedSessions, availableWorktreesByProject, isVSCode, knownProjectDirectories, sessions], + [ownership], ); return { diff --git a/packages/ui/src/components/session/sidebar/hooks/useSessionFolderCleanup.ts b/packages/ui/src/components/session/sidebar/hooks/useSessionFolderCleanup.ts index a0090803..103c0698 100644 --- a/packages/ui/src/components/session/sidebar/hooks/useSessionFolderCleanup.ts +++ b/packages/ui/src/components/session/sidebar/hooks/useSessionFolderCleanup.ts @@ -1,113 +1,80 @@ import React from 'react'; -import type { Session } from '@opencode-ai/sdk/v2'; -import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore'; -import { - collectKnownProjectDirectories, - dedupeSessionsById, - getArchivedScopeKey, - isSessionRelatedToProject, - normalizePath, -} from '../utils'; +import { getArchivedScopeKey, normalizePath } from '../utils'; +import type { SessionOwnershipIndex } from '../sessionOwnership'; + +type WorktreeMeta = { path: string }; type NormalizedProject = { id: string; normalizedPath: string; }; -type WorktreeMeta = { path: string }; - type Args = { isSessionsLoading: boolean; - hasLoadedGlobalSessions: boolean; - sessions: Session[]; - archivedSessions: Session[]; + hasAuthoritativeGlobalSessions: boolean; + isWorktreeTopologyLoading: boolean; normalizedProjects: NormalizedProject[]; - isVSCode: boolean; + ownership: SessionOwnershipIndex; availableWorktreesByProject: Map; + unresolvedWorktreeProjectPaths: ReadonlySet; cleanupSessions: (scopeKey: string, validSessionIds: Set) => void; }; export const useSessionFolderCleanup = (args: Args): void => { const { isSessionsLoading, - hasLoadedGlobalSessions, - sessions, - archivedSessions, + hasAuthoritativeGlobalSessions, + isWorktreeTopologyLoading, normalizedProjects, - isVSCode, + ownership, availableWorktreesByProject, + unresolvedWorktreeProjectPaths, cleanupSessions, } = args; - const knownProjectDirectories = React.useMemo( - () => collectKnownProjectDirectories(normalizedProjects, availableWorktreesByProject, isVSCode), - [normalizedProjects, availableWorktreesByProject, isVSCode], - ); - React.useEffect(() => { - if (isSessionsLoading || !hasLoadedGlobalSessions) { + if (isSessionsLoading || !hasAuthoritativeGlobalSessions || isWorktreeTopologyLoading) { return; } - if (sessions.length === 0 && archivedSessions.length === 0) { + if (ownership.bySessionId.size === 0) { return; } const idsByScope = new Map>(); - sessions.forEach((session) => { - const directory = normalizePath((session as Session & { directory?: string | null }).directory ?? null); - if (!directory) { - return; - } - const existing = idsByScope.get(directory); - if (existing) { - existing.add(session.id); - return; - } - idsByScope.set(directory, new Set([session.id])); + ownership.sessionsByScope.forEach((sessionIds, scopeDirectory) => { + idsByScope.set(scopeDirectory, new Set(sessionIds)); }); normalizedProjects.forEach((project) => { + if (unresolvedWorktreeProjectPaths.has(project.normalizedPath)) { + return; + } const scopeKey = getArchivedScopeKey(project.normalizedPath); - const worktreesForProject = isVSCode ? [] : (availableWorktreesByProject.get(project.normalizedPath) ?? []); - const validDirectories = new Set([ - project.normalizedPath, - ...worktreesForProject - .map((meta) => normalizePath(meta.path) ?? meta.path) - .filter((value): value is string => Boolean(value)), - ]); - - const archivedForProject = dedupeSessionsById([ - ...archivedSessions, - ...sessions.filter((session) => { - if (session.time?.archived) { - return false; - } - const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null); - if (sessionDirectory) { - return false; - } - return isSessionRelatedToProject(session, project.normalizedPath, validDirectories, knownProjectDirectories); - }), - ]).filter((session) => isSessionRelatedToProject(session, project.normalizedPath, validDirectories, knownProjectDirectories)); - - idsByScope.set(scopeKey, new Set(archivedForProject.map((session) => session.id))); + const archivedSessions = ownership.archivedSessionsByProject.get(project.id) ?? []; + idsByScope.set(scopeKey, new Set(archivedSessions.map((session) => session.id))); + if (!idsByScope.has(project.normalizedPath)) { + idsByScope.set(project.normalizedPath, new Set()); + } + for (const worktree of availableWorktreesByProject.get(project.normalizedPath) ?? []) { + const worktreePath = normalizePath(worktree.path); + if (worktreePath && !idsByScope.has(worktreePath)) { + idsByScope.set(worktreePath, new Set()); + } + } }); - const currentFoldersMap = useSessionFoldersStore.getState().foldersMap; - const allScopeKeys = new Set([...Object.keys(currentFoldersMap), ...idsByScope.keys()]); - allScopeKeys.forEach((scopeKey) => { - cleanupSessions(scopeKey, idsByScope.get(scopeKey) ?? new Set()); + idsByScope.forEach((sessionIds, scopeKey) => { + cleanupSessions(scopeKey, sessionIds); }); }, [ - archivedSessions, availableWorktreesByProject, cleanupSessions, - hasLoadedGlobalSessions, + hasAuthoritativeGlobalSessions, + isWorktreeTopologyLoading, isSessionsLoading, - isVSCode, - knownProjectDirectories, normalizedProjects, - sessions, + ownership, + unresolvedWorktreeProjectPaths, ]); }; diff --git a/packages/ui/src/components/session/sidebar/hooks/useSessionSidebarSections.ts b/packages/ui/src/components/session/sidebar/hooks/useSessionSidebarSections.ts index 1869ba0d..e0567af4 100644 --- a/packages/ui/src/components/session/sidebar/hooks/useSessionSidebarSections.ts +++ b/packages/ui/src/components/session/sidebar/hooks/useSessionSidebarSections.ts @@ -23,8 +23,8 @@ type ProjectSection = { type Args = { normalizedProjects: ProjectItem[]; - getSessionsForProject: (project: { normalizedPath: string }) => Session[]; - getArchivedSessionsForProject: (project: { normalizedPath: string }) => Session[]; + getSessionsForProject: (projectId: string) => Session[]; + getArchivedSessionsForProject: (projectId: string) => Session[]; availableWorktreesByProject: Map; projectRepoStatus: Map; projectRootBranches: Map; @@ -63,8 +63,8 @@ export const useSessionSidebarSections = (args: Args) => { const projectSections = React.useMemo(() => { return normalizedProjects.map((project) => { const projectSessions = dedupeSessionsById([ - ...getSessionsForProject(project), - ...getArchivedSessionsForProject(project), + ...getSessionsForProject(project.id), + ...getArchivedSessionsForProject(project.id), ]); const worktreesForProject = availableWorktreesByProject.get(project.normalizedPath) ?? []; const isRepo = projectRepoStatus.has(project.id) diff --git a/packages/ui/src/components/session/sidebar/sessionOwnership.test.ts b/packages/ui/src/components/session/sidebar/sessionOwnership.test.ts new file mode 100644 index 00000000..ca2e8e29 --- /dev/null +++ b/packages/ui/src/components/session/sidebar/sessionOwnership.test.ts @@ -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); + }); +}); diff --git a/packages/ui/src/components/session/sidebar/sessionOwnership.ts b/packages/ui/src/components/session/sidebar/sessionOwnership.ts new file mode 100644 index 00000000..9e3b7fdd --- /dev/null +++ b/packages/ui/src/components/session/sidebar/sessionOwnership.ts @@ -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; + sessionsByProject: Map; + archivedSessionsByProject: Map; + sessionsByScope: Map>; + 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, 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, + isVSCode: boolean, + archivedSessions: Session[] = [], +): SessionOwnershipIndex => { + const ownerByDirectory = new Map(); + const projectByRoot = new Map(); + + 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(); + const bySessionId = new Map(); + const sessionsByProject = new Map(); + const archivedSessionsByProject = new Map(); + const sessionsByScope = new Map>(); + + 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, + scopeTarget?: Map>, + ): 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, + }; +}; diff --git a/packages/ui/src/components/session/sidebar/utils.test.ts b/packages/ui/src/components/session/sidebar/utils.test.ts index 707bbcac..d9135d05 100644 --- a/packages/ui/src/components/session/sidebar/utils.test.ts +++ b/packages/ui/src/components/session/sidebar/utils.test.ts @@ -1,7 +1,5 @@ import { describe, expect, test } from 'bun:test'; -import type { Session } from '@opencode-ai/sdk/v2'; - -import { isPathWithinProject, isSessionRelatedToProject } from './utils'; +import { isPathWithinProject } from './utils'; describe('isPathWithinProject', () => { test('matches child directories for root projects', () => { @@ -28,86 +26,3 @@ describe('isPathWithinProject', () => { 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); - }); -}); diff --git a/packages/ui/src/components/session/sidebar/utils.tsx b/packages/ui/src/components/session/sidebar/utils.tsx index 6156b294..c24ef19e 100644 --- a/packages/ui/src/components/session/sidebar/utils.tsx +++ b/packages/ui/src/components/session/sidebar/utils.tsx @@ -83,67 +83,16 @@ export const formatSessionCompactDateLabel = (updatedMs: number): string => { export const isPathWithinProject = (directory?: string | null, projectPath?: string | null): boolean => { const normalizedDirectory = normalizePath(directory); 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 true; if (normalizedProjectPath === '/') return normalizedDirectory.startsWith('/'); return normalizedDirectory.startsWith(`${normalizedProjectPath}/`); }; -type NormalizedProjectPath = { normalizedPath: string }; -type WorktreePath = { path: string }; - -export const collectKnownProjectDirectories = ( - normalizedProjects: NormalizedProjectPath[], - availableWorktreesByProject: Map, - isVSCode: boolean, -): Set => { - const knownDirectories = new Set(); - - 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 | 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 => { return value .toLowerCase() @@ -223,33 +172,6 @@ export const resolveArchivedFolderName = (session: Session, projectRoot: string return segments[segments.length - 1] ?? 'unassigned'; }; -export const isSessionRelatedToProject = ( - session: Session, - projectRoot: string, - validDirectories?: Set, - knownDirectories?: Iterable, -): 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 => { return label .replace(/[-_]/g, ' ') diff --git a/packages/ui/src/stores/permissionStore.test.ts b/packages/ui/src/stores/permissionStore.test.ts index 6aa18e3d..128c9281 100644 --- a/packages/ui/src/stores/permissionStore.test.ts +++ b/packages/ui/src/stores/permissionStore.test.ts @@ -4,7 +4,7 @@ let fetchImpl: (input: string, init?: RequestInit) => Promise; mock.module('@/lib/runtime-fetch', () => ({ 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', () => ({ useSessionUIStore: { getState: () => ({ getDirectoryForSession: () => '/project' }) }, })); diff --git a/packages/ui/src/stores/permissionStore.ts b/packages/ui/src/stores/permissionStore.ts index 628e9118..9f1cd9c9 100644 --- a/packages/ui/src/stores/permissionStore.ts +++ b/packages/ui/src/stores/permissionStore.ts @@ -2,7 +2,7 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; import type { Session } from "@opencode-ai/sdk/v2/client"; 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 { isVSCodeRuntime } from "@/lib/desktop"; import { createDeferredSafeJSONStorage } from "./utils/safeStorage"; @@ -39,8 +39,11 @@ const readSnapshot = async (response: Response): Promise readSnapshot(await runtimeFetch(path, init)); -const isAutoAccepting = (autoAccept: PermissionAutoAcceptMap, sessions: Session[], sessionId: string) => - autoRespondsPermission({ autoAccept, sessions, sessionID: sessionId }); +const isAutoAccepting = ( + autoAccept: PermissionAutoAcceptMap, + sessionById: ReadonlyMap, + sessionId: string, +) => autoRespondsPermission({ autoAccept, sessions: [], sessionById, sessionID: sessionId }); export const usePermissionStore = create()(persist((set, get) => ({ autoAccept: {}, @@ -79,7 +82,9 @@ export const usePermissionStore = create()(persist((set, get) = isSessionAutoAccepting: (sessionId) => { 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) => { diff --git a/packages/ui/src/stores/utils/permissionAutoAccept.test.ts b/packages/ui/src/stores/utils/permissionAutoAccept.test.ts index 3ddb61a7..3fe090dc 100644 --- a/packages/ui/src/stores/utils/permissionAutoAccept.test.ts +++ b/packages/ui/src/stores/utils/permissionAutoAccept.test.ts @@ -60,6 +60,17 @@ describe("autoRespondsPermission", () => { })).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", () => { const autoAccept: PermissionAutoAcceptMap = { sibling: true } const sessions = [ diff --git a/packages/ui/src/stores/utils/permissionAutoAccept.ts b/packages/ui/src/stores/utils/permissionAutoAccept.ts index 1a4a67c1..ebb094a1 100644 --- a/packages/ui/src/stores/utils/permissionAutoAccept.ts +++ b/packages/ui/src/stores/utils/permissionAutoAccept.ts @@ -10,8 +10,12 @@ const buildSessionMap = (sessions: Session[]): Map => { return map; }; -const resolveLineage = (sessionID: string, sessions: Session[]): string[] => { - const map = buildSessionMap(sessions); +const resolveLineage = ( + sessionID: string, + sessions: Session[], + sessionById?: ReadonlyMap, +): string[] => { + const map = sessionById ?? buildSessionMap(sessions); const result: string[] = []; const seen = new Set(); let current: string | undefined = sessionID; @@ -28,10 +32,12 @@ const resolveLineage = (sessionID: string, sessions: Session[]): string[] => { export const autoRespondsPermission = (input: { autoAccept: PermissionAutoAcceptMap; sessions: Session[]; + sessionById?: ReadonlyMap; sessionID: string; }): boolean => { - const { autoAccept, sessions, sessionID } = input; - const lineage = resolveLineage(sessionID, sessions); + const { autoAccept, sessions, sessionById, sessionID } = input; + if (Object.keys(autoAccept).length === 0) return false; + const lineage = resolveLineage(sessionID, sessions, sessionById); for (const id of lineage) { if (!Object.prototype.hasOwnProperty.call(autoAccept, id)) { diff --git a/packages/ui/src/sync/DOCUMENTATION.md b/packages/ui/src/sync/DOCUMENTATION.md index d270ff1f..47a01834 100644 --- a/packages/ui/src/sync/DOCUMENTATION.md +++ b/packages/ui/src/sync/DOCUMENTATION.md @@ -80,6 +80,10 @@ Current consumers: - `Header.tsx` - 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 `useGlobalSessionsStore` is not maintained by SSE directly. It is kept correct by: diff --git a/packages/ui/src/sync/child-store.test.ts b/packages/ui/src/sync/child-store.test.ts new file mode 100644 index 00000000..ed3a5cb3 --- /dev/null +++ b/packages/ui/src/sync/child-store.test.ts @@ -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(); + }); +}); diff --git a/packages/ui/src/sync/child-store.ts b/packages/ui/src/sync/child-store.ts index 89355856..154331f2 100644 --- a/packages/ui/src/sync/child-store.ts +++ b/packages/ui/src/sync/child-store.ts @@ -238,4 +238,41 @@ export class ChildStoreManager { storeUnsubscribers.clear() } } + + subscribeAllSelected(selector: (state: DirectoryStore) => T, listener: () => void): () => void { + const storeUnsubscribers = new Map 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() + } + } } diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx index 0ffd4420..57645f42 100644 --- a/packages/ui/src/sync/sync-context.tsx +++ b/packages/ui/src/sync/sync-context.tsx @@ -109,7 +109,11 @@ function getLiveStates(childStores: ChildStoreManager): State[] { return Array.from(childStores.children.values(), (store) => store.getState()) } -function useLiveSyncSelector(selector: (states: State[]) => T, isEqual: (left: T, right: T) => boolean = Object.is): T { +function useLiveSyncSelector( + selector: (states: State[]) => T, + isEqual: (left: T, right: T) => boolean = Object.is, + subscribe?: (childStores: ChildStoreManager, notify: () => void) => () => void, +): T { const { childStores } = useSyncSystem() const cacheRef = useRef(undefined) const initializedRef = useRef(false) @@ -126,7 +130,10 @@ function useLiveSyncSelector(selector: (states: State[]) => T, isEqual: (left }, [childStores, isEqual, selector]) return React.useSyncExternalStore( - useCallback((notify) => childStores.subscribeAll(notify), [childStores]), + useCallback( + (notify) => subscribe ? subscribe(childStores, notify) : childStores.subscribeAll(notify), + [childStores, subscribe], + ), getSnapshot, getSnapshot, ) @@ -142,6 +149,14 @@ function useLiveSyncSelector(selector: (states: State[]) => T, isEqual: (left export function useGlobalSessionStatus(sessionId: string): SessionStatus | undefined { return useLiveSyncSelector( 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 { return useLiveSyncSelector( useCallback((states) => aggregateLiveSessionStatuses(states), []), areStatusMapsEquivalent, + useCallback( + (childStores: ChildStoreManager, notify: () => void) => childStores.subscribeAllSelected( + (state: State) => state.session_status, + notify, + ), + [], + ), ) } @@ -157,6 +179,13 @@ export function useAllLiveSessions(): Session[] { return useLiveSyncSelector( useCallback((states) => aggregateLiveSessions(states), []), areSessionListsEquivalent, + useCallback( + (childStores: ChildStoreManager, notify: () => void) => childStores.subscribeAllSelected( + (state: State) => state.session, + notify, + ), + [], + ), ) } diff --git a/packages/ui/src/sync/sync-refs.ts b/packages/ui/src/sync/sync-refs.ts index 4913d3e3..9b15f0d3 100644 --- a/packages/ui/src/sync/sync-refs.ts +++ b/packages/ui/src/sync/sync-refs.ts @@ -14,6 +14,9 @@ let _childStores: ChildStoreManager | null = null let _directory: string = "" let _registerSessionDirectory: ((sessionID: string, directory: string) => void) | null = null const configListeners = new Set<(directory: string, config: Config) => void>() +let cachedSessionManager: ChildStoreManager | null = null +let cachedSessionSlices = new Map() +let cachedSessionsById = new Map() export function setSyncRefs( _sdk: OpencodeClient, @@ -22,6 +25,11 @@ export function setSyncRefs( registerSessionDirectory?: (sessionID: string, directory: string) => void, ) { _childStores = childStores + if (cachedSessionManager !== childStores) { + cachedSessionManager = null + cachedSessionSlices = new Map() + cachedSessionsById = new Map() + } _directory = directory if (registerSessionDirectory) { _registerSessionDirectory = registerSessionDirectory @@ -76,17 +84,37 @@ export function getSyncSessions(directory?: string) { /** Read sessions across all initialized child stores */ export function getAllSyncSessions() { - const stores = _childStores - if (!stores) return [] + return Array.from(getAllSyncSessionMap().values()) +} - const deduped = new Map() - for (const store of stores.children.values()) { - for (const session of store.getState().session) { - if (!session?.id) continue - deduped.set(session.id, session) +/** Read the cached cross-directory session index, rebuilding only when a session slice changes. */ +export function getAllSyncSessionMap(): ReadonlyMap { + const stores = _childStores + if (!stores) return cachedSessionsById + + 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() + const nextSessionsById = new Map() + 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 */