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:
Bohdan Triapitsyn
2026-07-21 20:52:20 +03:00
committed by GitHub
parent 485efc7117
commit 85400459e9
197 changed files with 10835 additions and 3400 deletions
@@ -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,
]);
};
@@ -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;
};