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
@@ -7,10 +7,11 @@ import { isDesktopShell } from '@/lib/desktop';
import { sessionEvents } from '@/lib/sessionEvents';
import { formatDirectoryName, cn } from '@/lib/utils';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useAllLiveSessions } from '@/sync/sync-context';
import { useChildStoreManager } from '@/sync/sync-context';
import { getAllSyncSessionMap } from '@/sync/sync-refs';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useSync } from '@/sync/use-sync';
import { useSessionPrefetch } from './sidebar/hooks/useSessionPrefetch';
import { SessionPrefetchEffect } from './sidebar/hooks/useSessionPrefetch';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useUIStore } from '@/stores/useUIStore';
import { getDeferredSafeStorage } from '@/stores/utils/safeStorage';
@@ -22,7 +23,7 @@ import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
import { useArchivedAutoFolders } from './sidebar/hooks/useArchivedAutoFolders';
import { useSessionSidebarSections } from './sidebar/hooks/useSessionSidebarSections';
import { useProjectSessionSelection } from './sidebar/hooks/useProjectSessionSelection';
import { ProjectSessionSelectionEffect } from './sidebar/hooks/useProjectSessionSelection';
import { useGroupOrdering } from './sidebar/hooks/useGroupOrdering';
import { useSessionGrouping } from './sidebar/hooks/useSessionGrouping';
import { useSessionSearchEffects } from './sidebar/hooks/useSessionSearchEffects';
@@ -30,7 +31,7 @@ import { useSessionActions } from './sidebar/hooks/useSessionActions';
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 { useAuthoritativeSessionCleanup } from './sidebar/hooks/useAuthoritativeSessionCleanup';
import { createSessionOwnershipIndex } from './sidebar/sessionOwnership';
import { useStickyProjectHeaders } from './sidebar/hooks/useStickyProjectHeaders';
import { getGitHubPrStatusKey, usePrVisualSummaryByKeys, useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore';
@@ -69,30 +70,32 @@ import {
compareSessionsByPinnedAndTime,
formatProjectLabel,
normalizePath,
selectExpandedParentKeysForContext,
toggleExpandedParentKey,
} from './sidebar/utils';
import {
mergeLiveSessionWithGlobalSession,
refreshGlobalSessions,
refreshGlobalSessionsForDirectories,
getSessionStructuralSignature,
resolveGlobalSessionDirectory,
useGlobalSessionsStore,
} from '@/stores/useGlobalSessionsStore';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
import { subscribeOpenchamberEvents } from '@/lib/openchamberEvents';
import { buildSessionBootstrapDemands } from './sidebar/sessionBootstrapDemands';
import { getRuntimeKey } from '@/lib/runtime-switch';
import { streamPerfCount, streamPerfMark } from '@/stores/utils/streamDebug';
const PROJECT_COLLAPSE_STORAGE_KEY = 'oc.sessions.projectCollapse';
const GROUP_ORDER_STORAGE_KEY = 'oc.sessions.groupOrder';
const GROUP_COLLAPSE_STORAGE_KEY = 'oc.sessions.groupCollapse';
const PROJECT_ACTIVE_SESSION_STORAGE_KEY = 'oc.sessions.activeSessionByProject';
// v2 key holds composite "${renderContext}:${active|archived}:${sessionId}"
// v3 holds composite "${renderContext}:${active|archived}:${sessionId}"
// entries so the same session in different render contexts (e.g. "Recent"
// and a project's root) has independent expand state. v1 held bare session
// ids; useSidebarPersistence migrates v1 data on first read by fanning each
// id into all four context combinations.
const SESSION_EXPANDED_STORAGE_KEY = 'oc.sessions.expandedParents.v2';
const LEGACY_SESSION_EXPANDED_STORAGE_KEY = 'oc.sessions.expandedParents';
const SESSION_PINNED_STORAGE_KEY = 'oc.sessions.pinned';
// and a project's root) has independent expand state. Older expansion state
// mixed contexts and is intentionally not migrated.
const SESSION_EXPANDED_STORAGE_KEY = 'oc.sessions.expandedParents.v3';
type PrVisualState = 'draft' | 'open' | 'blocked' | 'merged' | 'closed';
@@ -157,6 +160,7 @@ const isKnownActiveSessionDirectory = (
const SIDEBAR_PR_NO_PR_RETRY_MS = 5 * 60_000;
const EMPTY_SUBTREE_SET: Set<string> = new Set();
const EMPTY_STRING_ARRAY: string[] = [];
const useStableRenderCallback = <Args extends unknown[], Return>(handler: (...args: Args) => Return): ((...args: Args) => Return) => {
const handlerRef = React.useRef(handler);
@@ -165,6 +169,7 @@ const useStableRenderCallback = <Args extends unknown[], Return>(handler: (...ar
};
interface SessionSidebarProps {
isVisible?: boolean;
mobileVariant?: boolean;
onSessionSelected?: (sessionId: string) => void;
allowReselect?: boolean;
@@ -172,13 +177,65 @@ interface SessionSidebarProps {
showOnlyMainWorkspace?: boolean;
}
export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const SidebarBootstrapDemandEffect: React.FC<{
owner: string;
childStores: ReturnType<typeof useChildStoreManager>;
projectSections: Parameters<typeof buildSessionBootstrapDemands>[0]['projectSections'];
activeProjectId: string | null;
collapsedProjects: ReadonlySet<string>;
collapsedGroups: ReadonlySet<string>;
currentDirectory: string | null;
}> = ({
owner,
childStores,
projectSections,
activeProjectId,
collapsedProjects,
collapsedGroups,
currentDirectory,
}) => {
const currentSessionDirectory = useSessionUIStore((state) => state.currentSessionDirectory);
React.useEffect(() => {
childStores.setBootstrapDemand(owner, buildSessionBootstrapDemands({
projectSections,
activeProjectId,
collapsedProjects,
collapsedGroups,
currentDirectory,
currentSessionDirectory,
}));
}, [
activeProjectId,
childStores,
collapsedGroups,
collapsedProjects,
currentDirectory,
currentSessionDirectory,
owner,
projectSections,
]);
React.useEffect(
() => () => childStores.clearBootstrapDemand(owner),
[childStores, owner],
);
return null;
};
const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
isVisible = true,
mobileVariant = false,
onSessionSelected,
allowReselect = false,
hideDirectoryControls = false,
showOnlyMainWorkspace = false,
}) => {
streamPerfMark('react.session_sidebar_render');
streamPerfCount('ui.session_sidebar.render');
streamPerfCount(`ui.session_sidebar.render.${mobileVariant ? 'mobile' : 'desktop'}`);
streamPerfCount(`ui.session_sidebar.render.${isVisible ? 'visible' : 'hidden'}`);
const { t } = useI18n();
const [isSessionSearchOpen, setIsSessionSearchOpen] = React.useState(false);
const [sessionSearchQuery, setSessionSearchQuery] = React.useState('');
@@ -204,7 +261,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const [deleteFolderConfirm, setDeleteFolderConfirm] = React.useState<DeleteFolderConfirmState>(null);
const [bulkDeleteConfirm, setBulkDeleteConfirm] = React.useState<BulkDeleteSessionsConfirmState>(null);
const pinnedSessionIds = useSessionPinnedStore((state) => state.ids);
const setPinnedSessionIds = useSessionPinnedStore((state) => state.setIds);
const togglePinnedSession = useSessionPinnedStore((state) => state.toggle);
const [collapsedGroups, setCollapsedGroups] = React.useState<Set<string>>(() => {
try {
@@ -236,7 +292,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
return new Map();
}
});
const [activeSessionByProject, setActiveSessionByProject] = React.useState<Map<string, string>>(() => {
const initialActiveSessionByProject = React.useMemo<Map<string, string>>(() => {
try {
const raw = getDeferredSafeStorage().getItem(PROJECT_ACTIVE_SESSION_STORAGE_KEY);
if (!raw) {
@@ -253,7 +309,12 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
} catch {
return new Map();
}
});
}, []);
const persistActiveSessionByProject = React.useCallback((value: Map<string, string>) => {
try {
safeStorage.setItem(PROJECT_ACTIVE_SESSION_STORAGE_KEY, JSON.stringify(Object.fromEntries(value.entries())));
} catch { /* ignored */ }
}, [safeStorage]);
const [projectRootBranches, setProjectRootBranches] = React.useState<Map<string, string>>(new Map());
const projectHeaderSentinelRefs = React.useRef<Map<string, HTMLDivElement | null>>(new Map());
@@ -261,7 +322,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
const setDirectory = useDirectoryStore((state) => state.setDirectory);
const projects = useProjectsStore((state) => state.projects);
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
@@ -302,26 +362,51 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const removeSessionFromFolder = useSessionFoldersStore((state) => state.removeSessionFromFolder);
const removeSessionsFromFolders = useSessionFoldersStore((state) => state.removeSessionsFromFolders);
const toggleFolderCollapse = useSessionFoldersStore((state) => state.toggleFolderCollapse);
const cleanupSessions = useSessionFoldersStore((state) => state.cleanupSessions);
const getSessionFolderId = useSessionFoldersStore((state) => state.getSessionFolderId);
useSessionSearchEffects({
enabled: isVisible,
isSessionSearchOpen,
setIsSessionSearchOpen,
sessionSearchInputRef,
sessionSearchContainerRef,
});
const gitBranches = useGitAllBranches();
const gitBranches = useGitAllBranches(isVisible);
const sync = useSync();
const liveSessions = useAllLiveSessions();
const childStores = useChildStoreManager();
const bootstrapDemandOwner = `session-sidebar:${React.useId()}`;
const liveSessionIndex = getAllSyncSessionMap();
const liveSessions = React.useMemo(() => Array.from(liveSessionIndex.values()), [liveSessionIndex]);
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
const hasAuthoritativeGlobalSessions = useGlobalSessionsStore((state) => state.status === 'ready');
const globalActiveSessions = useGlobalSessionsStore((state) => state.activeSessions);
const archivedSessions = useGlobalSessionsStore((state) => state.archivedSessions);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const newSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open));
const activeSessionStructure = useGlobalSessionsStore(useShallow(
(state) => state.activeSessions.map(getSessionStructuralSignature).sort(),
));
const archivedSessionStructure = useGlobalSessionsStore(useShallow(
(state) => state.archivedSessions.map(getSessionStructuralSignature).sort(),
));
const globalSessionSnapshot = useGlobalSessionsStore.getState();
const globalActiveSessions = globalSessionSnapshot.activeSessions;
const archivedSessions = globalSessionSnapshot.archivedSessions;
const liveFallbackCacheRef = React.useRef<{ signature: string; sessions: Session[] }>({
signature: '',
sessions: [],
});
const globalActiveSessionIds = React.useMemo(
() => new Set(globalActiveSessions.map((session) => session.id)),
[globalActiveSessions],
);
const liveFallbackSessions = (() => {
const candidates = liveSessions.filter((session) => !globalActiveSessionIds.has(session.id));
const signature = candidates.map(getSessionStructuralSignature).sort().join('\n');
if (liveFallbackCacheRef.current.signature === signature) {
return liveFallbackCacheRef.current.sessions;
}
liveFallbackCacheRef.current = { signature, sessions: candidates };
return candidates;
})();
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
const updateSessionTitle = useSessionUIStore((state) => state.updateSessionTitle);
const shareSession = useSessionUIStore((state) => state.shareSession);
@@ -359,14 +444,10 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
);
const sessions = React.useMemo(() => {
const liveById = new Map(liveSessions.map((session) => [session.id, session]));
const merged = globalActiveSessions.map((session) => {
const liveSession = liveById.get(session.id);
return liveSession ? mergeLiveSessionWithGlobalSession(liveSession, session) : session;
});
const merged = [...globalActiveSessions];
const seenIds = new Set(merged.map((session) => session.id));
liveSessions.forEach((session) => {
liveFallbackSessions.forEach((session) => {
if (seenIds.has(session.id)) {
return;
}
@@ -377,43 +458,24 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
allowUnknownDirectory: !isVSCode,
allowEmptyDirectorySet: !isVSCode,
}));
}, [globalActiveSessions, isVSCode, knownSessionDirectories, liveSessions]);
}, [globalActiveSessions, isVSCode, knownSessionDirectories, liveFallbackSessions]);
const persistenceSessions = React.useMemo(
() => [...globalActiveSessions, ...archivedSessions],
[archivedSessions, globalActiveSessions],
);
const syncSessionStructureSignature = React.useMemo(
() => liveSessions
.map((session) => {
const directory = normalizePath((session as Session & { directory?: string | null }).directory ?? null) ?? '';
return `${session.id}:${session.title ?? ''}:${session.time?.archived ? 1 : 0}:${directory}`;
})
.join('|'),
[liveSessions],
);
const syncSessionsSnapshotRef = React.useRef<Session[]>(liveSessions);
React.useEffect(() => {
syncSessionsSnapshotRef.current = liveSessions;
}, [syncSessionStructureSignature, liveSessions]);
// Batched live-session index. Building this here turns the per-row
// `useSession(session.id)` reads in SessionNodeItem (each of which
// iterates all child-stores via `findLiveSession`) into a single
// Map lookup. With M visible rows, that changes an O(M × child-stores)
// work to O(child-stores) once per Sidebar render.
const liveSessionById = React.useMemo(
() => new Map(liveSessions.map((session) => [session.id, session] as const)),
[liveSessions],
);
}, [liveSessions]);
const runtimeKey = getRuntimeKey();
const projectWorktreeDiscoveryKey = React.useMemo(
() => projects
() => `${runtimeKey}|${projects
.map((project) => `${project.id}:${normalizePath(project.path) ?? ''}`)
.join('|'),
[projects],
.join('|')}`,
[projects, runtimeKey],
);
const [resolvedWorktreeTopologyKey, setResolvedWorktreeTopologyKey] = React.useState<string | null>(
isVSCode ? projectWorktreeDiscoveryKey : null,
@@ -434,6 +496,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
let cancelled = false;
const discoverWorktrees = async () => {
const discoveryRuntimeKey = runtimeKey;
const projectEntries = useProjectsStore.getState().projects;
if (projectEntries.length === 0 || isVSCode) {
if (!cancelled) {
@@ -488,7 +551,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
});
await Promise.all(workers);
if (cancelled) return;
if (cancelled || getRuntimeKey() !== discoveryRuntimeKey) return;
const activeProjectPaths = new Set(projectEntries.map((project) => normalizePath(project.path)).filter(Boolean));
for (const projectPath of worktreesByProject.keys()) {
@@ -514,7 +577,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
return () => {
cancelled = true;
};
}, [isVSCode, projectWorktreeDiscoveryKey]);
}, [isVSCode, projectWorktreeDiscoveryKey, runtimeKey]);
React.useEffect(() => {
let refreshTimeout: ReturnType<typeof setTimeout> | null = null;
@@ -556,22 +619,14 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const { scheduleCollapsedProjectsPersist } = useSidebarPersistence({
isVSCode,
hasAuthoritativeGlobalSessions,
safeStorage,
keys: {
sessionExpanded: SESSION_EXPANDED_STORAGE_KEY,
sessionExpandedLegacy: LEGACY_SESSION_EXPANDED_STORAGE_KEY,
projectCollapse: PROJECT_COLLAPSE_STORAGE_KEY,
sessionPinned: SESSION_PINNED_STORAGE_KEY,
groupOrder: GROUP_ORDER_STORAGE_KEY,
projectActiveSession: PROJECT_ACTIVE_SESSION_STORAGE_KEY,
groupCollapse: GROUP_COLLAPSE_STORAGE_KEY,
},
sessions: persistenceSessions,
pinnedSessionIds,
setPinnedSessionIds,
groupOrderByProject,
activeSessionByProject,
collapsedGroups,
setExpandedParents,
setCollapsedProjects,
@@ -619,12 +674,12 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
return map;
}, [sortedSessions, pinnedSessionIds]);
const emptyState = (
const emptyState = React.useMemo(() => (
<div className="py-6 text-center text-muted-foreground">
<p className="typography-ui-label font-semibold">{t('sessions.sidebar.empty.noSessions.title')}</p>
<p className="typography-meta mt-1">{t('sessions.sidebar.empty.noSessions.description')}</p>
</div>
);
), [t]);
const editingProject = React.useMemo(
() => projects.find((project) => project.id === editingProjectDialogId) ?? null,
@@ -703,9 +758,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
handleDeleteSession,
confirmDeleteSession,
} = useSessionActions({
activeProjectId,
currentDirectory,
currentSessionId,
mobileVariant,
allowReselect,
onSessionSelected,
@@ -713,8 +765,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
sessionSearchQuery,
setSessionSearchQuery,
setIsSessionSearchOpen,
setActiveProjectIdOnly,
setDirectory,
setActiveMainTab,
setSessionSwitcherOpen,
setCurrentSession,
@@ -746,40 +796,9 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
sessionEvents.requestDirectoryDialog();
}, []);
// Auto-expand parent session when navigating to a subagent (child) session.
// We don't know which render context the user will look at the parent in
// (Recent, project root, archived bucket, ...), so fan out across all
// four combinations to ensure it's expanded wherever it appears.
React.useEffect(() => {
if (!currentSessionId) return;
const current = sessions.find((s) => s.id === currentSessionId);
const parentID = (current as Session & { parentID?: string | null })?.parentID;
if (!parentID) return;
const keysToAdd = [
`project:active:${parentID}`,
`project:archived:${parentID}`,
`recent:active:${parentID}`,
`recent:archived:${parentID}`,
];
setExpandedParents((prev) => {
if (keysToAdd.every((k) => prev.has(k))) return prev;
const next = new Set(prev);
keysToAdd.forEach((k) => next.add(k));
try {
safeStorage.setItem(SESSION_EXPANDED_STORAGE_KEY, JSON.stringify(Array.from(next)));
} catch { /* ignored */ }
return next;
});
}, [currentSessionId, sessions, safeStorage]);
const toggleParent = React.useCallback((expansionKey: string) => {
setExpandedParents((prev) => {
const next = new Set(prev);
if (next.has(expansionKey)) {
next.delete(expansionKey);
} else {
next.add(expansionKey);
}
setExpandedParents((previous) => {
const next = toggleExpandedParentKey(previous, expansionKey);
try {
safeStorage.setItem(SESSION_EXPANDED_STORAGE_KEY, JSON.stringify(Array.from(next)));
} catch { /* ignored */ }
@@ -964,12 +983,13 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const { github } = useRuntimeAPIs();
const githubAuthStatus = useGitHubAuthStore((state) => state.status);
const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
const gitRepoStatus = useGitRepoStatusMap(normalizedProjectPaths);
const gitRepoStatus = useGitRepoStatusMap(isVisible ? normalizedProjectPaths : EMPTY_STRING_ARRAY);
const ensurePrStatusEntry = useGitHubPrStatusStore((state) => state.ensureEntry);
const setPrStatusParams = useGitHubPrStatusStore((state) => state.setParams);
const refreshPrStatusTargets = useGitHubPrStatusStore((state) => state.refreshTargets);
useProjectRepoStatus({
enabled: isVisible,
normalizedProjects,
gitRepoStatus,
setProjectRepoStatus,
@@ -981,15 +1001,10 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
() => createSessionOwnershipIndex(sessions, normalizedProjects, availableWorktreesByProject, isVSCode, archivedSessions),
[archivedSessions, availableWorktreesByProject, isVSCode, normalizedProjects, sessions],
);
useSessionFolderCleanup({
isSessionsLoading,
useAuthoritativeSessionCleanup({
enabled: isVisible,
hasAuthoritativeGlobalSessions,
isWorktreeTopologyLoading,
normalizedProjects,
ownership: sessionOwnership,
availableWorktreesByProject,
unresolvedWorktreeProjectPaths,
cleanupSessions,
sessions: persistenceSessions,
});
const { getSessionsForProject, getArchivedSessionsForProject } = useProjectSessionLists({
@@ -997,6 +1012,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
});
useArchivedAutoFolders({
enabled: isVisible,
normalizedProjects,
ownership: sessionOwnership,
isSessionsLoading,
@@ -1006,7 +1022,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
foldersMap,
createFolder,
addSessionToFolder,
cleanupSessions,
});
// Keep last-known repo status to avoid UI jiggling during project switch
@@ -1019,6 +1034,89 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const showArchivedSessions = useSessionDisplayStore((state) => state.showArchivedSessions);
const projectSortOrder = useSessionDisplayStore((state) => state.projectSortOrder);
const manualProjectOrder = useProjectsStore((state) => state.manualProjectOrder);
const projectExpandedParentsRef = React.useRef<Set<string>>(new Set());
const recentExpandedParentsRef = React.useRef<Set<string>>(new Set());
const projectExpandedParents = selectExpandedParentKeysForContext(
projectExpandedParentsRef.current,
expandedParents,
'project',
);
const recentExpandedParents = selectExpandedParentKeysForContext(
recentExpandedParentsRef.current,
expandedParents,
'recent',
);
projectExpandedParentsRef.current = projectExpandedParents;
recentExpandedParentsRef.current = recentExpandedParents;
const sidebarRenderSources = {
isVisible,
mobileVariant,
onSessionSelected,
allowReselect,
hideDirectoryControls,
showOnlyMainWorkspace,
t,
isTablet,
liveSessions,
activeSessionStructure,
archivedSessionStructure,
globalActiveSessions,
archivedSessions,
projects,
activeProjectId,
manualProjectOrder,
currentDirectory,
worktreeMetadata,
availableWorktreesByProject,
pinnedSessionIds,
foldersMap,
collapsedFolderIds,
gitBranches,
gitRepoStatus,
githubAuthStatus,
githubAuthChecked,
updateStore,
showRecentSection,
showArchivedSessions,
projectSortOrder,
projectRepoStatus,
projectRootBranches,
resolvedWorktreeTopologyKey,
unresolvedWorktreeProjectPaths,
isSessionSearchOpen,
sessionSearchQuery,
editingId,
editTitle,
editingProjectDialogId,
expandedParents,
collapsedProjects,
visibleSessionCountByGroup,
updateDialogOpen,
openSidebarMenuKey,
renamingFolderId,
renameFolderDraft,
deleteSessionConfirm,
deleteFolderConfirm,
bulkDeleteConfirm,
collapsedGroups,
groupOrderByProject,
};
const previousSidebarRenderSourcesRef = React.useRef<typeof sidebarRenderSources | null>(null);
const previousSidebarRenderSources = previousSidebarRenderSourcesRef.current;
if (previousSidebarRenderSources) {
let attributed = false;
for (const source of Object.keys(sidebarRenderSources) as Array<keyof typeof sidebarRenderSources>) {
if (!Object.is(previousSidebarRenderSources[source], sidebarRenderSources[source])) {
streamPerfCount(`ui.session_sidebar.source.${source}`);
attributed = true;
}
}
if (!attributed) {
streamPerfCount('ui.session_sidebar.source.parent_or_context');
}
}
previousSidebarRenderSourcesRef.current = sidebarRenderSources;
const sortedProjects = React.useMemo(() => {
const list = [...normalizedProjects];
@@ -1079,26 +1177,12 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
foldersMap,
});
const searchEmptyState = (
const searchEmptyState = React.useMemo(() => (
<div className="py-6 text-center text-muted-foreground">
<p className="typography-ui-label font-semibold">{t('sessions.sidebar.empty.noMatches.title')}</p>
<p className="typography-meta mt-1">{t('sessions.sidebar.empty.noMatches.description')}</p>
</div>
);
useProjectSessionSelection({
projectSections,
activeProjectId,
activeSessionByProject,
setActiveSessionByProject,
currentSessionId,
handleSessionSelect,
newSessionDraftOpen,
mobileVariant,
openNewSessionDraft,
setActiveMainTab,
setSessionSwitcherOpen,
});
), [t]);
const { getOrderedGroups } = useGroupOrdering(groupOrderByProject);
const hasInitializedArchivedCollapseRef = React.useRef(false);
@@ -1231,19 +1315,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
);
const recentSessionIds = React.useMemo(() => {
return new Set(activeNowSessions.map((session) => session.id));
}, [activeNowSessions]);
const recentSessionIdsList = React.useMemo(() => [...recentSessionIds], [recentSessionIds]);
useSessionPrefetch({
currentSessionId,
sortedSessions,
recentSessionIds: recentSessionIdsList,
ensureSessionRenderable: sync.ensureSessionRenderable,
});
const sectionsForSidebarRender = React.useMemo(() => {
return showArchivedSessions
? sectionsForRender
@@ -1254,6 +1325,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
}, [sectionsForRender, showArchivedSessions]);
const prLookupKeys = React.useMemo(() => {
if (!isVisible) return EMPTY_STRING_ARRAY;
const keys = new Set<string>();
sectionsForSidebarRender.forEach((section) => {
section.groups.forEach((group) => {
@@ -1266,16 +1338,16 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
});
});
return [...keys];
}, [gitBranches, sectionsForSidebarRender]);
}, [gitBranches, isVisible, sectionsForSidebarRender]);
const prVisualSummaryMap = usePrVisualSummaryByKeys(prLookupKeys);
React.useEffect(() => {
if (!githubAuthChecked || !githubAuthStatus?.connected || !github) {
if (!isVisible || !githubAuthChecked || !githubAuthStatus?.connected || !github) {
return;
}
const missingTargets: Array<{ directory: string; branch: string; remoteName?: string | null }> = [];
const targetsByKey = new Map<string, { directory: string; branch: string }>();
const now = Date.now();
sectionsForSidebarRender.forEach((section) => {
@@ -1307,29 +1379,23 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
if (shouldRetryNoPr) {
retriedNoPrStatusKeysRef.current.add(retryKey);
}
missingTargets.push({ directory, branch });
if (!targetsByKey.has(key)) {
targetsByKey.set(key, { directory, branch });
}
}
});
});
if (missingTargets.length === 0) {
if (targetsByKey.size === 0) {
return;
}
const uniqueTargets = new Map<string, { directory: string; branch: string; remoteName?: string | null }>();
missingTargets.forEach((target) => {
const key = getGitHubPrStatusKey(target.directory, target.branch, target.remoteName ?? null);
if (!uniqueTargets.has(key)) {
uniqueTargets.set(key, target);
}
});
uniqueTargets.forEach((target, key) => {
targetsByKey.forEach((target, key) => {
ensurePrStatusEntry(key);
setPrStatusParams(key, {
directory: target.directory,
branch: target.branch,
remoteName: target.remoteName ?? null,
remoteName: null,
canShow: true,
github,
githubAuthChecked,
@@ -1337,7 +1403,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
});
});
void refreshPrStatusTargets([...uniqueTargets.values()], {
void refreshPrStatusTargets([...targetsByKey.values()], {
silent: true,
markInitialResolved: true,
});
@@ -1347,6 +1413,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
github,
githubAuthChecked,
githubAuthStatus?.connected,
isVisible,
gitBranches,
refreshPrStatusTargets,
sectionsForSidebarRender,
@@ -1360,6 +1427,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const headerActionButtonClass = mobileVariant ? mobileHeaderActionButtonClass : desktopHeaderActionButtonClass;
const headerActionIconClass = 'h-4.5 w-4.5';
const stuckProjectHeaders = useStickyProjectHeaders({
enabled: isVisible,
isDesktopShellRuntime,
projectSections,
projectHeaderSentinelRefs,
@@ -1382,9 +1450,8 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
groupDirectory={groupDirectory}
projectId={projectId}
archivedBucket={archivedBucket}
currentSessionId={currentSessionId}
pinnedSessionIds={pinnedSessionIds}
expandedParents={expandedParents}
expandedParents={renderContext === 'recent' ? recentExpandedParents : projectExpandedParents}
hasSessionSearchQuery={hasSessionSearchQuery}
normalizedSessionSearchQuery={normalizedSessionSearchQuery}
notifyOnSubtasks={notifyOnSubtasks}
@@ -1417,12 +1484,10 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
renderSessionNode={renderSessionNode}
secondaryMeta={secondaryMeta}
renderContext={renderContext}
subtreeContainsActive={renderExtras?.subtreeContainsActive ?? EMPTY_SUBTREE_SET}
subtreeContainsEditing={renderExtras?.subtreeContainsEditing ?? EMPTY_SUBTREE_SET}
menuOpenSessionId={renderExtras?.menuOpenSessionId ?? null}
nodeStructureKey={renderExtras?.nodeStructureKey ?? ''}
childRenderExtrasFor={renderExtras?.childRenderExtrasFor}
liveSessionById={liveSessionById}
/>
),
);
@@ -1505,13 +1570,11 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
setRenameFolderDraft={setRenameFolderDraft}
setRenamingFolderId={setRenamingFolderId}
pinnedSessionIds={pinnedSessionIds}
expandedParents={expandedParents}
expandedParents={projectExpandedParents}
sessionOrderIndex={sessionOrderIndex}
currentSessionId={currentSessionId}
editingId={editingId}
editTitle={editTitle}
openSidebarMenuKey={openSidebarMenuKey}
liveSessionById={liveSessionById}
prVisualStateByDirectoryBranch={prVisualStateByDirectoryBranch}
onToggleCollapsedGroup={toggleCollapsedGroup}
dragHandleProps={dragHandleProps}
@@ -1546,28 +1609,29 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
renamingFolderId,
renameFolderDraft,
pinnedSessionIds,
expandedParents,
projectExpandedParents,
sessionOrderIndex,
currentSessionId,
editingId,
editTitle,
openSidebarMenuKey,
liveSessionById,
prVisualStateByDirectoryBranch,
toggleCollapsedGroup,
],
);
const topContent = (!isVSCode && showRecentSection && !hasSessionSearchQuery) ? (
<SidebarActivitySections
sections={activitySections}
renderSessionNode={renderSessionNode}
currentSessionId={currentSessionId}
editingId={editingId}
openSidebarMenuKey={openSidebarMenuKey}
variant="section"
/>
) : null;
const topContent = React.useMemo(
() => (!isVSCode && showRecentSection && !hasSessionSearchQuery) ? (
<SidebarActivitySections
sections={activitySections}
renderSessionNode={renderSessionNode}
editingId={editingId}
openSidebarMenuKey={openSidebarMenuKey}
expansionState={recentExpandedParents}
variant="section"
/>
) : null,
[activitySections, editingId, hasSessionSearchQuery, isVSCode, openSidebarMenuKey, recentExpandedParents, renderSessionNode, showRecentSection],
);
const isInlineEditing = Boolean(renamingFolderId || editingId || editingProjectDialogId);
const {
@@ -1620,6 +1684,32 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
mobileVariant ? '' : 'bg-transparent',
)}
>
<SidebarBootstrapDemandEffect
owner={bootstrapDemandOwner}
childStores={childStores}
projectSections={projectSections}
activeProjectId={activeProjectId}
collapsedProjects={collapsedProjects}
collapsedGroups={collapsedGroups}
currentDirectory={currentDirectory}
/>
<ProjectSessionSelectionEffect
projectSections={projectSections}
activeProjectId={activeProjectId}
initialActiveSessionByProject={initialActiveSessionByProject}
persistActiveSessionByProject={persistActiveSessionByProject}
handleSessionSelect={stableHandleSessionSelect}
mobileVariant={mobileVariant}
openNewSessionDraft={openNewSessionDraft}
setActiveMainTab={setActiveMainTab}
setSessionSwitcherOpen={setSessionSwitcherOpen}
/>
<SessionPrefetchEffect
enabled={isVisible}
sortedSessions={sortedSessions}
recentSessions={activeNowSessions}
prefetchSession={sync.prefetchSession}
/>
<SidebarHeader
hideDirectoryControls={hideDirectoryControls}
showRecentControls={!isVSCode}
@@ -1643,7 +1733,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
onToggleSelectionMode={handleToggleSelectionMode}
/>
<SidebarProjectsList
{isVisible ? <SidebarProjectsList
topContent={topContent}
hasSharedSessions={hasActivitySectionItems}
sectionsForRender={sectionsForSidebarRender}
@@ -1678,7 +1768,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
openSidebarMenuKey={openSidebarMenuKey}
setOpenSidebarMenuKey={setOpenSidebarMenuKey}
isInlineEditing={isInlineEditing}
/>
/> : null}
{selectionModeEnabled && hasSelection ? (
<BulkActionBar
@@ -1770,3 +1860,5 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
</div>
);
};
export const SessionSidebar = React.memo(SessionSidebarComponent);
@@ -10,9 +10,10 @@
- Archived groups are collapsed by default and support bulk deletion at group/folder level.
- Session rows support compact inline dates in minimal mode and simplified metadata in default mode.
- Root session menus can quickly create a worktree from the session directory's current branch and move the full session subtree there while idle.
- Directory loading is demand-driven: the sidebar publishes one complete priority plan for all known project/worktree directories, while the sync layer owns bounded execution.
- New extractions in latest pass reduced local effect/callback bulk further:
- project session list builders
- folder cleanup sync
- authoritative deletion cleanup
- sticky project header observer
## VS Code grouping
@@ -29,8 +30,8 @@
- `SidebarActivitySections.tsx`: Global top section renderer; currently used for the `recent` section only.
- `SidebarFooter.tsx`: Static footer with icon-only settings, shortcuts, and about actions.
- `SidebarProjectsList.tsx`: Main scrollable tree renderer for projects, root sessions, worktrees/groups, and empty/search states.
- `SessionGroupSection.tsx`: Renders a single worktree/archived group, collapse/expand, folder subtree, and group-level controls.
- `SessionNodeItem.tsx`: Renders one session row/tree node with inline metadata, menu actions, minimal/default variants, and nested children.
- `SessionGroupSection.tsx`: Renders a single worktree/archived group, collapse/expand, folder subtree, group-level controls, and explicit loading/error/retry state for empty groups.
- `SessionNodeItem.tsx`: Renders one session row/tree node with inline metadata, menu actions, minimal/default variants, and nested children. Rows do not initiate directory bootstrap on mount.
- `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.
@@ -40,7 +41,7 @@
- `hooks/useSessionActions.ts`: Centralizes session row actions (select/open, rename, share/unshare, archive/delete, confirmations).
- `hooks/useSessionSearchEffects.ts`: Handles search open/close UX and input focus behavior.
- `hooks/useSessionPrefetch.ts`: Prefetches messages for nearby/active sessions to improve perceived load speed.
- `hooks/useSessionPrefetch.ts`: Publishes directory-aware nearby/active session prefetch demand to the shared message loader. Recent may prefetch across projects without substituting the current directory.
- `hooks/useSessionGrouping.ts`: Builds grouped session structures and search text/filter helpers.
- `hooks/useSessionSidebarSections.ts`: Composes final per-project sections and group search metadata for rendering.
- `hooks/useProjectSessionSelection.ts`: Resolves active/current project-session selection logic and session-directory context.
@@ -49,11 +50,29 @@
- `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`: 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/useAuthoritativeSessionCleanup.ts`: Establishes the first complete active+archived list as a non-destructive baseline, then cleans persisted state only for sessions omitted by a later authoritative snapshot.
- `hooks/useStickyProjectHeaders.ts`: Tracks which project headers are sticky/stuck via `IntersectionObserver`.
### Types and utilities
- `types.ts`: Shared sidebar types (`SessionNode`, `SessionGroup`, summary/search metadata).
- `activitySections.ts`: Persisted top-section storage/helpers for the current `recent` session list.
- `sessionBootstrapDemands.ts`: Builds the deduplicated directory demand plan. Selected directories rank above active projects, expanded groups, visible collapsed groups, and background/collapsed projects.
- `utils.tsx`: Shared sidebar utilities (path normalization, sorting, dedupe, archived scope keys, project relation checks, text highlight, labels, compact/default date formatting).
## Loading rules
- Always publish every known project root and worktree directory. Collapse/visibility changes priority only; they do not opt a directory out of authoritative refresh.
- Current directory and selected-session directory are `selected` demand and therefore run first.
- Expanded projects/worktrees outrank merely visible and background groups.
- The sync scheduler deduplicates, promotes, retries, and limits work. Sidebar components must not reproduce that lifecycle with mount effects.
- Hide speculative work when the sidebar/chat surface is hidden: message prefetch, Git/PR enrichment and subscriptions, search listeners, sticky-header observation, and archived-folder derivation stop. The session row tree unmounts so row-owned status, permission, unseen, and viewport subscriptions do no background work. The outer sidebar remains mounted, preserving UI state and authoritative directory refresh for an immediate reopen; deferred derived work reruns from current state when visibility returns.
- The sidebar does not subscribe its whole tree to the cross-directory live-session aggregate. Global create/structural/lifecycle snapshots drive rendered session metadata; the cached sync index only fills sessions not yet present globally and provides refresh fallback data. Row activity continues to come from the session-keyed live status index.
- Session selection does not invalidate the sidebar orchestration component. Each mounted row selects only whether its own session ID is active, while parent expansion, project selection memory, and neighbor prefetch run in small effect-only subscribers.
- Parent expansion is exclusively manual. Selecting or navigating to a subsession never expands its parent automatically. Project/worktree and `recent` trees use independent persisted context keys and receive separate stable projections, so expansion changes in one context neither invalidate nor change the other. The persisted storage key remains `v3`; older state mixed contexts and is not migrated into this contract.
- Folder membership may contain both a parent session and its descendants. Rendering treats only the highest assigned ancestors as folder roots because their normal session trees already include assigned descendants; persisted membership remains unchanged for cleanup and move semantics.
- Sidebar selection holds the clicked row's viewport position across navigation-driven sidebar updates. Wheel or touch input cancels the hold immediately, so programmatic compensation never fights intentional scrolling.
- Global session subscriptions are structural: create/delete, title, share, archive, directory, parent, and slug changes invalidate the tree. Recency-only `time.updated` changes are read from the authoritative snapshot on the next sidebar render rather than triggering a full tree rebuild themselves.
- Structural updates rebuild grouped nodes only for projects whose local sessions, worktrees, repository state, or branch changed; unchanged project sections preserve references so memoized group/session descendants skip the update wave.
- Empty successful lists, unresolved loads, and failed loads are separate UI states. Failed groups expose Retry and retain prior data.
- Pins and folder assignments are not pruned from the first startup snapshot or from optimistic mutations. Confirmed local deletion and routed external deletion clean immediately; a later authoritative omission after an established baseline covers missed external delete events.
@@ -5,11 +5,6 @@ import type { Session } from '@opencode-ai/sdk/v2';
// Archived buckets routinely grow into the hundreds/thousands; virtualize
// when we cross this row count so the DOM stays bounded.
const ARCHIVED_VIRTUALIZE_THRESHOLD = 50;
// Active/worktree groups can also grow large (a single worktree with 80+
// sessions), and unlike the archive they're interactive from the start.
// Virtualize eagerly for non-archived groups to keep the rendered row
// count bounded. With overscan ~8 the visible behavior is identical.
const ACTIVE_VIRTUALIZE_THRESHOLD = 30;
// Compact rows in the archived bucket without nested subagents render
// around 24-32px; virtua measures mounted rows and uses this as the initial hint.
const ARCHIVED_ROW_ESTIMATE_PX = 28;
@@ -26,8 +21,10 @@ import { compareSessionsByPinnedAndTime, isBranchDifferentFromLabel, normalizePa
import {
collectSubtreeContainingId,
computeNodeStructureKey,
nodeHasPinnedMembershipChange,
nodeContainsSessionId,
resolveMenuOpenSessionId,
selectFolderRootNodes,
} from './sessionNodeItemUtils';
import type { SessionNodeRenderExtras } from './sessionNodeItemUtils';
import type { SessionFolder } from '@/stores/useSessionFoldersStore';
@@ -36,6 +33,7 @@ import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
import { openExternalUrl } from '@/lib/url';
import { isVSCodeRuntime } from '@/lib/desktop';
import { useI18n } from '@/lib/i18n';
import { useChildStoreManager } from '@/sync/sync-context';
type DeleteFolderConfirm = {
scopeKey: string;
@@ -92,11 +90,9 @@ type Props = {
pinnedSessionIds: Set<string>;
expandedParents: Set<string>;
sessionOrderIndex: Map<string, number>;
currentSessionId: string | null;
editingId: string | null;
editTitle: string;
openSidebarMenuKey: string | null;
liveSessionById: Map<string, Session>;
prVisualStateByDirectoryBranch: Map<string, {
visualState: 'draft' | 'open' | 'blocked' | 'merged' | 'closed';
number: number;
@@ -142,12 +138,14 @@ const groupHasPinnedMembershipChange = (
prevPinnedSessionIds: Set<string>,
nextPinnedSessionIds: Set<string>,
): boolean => {
const visit = (node: SessionNode): boolean => {
const sessionId = node.session.id;
if (prevPinnedSessionIds.has(sessionId) !== nextPinnedSessionIds.has(sessionId)) return true;
return node.children.some(visit);
};
return group.sessions.some(visit);
return group.sessions.some((node) => nodeHasPinnedMembershipChange(
node,
node,
prevPinnedSessionIds,
nextPinnedSessionIds,
group.directory,
group.directory,
));
};
const groupHasSessionOrderChange = (
@@ -177,21 +175,6 @@ const groupHasExpansionMembershipChange = (
return group.sessions.some(visit);
};
const groupHasResolvedSessionChange = (
group: SessionGroup,
prevLiveSessionById: Map<string, Session>,
nextLiveSessionById: Map<string, Session>,
): boolean => {
const visit = (node: SessionNode): boolean => {
const sessionId = node.session.id;
if ((prevLiveSessionById.get(sessionId) ?? node.session) !== (nextLiveSessionById.get(sessionId) ?? node.session)) {
return true;
}
return node.children.some(visit);
};
return group.sessions.some(visit);
};
const getProjectRepoStatusValue = (props: Props): boolean | null | undefined => {
if (!props.projectId) return undefined;
return props.projectRepoStatus.has(props.projectId)
@@ -236,11 +219,6 @@ const areGroupPropsEqual = (prev: Props, next: Props): boolean => {
return false;
}
if (prev.currentSessionId !== next.currentSessionId
&& (groupContainsSessionId(prev.group, prev.currentSessionId) || groupContainsSessionId(next.group, next.currentSessionId))) {
return false;
}
if (prev.editingId !== next.editingId
&& (groupContainsSessionId(prev.group, prev.editingId) || groupContainsSessionId(next.group, next.editingId))) {
return false;
@@ -257,11 +235,6 @@ const areGroupPropsEqual = (prev: Props, next: Props): boolean => {
if (prevMenuSessionId || nextMenuSessionId) return false;
}
if (prev.liveSessionById !== next.liveSessionById
&& groupHasResolvedSessionChange(next.group, prev.liveSessionById, next.liveSessionById)) {
return false;
}
// Per-row / per-state props. The PR-visual-state map flips frequently
// during bootstrap but a single group's value is usually stable, so we
// compare only the value this group actually consumes instead of the
@@ -352,7 +325,6 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
pinnedSessionIds,
expandedParents,
sessionOrderIndex,
currentSessionId,
editingId,
openSidebarMenuKey,
prVisualStateByDirectoryBranch,
@@ -379,6 +351,19 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
// VS Code always uses the expanded layout (see SessionNodeItem).
const isMinimalMode = displayMode === 'minimal' && !isVSCodeRuntime();
const isCollapsed = hasSessionSearchQuery ? false : collapsedGroups.has(groupKey);
const childStores = useChildStoreManager();
const bootstrapDirectory = normalizePath(group.directory ?? null);
const bootstrapState = React.useSyncExternalStore(
React.useCallback(
(notify) => bootstrapDirectory ? childStores.subscribeBootstrap(notify) : () => undefined,
[bootstrapDirectory, childStores],
),
React.useCallback(
() => bootstrapDirectory ? childStores.getBootstrapState(bootstrapDirectory) : undefined,
[bootstrapDirectory, childStores],
),
React.useCallback(() => undefined, []),
);
const maxVisible = hideDirectoryControls ? 10 : 5;
const nonArchivedVisibleCount = Math.max(maxVisible, visibleSessionCount ?? maxVisible);
const groupMatchesSearch = hasSessionSearchQuery ? searchData?.groupMatches === true : false;
@@ -409,10 +394,7 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
}, [sourceGroupNodes]);
const allFoldersForGroupBase = React.useMemo(() => scopeFolders.map((folder) => {
const nodes = folder.sessionIds
.map((sid) => nodeBySessionId.get(sid))
.filter((n): n is SessionNode => Boolean(n))
.sort(compareSessionNodes);
const nodes = selectFolderRootNodes(folder.sessionIds, nodeBySessionId).sort(compareSessionNodes);
return { folder, nodes };
}), [scopeFolders, nodeBySessionId, compareSessionNodes]);
@@ -472,21 +454,12 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
const ungroupedSessions = React.useMemo(() => sourceGroupNodes.filter((node) => !sessionIdsInFolders.has(node.session.id)), [sourceGroupNodes, sessionIdsInFolders]);
const rootFolders = React.useMemo(() => allFoldersForGroup.filter(({ folder }) => !folder.parentId), [allFoldersForGroup]);
// Precompute per-row "subtree contains active session" and "subtree contains
// editing session" lookups once per render. The previous design walked the
// Precompute the per-row "subtree contains editing session" lookup once per
// render. The previous design walked the
// node tree inside SessionNodeItem.areEqual for every row, which is O(M^2)
// across the whole sidebar. These sets let areEqual answer with a single
// Set.has lookup, so the cost is O(M) once per SessionGroupSection render.
const renderContextForGroup = 'project' as const;
const subtreeContainsActive = React.useMemo(() => {
const set = new Set<string>();
collectSubtreeContainingId(sourceGroupNodes, currentSessionId, set);
allFoldersForGroup.forEach(({ nodes }) => {
collectSubtreeContainingId(nodes, currentSessionId, set);
});
return set;
}, [sourceGroupNodes, allFoldersForGroup, currentSessionId]);
const subtreeContainsEditing = React.useMemo(() => {
const set = new Set<string>();
collectSubtreeContainingId(sourceGroupNodes, editingId, set);
@@ -539,11 +512,10 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
}, [nodeStructureKeyBySourceNode, nodeStructureKeyByFolderNode]);
const childRenderExtrasFor = React.useCallback((child: SessionNode) => ({
subtreeContainsActive,
subtreeContainsEditing,
menuOpenSessionId,
nodeStructureKey: resolveNodeStructureKey(child),
}), [subtreeContainsActive, subtreeContainsEditing, menuOpenSessionId, resolveNodeStructureKey]);
}), [subtreeContainsEditing, menuOpenSessionId, resolveNodeStructureKey]);
const totalSessions = ungroupedSessions.length;
const visibleSessions = group.isArchivedBucket
@@ -554,21 +526,14 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
const remainingCount = totalSessions - visibleSessions.length;
const canShowLess = !group.isArchivedBucket && !hasSessionSearchQuery && totalSessions > maxVisible && remainingCount === 0;
// Virtualize large groups. Archived buckets grow into the hundreds or
// thousands of rows; active/worktree groups can also hit 80+ sessions
// when a single worktree accumulates over time. Both paths share the
// same virtua Virtualizer; the threshold just controls when we mount
// it. The visible behavior is identical because virtua uses overscan
// (8) for the buffer zone. All hooks below MUST stay above the
// search-empty early-return so they fire in the same order every
// render — rules-of-hooks.
const shouldVirtualizeArchived = group.isArchivedBucket === true
// Virtualize archived buckets, which can grow into the thousands. Active
// groups retain normal flow because their incremental Show more control and
// the shared ancestor scroller cannot expose an unmounted virtual tail.
// Hooks below MUST stay above the search-empty early-return so they fire in
// the same order every render — rules-of-hooks.
const shouldVirtualize = group.isArchivedBucket === true
&& !hasSessionSearchQuery
&& visibleSessions.length >= ARCHIVED_VIRTUALIZE_THRESHOLD;
const shouldVirtualizeActive = group.isArchivedBucket !== true
&& !hasSessionSearchQuery
&& visibleSessions.length >= ACTIVE_VIRTUALIZE_THRESHOLD;
const shouldVirtualize = shouldVirtualizeArchived || shouldVirtualizeActive;
// Check if any parent node is expanded - expanded parents render their
// children inline, making them much taller than the fixed estimate.
@@ -864,7 +829,6 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
renderSessionNode={renderSessionNode}
getRenderExtras={resolveNodeStructureKey
? (node) => ({
subtreeContainsActive,
subtreeContainsEditing,
menuOpenSessionId,
nodeStructureKey: resolveNodeStructureKey(node),
@@ -941,7 +905,6 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
// meanwhile keeps the container's height real so the scroller
// never collapses/clamps during the flip.
visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true, undefined, 'project', {
subtreeContainsActive,
subtreeContainsEditing,
menuOpenSessionId,
nodeStructureKey: resolveNodeStructureKey(node),
@@ -980,7 +943,6 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
}}
>
{renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true, undefined, 'project', {
subtreeContainsActive,
subtreeContainsEditing,
menuOpenSessionId,
nodeStructureKey: resolveNodeStructureKey(node),
@@ -994,7 +956,6 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
</div>
) : (
visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true, undefined, 'project', {
subtreeContainsActive,
subtreeContainsEditing,
menuOpenSessionId,
nodeStructureKey: resolveNodeStructureKey(node),
@@ -1005,6 +966,31 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
<div className="py-1 text-left typography-micro text-muted-foreground">
{group.isArchivedBucket
? t('sessions.sidebar.group.empty.noArchivedSessions')
: bootstrapState === 'queued' || bootstrapState === 'running'
? (
<span className="inline-flex items-center gap-1.5">
<Icon name="loader-4" className="size-3 animate-spin" />
{t('sessions.sidebar.group.empty.loadingSessions')}
</span>
)
: bootstrapState === 'failed' && bootstrapDirectory
? (
<span className="inline-flex items-center gap-1.5">
{t('sessions.sidebar.group.empty.loadFailed')}
<button
type="button"
className="text-foreground hover:underline"
onClick={() => childStores.requestBootstrap({
directory: bootstrapDirectory,
priority: isCollapsed ? 'visible' : 'expanded',
reason: group.isMain ? 'project-expanded' : 'worktree-expanded',
force: true,
})}
>
{t('sessions.sidebar.group.empty.retry')}
</button>
</span>
)
: t('sessions.sidebar.group.empty.noSessionsInWorkspace')}
</div>
) : null}
@@ -18,6 +18,7 @@ import { canUseElectronDesktopIPC, invokeDesktop, isVSCodeRuntime } from '@/lib/
import { toast } from '@/components/ui';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { isSessionPinned, type SessionPinnedTarget } from '@/stores/useSessionPinnedStore';
import { Icon } from "@/components/icon/Icon";
import { buildExportFilename, downloadAsMarkdown, formatSessionAsMarkdown, getExportRevealLabelKey, revealExportedMarkdown, saveAsMarkdownDesktop } from '@/lib/exportSession';
import type { ChildSessionExport } from '@/lib/exportSession';
@@ -25,7 +26,7 @@ import { buildSessionMessageRecordsSnapshot, useDirectoryStore, useGlobalSession
import { useSync } from '@/sync/use-sync';
import { useViewportStore, viewportSessionKey } from '@/sync/viewport-store';
import { DraggableSessionRow } from './sessionFolderDnd';
import { nodeContainsSessionId } from './sessionNodeItemUtils';
import { nodeContainsSessionId, nodeHasPinnedMembershipChange } from './sessionNodeItemUtils';
import type { SessionNodeChildRenderExtras, SessionNodeRenderExtras } from './sessionNodeItemUtils';
import type { SessionNode } from './types';
import { formatSessionCompactDateLabel, formatSessionDateLabel, normalizePath, renderHighlightedText } from './utils';
@@ -43,6 +44,8 @@ import { MultiRunFusionDialog } from '@/components/multirun/MultiRunFusionDialog
import { FusionIcon } from '@/components/icons/FusionIcon';
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
import { startSessionTreeWorktreeMove, useIsSessionWorktreeMovePending } from '@/lib/worktrees/sessionWorktreeMove';
import { streamPerfCount } from '@/stores/utils/streamDebug';
import { useSessionUIStore } from '@/sync/session-ui-store';
type Folder = { id: string; name: string; sessionIds: string[] };
@@ -57,7 +60,6 @@ type Props = {
groupDirectory?: string | null;
projectId?: string | null;
archivedBucket?: boolean;
currentSessionId: string | null;
pinnedSessionIds: Set<string>;
expandedParents: Set<string>;
hasSessionSearchQuery: boolean;
@@ -70,9 +72,9 @@ type Props = {
handleSaveEdit: (titleOverride?: string) => void;
handleCancelEdit: () => void;
toggleParent: (expansionKey: string) => void;
handleSessionSelect: (sessionId: string, sessionDirectory: string | null, projectId?: string | null) => void;
handleSessionSelect: (sessionId: string, sessionDirectory: string | null) => void;
handleSessionDoubleClick: (sessionId: string, sessionTitle: string) => void;
togglePinnedSession: (sessionId: string) => void;
togglePinnedSession: (target: SessionPinnedTarget) => void;
handleShareSession: (session: Session) => void;
copiedSessionId: string | null;
handleCopyShareUrl: (url: string, sessionId: string) => void;
@@ -101,16 +103,9 @@ type Props = {
) => React.ReactNode;
secondaryMeta?: SecondaryMeta | null;
renderContext?: 'project' | 'recent';
/**
* Precomputed set of session IDs whose subtree contains the current
* active session. Computed once per SessionGroupSection render (when
* currentSessionId changes) instead of being recomputed in every row's
* React.memo comparator.
*/
subtreeContainsActive: Set<string>;
/**
* Precomputed set of session IDs whose subtree contains the session
* currently being edited. Same rationale as subtreeContainsActive.
* currently being edited. Precomputed once per group render.
*/
subtreeContainsEditing: Set<string>;
/**
@@ -132,15 +127,52 @@ type Props = {
* to fetch the right key for each child it produces.
*/
childRenderExtrasFor?: (child: SessionNode) => SessionNodeChildRenderExtras;
/**
* Batched index of live session objects keyed by id. The previous
* implementation called `useSession(session.id)` per row, which used
* `findLiveSession` to iterate every child-store on every SSE event.
* With M visible rows that's M×child-stores per event; the batched
* map turns it into a single Map.get per row. The parent falls back
* to `useSession` only when this map returns undefined.
*/
liveSessionById: Map<string, Session>;
};
const cancelScrollAnchorByContainer = new WeakMap<HTMLElement, () => void>();
const holdSessionRowPosition = (target: HTMLElement): void => {
if (typeof window === 'undefined') return;
const row = target.closest<HTMLElement>('[data-session-row]');
const container = row?.closest<HTMLElement>('.overlay-scrollbar-container');
if (!row || !container) return;
cancelScrollAnchorByContainer.get(container)?.();
const initialTop = row.getBoundingClientRect().top;
let remainingFrames = 3;
let cancelled = false;
let frameId: number | null = null;
const cancel = () => {
cancelled = true;
if (frameId !== null) window.cancelAnimationFrame(frameId);
frameId = null;
cancelScrollAnchorByContainer.delete(container);
container.removeEventListener('wheel', cancel);
container.removeEventListener('touchstart', cancel);
};
const restore = () => {
if (cancelled || !row.isConnected || !container.isConnected) {
cancel();
return;
}
const delta = row.getBoundingClientRect().top - initialTop;
if (Math.abs(delta) > 0.5) {
container.scrollTop += delta;
streamPerfCount('ui.sidebar.selection_scroll_anchor_adjustment');
}
remainingFrames -= 1;
if (remainingFrames <= 0) {
cancel();
return;
}
frameId = window.requestAnimationFrame(restore);
};
container.addEventListener('wheel', cancel, { passive: true });
container.addEventListener('touchstart', cancel, { passive: true });
cancelScrollAnchorByContainer.set(container, cancel);
frameId = window.requestAnimationFrame(restore);
};
type QuickSessionActionProps = {
@@ -206,6 +238,7 @@ const QuickSessionAction = React.memo(function QuickSessionAction({
});
function SessionNodeItemComponent(props: Props): React.ReactNode {
streamPerfCount('ui.sidebar_session_node.render');
const { t } = useI18n();
const {
node,
@@ -213,7 +246,6 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
groupDirectory,
projectId,
archivedBucket = false,
currentSessionId,
pinnedSessionIds,
expandedParents,
hasSessionSearchQuery,
@@ -248,11 +280,9 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
renderSessionNode,
secondaryMeta,
renderContext = 'project',
subtreeContainsActive,
subtreeContainsEditing,
menuOpenSessionId,
childRenderExtrasFor,
liveSessionById,
} = props;
const hasSecondaryProjectLabel = Boolean(secondaryMeta?.projectLabel);
const hasSecondaryBranchLabel = Boolean(secondaryMeta?.branchLabel);
@@ -307,25 +337,15 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
const formRef = React.useRef<HTMLFormElement>(null);
const session = node.session;
// Batched live-session lookup. `liveSessionById` is built once per
// Sidebar render from the same `useAllLiveSessions` selector that
// `useSession` would have iterated per child-store, so a Map.get
// here is equivalent in observed state but O(1) per row instead of
// O(child-stores). Falls back to the row session when the live map
// hasn't seen this id yet (sub-render latency between when a session
// is created and when the SSE-driven aggregate picks it up).
const resolvedSession = liveSessionById.get(session.id) ?? session;
const resolvedSession = session;
const isActive = useSessionUIStore((state) => state.currentSessionId === session.id);
const sessionDirectory =
normalizePath((session as Session & { directory?: string | null }).directory ?? null)
?? normalizePath(groupDirectory ?? null);
// Archived rows are historical and never need live state, yet they point at
// dozens of (often deleted) worktrees — bootstrapping each from the sidebar
// triggers a pointless session-list fetch + 6×2s empty-retry storm on startup.
// Skip bootstrap for archived rows; the store ref is only read on-demand via
// getState() in the export handlers (never subscribed). Active rows keep
// bootstrapping so live cross-directory session/status still aggregates.
const directoryStore = useDirectoryStore(sessionDirectory ?? undefined, { bootstrap: !archivedBucket });
// Directory bootstrap is scheduled once at sidebar level. A row only needs
// the lightweight store reference for scoped state and export actions.
const directoryStore = useDirectoryStore(sessionDirectory ?? undefined, { bootstrap: false });
const sync = useSync();
const selectionModeEnabled = useSessionMultiSelectStore((state) => state.enabled);
@@ -368,7 +388,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
);
const sessionStatus = useGlobalSessionStatus(session.id);
const isMovingToWorktree = useIsSessionWorktreeMovePending(session.id);
const sessionPermissions = useSessionPermissions(session.id, sessionDirectory ?? undefined);
const sessionPermissions = useSessionPermissions(session.id, sessionDirectory ?? undefined, { bootstrap: false });
const sessionGoal = getSessionGoal(resolvedSession);
const sessionGoalGlyph = sessionGoal ? (
<span
@@ -379,10 +399,9 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
<Icon name="target" className="h-3 w-3" style={{ color: sessionGoalStatusColor[sessionGoal.status] }} />
</span>
) : null;
const isActive = currentSessionId === session.id;
const sessionTitle = resolvedSession.title || t('sessions.sidebar.session.untitled');
const hasChildren = node.children.length > 0;
const isPinnedSession = pinnedSessionIds.has(session.id);
const isPinnedSession = isSessionPinned(pinnedSessionIds, sessionDirectory, session.id);
// Per-render-context expansion key: the same session can appear in both
// the project's root and the "Recent" list, and expanding one should not
// expand the other. Matches the format of menuInstanceKey.
@@ -409,7 +428,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
let skipped = 0;
for (const child of children) {
try {
await sync.ensureSessionRenderable(child.session.id);
await sync.ensureSessionRenderable(child.session.id, false, sessionDirectory ?? undefined);
const childRecords = buildSessionMessageRecordsSnapshot(directoryStore.getState(), child.session.id).list;
const childTitle = child.session.title || t('sessions.sidebar.session.export.untitledSubagent');
const childAgent = (child.session as Session & { agent?: string }).agent;
@@ -426,7 +445,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
}
}
return { children: results, skipped };
}, [collectNodeDescendantIds, directoryStore, sync, t]);
}, [collectNodeDescendantIds, directoryStore, sessionDirectory, sync, t]);
const showSkippedSubtasksWarning = React.useCallback((count: number) => {
if (count <= 0) return;
@@ -441,7 +460,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
return;
}
await sync.ensureSessionRenderable(session.id);
await sync.ensureSessionRenderable(session.id, false, sessionDirectory);
const records = buildSessionMessageRecordsSnapshot(directoryStore.getState(), session.id).list;
if (records.length === 0) {
@@ -775,7 +794,8 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
toggleRowSelected(session.id, sessionDirectory ?? null, collectNodeDescendantIds(node));
return;
}
handleSessionSelect(session.id, sessionDirectory, projectId);
if (event?.currentTarget) holdSessionRowPosition(event.currentTarget);
handleSessionSelect(session.id, sessionDirectory);
};
// The selection/active highlight covers the WHOLE row box (gutter, edge
@@ -832,7 +852,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
<Icon name="pencil-ai" className="mr-1 h-4 w-4" />
{t('sessions.sidebar.session.menu.rename')}
</Item>
<Item onClick={() => togglePinnedSession(session.id)} className="[&>svg]:mr-1">
<Item onClick={() => sessionDirectory && togglePinnedSession({ directory: sessionDirectory, sessionId: session.id })} className="[&>svg]:mr-1">
{isPinnedSession ? <Icon name="unpin" className="mr-1 h-4 w-4" /> : <Icon name="pushpin" className="mr-1 h-4 w-4" />}
{isPinnedSession ? t('sessions.sidebar.session.menu.unpin') : t('sessions.sidebar.session.menu.pin')}
</Item>
@@ -1267,7 +1287,6 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
const childRenderExtras: SessionNodeChildRenderExtras = childRenderExtrasFor
? childRenderExtrasFor(child)
: {
subtreeContainsActive,
subtreeContainsEditing,
menuOpenSessionId,
nodeStructureKey: '',
@@ -1387,26 +1406,6 @@ const hasSetMembershipChangeInNode = (
return false;
};
const hasResolvedSessionChangeInNode = (
prevNode: SessionNode,
nextNode: SessionNode,
prevLiveSessionById: Map<string, Session>,
nextLiveSessionById: Map<string, Session>,
): boolean => {
if (prevNode.session.id !== nextNode.session.id) return true;
const sessionId = prevNode.session.id;
if ((prevLiveSessionById.get(sessionId) ?? prevNode.session) !== (nextLiveSessionById.get(sessionId) ?? nextNode.session)) {
return true;
}
if (prevNode.children.length !== nextNode.children.length) return true;
for (let i = 0; i < prevNode.children.length; i += 1) {
if (hasResolvedSessionChangeInNode(prevNode.children[i], nextNode.children[i], prevLiveSessionById, nextLiveSessionById)) {
return true;
}
}
return false;
};
const hasExpansionMembershipChange = (prev: Props, next: Props): boolean => {
if (prev.hasSessionSearchQuery || next.hasSessionSearchQuery) return false;
const prevBucketTag = prev.archivedBucket ? 'archived' : 'active';
@@ -1428,6 +1427,7 @@ const hasExpansionMembershipChange = (prev: Props, next: Props): boolean => {
const areSessionNodeItemPropsEqual = (prev: Props, next: Props): boolean => {
if (prev.node.session.id !== next.node.session.id) return false;
if (prev.node.session !== next.node.session) return false;
if (prev.depth !== next.depth) return false;
if (prev.groupDirectory !== next.groupDirectory) return false;
if (prev.projectId !== next.projectId) return false;
@@ -1442,13 +1442,15 @@ const areSessionNodeItemPropsEqual = (prev: Props, next: Props): boolean => {
if (getNodeSessionDirectory(prev.node) !== getNodeSessionDirectory(next.node)) return false;
if (!isSecondaryMetaEqual(prev.secondaryMeta, next.secondaryMeta)) return false;
if (prev.liveSessionById !== next.liveSessionById
&& hasResolvedSessionChangeInNode(prev.node, next.node, prev.liveSessionById, next.liveSessionById)) {
return false;
}
if (prev.pinnedSessionIds !== next.pinnedSessionIds
&& hasSetMembershipChangeInNode(prev.node, next.node, prev.pinnedSessionIds, next.pinnedSessionIds, (node) => node.session.id)) {
&& nodeHasPinnedMembershipChange(
prev.node,
next.node,
prev.pinnedSessionIds,
next.pinnedSessionIds,
prev.groupDirectory,
next.groupDirectory,
)) {
return false;
}
@@ -1456,14 +1458,6 @@ const areSessionNodeItemPropsEqual = (prev: Props, next: Props): boolean => {
return false;
}
if (prev.currentSessionId !== next.currentSessionId
&& (
subtreeContainsSession(prev, prev.currentSessionId, prev.subtreeContainsActive)
|| subtreeContainsSession(next, next.currentSessionId, next.subtreeContainsActive)
)) {
return false;
}
if (prev.editingId !== next.editingId
&& (
subtreeContainsSession(prev, prev.editingId, prev.subtreeContainsEditing)
@@ -38,9 +38,9 @@ type Props = {
renderContext?: 'project' | 'recent',
renderExtras?: SessionNodeRenderExtras,
) => React.ReactNode;
currentSessionId: string | null;
editingId: string | null;
openSidebarMenuKey: string | null;
expansionState?: ReadonlySet<string>;
variant?: 'section' | 'flat';
initialVisibleCount?: number;
batchSize?: number;
@@ -50,16 +50,16 @@ type RenderExtras = SessionNodeRenderExtras;
const MAX_VISIBLE_RECENT_SESSIONS = 7;
export function SidebarActivitySections({
sections,
renderSessionNode,
currentSessionId,
editingId,
openSidebarMenuKey,
variant = 'section',
initialVisibleCount = MAX_VISIBLE_RECENT_SESSIONS,
batchSize = MAX_VISIBLE_RECENT_SESSIONS,
}: Props): React.ReactNode {
export function SidebarActivitySections(props: Props): React.ReactNode {
const {
sections,
renderSessionNode,
editingId,
openSidebarMenuKey,
variant = 'section',
initialVisibleCount = MAX_VISIBLE_RECENT_SESSIONS,
batchSize = MAX_VISIBLE_RECENT_SESSIONS,
} = props;
const { t } = useI18n();
const [collapsed, setCollapsed] = React.useState<Set<string>>(new Set());
const [visibleCountBySection, setVisibleCountBySection] = React.useState<Map<string, number>>(new Map());
@@ -101,8 +101,6 @@ export function SidebarActivitySections({
}, [batchSize]);
const buildRenderExtras = React.useCallback((nodes: SessionNode[]) => {
const subtreeContainsActive = new Set<string>();
collectSubtreeContainingId(nodes, currentSessionId, subtreeContainsActive);
const subtreeContainsEditing = new Set<string>();
collectSubtreeContainingId(nodes, editingId, subtreeContainsEditing);
const menuOpenSessionId = resolveMenuOpenSessionId(nodes, openSidebarMenuKey, 'recent', false);
@@ -114,7 +112,6 @@ export function SidebarActivitySections({
nodes.forEach(visit);
const childRenderExtrasFor = (child: SessionNode): RenderExtras => ({
subtreeContainsActive,
subtreeContainsEditing,
menuOpenSessionId,
nodeStructureKey: nodeStructureKeyByNode.get(child) ?? '',
@@ -122,13 +119,12 @@ export function SidebarActivitySections({
});
return (node: SessionNode): RenderExtras => ({
subtreeContainsActive,
subtreeContainsEditing,
menuOpenSessionId,
nodeStructureKey: nodeStructureKeyByNode.get(node) ?? '',
childRenderExtrasFor,
});
}, [currentSessionId, editingId, openSidebarMenuKey]);
}, [editingId, openSidebarMenuKey]);
const visibleSections = sections.filter((section) => section.items.length > 0);
if (visibleSections.length === 0) {
@@ -18,6 +18,7 @@ import { formatProjectLabel } from './utils';
import { useI18n } from '@/lib/i18n';
import type { MainTab } from '@/stores/useUIStore';
import type { ProjectSortOrder } from '@/stores/useSessionDisplayStore';
import { streamPerfCount } from '@/stores/utils/streamDebug';
type ProjectSection = {
project: {
@@ -78,7 +79,8 @@ type Props = {
isInlineEditing: boolean;
};
export function SidebarProjectsList(props: Props): React.ReactNode {
function SidebarProjectsListComponent(props: Props): React.ReactNode {
streamPerfCount('ui.sidebar_projects_list.render');
const { t } = useI18n();
const projectSensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
@@ -311,3 +313,5 @@ export function SidebarProjectsList(props: Props): React.ReactNode {
</ScrollableOverlay>
);
}
export const SidebarProjectsList = React.memo(SidebarProjectsListComponent);
@@ -0,0 +1,31 @@
import type { Session } from '@opencode-ai/sdk/v2';
import { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
type AuthoritativeSessionIdentity = {
directory: string;
sessionId: string;
};
export const buildAuthoritativeSessionIdentityMap = (
sessions: Session[],
): Map<string, AuthoritativeSessionIdentity> => {
const identities = new Map<string, AuthoritativeSessionIdentity>();
for (const session of sessions) {
const directory = resolveGlobalSessionDirectory(session);
if (!directory) continue;
identities.set(session.id, { directory, sessionId: session.id });
}
return identities;
};
export const findRemovedAuthoritativeSessions = (
previous: ReadonlyMap<string, AuthoritativeSessionIdentity> | null,
current: ReadonlyMap<string, AuthoritativeSessionIdentity>,
): AuthoritativeSessionIdentity[] => {
if (!previous) return [];
const removed: AuthoritativeSessionIdentity[] = [];
previous.forEach((identity, key) => {
if (!current.has(key)) removed.push(identity);
});
return removed;
};
@@ -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;
};
@@ -0,0 +1,47 @@
import { describe, expect, test } from "bun:test"
import { buildSessionBootstrapDemands } from "./sessionBootstrapDemands"
const sections = [{
project: { id: "project-a", normalizedPath: "/repo" },
groups: [
{ id: "root", directory: "/repo", isMain: true },
{ id: "worktree:/repo/wt-a", directory: "/repo/wt-a", isMain: false },
{ id: "worktree:/repo/wt-b", directory: "/repo/wt-b", isMain: false },
],
}]
describe("buildSessionBootstrapDemands", () => {
test("keeps collapsed worktrees eligible at background priority", () => {
const demands = buildSessionBootstrapDemands({
projectSections: sections,
activeProjectId: null,
collapsedProjects: new Set(["project-a"]),
collapsedGroups: new Set(),
currentDirectory: null,
currentSessionDirectory: null,
})
expect(demands.map(({ directory, priority }) => [directory, priority])).toEqual([
["/repo", "background"],
["/repo/wt-a", "background"],
["/repo/wt-b", "background"],
])
})
test("promotes expansion and selected session without duplicate directories", () => {
const demands = buildSessionBootstrapDemands({
projectSections: sections,
activeProjectId: "project-a",
collapsedProjects: new Set(),
collapsedGroups: new Set(["project-a:worktree:/repo/wt-b"]),
currentDirectory: "/repo",
currentSessionDirectory: "/repo/wt-b",
})
const byDirectory = new Map(demands.map((demand) => [demand.directory, demand]))
expect(demands.length).toBe(3)
expect(byDirectory.get("/repo")?.priority).toBe("selected")
expect(byDirectory.get("/repo/wt-a")?.priority).toBe("expanded")
expect(byDirectory.get("/repo/wt-b")?.priority).toBe("selected")
})
})
@@ -0,0 +1,77 @@
import type { DirectoryBootstrapDemand, DirectoryBootstrapPriority } from "@/sync/child-store"
import { normalizePath } from "./utils"
type BootstrapProjectSection = {
project: { id: string; normalizedPath: string }
groups: Array<{
id: string
directory: string | null
isArchivedBucket?: boolean
isMain: boolean
}>
}
const PRIORITY_RANK: Record<DirectoryBootstrapPriority, number> = {
selected: 0,
"active-project": 1,
expanded: 2,
visible: 3,
background: 4,
}
export function buildSessionBootstrapDemands(input: {
projectSections: BootstrapProjectSection[]
activeProjectId: string | null
collapsedProjects: ReadonlySet<string>
collapsedGroups: ReadonlySet<string>
currentDirectory: string | null
currentSessionDirectory: string | null
}): DirectoryBootstrapDemand[] {
const byDirectory = new Map<string, DirectoryBootstrapDemand>()
const add = (
directory: string | null | undefined,
priority: DirectoryBootstrapPriority,
reason: DirectoryBootstrapDemand["reason"],
) => {
const normalizedDirectory = normalizePath(directory ?? null)
if (!normalizedDirectory) return
const existing = byDirectory.get(normalizedDirectory)
if (existing && PRIORITY_RANK[existing.priority] <= PRIORITY_RANK[priority]) return
byDirectory.set(normalizedDirectory, { directory: normalizedDirectory, priority, reason })
}
for (const section of input.projectSections) {
const projectExpanded = !input.collapsedProjects.has(section.project.id)
let projectPriority: DirectoryBootstrapPriority = "background"
if (section.project.id === input.activeProjectId) {
projectPriority = "active-project"
} else if (projectExpanded) {
projectPriority = "expanded"
}
add(
section.project.normalizedPath,
projectPriority,
projectExpanded ? "project-expanded" : "known-project",
)
for (const group of section.groups) {
if (!group.directory || group.isArchivedBucket || group.isMain) continue
const groupExpanded = projectExpanded && !input.collapsedGroups.has(`${section.project.id}:${group.id}`)
let groupPriority: DirectoryBootstrapPriority = "background"
if (groupExpanded) {
groupPriority = "expanded"
} else if (projectExpanded) {
groupPriority = "visible"
}
add(
group.directory,
groupPriority,
groupExpanded ? "worktree-expanded" : "known-worktree",
)
}
}
add(input.currentDirectory, "selected", "current-directory")
add(input.currentSessionDirectory, "selected", "selected-session")
return [...byDirectory.values()]
}
@@ -0,0 +1,125 @@
import { describe, expect, test } from 'bun:test';
import type { Session } from '@opencode-ai/sdk/v2';
import { getRuntimeKey } from '@/lib/runtime-switch';
import { getPinnedSessionKey } from '@/stores/useSessionPinnedStore';
import { computeNodeStructureKey, nodeHasPinnedMembershipChange, selectFolderRootNodes } from './sessionNodeItemUtils';
import type { SessionNode } from './types';
const session = (id: string, title: string): Session => ({
id,
title,
time: { created: 1, updated: 1 },
} as Session);
const rootWithChild = (childSession: Session): SessionNode => ({
session: session('root', 'Root'),
children: [{ session: childSession, children: [], worktree: null }],
worktree: null,
});
describe('computeNodeStructureKey', () => {
test('stays stable across grouping rebuilds that reuse session objects', () => {
const child = session('child', 'Child');
expect(computeNodeStructureKey(rootWithChild(child))).toBe(computeNodeStructureKey(rootWithChild(child)));
});
test('changes when a descendant session object changes', () => {
const previous = session('child', 'Before');
const next = { ...previous, title: 'After' };
expect(computeNodeStructureKey(rootWithChild(previous))).not.toBe(computeNodeStructureKey(rootWithChild(next)));
});
});
describe('nodeHasPinnedMembershipChange', () => {
test('detects composite pin changes using the group directory fallback', () => {
const node: SessionNode = {
session: session('root', 'Root'),
children: [],
worktree: null,
};
const pinnedKey = getPinnedSessionKey(getRuntimeKey(), '/repo', 'root');
expect(pinnedKey).not.toBeNull();
expect(nodeHasPinnedMembershipChange(
node,
node,
new Set(),
new Set([pinnedKey!]),
'/repo',
'/repo',
)).toBe(true);
});
test('ignores pin changes for the same session id in another directory', () => {
const node: SessionNode = {
session: session('root', 'Root'),
children: [],
worktree: null,
};
const pinnedKey = getPinnedSessionKey(getRuntimeKey(), '/other-repo', 'root');
expect(pinnedKey).not.toBeNull();
expect(nodeHasPinnedMembershipChange(
node,
node,
new Set(),
new Set([pinnedKey!]),
'/repo',
'/repo',
)).toBe(false);
});
});
describe('selectFolderRootNodes', () => {
test('does not render assigned descendants again beside their assigned parent tree', () => {
const grandchild: SessionNode = {
session: { ...session('grandchild', 'Grandchild'), parentID: 'child' } as Session,
children: [],
worktree: null,
};
const child: SessionNode = {
session: { ...session('child', 'Child'), parentID: 'root' } as Session,
children: [grandchild],
worktree: null,
};
const root: SessionNode = {
session: session('root', 'Root'),
children: [child],
worktree: null,
};
const nodes = new Map([
['root', root],
['child', child],
['grandchild', grandchild],
]);
expect(selectFolderRootNodes(['root', 'child', 'grandchild'], nodes)).toEqual([root]);
});
test('keeps a child as a folder root when none of its ancestors are assigned', () => {
const child: SessionNode = {
session: { ...session('child', 'Child'), parentID: 'root' } as Session,
children: [],
worktree: null,
};
const root: SessionNode = {
session: session('root', 'Root'),
children: [child],
worktree: null,
};
expect(selectFolderRootNodes(['child'], new Map([['root', root], ['child', child]]))).toEqual([child]);
});
test('keeps a child when an assigned ancestor is not available in the group', () => {
const child: SessionNode = {
session: { ...session('child', 'Child'), parentID: 'missing-root' } as Session,
children: [],
worktree: null,
};
expect(selectFolderRootNodes(['missing-root', 'child'], new Map([['child', child]]))).toEqual([child]);
});
});
@@ -1,3 +1,5 @@
import { getRuntimeKey } from '@/lib/runtime-switch';
import { getPinnedSessionKey } from '@/stores/useSessionPinnedStore';
import type { SessionNode } from './types';
/**
@@ -10,7 +12,6 @@ import type { SessionNode } from './types';
* each child's extras object.
*/
export type SessionNodeChildRenderExtras = {
subtreeContainsActive: Set<string>;
subtreeContainsEditing: Set<string>;
menuOpenSessionId: string | null;
nodeStructureKey: string;
@@ -24,7 +25,7 @@ export type SessionNodeRenderExtras<TNode = SessionNode> = SessionNodeChildRende
* Walk `nodes` and add `node.session.id` to `result` for every node
* whose subtree contains `targetId`. This is used to precompute, once
* per SessionGroupSection render, which rows need to update when
* `currentSessionId` or `editingId` changes. With M visible rows, this
* `editingId` changes. With M visible rows, this
* turns an O(M × subtree-depth) walk inside `SessionNodeItem.areEqual`
* into a single O(M) `Set.has` per row.
*/
@@ -69,12 +70,45 @@ export const nodeContainsSessionId = (node: SessionNode, sessionId: string | nul
return false;
};
export const selectFolderRootNodes = (
sessionIds: string[],
nodeBySessionId: ReadonlyMap<string, SessionNode>,
): SessionNode[] => {
const assignedSessionIds = new Set(sessionIds);
return sessionIds
.map((sessionId) => nodeBySessionId.get(sessionId))
.filter((node): node is SessionNode => {
if (!node) return false;
const visited = new Set<string>();
let parentID = (node.session as SessionNode['session'] & { parentID?: string | null }).parentID ?? null;
while (parentID && !visited.has(parentID)) {
if (assignedSessionIds.has(parentID) && nodeBySessionId.has(parentID)) return false;
visited.add(parentID);
const parentNode = nodeBySessionId.get(parentID);
parentID = (parentNode?.session as (SessionNode['session'] & { parentID?: string | null }) | undefined)?.parentID ?? null;
}
return true;
});
};
const sessionObjectVersions = new WeakMap<object, number>();
let nextSessionObjectVersion = 1;
const getSessionObjectVersion = (session: object): number => {
const existing = sessionObjectVersions.get(session);
if (existing !== undefined) return existing;
const version = nextSessionObjectVersion;
nextSessionObjectVersion += 1;
sessionObjectVersions.set(session, version);
return version;
};
/**
* Build a structural key for `node` that encodes the IDs of all
* descendants. Used by `SessionNodeItem.areEqual` so a reference-only
* rebuild of the tree (which happens on every `buildGroupedSessions`
* pass) can be detected with a single string compare instead of a
* recursive walk per row.
* Build a key encoding descendant IDs and session object versions. This lets
* row memoization detect one changed descendant without recursively comparing
* every subtree after a reference-only grouping rebuild.
*/
export const computeNodeStructureKey = (node: SessionNode): string => {
if (node.children.length === 0) {
@@ -82,15 +116,49 @@ export const computeNodeStructureKey = (node: SessionNode): string => {
}
const childKeys = node.children.map((child) => {
const childVersion = getSessionObjectVersion(child.session);
if (child.children.length === 0) {
return child.session.id;
return `${child.session.id}@${childVersion}`;
}
return `${child.session.id}:${computeNodeStructureKey(child)}`;
return `${child.session.id}@${childVersion}:${computeNodeStructureKey(child)}`;
});
return childKeys.join('|');
};
export const nodeHasPinnedMembershipChange = (
prevNode: SessionNode,
nextNode: SessionNode,
prevPinnedSessionIds: Set<string>,
nextPinnedSessionIds: Set<string>,
prevGroupDirectory?: string | null,
nextGroupDirectory?: string | null,
): boolean => {
const runtimeKey = getRuntimeKey();
const visit = (previous: SessionNode, current: SessionNode): boolean => {
if (previous.session.id !== current.session.id || previous.children.length !== current.children.length) {
return true;
}
const prevDirectory = (previous.session as SessionNode['session'] & { directory?: string | null }).directory
?? prevGroupDirectory;
const nextDirectory = (current.session as SessionNode['session'] & { directory?: string | null }).directory
?? nextGroupDirectory;
const prevKey = getPinnedSessionKey(runtimeKey, prevDirectory ?? '', previous.session.id);
const nextKey = getPinnedSessionKey(runtimeKey, nextDirectory ?? '', current.session.id);
if (
(prevKey ? prevPinnedSessionIds.has(prevKey) : false)
!== (nextKey ? nextPinnedSessionIds.has(nextKey) : false)
) {
return true;
}
return previous.children.some((child, index) => visit(child, current.children[index]));
};
return visit(prevNode, nextNode);
};
/**
* Resolve the session id whose sidebar menu is open, or null if no
* menu is open. Only one row can have its menu open at a time.
@@ -1,5 +1,9 @@
import { describe, expect, test } from 'bun:test';
import { isPathWithinProject } from './utils';
import {
isPathWithinProject,
selectExpandedParentKeysForContext,
toggleExpandedParentKey,
} from './utils';
describe('isPathWithinProject', () => {
test('matches child directories for root projects', () => {
@@ -26,3 +30,48 @@ describe('isPathWithinProject', () => {
expect(isPathWithinProject('/workspace/app/sub/dir', '/workspace/app')).toBe(true);
});
});
describe('selectExpandedParentKeysForContext', () => {
test('keeps project and recent expansion state isolated', () => {
const expanded = new Set([
'project:active:parent-a',
'project:archived:parent-b',
'recent:active:parent-a',
]);
expect(selectExpandedParentKeysForContext(new Set(), expanded, 'project')).toEqual(new Set([
'project:active:parent-a',
'project:archived:parent-b',
]));
expect(selectExpandedParentKeysForContext(new Set(), expanded, 'recent')).toEqual(new Set([
'recent:active:parent-a',
]));
});
test('preserves a context projection when only another context changes', () => {
const recent = new Set(['recent:active:parent-a']);
const expanded = new Set(['recent:active:parent-a', 'project:active:parent-a']);
expect(selectExpandedParentKeysForContext(recent, expanded, 'recent')).toBe(recent);
});
});
describe('parent expansion state', () => {
const recentKey = 'recent:active:parent-a';
const projectKey = 'project:active:parent-a';
test('manually expands and collapses a parent', () => {
const expanded = toggleExpandedParentKey(new Set(), recentKey);
expect(expanded).toEqual(new Set([recentKey]));
expect(toggleExpandedParentKey(expanded, recentKey)).toEqual(new Set());
});
test('does not change the other render context', () => {
const recentExpanded = new Set([recentKey]);
const bothExpanded = toggleExpandedParentKey(recentExpanded, projectKey);
const projectCollapsed = toggleExpandedParentKey(bothExpanded, projectKey);
expect(selectExpandedParentKeysForContext(new Set(), bothExpanded, 'recent')).toEqual(new Set([recentKey]));
expect(selectExpandedParentKeysForContext(new Set(), projectCollapsed, 'recent')).toEqual(new Set([recentKey]));
});
});
@@ -1,11 +1,36 @@
import React from 'react';
import type { Session } from '@opencode-ai/sdk/v2';
import { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
import { isSessionPinned } from '@/stores/useSessionPinnedStore';
import { getCurrentIntlLocale } from '@/lib/i18n';
import { formatMessage, useI18nStore } from '@/lib/i18n/store';
import { normalizePath } from '@/lib/pathNormalization';
export { normalizePath };
export const selectExpandedParentKeysForContext = (
previous: Set<string>,
expanded: ReadonlySet<string>,
context: 'project' | 'recent',
): Set<string> => {
const prefix = `${context}:`;
const next = new Set([...expanded].filter((key) => key.startsWith(prefix)));
if (previous.size === next.size && [...next].every((key) => previous.has(key))) {
return previous;
}
return next;
};
export const toggleExpandedParentKey = (
expanded: Set<string>,
key: string,
): Set<string> => {
const next = new Set(expanded);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
};
const t = (key: Parameters<typeof formatMessage>[1], params?: Parameters<typeof formatMessage>[2]) =>
formatMessage(useI18nStore.getState().dictionary, key, params);
@@ -132,8 +157,8 @@ export const compareSessionsByPinnedAndTime = (
b: Session,
pinnedSessionIds: Set<string>,
): number => {
const aPinned = pinnedSessionIds.has(a.id);
const bPinned = pinnedSessionIds.has(b.id);
const aPinned = isSessionPinned(pinnedSessionIds, resolveGlobalSessionDirectory(a), a.id);
const bPinned = isSessionPinned(pinnedSessionIds, resolveGlobalSessionDirectory(b), b.id);
if (aPinned !== bPinned) {
return aPinned ? -1 : 1;
}