perf: overhaul session loading, caching, and runtime isolation (#2360)
Improve OpenChamber responsiveness under large session workloads while fixing cache, synchronization, and persistence correctness across runtimes, projects, directories, and worktrees. - prioritize selected and visible sessions during bootstrap and defer non-critical enrichment work - reduce redundant message loading, event processing, store publication, and hidden sidebar work - prevent stale session and message requests from overwriting newer authoritative state - preserve existing data when authoritative fetches fail instead of treating failures as successful empty responses - scope session materialization, messages, drafts, queues, todos, pins, permissions, folders, tabs, Git state, and pull request data by runtime and directory identity - harden runtime switching, reconnect, cleanup, mutation reconciliation, and persisted-state ordering - preserve live subagent Task linkage when metadata arrives after an older message request or while streaming parts are suspended - coalesce overlapping tail refreshes without losing newer refresh demand - improve cold-session loading by moving deferrable work out of the critical bootstrap path - isolate URL authentication, mobile credentials, native secrets, and other runtime-owned state across endpoint changes - bound long-lived caches and remove avoidable allocations from event and rendering hot paths - limit virtualization to archive collections where it improves rendering without disrupting active sidebar layout - stabilize session folders, pin ordering, expanded state, and persisted sidebar behavior - open skill files through the same secure editor and outside-workspace grant flow used by file navigation, including worktree sessions - expand regression coverage for stale completions, runtime collisions, reconnect behavior, persistence races, authoritative empty results, and subagent refresh ordering - document the updated synchronization, cache ownership, performance, and runtime-isolation invariants
This commit is contained in:
committed by
GitHub
parent
485efc7117
commit
85400459e9
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user