perf: overhaul session loading, caching, and runtime isolation (#2360)
Improve OpenChamber responsiveness under large session workloads while fixing cache, synchronization, and persistence correctness across runtimes, projects, directories, and worktrees. - prioritize selected and visible sessions during bootstrap and defer non-critical enrichment work - reduce redundant message loading, event processing, store publication, and hidden sidebar work - prevent stale session and message requests from overwriting newer authoritative state - preserve existing data when authoritative fetches fail instead of treating failures as successful empty responses - scope session materialization, messages, drafts, queues, todos, pins, permissions, folders, tabs, Git state, and pull request data by runtime and directory identity - harden runtime switching, reconnect, cleanup, mutation reconciliation, and persisted-state ordering - preserve live subagent Task linkage when metadata arrives after an older message request or while streaming parts are suspended - coalesce overlapping tail refreshes without losing newer refresh demand - improve cold-session loading by moving deferrable work out of the critical bootstrap path - isolate URL authentication, mobile credentials, native secrets, and other runtime-owned state across endpoint changes - bound long-lived caches and remove avoidable allocations from event and rendering hot paths - limit virtualization to archive collections where it improves rendering without disrupting active sidebar layout - stabilize session folders, pin ordering, expanded state, and persisted sidebar behavior - open skill files through the same secure editor and outside-workspace grant flow used by file navigation, including worktree sessions - expand regression coverage for stale completions, runtime collisions, reconnect behavior, persistence races, authoritative empty results, and subagent refresh ordering - document the updated synchronization, cache ownership, performance, and runtime-isolation invariants
This commit is contained in:
committed by
GitHub
parent
485efc7117
commit
85400459e9
@@ -1,20 +0,0 @@
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
|
||||
export const prunePinnedSessionIds = (
|
||||
sessions: Array<Pick<Session, 'id'>>,
|
||||
pinnedSessionIds: Set<string>,
|
||||
): Set<string> => {
|
||||
const existingSessionIds = new Set(sessions.map((session) => session.id));
|
||||
let changed = false;
|
||||
const next = new Set<string>();
|
||||
|
||||
pinnedSessionIds.forEach((id) => {
|
||||
if (existingSessionIds.has(id)) {
|
||||
next.add(id);
|
||||
return;
|
||||
}
|
||||
changed = true;
|
||||
});
|
||||
|
||||
return changed ? next : pinnedSessionIds;
|
||||
};
|
||||
@@ -17,6 +17,7 @@ type FolderEntry = {
|
||||
};
|
||||
|
||||
type Args = {
|
||||
enabled?: boolean;
|
||||
normalizedProjects: ProjectForArchivedFolders[];
|
||||
ownership: SessionOwnershipIndex;
|
||||
isSessionsLoading: boolean;
|
||||
@@ -26,12 +27,12 @@ type Args = {
|
||||
foldersMap: Record<string, FolderEntry[]>;
|
||||
createFolder: (scopeKey: string, name: string, parentId?: string | null) => FolderEntry;
|
||||
addSessionToFolder: (scopeKey: string, folderId: string, sessionId: string) => void;
|
||||
cleanupSessions: (scopeKey: string, existingSessionIds: Set<string>) => void;
|
||||
};
|
||||
|
||||
export const useArchivedAutoFolders = (args: Args): void => {
|
||||
const {
|
||||
normalizedProjects,
|
||||
enabled = true,
|
||||
ownership,
|
||||
isSessionsLoading,
|
||||
hasAuthoritativeGlobalSessions,
|
||||
@@ -40,11 +41,10 @@ export const useArchivedAutoFolders = (args: Args): void => {
|
||||
foldersMap,
|
||||
createFolder,
|
||||
addSessionToFolder,
|
||||
cleanupSessions,
|
||||
} = args;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isSessionsLoading || !hasAuthoritativeGlobalSessions || isWorktreeTopologyLoading) {
|
||||
if (!enabled || isSessionsLoading || !hasAuthoritativeGlobalSessions || isWorktreeTopologyLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -54,8 +54,6 @@ export const useArchivedAutoFolders = (args: Args): void => {
|
||||
}
|
||||
const scopeKey = getArchivedScopeKey(project.normalizedPath);
|
||||
const projectArchivedSessions = ownership.archivedSessionsByProject.get(project.id) ?? [];
|
||||
const sessionIds = new Set(projectArchivedSessions.map((session) => session.id));
|
||||
|
||||
const existingFolders = foldersMap[scopeKey] ?? [];
|
||||
const folderByName = new Map(existingFolders.map((folder) => [folder.name.toLowerCase(), folder]));
|
||||
|
||||
@@ -72,11 +70,10 @@ export const useArchivedAutoFolders = (args: Args): void => {
|
||||
addSessionToFolder(scopeKey, folder.id, session.id);
|
||||
}
|
||||
});
|
||||
|
||||
cleanupSessions(scopeKey, sessionIds);
|
||||
});
|
||||
}, [
|
||||
normalizedProjects,
|
||||
enabled,
|
||||
ownership,
|
||||
isSessionsLoading,
|
||||
hasAuthoritativeGlobalSessions,
|
||||
@@ -85,6 +82,5 @@ export const useArchivedAutoFolders = (args: Args): void => {
|
||||
foldersMap,
|
||||
createFolder,
|
||||
addSessionToFolder,
|
||||
cleanupSessions,
|
||||
]);
|
||||
};
|
||||
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import {
|
||||
buildAuthoritativeSessionIdentityMap,
|
||||
findRemovedAuthoritativeSessions,
|
||||
} from '../authoritativeSessionCleanup';
|
||||
|
||||
const session = (id: string, directory = '/repo'): Session => ({ id, directory }) as Session;
|
||||
|
||||
describe('authoritative session cleanup', () => {
|
||||
test('does not infer deletion from the first authoritative startup snapshot', () => {
|
||||
const current = buildAuthoritativeSessionIdentityMap([]);
|
||||
|
||||
expect(findRemovedAuthoritativeSessions(null, current)).toEqual([]);
|
||||
});
|
||||
|
||||
test('finds sessions omitted after an established authoritative baseline', () => {
|
||||
const previous = buildAuthoritativeSessionIdentityMap([
|
||||
session('deleted'),
|
||||
session('retained'),
|
||||
]);
|
||||
const current = buildAuthoritativeSessionIdentityMap([session('retained')]);
|
||||
|
||||
expect(findRemovedAuthoritativeSessions(previous, current)).toEqual([
|
||||
{ directory: '/repo', sessionId: 'deleted' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('treats archive membership as retained authority', () => {
|
||||
const previous = buildAuthoritativeSessionIdentityMap([session('archived')]);
|
||||
const current = buildAuthoritativeSessionIdentityMap([
|
||||
{ ...session('archived'), time: { archived: 10 } } as Session,
|
||||
]);
|
||||
|
||||
expect(findRemovedAuthoritativeSessions(previous, current)).toEqual([]);
|
||||
});
|
||||
|
||||
test('does not treat a directory move as session deletion', () => {
|
||||
const previous = buildAuthoritativeSessionIdentityMap([session('moved', '/repo-a')]);
|
||||
const current = buildAuthoritativeSessionIdentityMap([session('moved', '/repo-b')]);
|
||||
|
||||
expect(findRemovedAuthoritativeSessions(previous, current)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
import { cleanupPersistedSessionState } from '@/sync/session-deletion-cleanup';
|
||||
import {
|
||||
buildAuthoritativeSessionIdentityMap,
|
||||
findRemovedAuthoritativeSessions,
|
||||
} from '../authoritativeSessionCleanup';
|
||||
|
||||
export const useAuthoritativeSessionCleanup = (args: {
|
||||
enabled?: boolean;
|
||||
hasAuthoritativeGlobalSessions: boolean;
|
||||
sessions: Session[];
|
||||
}): void => {
|
||||
const { enabled = true, hasAuthoritativeGlobalSessions, sessions } = args;
|
||||
const baselineRef = React.useRef<{
|
||||
runtimeKey: string;
|
||||
identities: ReturnType<typeof buildAuthoritativeSessionIdentityMap>;
|
||||
} | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!enabled || !hasAuthoritativeGlobalSessions) return;
|
||||
|
||||
const runtimeKey = getRuntimeKey();
|
||||
const current = buildAuthoritativeSessionIdentityMap(sessions);
|
||||
const previous = baselineRef.current?.runtimeKey === runtimeKey
|
||||
? baselineRef.current.identities
|
||||
: null;
|
||||
|
||||
for (const identity of findRemovedAuthoritativeSessions(previous, current)) {
|
||||
cleanupPersistedSessionState({ runtimeKey, ...identity });
|
||||
}
|
||||
baselineRef.current = { runtimeKey, identities: current };
|
||||
}, [enabled, hasAuthoritativeGlobalSessions, sessions]);
|
||||
};
|
||||
@@ -5,8 +5,10 @@ import { useGitStore } from '@/stores/useGitStore';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
|
||||
type Project = { id: string; path: string; normalizedPath: string };
|
||||
const ROOT_BRANCH_TTL_MS = 5 * 60_000;
|
||||
|
||||
type Args = {
|
||||
enabled?: boolean;
|
||||
normalizedProjects: Project[];
|
||||
gitRepoStatus: Map<string, { isGitRepo: boolean | null; branch: string | null }>;
|
||||
setProjectRepoStatus: React.Dispatch<React.SetStateAction<Map<string, boolean | null>>>;
|
||||
@@ -16,6 +18,7 @@ type Args = {
|
||||
export const useProjectRepoStatus = (args: Args): void => {
|
||||
const {
|
||||
normalizedProjects,
|
||||
enabled = true,
|
||||
gitRepoStatus,
|
||||
setProjectRepoStatus,
|
||||
setProjectRootBranches,
|
||||
@@ -26,7 +29,7 @@ export const useProjectRepoStatus = (args: Args): void => {
|
||||
|
||||
// Derive repo status from centralized Git store
|
||||
React.useEffect(() => {
|
||||
if (!git || normalizedProjects.length === 0) {
|
||||
if (!enabled || !git || normalizedProjects.length === 0) {
|
||||
setProjectRepoStatus(new Map());
|
||||
return;
|
||||
}
|
||||
@@ -35,16 +38,17 @@ export const useProjectRepoStatus = (args: Args): void => {
|
||||
normalizedProjects.forEach((project) => {
|
||||
void ensureStatus(project.normalizedPath, git);
|
||||
});
|
||||
}, [normalizedProjects, git, ensureStatus, setProjectRepoStatus]);
|
||||
}, [enabled, normalizedProjects, git, ensureStatus, setProjectRepoStatus]);
|
||||
|
||||
// Read isGitRepo from the store-populated state
|
||||
React.useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const next = new Map<string, boolean | null>();
|
||||
normalizedProjects.forEach((project) => {
|
||||
next.set(project.id, gitRepoStatus.get(project.normalizedPath)?.isGitRepo ?? null);
|
||||
});
|
||||
setProjectRepoStatus(next);
|
||||
}, [normalizedProjects, gitRepoStatus, setProjectRepoStatus]);
|
||||
}, [enabled, normalizedProjects, gitRepoStatus, setProjectRepoStatus]);
|
||||
|
||||
const projectGitBranchesKey = React.useMemo(() => {
|
||||
return normalizedProjects
|
||||
@@ -69,9 +73,9 @@ export const useProjectRepoStatus = (args: Args): void => {
|
||||
// background updates and only re-resolve on cold start or actual
|
||||
// branch changes (those still invalidate via the input-key check).
|
||||
const rootBranchCacheRef = React.useRef<Map<string, { branch: string; at: number }>>(new Map());
|
||||
const ROOT_BRANCH_TTL_MS = 5 * 60_000;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!enabled) return;
|
||||
let cancelled = false;
|
||||
|
||||
// Debounce so the initial burst of per-project `ensureStatus` updates
|
||||
@@ -164,9 +168,5 @@ export const useProjectRepoStatus = (args: Args): void => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
// ROOT_BRANCH_TTL_MS is a module-level constant; intentionally not
|
||||
// in the deps array since it never changes during the component
|
||||
// lifetime.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [normalizedProjects, projectGitBranchesKey, gitRepoStatus, setProjectRootBranches]);
|
||||
}, [enabled, normalizedProjects, projectGitBranchesKey, gitRepoStatus, setProjectRootBranches]);
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { SessionGroup, SessionNode } from '../types';
|
||||
import { normalizePath } from '../utils';
|
||||
import type { MainTab } from '@/stores/useUIStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
|
||||
type ProjectSection = {
|
||||
project: { id: string; normalizedPath: string };
|
||||
@@ -16,7 +17,7 @@ type Args = {
|
||||
activeSessionByProject: Map<string, string>;
|
||||
setActiveSessionByProject: React.Dispatch<React.SetStateAction<Map<string, string>>>;
|
||||
currentSessionId: string | null;
|
||||
handleSessionSelect: (sessionId: string, sessionDirectory: string | null, projectId?: string | null) => void;
|
||||
handleSessionSelect: (sessionId: string, sessionDirectory: string | null) => void;
|
||||
newSessionDraftOpen: boolean;
|
||||
mobileVariant: boolean;
|
||||
openNewSessionDraft: (options?: { selectedProjectId?: string | null; directoryOverride?: string | null }) => void;
|
||||
@@ -148,7 +149,7 @@ export const useProjectSessionSelection = (args: Args): void => {
|
||||
return;
|
||||
}
|
||||
const targetDirectory = projectMap.get(targetSessionId)?.directory ?? null;
|
||||
handleSessionSelect(targetSessionId, targetDirectory, activeProjectId);
|
||||
handleSessionSelect(targetSessionId, targetDirectory);
|
||||
}, [
|
||||
activeProjectId,
|
||||
activeSessionByProject,
|
||||
@@ -183,3 +184,34 @@ export const useProjectSessionSelection = (args: Args): void => {
|
||||
}, [activeProjectId, currentSessionId, projectSessionMeta, setActiveSessionByProject]);
|
||||
|
||||
};
|
||||
|
||||
type ProjectSessionSelectionEffectProps = Omit<
|
||||
Args,
|
||||
'activeSessionByProject' | 'setActiveSessionByProject' | 'currentSessionId' | 'newSessionDraftOpen'
|
||||
> & {
|
||||
initialActiveSessionByProject: Map<string, string>;
|
||||
persistActiveSessionByProject: (value: Map<string, string>) => void;
|
||||
};
|
||||
|
||||
export const ProjectSessionSelectionEffect: React.FC<ProjectSessionSelectionEffectProps> = ({
|
||||
initialActiveSessionByProject,
|
||||
persistActiveSessionByProject,
|
||||
...args
|
||||
}) => {
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const newSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open));
|
||||
const [activeSessionByProject, setActiveSessionByProject] = React.useState(
|
||||
() => new Map(initialActiveSessionByProject),
|
||||
);
|
||||
useProjectSessionSelection({
|
||||
...args,
|
||||
activeSessionByProject,
|
||||
setActiveSessionByProject,
|
||||
currentSessionId,
|
||||
newSessionDraftOpen,
|
||||
});
|
||||
React.useEffect(() => {
|
||||
persistActiveSessionByProject(activeSessionByProject);
|
||||
}, [activeSessionByProject, persistActiveSessionByProject]);
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -4,6 +4,8 @@ import { toast } from '@/components/ui';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import type { MainTab } from '@/stores/useUIStore';
|
||||
import { streamPerfMark } from '@/stores/utils/streamDebug';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
|
||||
type DeleteSessionConfirmSetter = React.Dispatch<React.SetStateAction<{
|
||||
session: Session;
|
||||
@@ -20,9 +22,6 @@ type DeleteSessionSource = {
|
||||
};
|
||||
|
||||
type Args = {
|
||||
activeProjectId: string | null;
|
||||
currentDirectory: string | null;
|
||||
currentSessionId: string | null;
|
||||
mobileVariant: boolean;
|
||||
allowReselect: boolean;
|
||||
onSessionSelected?: (sessionId: string) => void;
|
||||
@@ -30,8 +29,6 @@ type Args = {
|
||||
sessionSearchQuery: string;
|
||||
setSessionSearchQuery: (value: string) => void;
|
||||
setIsSessionSearchOpen: (open: boolean) => void;
|
||||
setActiveProjectIdOnly: (id: string) => void;
|
||||
setDirectory: (directory: string, options?: { showOverlay?: boolean }) => void;
|
||||
setActiveMainTab: (tab: MainTab) => void;
|
||||
setSessionSwitcherOpen: (open: boolean) => void;
|
||||
setCurrentSession: (sessionId: string | null, directoryHint?: string | null) => void;
|
||||
@@ -66,7 +63,8 @@ export const useSessionActions = (args: Args) => {
|
||||
}, []);
|
||||
|
||||
const handleSessionSelect = React.useCallback(
|
||||
(sessionId: string, sessionDirectory?: string | null, projectId?: string | null) => {
|
||||
(sessionId: string, sessionDirectory?: string | null) => {
|
||||
streamPerfMark('navigation.session_select');
|
||||
const resetSessionSearch = () => {
|
||||
if (!args.isSessionSearchOpen && args.sessionSearchQuery.length === 0) {
|
||||
return;
|
||||
@@ -75,26 +73,19 @@ export const useSessionActions = (args: Args) => {
|
||||
args.setIsSessionSearchOpen(false);
|
||||
};
|
||||
|
||||
if (projectId && projectId !== args.activeProjectId) {
|
||||
args.setActiveProjectIdOnly(projectId);
|
||||
}
|
||||
|
||||
if (sessionDirectory && sessionDirectory !== args.currentDirectory) {
|
||||
args.setDirectory(sessionDirectory, { showOverlay: false });
|
||||
}
|
||||
|
||||
if (args.mobileVariant) {
|
||||
args.setActiveMainTab('chat');
|
||||
args.setSessionSwitcherOpen(false);
|
||||
}
|
||||
|
||||
if (sessionId === args.currentSessionId) {
|
||||
if (sessionId === useSessionUIStore.getState().currentSessionId) {
|
||||
if (args.allowReselect) {
|
||||
args.onSessionSelected?.(sessionId);
|
||||
}
|
||||
resetSessionSearch();
|
||||
return;
|
||||
}
|
||||
streamPerfMark('navigation.session_state_set');
|
||||
args.setCurrentSession(sessionId, sessionDirectory ?? null);
|
||||
args.onSessionSelected?.(sessionId);
|
||||
resetSessionSearch();
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
import React from 'react';
|
||||
import { getArchivedScopeKey, normalizePath } from '../utils';
|
||||
import type { SessionOwnershipIndex } from '../sessionOwnership';
|
||||
|
||||
type WorktreeMeta = { path: string };
|
||||
|
||||
type NormalizedProject = {
|
||||
id: string;
|
||||
normalizedPath: string;
|
||||
};
|
||||
|
||||
type Args = {
|
||||
isSessionsLoading: boolean;
|
||||
hasAuthoritativeGlobalSessions: boolean;
|
||||
isWorktreeTopologyLoading: boolean;
|
||||
normalizedProjects: NormalizedProject[];
|
||||
ownership: SessionOwnershipIndex;
|
||||
availableWorktreesByProject: Map<string, WorktreeMeta[]>;
|
||||
unresolvedWorktreeProjectPaths: ReadonlySet<string>;
|
||||
cleanupSessions: (scopeKey: string, validSessionIds: Set<string>) => void;
|
||||
};
|
||||
|
||||
export const useSessionFolderCleanup = (args: Args): void => {
|
||||
const {
|
||||
isSessionsLoading,
|
||||
hasAuthoritativeGlobalSessions,
|
||||
isWorktreeTopologyLoading,
|
||||
normalizedProjects,
|
||||
ownership,
|
||||
availableWorktreesByProject,
|
||||
unresolvedWorktreeProjectPaths,
|
||||
cleanupSessions,
|
||||
} = args;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isSessionsLoading || !hasAuthoritativeGlobalSessions || isWorktreeTopologyLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ownership.bySessionId.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const idsByScope = new Map<string, Set<string>>();
|
||||
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 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());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
idsByScope.forEach((sessionIds, scopeKey) => {
|
||||
cleanupSessions(scopeKey, sessionIds);
|
||||
});
|
||||
}, [
|
||||
availableWorktreesByProject,
|
||||
cleanupSessions,
|
||||
hasAuthoritativeGlobalSessions,
|
||||
isWorktreeTopologyLoading,
|
||||
isSessionsLoading,
|
||||
normalizedProjects,
|
||||
ownership,
|
||||
unresolvedWorktreeProjectPaths,
|
||||
]);
|
||||
};
|
||||
@@ -10,120 +10,152 @@ const SESSION_PREFETCH_CONCURRENCY = 1;
|
||||
const SESSION_PREFETCH_PENDING_LIMIT = 6;
|
||||
|
||||
type Args = {
|
||||
enabled?: boolean;
|
||||
currentSessionId: string | null;
|
||||
sortedSessions: Session[];
|
||||
recentSessionIds?: string[];
|
||||
ensureSessionRenderable: (sessionId: string) => Promise<unknown>;
|
||||
recentSessions?: Session[];
|
||||
prefetchSession: (sessionId: string, directory: string) => Promise<unknown>;
|
||||
};
|
||||
|
||||
export const useSessionPrefetch = ({ currentSessionId, sortedSessions, recentSessionIds = [], ensureSessionRenderable }: Args): void => {
|
||||
type PrefetchRequest = {
|
||||
sessionId: string;
|
||||
directory: string;
|
||||
generation: number;
|
||||
};
|
||||
|
||||
const sessionDirectory = (session: Session | null | undefined): string | null => {
|
||||
const directory = (session as (Session & { directory?: string | null }) | null | undefined)?.directory;
|
||||
return typeof directory === 'string' && directory.trim() ? directory : null;
|
||||
};
|
||||
|
||||
export const useSessionPrefetch = ({ enabled = true, currentSessionId, sortedSessions, recentSessions = [], prefetchSession }: Args): void => {
|
||||
const sessionPrefetchTimersRef = React.useRef<Map<string, number>>(new Map());
|
||||
const sessionPrefetchQueueRef = React.useRef<string[]>([]);
|
||||
const sessionPrefetchQueueRef = React.useRef<PrefetchRequest[]>([]);
|
||||
const sessionPrefetchInFlightRef = React.useRef<Set<string>>(new Set());
|
||||
const generationRef = React.useRef(0);
|
||||
const prefetchDisabled = React.useMemo(() => isVSCodeRuntime(), []);
|
||||
|
||||
const requestKey = React.useCallback((request: Pick<PrefetchRequest, 'directory' | 'sessionId'>) => (
|
||||
`${request.directory}\n${request.sessionId}`
|
||||
), []);
|
||||
|
||||
const clearPendingPrefetches = React.useCallback(() => {
|
||||
generationRef.current += 1;
|
||||
sessionPrefetchQueueRef.current = [];
|
||||
sessionPrefetchTimersRef.current.forEach((timer) => window.clearTimeout(timer));
|
||||
sessionPrefetchTimersRef.current.clear();
|
||||
}, []);
|
||||
|
||||
const pumpSessionPrefetchQueue = React.useCallback(() => {
|
||||
if (prefetchDisabled || typeof window === 'undefined') {
|
||||
if (!enabled || prefetchDisabled || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
while (sessionPrefetchInFlightRef.current.size < SESSION_PREFETCH_CONCURRENCY && sessionPrefetchQueueRef.current.length > 0) {
|
||||
const nextSessionId = sessionPrefetchQueueRef.current.shift();
|
||||
if (!nextSessionId) {
|
||||
const request = sessionPrefetchQueueRef.current.shift();
|
||||
if (!request) {
|
||||
break;
|
||||
}
|
||||
if (request.generation !== generationRef.current) continue;
|
||||
|
||||
const state = useSessionUIStore.getState();
|
||||
if (state.currentSessionId === nextSessionId) {
|
||||
if (state.currentSessionId === request.sessionId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if the session is already renderable in the sync child store.
|
||||
if (getSyncSessionMaterializationStatus(nextSessionId).renderable) {
|
||||
if (getSyncSessionMaterializationStatus(request.sessionId, request.directory).renderable) {
|
||||
continue;
|
||||
}
|
||||
|
||||
sessionPrefetchInFlightRef.current.add(nextSessionId);
|
||||
void ensureSessionRenderable(nextSessionId)
|
||||
const key = requestKey(request);
|
||||
sessionPrefetchInFlightRef.current.add(key);
|
||||
void prefetchSession(request.sessionId, request.directory)
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
sessionPrefetchInFlightRef.current.delete(nextSessionId);
|
||||
sessionPrefetchInFlightRef.current.delete(key);
|
||||
pumpSessionPrefetchQueue();
|
||||
});
|
||||
}
|
||||
}, [ensureSessionRenderable, prefetchDisabled]);
|
||||
}, [enabled, prefetchDisabled, prefetchSession, requestKey]);
|
||||
|
||||
const scheduleSessionPrefetch = React.useCallback((sessionId: string | null | undefined) => {
|
||||
if (prefetchDisabled || !sessionId || sessionId === currentSessionId || typeof window === 'undefined') {
|
||||
const scheduleSessionPrefetch = React.useCallback((session: Session | null | undefined) => {
|
||||
const sessionId = session?.id;
|
||||
const directory = sessionDirectory(session);
|
||||
if (!enabled || prefetchDisabled || !sessionId || !directory || sessionId === currentSessionId || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const request = { sessionId, directory, generation: generationRef.current };
|
||||
const key = requestKey(request);
|
||||
|
||||
// Already renderable in sync
|
||||
if (getSyncSessionMaterializationStatus(sessionId).renderable) {
|
||||
if (getSyncSessionMaterializationStatus(sessionId, directory).renderable) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sessionPrefetchInFlightRef.current.has(sessionId)) {
|
||||
if (sessionPrefetchInFlightRef.current.has(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sessionPrefetchQueueRef.current.includes(sessionId)) {
|
||||
if (sessionPrefetchQueueRef.current.some((candidate) => requestKey(candidate) === key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sessionPrefetchQueueRef.current.length >= SESSION_PREFETCH_PENDING_LIMIT) {
|
||||
sessionPrefetchQueueRef.current.shift();
|
||||
}
|
||||
|
||||
const existingTimer = sessionPrefetchTimersRef.current.get(sessionId);
|
||||
const existingTimer = sessionPrefetchTimersRef.current.get(key);
|
||||
if (existingTimer !== undefined) {
|
||||
window.clearTimeout(existingTimer);
|
||||
}
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
sessionPrefetchTimersRef.current.delete(sessionId);
|
||||
sessionPrefetchQueueRef.current.push(sessionId);
|
||||
sessionPrefetchTimersRef.current.delete(key);
|
||||
if (request.generation !== generationRef.current) return;
|
||||
const queue = sessionPrefetchQueueRef.current;
|
||||
if (queue.length >= SESSION_PREFETCH_PENDING_LIMIT) {
|
||||
queue.shift();
|
||||
}
|
||||
queue.push(request);
|
||||
pumpSessionPrefetchQueue();
|
||||
}, SESSION_PREFETCH_HOVER_DELAY_MS);
|
||||
sessionPrefetchTimersRef.current.set(sessionId, timer);
|
||||
}, [currentSessionId, prefetchDisabled, pumpSessionPrefetchQueue]);
|
||||
sessionPrefetchTimersRef.current.set(key, timer);
|
||||
}, [currentSessionId, enabled, prefetchDisabled, pumpSessionPrefetchQueue, requestKey]);
|
||||
|
||||
React.useEffect(() => {
|
||||
clearPendingPrefetches();
|
||||
}, [clearPendingPrefetches, currentSessionId, enabled, prefetchDisabled]);
|
||||
|
||||
// Wait for the active session to finish loading before prefetching neighbors.
|
||||
// On rapid session switches the timer resets, so only the final session triggers prefetch.
|
||||
React.useEffect(() => {
|
||||
if (prefetchDisabled || !currentSessionId || sortedSessions.length === 0) {
|
||||
if (!enabled || prefetchDisabled || !currentSessionId || sortedSessions.length === 0) {
|
||||
return;
|
||||
}
|
||||
const timer = window.setTimeout(() => {
|
||||
const currentIndex = sortedSessions.findIndex((session) => session.id === currentSessionId);
|
||||
if (currentIndex < 0) return;
|
||||
scheduleSessionPrefetch(sortedSessions[currentIndex - 1]?.id);
|
||||
scheduleSessionPrefetch(sortedSessions[currentIndex + 1]?.id);
|
||||
scheduleSessionPrefetch(sortedSessions[currentIndex - 1]);
|
||||
scheduleSessionPrefetch(sortedSessions[currentIndex + 1]);
|
||||
}, SESSION_PREFETCH_SETTLE_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [currentSessionId, prefetchDisabled, scheduleSessionPrefetch, sortedSessions]);
|
||||
}, [currentSessionId, enabled, prefetchDisabled, scheduleSessionPrefetch, sortedSessions]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (prefetchDisabled || !currentSessionId || recentSessionIds.length === 0) {
|
||||
if (!enabled || prefetchDisabled || !currentSessionId || recentSessions.length === 0) {
|
||||
return;
|
||||
}
|
||||
const timer = window.setTimeout(() => {
|
||||
const currentIndex = recentSessionIds.indexOf(currentSessionId);
|
||||
const currentIndex = recentSessions.findIndex((session) => session.id === currentSessionId);
|
||||
if (currentIndex < 0) return;
|
||||
scheduleSessionPrefetch(recentSessionIds[currentIndex - 1]);
|
||||
scheduleSessionPrefetch(recentSessionIds[currentIndex + 1]);
|
||||
scheduleSessionPrefetch(recentSessions[currentIndex - 1]);
|
||||
scheduleSessionPrefetch(recentSessions[currentIndex + 1]);
|
||||
}, SESSION_PREFETCH_SETTLE_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [currentSessionId, prefetchDisabled, recentSessionIds, scheduleSessionPrefetch]);
|
||||
}, [currentSessionId, enabled, prefetchDisabled, recentSessions, scheduleSessionPrefetch]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const prefetchTimers = sessionPrefetchTimersRef.current;
|
||||
return () => {
|
||||
prefetchTimers.forEach((timer) => {
|
||||
clearTimeout(timer);
|
||||
});
|
||||
prefetchTimers.clear();
|
||||
sessionPrefetchQueueRef.current = [];
|
||||
};
|
||||
}, []);
|
||||
React.useEffect(() => clearPendingPrefetches, [clearPendingPrefetches]);
|
||||
};
|
||||
|
||||
export const SessionPrefetchEffect: React.FC<Omit<Args, 'currentSessionId'>> = (args) => {
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
useSessionPrefetch({ ...args, currentSessionId });
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
type Args = {
|
||||
enabled?: boolean;
|
||||
isSessionSearchOpen: boolean;
|
||||
setIsSessionSearchOpen: (open: boolean) => void;
|
||||
sessionSearchInputRef: React.RefObject<HTMLInputElement | null>;
|
||||
@@ -8,13 +9,14 @@ type Args = {
|
||||
};
|
||||
|
||||
export const useSessionSearchEffects = ({
|
||||
enabled = true,
|
||||
isSessionSearchOpen,
|
||||
setIsSessionSearchOpen,
|
||||
sessionSearchInputRef,
|
||||
sessionSearchContainerRef,
|
||||
}: Args): void => {
|
||||
React.useEffect(() => {
|
||||
if (!isSessionSearchOpen || typeof window === 'undefined') {
|
||||
if (!enabled || !isSessionSearchOpen || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const raf = window.requestAnimationFrame(() => {
|
||||
@@ -22,10 +24,10 @@ export const useSessionSearchEffects = ({
|
||||
sessionSearchInputRef.current?.select();
|
||||
});
|
||||
return () => window.cancelAnimationFrame(raf);
|
||||
}, [isSessionSearchOpen, sessionSearchInputRef]);
|
||||
}, [enabled, isSessionSearchOpen, sessionSearchInputRef]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isSessionSearchOpen || typeof document === 'undefined') {
|
||||
if (!enabled || !isSessionSearchOpen || typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const handlePointerDown = (event: MouseEvent) => {
|
||||
@@ -38,5 +40,5 @@ export const useSessionSearchEffects = ({
|
||||
};
|
||||
document.addEventListener('mousedown', handlePointerDown);
|
||||
return () => document.removeEventListener('mousedown', handlePointerDown);
|
||||
}, [isSessionSearchOpen, setIsSessionSearchOpen, sessionSearchContainerRef]);
|
||||
}, [enabled, isSessionSearchOpen, setIsSessionSearchOpen, sessionSearchContainerRef]);
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { SessionGroup, SessionNode, GroupSearchData } from '../types';
|
||||
import { dedupeSessionsById, normalizePath } from '../utils';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
import type { SessionFoldersMap } from '@/stores/useSessionFoldersStore';
|
||||
import { streamPerfCount } from '@/stores/utils/streamDebug';
|
||||
|
||||
type ProjectItem = {
|
||||
id: string;
|
||||
@@ -21,6 +22,19 @@ type ProjectSection = {
|
||||
groups: SessionGroup[];
|
||||
};
|
||||
|
||||
type ProjectSectionCacheEntry = {
|
||||
project: ProjectItem;
|
||||
activeSessions: Session[];
|
||||
archivedSessions: Session[];
|
||||
availableWorktrees: WorktreeMetadata[];
|
||||
rootBranch: string | null;
|
||||
isRepo: boolean;
|
||||
buildGroupedSessions: Args['buildGroupedSessions'];
|
||||
section: ProjectSection;
|
||||
};
|
||||
|
||||
const EMPTY_WORKTREES: WorktreeMetadata[] = [];
|
||||
|
||||
type Args = {
|
||||
normalizedProjects: ProjectItem[];
|
||||
getSessionsForProject: (projectId: string) => Session[];
|
||||
@@ -59,26 +73,67 @@ export const useSessionSidebarSections = (args: Args) => {
|
||||
buildGroupSearchText,
|
||||
foldersMap,
|
||||
} = args;
|
||||
const projectSectionCacheRef = React.useRef<Map<string, ProjectSectionCacheEntry>>(new Map());
|
||||
|
||||
const projectSections = React.useMemo<ProjectSection[]>(() => {
|
||||
return normalizedProjects.map((project) => {
|
||||
const projectSessions = dedupeSessionsById([
|
||||
...getSessionsForProject(project.id),
|
||||
...getArchivedSessionsForProject(project.id),
|
||||
]);
|
||||
const worktreesForProject = availableWorktreesByProject.get(project.normalizedPath) ?? [];
|
||||
const previousCache = projectSectionCacheRef.current;
|
||||
const nextCache = new Map<string, ProjectSectionCacheEntry>();
|
||||
let reusedSections = 0;
|
||||
let rebuiltSections = 0;
|
||||
const sameSessions = (left: Session[], right: Session[]): boolean => (
|
||||
left.length === right.length && left.every((session, index) => session === right[index])
|
||||
);
|
||||
|
||||
const sections = normalizedProjects.map((project) => {
|
||||
const activeSessions = getSessionsForProject(project.id);
|
||||
const archivedSessions = getArchivedSessionsForProject(project.id);
|
||||
const worktreesForProject = availableWorktreesByProject.get(project.normalizedPath) ?? EMPTY_WORKTREES;
|
||||
const isRepo = projectRepoStatus.has(project.id)
|
||||
? Boolean(projectRepoStatus.get(project.id))
|
||||
: lastRepoStatus;
|
||||
const rootBranch = projectRootBranches.get(project.id) ?? null;
|
||||
const cached = previousCache.get(project.id);
|
||||
if (
|
||||
cached
|
||||
&& cached.project === project
|
||||
&& sameSessions(cached.activeSessions, activeSessions)
|
||||
&& sameSessions(cached.archivedSessions, archivedSessions)
|
||||
&& cached.availableWorktrees === worktreesForProject
|
||||
&& cached.rootBranch === rootBranch
|
||||
&& cached.isRepo === isRepo
|
||||
&& cached.buildGroupedSessions === buildGroupedSessions
|
||||
) {
|
||||
reusedSections += 1;
|
||||
nextCache.set(project.id, cached);
|
||||
return cached.section;
|
||||
}
|
||||
|
||||
rebuiltSections += 1;
|
||||
const projectSessions = dedupeSessionsById([...activeSessions, ...archivedSessions]);
|
||||
const groups = buildGroupedSessions(
|
||||
projectSessions,
|
||||
project.normalizedPath,
|
||||
worktreesForProject,
|
||||
projectRootBranches.get(project.id) ?? null,
|
||||
rootBranch,
|
||||
isRepo,
|
||||
);
|
||||
return { project, groups };
|
||||
const section = { project, groups };
|
||||
nextCache.set(project.id, {
|
||||
project,
|
||||
activeSessions,
|
||||
archivedSessions,
|
||||
availableWorktrees: worktreesForProject,
|
||||
rootBranch,
|
||||
isRepo,
|
||||
buildGroupedSessions,
|
||||
section,
|
||||
});
|
||||
return section;
|
||||
});
|
||||
projectSectionCacheRef.current = nextCache;
|
||||
if (reusedSections > 0) streamPerfCount('ui.sidebar.project_section.reused', reusedSections);
|
||||
if (rebuiltSections > 0) streamPerfCount('ui.sidebar.project_section.rebuilt', rebuiltSections);
|
||||
return sections;
|
||||
}, [
|
||||
normalizedProjects,
|
||||
getSessionsForProject,
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { prunePinnedSessionIds } from './pinnedSessionCleanup';
|
||||
|
||||
const makeSession = (id: string): Pick<Session, 'id'> => ({ id });
|
||||
|
||||
describe('prunePinnedSessionIds', () => {
|
||||
test('keeps pinned ids that still exist in the authoritative session list', () => {
|
||||
const sessions = [makeSession('visible-session'), makeSession('hidden-session')];
|
||||
const pinnedSessionIds = new Set(['hidden-session', 'missing-session']);
|
||||
|
||||
const next = prunePinnedSessionIds(sessions, pinnedSessionIds);
|
||||
|
||||
expect([...next]).toEqual(['hidden-session']);
|
||||
expect(next).not.toBe(pinnedSessionIds);
|
||||
});
|
||||
|
||||
test('returns the original set when nothing needs pruning', () => {
|
||||
const sessions = [makeSession('visible-session'), makeSession('hidden-session')];
|
||||
const pinnedSessionIds = new Set(['visible-session', 'hidden-session']);
|
||||
|
||||
const next = prunePinnedSessionIds(sessions, pinnedSessionIds);
|
||||
|
||||
expect(next).toBe(pinnedSessionIds);
|
||||
});
|
||||
});
|
||||
@@ -1,46 +1,24 @@
|
||||
import React from 'react';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { prunePinnedSessionIds } from './pinnedSessionCleanup';
|
||||
|
||||
type SafeStorageLike = {
|
||||
getItem: (key: string) => string | null;
|
||||
setItem: (key: string, value: string) => void;
|
||||
removeItem?: (key: string) => void;
|
||||
};
|
||||
|
||||
type Keys = {
|
||||
sessionExpanded: string;
|
||||
// v1 key, still on disk for users upgrading from pre-per-context expansion.
|
||||
// When present, its bare-session-id entries are fanned out to all four
|
||||
// (project|recent) × (active|archived) context combinations and rewritten
|
||||
// under `sessionExpanded`. After migration the v1 key is removed.
|
||||
sessionExpandedLegacy: string;
|
||||
projectCollapse: string;
|
||||
sessionPinned: string;
|
||||
groupOrder: string;
|
||||
projectActiveSession: string;
|
||||
groupCollapse: string;
|
||||
};
|
||||
|
||||
const LEGACY_EXPANSION_CONTEXT_PREFIXES = [
|
||||
'project:active:',
|
||||
'project:archived:',
|
||||
'recent:active:',
|
||||
'recent:archived:',
|
||||
];
|
||||
|
||||
type Args = {
|
||||
isVSCode: boolean;
|
||||
hasAuthoritativeGlobalSessions: boolean;
|
||||
safeStorage: SafeStorageLike;
|
||||
keys: Keys;
|
||||
sessions: Session[];
|
||||
pinnedSessionIds: Set<string>;
|
||||
setPinnedSessionIds: React.Dispatch<React.SetStateAction<Set<string>>>;
|
||||
groupOrderByProject: Map<string, string[]>;
|
||||
activeSessionByProject: Map<string, string>;
|
||||
collapsedGroups: Set<string>;
|
||||
setExpandedParents: React.Dispatch<React.SetStateAction<Set<string>>>;
|
||||
setCollapsedProjects: React.Dispatch<React.SetStateAction<Set<string>>>;
|
||||
@@ -49,13 +27,9 @@ type Args = {
|
||||
export const useSidebarPersistence = (args: Args) => {
|
||||
const {
|
||||
isVSCode,
|
||||
hasAuthoritativeGlobalSessions,
|
||||
safeStorage,
|
||||
keys,
|
||||
sessions,
|
||||
setPinnedSessionIds,
|
||||
groupOrderByProject,
|
||||
activeSessionByProject,
|
||||
collapsedGroups,
|
||||
setExpandedParents,
|
||||
setCollapsedProjects,
|
||||
@@ -115,28 +89,6 @@ export const useSidebarPersistence = (args: Args) => {
|
||||
if (Array.isArray(parsed)) {
|
||||
setExpandedParents(new Set(parsed.filter((item) => typeof item === 'string')));
|
||||
}
|
||||
} else {
|
||||
// No v2 data — migrate from v1 (bare session ids) if present.
|
||||
const legacyRaw = safeStorage.getItem(keys.sessionExpandedLegacy);
|
||||
if (legacyRaw) {
|
||||
try {
|
||||
const parsedLegacy = JSON.parse(legacyRaw);
|
||||
if (Array.isArray(parsedLegacy)) {
|
||||
const migrated = new Set<string>();
|
||||
parsedLegacy.forEach((item) => {
|
||||
if (typeof item !== 'string' || item.length === 0) return;
|
||||
LEGACY_EXPANSION_CONTEXT_PREFIXES.forEach((prefix) => migrated.add(`${prefix}${item}`));
|
||||
});
|
||||
if (migrated.size > 0) {
|
||||
setExpandedParents(migrated);
|
||||
try { safeStorage.setItem(keys.sessionExpanded, JSON.stringify(Array.from(migrated))); } catch { /* ignored */ }
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// legacy data was malformed; ignore and let it expire
|
||||
}
|
||||
try { safeStorage.removeItem?.(keys.sessionExpandedLegacy); } catch { /* ignored */ }
|
||||
}
|
||||
}
|
||||
const storedProjects = safeStorage.getItem(keys.projectCollapse);
|
||||
if (storedProjects) {
|
||||
@@ -148,17 +100,7 @@ export const useSidebarPersistence = (args: Args) => {
|
||||
} catch {
|
||||
// ignored
|
||||
}
|
||||
}, [keys.projectCollapse, keys.sessionExpanded, keys.sessionExpandedLegacy, safeStorage, setCollapsedProjects, setExpandedParents]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!hasAuthoritativeGlobalSessions) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPinnedSessionIds((prev) => {
|
||||
return prunePinnedSessionIds(sessions, prev);
|
||||
});
|
||||
}, [hasAuthoritativeGlobalSessions, sessions, setPinnedSessionIds]);
|
||||
}, [keys.projectCollapse, keys.sessionExpanded, safeStorage, setCollapsedProjects, setExpandedParents]);
|
||||
|
||||
React.useEffect(() => {
|
||||
try {
|
||||
@@ -169,15 +111,6 @@ export const useSidebarPersistence = (args: Args) => {
|
||||
}
|
||||
}, [groupOrderByProject, keys.groupOrder, safeStorage]);
|
||||
|
||||
React.useEffect(() => {
|
||||
try {
|
||||
const serialized = Object.fromEntries(activeSessionByProject.entries());
|
||||
safeStorage.setItem(keys.projectActiveSession, JSON.stringify(serialized));
|
||||
} catch {
|
||||
// ignored
|
||||
}
|
||||
}, [activeSessionByProject, keys.projectActiveSession, safeStorage]);
|
||||
|
||||
React.useEffect(() => {
|
||||
try {
|
||||
safeStorage.setItem(keys.groupCollapse, JSON.stringify(Array.from(collapsedGroups)));
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import React from 'react';
|
||||
|
||||
type Args = {
|
||||
enabled?: boolean;
|
||||
isDesktopShellRuntime: boolean;
|
||||
projectSections: unknown[];
|
||||
projectHeaderSentinelRefs: React.MutableRefObject<Map<string, HTMLDivElement | null>>;
|
||||
};
|
||||
|
||||
export const useStickyProjectHeaders = (args: Args): Set<string> => {
|
||||
const { isDesktopShellRuntime, projectSections, projectHeaderSentinelRefs } = args;
|
||||
const { enabled = true, isDesktopShellRuntime, projectSections, projectHeaderSentinelRefs } = args;
|
||||
const [stuckProjectHeaders, setStuckProjectHeaders] = React.useState<Set<string>>(new Set());
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isDesktopShellRuntime) {
|
||||
if (!enabled || !isDesktopShellRuntime) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -24,12 +25,16 @@ export const useStickyProjectHeaders = (args: Args): Set<string> => {
|
||||
}
|
||||
|
||||
setStuckProjectHeaders((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (!entry.isIntersecting) {
|
||||
if (prev.has(projectId)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.add(projectId);
|
||||
} else {
|
||||
next.delete(projectId);
|
||||
return next;
|
||||
}
|
||||
|
||||
if (!prev.has(projectId)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.delete(projectId);
|
||||
return next;
|
||||
});
|
||||
});
|
||||
@@ -44,7 +49,7 @@ export const useStickyProjectHeaders = (args: Args): Set<string> => {
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [isDesktopShellRuntime, projectHeaderSentinelRefs, projectSections]);
|
||||
}, [enabled, isDesktopShellRuntime, projectHeaderSentinelRefs, projectSections]);
|
||||
|
||||
return stuckProjectHeaders;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user