Files
openchamber/packages/ui/src/components/session/SessionSidebar.tsx
T
Bohdan Triapitsyn 63d37eecdf fix: map PR summaries to directory branch keys
Preserves a stable lookup key for PR status summaries
Displays PR indicators using directory and branch labels
Skips unmatched summary entries safely
2026-07-22 16:34:25 +03:00

1877 lines
70 KiB
TypeScript

import React from 'react';
import type { Session } from '@opencode-ai/sdk/v2';
import { toast } from '@/components/ui';
import { useI18n } from '@/lib/i18n';
import { useDeviceInfo } from '@/lib/device';
import { isDesktopShell } from '@/lib/desktop';
import { sessionEvents } from '@/lib/sessionEvents';
import { formatDirectoryName, cn } from '@/lib/utils';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useChildStoreManager } from '@/sync/sync-context';
import { getAllSyncSessionMap } from '@/sync/sync-refs';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useSync } from '@/sync/use-sync';
import { SessionPrefetchEffect } from './sidebar/hooks/useSessionPrefetch';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useUIStore } from '@/stores/useUIStore';
import { getDeferredSafeStorage } from '@/stores/utils/safeStorage';
import { useGitStore, useGitAllBranches, useGitRepoStatusMap } from '@/stores/useGitStore';
import { isVSCodeRuntime } from '@/lib/desktop';
import { NewWorktreeDialog } from './NewWorktreeDialog';
import { ScheduledTasksDialog } from './ScheduledTasksDialog';
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
import { useArchivedAutoFolders } from './sidebar/hooks/useArchivedAutoFolders';
import { useSessionSidebarSections } from './sidebar/hooks/useSessionSidebarSections';
import { ProjectSessionSelectionEffect } from './sidebar/hooks/useProjectSessionSelection';
import { useGroupOrdering } from './sidebar/hooks/useGroupOrdering';
import { useSessionGrouping } from './sidebar/hooks/useSessionGrouping';
import { useSessionSearchEffects } from './sidebar/hooks/useSessionSearchEffects';
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 { useAuthoritativeSessionCleanup } from './sidebar/hooks/useAuthoritativeSessionCleanup';
import { createSessionOwnershipIndex } from './sidebar/sessionOwnership';
import { useStickyProjectHeaders } from './sidebar/hooks/useStickyProjectHeaders';
import { getGitHubPrStatusKey, usePrVisualSummaryByKeys, useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore';
import { ProjectEditDialog } from '@/components/layout/ProjectEditDialog';
import { UpdateDialog } from '@/components/ui/UpdateDialog';
import { SessionGroupSection } from './sidebar/SessionGroupSection';
import { SidebarHeader } from './sidebar/SidebarHeader';
import { SidebarActivitySections } from './sidebar/SidebarActivitySections';
import { SidebarFooter } from './sidebar/SidebarFooter';
import { SidebarProjectsList } from './sidebar/SidebarProjectsList';
import { SessionNodeItem } from './sidebar/SessionNodeItem';
import type { SessionNodeRenderExtras } from './sidebar/sessionNodeItemUtils';
import { useUpdateStore } from '@/stores/useUpdateStore';
import { useShallow } from 'zustand/react/shallow';
import { listProjectWorktrees, worktreeMapsEqual } from '@/lib/worktrees/worktreeManager';
import { checkIsGitRepository } from '@/lib/gitApi';
import type { WorktreeMetadata } from '@/types/worktree';
import type { SortableDragHandleProps } from './sidebar/sortableItems';
import {
BulkSessionDeleteConfirmDialog,
FolderDeleteConfirmDialog,
SessionDeleteConfirmDialog,
type BulkDeleteSessionsConfirmState,
type DeleteFolderConfirmState,
type DeleteSessionConfirmState,
} from './sidebar/ConfirmDialogs';
import { BulkActionBar } from './sidebar/BulkActionBar';
import { useSidebarBulkActions } from './sidebar/hooks/useSidebarBulkActions';
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
import { type SessionGroup, type SessionNode } from './sidebar/types';
import {
deriveRecentSessions,
} from './sidebar/activitySections';
import { useSessionPinnedStore } from '@/stores/useSessionPinnedStore';
import {
compareSessionsByPinnedAndTime,
formatProjectLabel,
normalizePath,
selectExpandedParentKeysForContext,
toggleExpandedParentKey,
} from './sidebar/utils';
import {
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';
// 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. 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';
type PrIndicator = {
visualState: PrVisualState;
number: number;
url: string | null;
state: 'open' | 'closed' | 'merged';
draft: boolean;
title: string | null;
base: string | null;
head: string | null;
checks: {
state: 'success' | 'failure' | 'pending' | 'unknown';
total: number;
success: number;
failure: number;
pending: number;
} | null;
canMerge: boolean | null;
mergeableState: string | null;
repo: {
owner: string;
repo: string;
} | null;
};
const buildKnownSessionDirectories = (
projects: Array<{ path: string }>,
availableWorktreesByProject: Map<string, WorktreeMetadata[]>,
options?: { includeWorktrees?: boolean },
): Set<string> => {
const directories = new Set<string>();
for (const project of projects) {
const normalized = normalizePath(project.path)?.toLowerCase();
if (normalized) directories.add(normalized);
}
if (options?.includeWorktrees === false) {
return directories;
}
for (const worktrees of availableWorktreesByProject.values()) {
for (const worktree of worktrees) {
const normalized = normalizePath(worktree.path)?.toLowerCase();
if (normalized) directories.add(normalized);
}
}
return directories;
};
const isKnownActiveSessionDirectory = (
session: Session,
knownDirectories: Set<string>,
options?: { allowUnknownDirectory?: boolean; allowEmptyDirectorySet?: boolean },
): boolean => {
if (session.time?.archived) return true;
const directory = normalizePath(resolveGlobalSessionDirectory(session))?.toLowerCase();
if (!directory) return options?.allowUnknownDirectory ?? true;
if (knownDirectories.size === 0) return options?.allowEmptyDirectorySet ?? true;
return knownDirectories.has(directory);
};
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);
handlerRef.current = handler;
return React.useCallback((...args: Args) => handlerRef.current(...args), []);
};
interface SessionSidebarProps {
isVisible?: boolean;
mobileVariant?: boolean;
onSessionSelected?: (sessionId: string) => void;
allowReselect?: boolean;
hideDirectoryControls?: boolean;
showOnlyMainWorkspace?: boolean;
}
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('');
const sessionSearchContainerRef = React.useRef<HTMLDivElement | null>(null);
const sessionSearchInputRef = React.useRef<HTMLInputElement | null>(null);
const retriedNoPrStatusKeysRef = React.useRef<Set<string>>(new Set());
const [editingId, setEditingId] = React.useState<string | null>(null);
const [editTitle, setEditTitle] = React.useState('');
const [editingProjectDialogId, setEditingProjectDialogId] = React.useState<string | null>(null);
const [expandedParents, setExpandedParents] = React.useState<Set<string>>(new Set());
const safeStorage = React.useMemo(() => getDeferredSafeStorage(), []);
const [collapsedProjects, setCollapsedProjects] = React.useState<Set<string>>(new Set());
const [projectRepoStatus, setProjectRepoStatus] = React.useState<Map<string, boolean | null>>(new Map());
const [visibleSessionCountByGroup, setVisibleSessionCountByGroup] = React.useState<Map<string, number>>(new Map());
const newWorktreeDialogOpen = useUIStore((state) => state.isNewWorktreeDialogOpen);
const setNewWorktreeDialogOpen = useUIStore((state) => state.setNewWorktreeDialogOpen);
const [updateDialogOpen, setUpdateDialogOpen] = React.useState(false);
const [openSidebarMenuKey, setOpenSidebarMenuKey] = React.useState<string | null>(null);
const [renamingFolderId, setRenamingFolderId] = React.useState<string | null>(null);
const [renameFolderDraft, setRenameFolderDraft] = React.useState('');
const [deleteSessionConfirm, setDeleteSessionConfirm] = React.useState<DeleteSessionConfirmState>(null);
const [deleteFolderConfirm, setDeleteFolderConfirm] = React.useState<DeleteFolderConfirmState>(null);
const [bulkDeleteConfirm, setBulkDeleteConfirm] = React.useState<BulkDeleteSessionsConfirmState>(null);
const pinnedSessionIds = useSessionPinnedStore((state) => state.ids);
const togglePinnedSession = useSessionPinnedStore((state) => state.toggle);
const [collapsedGroups, setCollapsedGroups] = React.useState<Set<string>>(() => {
try {
const raw = getDeferredSafeStorage().getItem(GROUP_COLLAPSE_STORAGE_KEY);
if (!raw) {
return new Set();
}
const parsed = JSON.parse(raw) as string[];
return new Set(Array.isArray(parsed) ? parsed.filter((item) => typeof item === 'string') : []);
} catch {
return new Set();
}
});
const [groupOrderByProject, setGroupOrderByProject] = React.useState<Map<string, string[]>>(() => {
try {
const raw = getDeferredSafeStorage().getItem(GROUP_ORDER_STORAGE_KEY);
if (!raw) {
return new Map();
}
const parsed = JSON.parse(raw) as Record<string, string[]>;
const next = new Map<string, string[]>();
Object.entries(parsed).forEach(([projectId, order]) => {
if (Array.isArray(order)) {
next.set(projectId, order.filter((item) => typeof item === 'string'));
}
});
return next;
} catch {
return new Map();
}
});
const initialActiveSessionByProject = React.useMemo<Map<string, string>>(() => {
try {
const raw = getDeferredSafeStorage().getItem(PROJECT_ACTIVE_SESSION_STORAGE_KEY);
if (!raw) {
return new Map();
}
const parsed = JSON.parse(raw) as Record<string, string>;
const next = new Map<string, string>();
Object.entries(parsed).forEach(([projectId, sessionId]) => {
if (typeof sessionId === 'string' && sessionId.length > 0) {
next.set(projectId, sessionId);
}
});
return next;
} 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());
const ignoreIntersectionUntil = React.useRef<number>(0);
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
const projects = useProjectsStore((state) => state.projects);
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
const removeProject = useProjectsStore((state) => state.removeProject);
const setActiveProjectIdOnly = useProjectsStore((state) => state.setActiveProjectIdOnly);
const updateProjectMeta = useProjectsStore((state) => state.updateProjectMeta);
const reorderProjects = useProjectsStore((state) => state.reorderProjects);
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
const openContextPanelTab = useUIStore((state) => state.openContextPanelTab);
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
const toggleHelpDialog = useUIStore((state) => state.toggleHelpDialog);
const setAboutDialogOpen = useUIStore((state) => state.setAboutDialogOpen);
const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen);
const setScheduledTasksDialogOpen = useUIStore((state) => state.setScheduledTasksDialogOpen);
const openMultiRunLauncher = useUIStore((state) => state.openMultiRunLauncher);
const notifyOnSubtasks = useUIStore((state) => state.notifyOnSubtasks);
const showDeletionDialog = useUIStore((state) => state.showDeletionDialog);
const setShowDeletionDialog = useUIStore((state) => state.setShowDeletionDialog);
const debouncedSessionSearchQuery = useDebouncedValue(sessionSearchQuery, 120);
const normalizedSessionSearchQuery = React.useMemo(
() => debouncedSessionSearchQuery.trim().toLowerCase(),
[debouncedSessionSearchQuery],
);
const hasSessionSearchQuery = normalizedSessionSearchQuery.length > 0;
// Session Folders store
const collapsedFolderIds = useSessionFoldersStore((state) => state.collapsedFolderIds);
const foldersMap = useSessionFoldersStore((state) => state.foldersMap);
const getFoldersForScope = useSessionFoldersStore((state) => state.getFoldersForScope);
const createFolder = useSessionFoldersStore((state) => state.createFolder);
const renameFolder = useSessionFoldersStore((state) => state.renameFolder);
const deleteFolder = useSessionFoldersStore((state) => state.deleteFolder);
const addSessionToFolder = useSessionFoldersStore((state) => state.addSessionToFolder);
const addSessionsToFolder = useSessionFoldersStore((state) => state.addSessionsToFolder);
const removeSessionFromFolder = useSessionFoldersStore((state) => state.removeSessionFromFolder);
const removeSessionsFromFolders = useSessionFoldersStore((state) => state.removeSessionsFromFolders);
const toggleFolderCollapse = useSessionFoldersStore((state) => state.toggleFolderCollapse);
const getSessionFolderId = useSessionFoldersStore((state) => state.getSessionFolderId);
useSessionSearchEffects({
enabled: isVisible,
isSessionSearchOpen,
setIsSessionSearchOpen,
sessionSearchInputRef,
sessionSearchContainerRef,
});
const gitBranches = useGitAllBranches(isVisible);
const sync = useSync();
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 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);
const unshareSession = useSessionUIStore((state) => state.unshareSession);
// sessionAttentionStates removed — now using notification-store directly in SessionNodeItem
const worktreeMetadata = useSessionUIStore((state) => state.worktreeMetadata);
const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft);
// The sidebar tree's +-buttons (project / group / folder) open a draft but,
// unlike selecting an existing session, don't navigate. VS Code's compact view
// is driven by the openchamber:navigate event, so switch to chat explicitly
// (a no-op in the expanded side-by-side layout, which is always showing chat).
const openNewSessionDraftFromTree = React.useCallback<typeof openNewSessionDraft>((options) => {
openNewSessionDraft(options);
if (isVSCode) {
window.dispatchEvent(new CustomEvent('openchamber:navigate', { detail: { view: 'chat' } }));
}
}, [isVSCode, openNewSessionDraft]);
const updateStore = useUpdateStore(useShallow((s) => ({
checkForUpdates: s.checkForUpdates,
available: s.available,
runtimeType: s.runtimeType,
info: s.info,
downloading: s.downloading,
downloaded: s.downloaded,
progress: s.progress,
error: s.error,
downloadUpdate: s.downloadUpdate,
restartToUpdate: s.restartToUpdate,
})));
const knownSessionDirectories = React.useMemo(
() => buildKnownSessionDirectories(projects, availableWorktreesByProject, { includeWorktrees: !isVSCode }),
[availableWorktreesByProject, isVSCode, projects],
);
const sessions = React.useMemo(() => {
const merged = [...globalActiveSessions];
const seenIds = new Set(merged.map((session) => session.id));
liveFallbackSessions.forEach((session) => {
if (seenIds.has(session.id)) {
return;
}
merged.push(session);
});
return merged.filter((session) => isKnownActiveSessionDirectory(session, knownSessionDirectories, {
allowUnknownDirectory: !isVSCode,
allowEmptyDirectorySet: !isVSCode,
}));
}, [globalActiveSessions, isVSCode, knownSessionDirectories, liveFallbackSessions]);
const persistenceSessions = React.useMemo(
() => [...globalActiveSessions, ...archivedSessions],
[archivedSessions, globalActiveSessions],
);
const syncSessionsSnapshotRef = React.useRef<Session[]>(liveSessions);
React.useEffect(() => {
syncSessionsSnapshotRef.current = liveSessions;
}, [liveSessions]);
const runtimeKey = getRuntimeKey();
const projectWorktreeDiscoveryKey = React.useMemo(
() => `${runtimeKey}|${projects
.map((project) => `${project.id}:${normalizePath(project.path) ?? ''}`)
.join('|')}`,
[projects, runtimeKey],
);
const [resolvedWorktreeTopologyKey, setResolvedWorktreeTopologyKey] = React.useState<string | null>(
isVSCode ? projectWorktreeDiscoveryKey : null,
);
const isWorktreeTopologyLoading = !isVSCode && resolvedWorktreeTopologyKey !== projectWorktreeDiscoveryKey;
const [unresolvedWorktreeProjectPaths, setUnresolvedWorktreeProjectPaths] = React.useState<ReadonlySet<string>>(new Set());
const initialGlobalSessionsRefreshStartedRef = React.useRef(false);
React.useEffect(() => {
if (initialGlobalSessionsRefreshStartedRef.current) {
return;
}
initialGlobalSessionsRefreshStartedRef.current = true;
void refreshGlobalSessions(syncSessionsSnapshotRef.current);
}, []);
React.useEffect(() => {
let cancelled = false;
const discoverWorktrees = async () => {
const discoveryRuntimeKey = runtimeKey;
const projectEntries = useProjectsStore.getState().projects;
if (projectEntries.length === 0 || isVSCode) {
if (!cancelled) {
setUnresolvedWorktreeProjectPaths(new Set());
setResolvedWorktreeTopologyKey(projectWorktreeDiscoveryKey);
}
return;
}
const currentByProject = useSessionUIStore.getState().availableWorktreesByProject;
const worktreesByProject = new Map(currentByProject);
const unresolvedProjectPaths = new Set<string>();
// Constrain fanout: previously `Promise.all(projects.map(...))` could
// spawn dozens of concurrent `git worktree list` and
// `checkIsGitRepository` calls on cold start, each touching the
// worktree process. Concurrency=3 keeps startup latency low while
// bounding peak worktree-process load.
const worktreeConcurrency = 3;
let cursor = 0;
const workers = Array.from({ length: worktreeConcurrency }, async () => {
while (true) {
const nextIndex = cursor;
cursor += 1;
if (nextIndex >= projectEntries.length) return;
const project = projectEntries[nextIndex];
const projectPath = normalizePath(project.path);
if (!projectPath) continue;
try {
// Use store-cached isGitRepo when available; fall back to
// a direct check for projects the Git store hasn't seen yet.
// Forcing `ensureStatus` here also warms the store so the
// PR/render paths downstream can read isGitRepo for free.
const cachedIsGitRepo = useGitStore.getState().directories.get(projectPath)?.isGitRepo;
const isGitRepo = cachedIsGitRepo ?? await checkIsGitRepository(projectPath);
if (!isGitRepo) {
worktreesByProject.delete(projectPath);
continue;
}
const worktrees = await listProjectWorktrees({ id: project.id, path: projectPath });
if (cancelled) return;
if (worktrees.length === 0) {
worktreesByProject.delete(projectPath);
} else {
worktreesByProject.set(projectPath, worktrees);
}
} catch {
// Keep last-known worktrees when a project is temporarily unavailable.
unresolvedProjectPaths.add(projectPath);
}
}
});
await Promise.all(workers);
if (cancelled || getRuntimeKey() !== discoveryRuntimeKey) return;
const activeProjectPaths = new Set(projectEntries.map((project) => normalizePath(project.path)).filter(Boolean));
for (const projectPath of worktreesByProject.keys()) {
if (!activeProjectPaths.has(projectPath)) {
worktreesByProject.delete(projectPath);
}
}
const allWorktrees = [...worktreesByProject.values()].flat();
// Skip update if nothing changed — see worktreeMapsEqual JSDoc.
if (!worktreeMapsEqual(worktreesByProject, currentByProject)) {
useSessionUIStore.setState({
availableWorktrees: allWorktrees,
availableWorktreesByProject: worktreesByProject,
});
}
setUnresolvedWorktreeProjectPaths(unresolvedProjectPaths);
setResolvedWorktreeTopologyKey(projectWorktreeDiscoveryKey);
};
void discoverWorktrees();
return () => {
cancelled = true;
};
}, [isVSCode, projectWorktreeDiscoveryKey, runtimeKey]);
React.useEffect(() => {
let refreshTimeout: ReturnType<typeof setTimeout> | null = null;
const unsubscribe = subscribeOpenchamberEvents((event) => {
if (event.type !== 'scheduled-task-ran') {
return;
}
if (refreshTimeout) {
clearTimeout(refreshTimeout);
}
refreshTimeout = setTimeout(() => {
void refreshGlobalSessions(syncSessionsSnapshotRef.current);
}, 500);
});
return () => {
if (refreshTimeout) {
clearTimeout(refreshTimeout);
}
unsubscribe();
};
}, []);
const isDesktopShellRuntime = React.useMemo(() => isDesktopShell(), []);
const { isTablet } = useDeviceInfo();
const alwaysShowSidebarActions = mobileVariant || isTablet;
const {
buildGroupSearchText,
filterSessionNodesForSearch,
buildGroupedSessions,
} = useSessionGrouping({
homeDirectory,
worktreeMetadata,
pinnedSessionIds,
gitBranches,
isVSCode,
});
const { scheduleCollapsedProjectsPersist } = useSidebarPersistence({
isVSCode,
safeStorage,
keys: {
sessionExpanded: SESSION_EXPANDED_STORAGE_KEY,
projectCollapse: PROJECT_COLLAPSE_STORAGE_KEY,
groupOrder: GROUP_ORDER_STORAGE_KEY,
groupCollapse: GROUP_COLLAPSE_STORAGE_KEY,
},
groupOrderByProject,
collapsedGroups,
setExpandedParents,
setCollapsedProjects,
});
const sortedSessions = React.useMemo(() => {
return [...sessions].sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds));
}, [sessions, pinnedSessionIds]);
// Stable signature: id + updatedAt joined. When this string is
// unchanged, the relative ordering of sessions is identical and the
// derived `sessionOrderIndex` Map can return the previous reference.
// Without this, a fresh `sortedSessions` array (cheap to rebuild) would
// still hand a new Map identity to the entire SessionGroupSection
// memo chain, invalidating sourceGroupNodes, nodeBySessionId, and the
// rest of the down-stream useMemo chain.
const sessionOrderSignature = React.useMemo(
() => sortedSessions.map((s) => `${s.id}:${s.time?.updated ?? 0}`).join('|'),
[sortedSessions],
);
const sessionOrderIndexRef = React.useRef<{ signature: string; map: Map<string, number> } | null>(null);
const sessionOrderIndex = React.useMemo(() => {
const cached = sessionOrderIndexRef.current;
if (cached && cached.signature === sessionOrderSignature) {
return cached.map;
}
const next = new Map(sortedSessions.map((session, index) => [session.id, index]));
sessionOrderIndexRef.current = { signature: sessionOrderSignature, map: next };
return next;
}, [sessionOrderSignature, sortedSessions]);
const childrenMap = React.useMemo(() => {
const map = new Map<string, Session[]>();
sortedSessions.forEach((session) => {
const parentID = (session as Session & { parentID?: string | null }).parentID;
if (!parentID) {
return;
}
const collection = map.get(parentID) ?? [];
collection.push(session);
map.set(parentID, collection);
});
map.forEach((list) => list.sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds)));
return map;
}, [sortedSessions, pinnedSessionIds]);
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,
[projects, editingProjectDialogId],
);
const handleSaveProjectEdit = React.useCallback((data: {
label: string;
icon: string | null;
color: string | null;
iconBackground: string | null;
defaultModel: string | null;
}) => {
if (!editingProjectDialogId) {
return;
}
updateProjectMeta(editingProjectDialogId, {
label: data.label,
icon: data.icon,
color: data.color,
iconBackground: data.iconBackground,
defaultModel: data.defaultModel ?? null,
});
}, [editingProjectDialogId, updateProjectMeta]);
const openNewWorktreeDialog = React.useCallback(() => {
setNewWorktreeDialogOpen(true);
}, [setNewWorktreeDialogOpen]);
const handleOpenUpdateDialog = React.useCallback(() => {
const current = useUpdateStore.getState();
if (current.available && current.info) {
setUpdateDialogOpen(true);
return;
}
void updateStore.checkForUpdates().then(() => {
const { available, error } = useUpdateStore.getState();
if (error) {
toast.error(t('sessions.sidebar.updateCheck.errorTitle'), { description: error });
return;
}
if (!available) {
toast.success(t('sessions.sidebar.updateCheck.latestVersion'));
return;
}
setUpdateDialogOpen(true);
});
}, [t, updateStore]);
const handleOpenSettings = React.useCallback(() => {
if (mobileVariant) {
setSessionSwitcherOpen(false);
}
setSettingsDialogOpen(true);
}, [mobileVariant, setSessionSwitcherOpen, setSettingsDialogOpen]);
const showSidebarUpdateButton =
updateStore.available &&
(updateStore.runtimeType === 'desktop' || updateStore.runtimeType === 'web');
const deleteSession = useSessionUIStore((state) => state.deleteSession);
const deleteSessions = useSessionUIStore((state) => state.deleteSessions);
const archiveSession = useSessionUIStore((state) => state.archiveSession);
const archiveSessions = useSessionUIStore((state) => state.archiveSessions);
const {
copiedSessionId,
handleSessionSelect,
handleSessionDoubleClick,
handleSaveEdit,
handleCancelEdit,
handleShareSession,
handleCopyShareUrl,
handleUnshareSession,
handleDeleteSession,
confirmDeleteSession,
} = useSessionActions({
mobileVariant,
allowReselect,
onSessionSelected,
isSessionSearchOpen,
sessionSearchQuery,
setSessionSearchQuery,
setIsSessionSearchOpen,
setActiveMainTab,
setSessionSwitcherOpen,
setCurrentSession,
updateSessionTitle,
shareSession,
unshareSession,
deleteSession,
deleteSessions,
archiveSession,
archiveSessions,
childrenMap,
showDeletionDialog,
setDeleteSessionConfirm,
deleteSessionConfirm,
setEditingId,
setEditTitle,
editingId,
editTitle,
});
const confirmDeleteFolder = React.useCallback(() => {
if (!deleteFolderConfirm) return;
const { scopeKey, folderId } = deleteFolderConfirm;
setDeleteFolderConfirm(null);
deleteFolder(scopeKey, folderId);
}, [deleteFolderConfirm, deleteFolder]);
const handleOpenDirectoryDialog = React.useCallback(() => {
sessionEvents.requestDirectoryDialog();
}, []);
const toggleParent = React.useCallback((expansionKey: string) => {
setExpandedParents((previous) => {
const next = toggleExpandedParentKey(previous, expansionKey);
try {
safeStorage.setItem(SESSION_EXPANDED_STORAGE_KEY, JSON.stringify(Array.from(next)));
} catch { /* ignored */ }
return next;
});
}, [safeStorage]);
const createFolderAndStartRename = React.useCallback(
(scopeKey: string, parentId?: string | null) => {
if (!scopeKey) {
return null;
}
if (parentId && collapsedFolderIds.has(parentId)) {
toggleFolderCollapse(parentId);
}
const newFolder = createFolder(scopeKey, t('sessions.sidebar.folder.newFolderName'), parentId);
setRenamingFolderId(newFolder.id);
setRenameFolderDraft(newFolder.name);
return newFolder;
},
[collapsedFolderIds, toggleFolderCollapse, createFolder, t],
);
const stableHandleSessionSelect = useStableRenderCallback(handleSessionSelect);
const stableHandleSessionDoubleClick = useStableRenderCallback(handleSessionDoubleClick);
const stableHandleSaveEdit = useStableRenderCallback(handleSaveEdit);
const stableHandleCancelEdit = useStableRenderCallback(handleCancelEdit);
const stableHandleShareSession = useStableRenderCallback(handleShareSession);
const stableHandleCopyShareUrl = useStableRenderCallback(handleCopyShareUrl);
const stableHandleUnshareSession = useStableRenderCallback(handleUnshareSession);
const stableHandleDeleteSession = useStableRenderCallback(handleDeleteSession);
const stableCreateFolderAndStartRename = useStableRenderCallback(createFolderAndStartRename);
const showMoreGroupSessions = React.useCallback((groupId: string, currentVisibleCount: number) => {
setVisibleSessionCountByGroup((prev) => {
const next = new Map(prev);
next.set(groupId, currentVisibleCount + 7);
return next;
});
}, []);
const resetGroupSessionLimit = React.useCallback((groupId: string) => {
setVisibleSessionCountByGroup((prev) => {
if (!prev.has(groupId)) {
return prev;
}
const next = new Map(prev);
next.delete(groupId);
return next;
});
}, []);
const resetProjectSessionLimits = React.useCallback((projectId: string) => {
setVisibleSessionCountByGroup((prev) => {
let changed = false;
const next = new Map(prev);
const projectGroupPrefix = `${projectId}:`;
for (const groupId of next.keys()) {
if (groupId.startsWith(projectGroupPrefix)) {
next.delete(groupId);
changed = true;
}
}
return changed ? next : prev;
});
}, []);
const collapseAllProjects = React.useCallback(() => {
ignoreIntersectionUntil.current = Date.now() + 150;
setVisibleSessionCountByGroup(new Map());
setCollapsedProjects(() => {
const allIds = new Set(projects.map((p) => p.id));
try {
safeStorage.setItem(PROJECT_COLLAPSE_STORAGE_KEY, JSON.stringify(Array.from(allIds)));
} catch { /* ignored */ }
if (!isVSCode) {
scheduleCollapsedProjectsPersist(allIds);
}
return allIds;
});
}, [projects, isVSCode, safeStorage, scheduleCollapsedProjectsPersist]);
const expandAllProjects = React.useCallback(() => {
ignoreIntersectionUntil.current = Date.now() + 150;
setVisibleSessionCountByGroup(new Map());
setCollapsedProjects(() => {
const empty = new Set<string>();
try {
safeStorage.setItem(PROJECT_COLLAPSE_STORAGE_KEY, JSON.stringify([]));
} catch { /* ignored */ }
if (!isVSCode) {
scheduleCollapsedProjectsPersist(empty);
}
return empty;
});
}, [isVSCode, safeStorage, scheduleCollapsedProjectsPersist]);
const toggleProject = React.useCallback((projectId: string) => {
// Ignore intersection events for a short period after toggling
ignoreIntersectionUntil.current = Date.now() + 150;
resetProjectSessionLimits(projectId);
setCollapsedProjects((prev) => {
const next = new Set(prev);
if (next.has(projectId)) {
next.delete(projectId);
} else {
next.add(projectId);
}
try {
safeStorage.setItem(PROJECT_COLLAPSE_STORAGE_KEY, JSON.stringify(Array.from(next)));
} catch { /* ignored */ }
// Persist collapse state to server settings (web + desktop local/remote).
if (!isVSCode) {
scheduleCollapsedProjectsPersist(next);
}
return next;
});
}, [isVSCode, resetProjectSessionLimits, safeStorage, scheduleCollapsedProjectsPersist]);
const normalizedProjects = React.useMemo(() => {
return projects
.map((project) => ({
...project,
normalizedPath: normalizePath(project.path),
}))
.filter((project) => Boolean(project.normalizedPath)) as Array<{
id: string;
path: string;
label?: string;
normalizedPath: string;
icon?: string;
color?: string;
iconImage?: { mime: string; updatedAt: number; source: 'custom' | 'auto' };
iconBackground?: string;
addedAt?: number;
lastOpenedAt?: number;
sidebarCollapsed?: boolean;
}>;
}, [projects]);
const normalizedProjectPaths = React.useMemo(
() => normalizedProjects.map((project) => project.normalizedPath),
[normalizedProjects],
);
const projectSessionDirectories = React.useMemo(() => {
const directories = new Set(normalizedProjects.map((project) => project.normalizedPath));
if (!isVSCode) {
for (const worktrees of availableWorktreesByProject.values()) {
for (const worktree of worktrees) {
const directory = normalizePath(worktree.path);
if (directory) directories.add(directory);
}
}
}
return [...directories].sort();
}, [availableWorktreesByProject, isVSCode, normalizedProjects]);
const knownProjectSessionDirectoriesRef = React.useRef<Set<string> | null>(null);
React.useEffect(() => {
const nextDirectories = new Set(projectSessionDirectories);
const previousDirectories = knownProjectSessionDirectoriesRef.current;
knownProjectSessionDirectoriesRef.current = nextDirectories;
if (!previousDirectories) {
if (isVSCode && projectSessionDirectories.length > 0) {
void refreshGlobalSessionsForDirectories(projectSessionDirectories, syncSessionsSnapshotRef.current);
}
return;
}
const addedDirectories = projectSessionDirectories.filter((directory) => !previousDirectories.has(directory));
if (addedDirectories.length === 0) {
return;
}
void refreshGlobalSessionsForDirectories(addedDirectories, syncSessionsSnapshotRef.current);
}, [isVSCode, projectSessionDirectories]);
const { github } = useRuntimeAPIs();
const githubAuthStatus = useGitHubAuthStore((state) => state.status);
const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
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,
setProjectRootBranches,
});
const isSessionsLoading = useSessionUIStore((state) => state.isLoading);
const sessionOwnership = React.useMemo(
() => createSessionOwnershipIndex(sessions, normalizedProjects, availableWorktreesByProject, isVSCode, archivedSessions),
[archivedSessions, availableWorktreesByProject, isVSCode, normalizedProjects, sessions],
);
useAuthoritativeSessionCleanup({
enabled: isVisible,
hasAuthoritativeGlobalSessions,
sessions: persistenceSessions,
});
const { getSessionsForProject, getArchivedSessionsForProject } = useProjectSessionLists({
ownership: sessionOwnership,
});
useArchivedAutoFolders({
enabled: isVisible,
normalizedProjects,
ownership: sessionOwnership,
isSessionsLoading,
hasAuthoritativeGlobalSessions,
isWorktreeTopologyLoading,
unresolvedWorktreeProjectPaths,
foldersMap,
createFolder,
addSessionToFolder,
});
// Keep last-known repo status to avoid UI jiggling during project switch
const lastRepoStatusRef = React.useRef(false);
if (activeProjectId && projectRepoStatus.has(activeProjectId)) {
lastRepoStatusRef.current = Boolean(projectRepoStatus.get(activeProjectId));
}
const showRecentSection = useSessionDisplayStore((state) => state.showRecentSection);
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];
switch (projectSortOrder) {
case 'a-z':
list.sort((a, b) => {
const aLabel = (a.label || a.path).toLowerCase();
const bLabel = (b.label || b.path).toLowerCase();
return aLabel.localeCompare(bLabel);
});
break;
case 'z-a':
list.sort((a, b) => {
const aLabel = (a.label || a.path).toLowerCase();
const bLabel = (b.label || b.path).toLowerCase();
return bLabel.localeCompare(aLabel);
});
break;
case 'date-added':
list.sort((a, b) => (b.addedAt ?? 0) - (a.addedAt ?? 0));
break;
case 'recent':
list.sort((a, b) => (b.lastOpenedAt ?? 0) - (a.lastOpenedAt ?? 0));
break;
case 'manual': {
const orderMap = new Map(manualProjectOrder.map((id, i) => [id, i]));
list.sort((a, b) => {
const ai = orderMap.get(a.id) ?? Infinity;
const bi = orderMap.get(b.id) ?? Infinity;
return ai - bi;
});
break;
}
}
return list;
}, [normalizedProjects, projectSortOrder, manualProjectOrder]);
const {
projectSections,
groupSearchDataByGroup,
sectionsForRender,
searchMatchCount,
} = useSessionSidebarSections({
normalizedProjects: sortedProjects,
getSessionsForProject,
getArchivedSessionsForProject,
availableWorktreesByProject,
projectRepoStatus,
projectRootBranches,
lastRepoStatus: lastRepoStatusRef.current,
buildGroupedSessions,
hasSessionSearchQuery,
normalizedSessionSearchQuery,
filterSessionNodesForSearch,
buildGroupSearchText,
foldersMap,
});
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>
), [t]);
const { getOrderedGroups } = useGroupOrdering(groupOrderByProject);
const hasInitializedArchivedCollapseRef = React.useRef(false);
React.useEffect(() => {
if (hasInitializedArchivedCollapseRef.current || projectSections.length === 0) {
return;
}
const archivedGroupKeys = projectSections.flatMap((section) =>
section.groups
.filter((group) => group.isArchivedBucket)
.map((group) => `${section.project.id}:${group.id}`),
);
if (archivedGroupKeys.length > 0) {
setCollapsedGroups((prev) => new Set([...prev, ...archivedGroupKeys]));
}
hasInitializedArchivedCollapseRef.current = true;
}, [projectSections]);
const sessionSidebarMetaById = React.useMemo(() => {
const meta = new Map<string, {
node: SessionNode;
projectId: string | null;
groupDirectory: string | null;
secondaryMeta: {
projectLabel?: string | null;
branchLabel?: string | null;
} | null;
}>();
const projectPathLengthBySessionId = new Map<string, number>();
projectSections.forEach((section) => {
const projectLabel = formatProjectLabel(
section.project.label?.trim()
|| formatDirectoryName(section.project.normalizedPath, homeDirectory)
|| section.project.normalizedPath,
);
section.groups.forEach((group) => {
const branchCandidate = group.branch && group.branch !== 'HEAD' && group.branch !== projectLabel
? group.branch
: null;
const secondaryMeta = { projectLabel, branchLabel: branchCandidate };
const visit = (nodes: SessionNode[]) => {
nodes.forEach((node) => {
const nextProjectPathLength = section.project.normalizedPath.length;
const currentProjectPathLength = projectPathLengthBySessionId.get(node.session.id) ?? -1;
if (nextProjectPathLength < currentProjectPathLength) {
return;
}
meta.set(node.session.id, {
node,
projectId: section.project.id,
groupDirectory: group.directory,
secondaryMeta,
});
projectPathLengthBySessionId.set(node.session.id, nextProjectPathLength);
if (node.children.length > 0) {
visit(node.children);
}
});
};
visit(group.sessions);
});
});
return meta;
}, [projectSections, homeDirectory]);
const activeNowSessions = React.useMemo(() => {
if (!showRecentSection || isVSCode) {
return [];
}
return deriveRecentSessions(sessions)
.sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds));
}, [isVSCode, pinnedSessionIds, sessions, showRecentSection]);
// Prefetch is wired below, after recentSessionIds is computed.
const activitySections = React.useMemo(() => {
// VS Code renders the full grouped project view (one group per open
// workspace, folders + pinned native); the flat "recent" activity list is
// web/desktop-only.
if (isVSCode || !showRecentSection) {
return [];
}
const recentSessions = activeNowSessions;
const toItem = (session: Session) => {
const existing = sessionSidebarMetaById.get(session.id);
const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null);
const node = existing?.node ?? { session, children: [], worktree: null };
const filteredNodes = hasSessionSearchQuery
? filterSessionNodesForSearch([node], normalizedSessionSearchQuery)
: [node];
const filteredNode = filteredNodes[0];
if (!filteredNode) {
return null;
}
const secondaryMeta = existing?.secondaryMeta
? {
projectLabel: existing.secondaryMeta.projectLabel,
branchLabel: isVSCode ? null : existing.secondaryMeta.branchLabel,
}
: null;
return {
node: filteredNode,
projectId: existing?.projectId ?? null,
groupDirectory: existing?.groupDirectory ?? sessionDirectory,
secondaryMeta,
};
};
const items = recentSessions
.map(toItem)
.filter((item): item is NonNullable<ReturnType<typeof toItem>> => item !== null);
return [
{ key: 'active-now' as const, title: t('sessions.sidebar.activity.recentTitle'), items },
];
}, [activeNowSessions, filterSessionNodesForSearch, hasSessionSearchQuery, isVSCode, normalizedSessionSearchQuery, sessionSidebarMetaById, showRecentSection, t]);
const hasActivitySectionItems = React.useMemo(
() => activitySections.some((section) => section.items.length > 0),
[activitySections],
);
const sectionsForSidebarRender = React.useMemo(() => {
return showArchivedSessions
? sectionsForRender
: sectionsForRender.map((section) => ({
...section,
groups: section.groups.filter((group) => !group.isArchivedBucket),
}));
}, [sectionsForRender, showArchivedSessions]);
const prLookup = React.useMemo(() => {
if (!isVisible) {
return {
keys: EMPTY_STRING_ARRAY,
displayKeyByLookupKey: new Map<string, string>(),
};
}
const keys = new Set<string>();
const displayKeyByLookupKey = new Map<string, string>();
sectionsForSidebarRender.forEach((section) => {
section.groups.forEach((group) => {
const directory = normalizePath(group.directory ?? null);
const branch = group.branch?.trim() || gitBranches.get(directory || '')?.trim();
if (!directory || !branch) {
return;
}
const lookupKey = getGitHubPrStatusKey(directory, branch);
keys.add(lookupKey);
displayKeyByLookupKey.set(lookupKey, `${directory}::${branch}`);
});
});
return { keys: [...keys], displayKeyByLookupKey };
}, [gitBranches, isVisible, sectionsForSidebarRender]);
const prVisualSummaryMap = usePrVisualSummaryByKeys(prLookup.keys);
React.useEffect(() => {
if (!isVisible || !githubAuthChecked || !githubAuthStatus?.connected || !github) {
return;
}
const targetsByKey = new Map<string, { directory: string; branch: string }>();
const now = Date.now();
sectionsForSidebarRender.forEach((section) => {
if (collapsedProjects.has(section.project.id)) {
return;
}
section.groups.forEach((group) => {
const directory = normalizePath(group.directory ?? null);
const branch = group.branch?.trim() || gitBranches.get(directory || '')?.trim();
if (!directory || !branch) {
return;
}
const key = getGitHubPrStatusKey(directory, branch);
const entry = useGitHubPrStatusStore.getState().entries[key];
const hasPr = Boolean(entry?.status?.pr);
const retryKey = `${directory}::${branch}`;
const noPrLastCheckedAt = Math.max(entry?.lastRefreshAt ?? 0, entry?.lastDiscoveryPollAt ?? 0);
const shouldRetryNoPr = Boolean(
entry?.isInitialStatusResolved
&& !hasPr
&& (
!retriedNoPrStatusKeysRef.current.has(retryKey)
|| now - noPrLastCheckedAt >= SIDEBAR_PR_NO_PR_RETRY_MS
),
);
if (!entry || !entry.isInitialStatusResolved || shouldRetryNoPr) {
if (shouldRetryNoPr) {
retriedNoPrStatusKeysRef.current.add(retryKey);
}
if (!targetsByKey.has(key)) {
targetsByKey.set(key, { directory, branch });
}
}
});
});
if (targetsByKey.size === 0) {
return;
}
targetsByKey.forEach((target, key) => {
ensurePrStatusEntry(key);
setPrStatusParams(key, {
directory: target.directory,
branch: target.branch,
remoteName: null,
canShow: true,
github,
githubAuthChecked,
githubConnected: githubAuthStatus.connected,
});
});
void refreshPrStatusTargets([...targetsByKey.values()], {
silent: true,
markInitialResolved: true,
});
}, [
collapsedProjects,
ensurePrStatusEntry,
github,
githubAuthChecked,
githubAuthStatus?.connected,
isVisible,
gitBranches,
refreshPrStatusTargets,
sectionsForSidebarRender,
setPrStatusParams,
]);
const desktopHeaderActionButtonClass =
'inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md leading-none text-foreground hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 disabled:cursor-not-allowed';
const mobileHeaderActionButtonClass =
'inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md leading-none text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 disabled:cursor-not-allowed';
const headerActionButtonClass = mobileVariant ? mobileHeaderActionButtonClass : desktopHeaderActionButtonClass;
const headerActionIconClass = 'h-4.5 w-4.5';
const stuckProjectHeaders = useStickyProjectHeaders({
enabled: isVisible,
isDesktopShellRuntime,
projectSections,
projectHeaderSentinelRefs,
});
const renderSessionNode = useStableRenderCallback(
(
node: SessionNode,
depth: number = 0,
groupDirectory?: string | null,
projectId?: string | null,
archivedBucket: boolean = false,
secondaryMeta?: { projectLabel?: string | null; branchLabel?: string | null } | null,
renderContext: 'project' | 'recent' = 'project',
renderExtras?: SessionNodeRenderExtras,
): React.ReactNode => (
<SessionNodeItem
node={node}
depth={depth}
groupDirectory={groupDirectory}
projectId={projectId}
archivedBucket={archivedBucket}
pinnedSessionIds={pinnedSessionIds}
expandedParents={renderContext === 'recent' ? recentExpandedParents : projectExpandedParents}
hasSessionSearchQuery={hasSessionSearchQuery}
normalizedSessionSearchQuery={normalizedSessionSearchQuery}
notifyOnSubtasks={notifyOnSubtasks}
editingId={editingId}
setEditingId={setEditingId}
editTitle={editTitle}
setEditTitle={setEditTitle}
handleSaveEdit={stableHandleSaveEdit}
handleCancelEdit={stableHandleCancelEdit}
toggleParent={toggleParent}
handleSessionSelect={stableHandleSessionSelect}
handleSessionDoubleClick={stableHandleSessionDoubleClick}
togglePinnedSession={togglePinnedSession}
handleShareSession={stableHandleShareSession}
copiedSessionId={copiedSessionId}
handleCopyShareUrl={stableHandleCopyShareUrl}
handleUnshareSession={stableHandleUnshareSession}
openSidebarMenuKey={openSidebarMenuKey}
setOpenSidebarMenuKey={setOpenSidebarMenuKey}
renamingFolderId={renamingFolderId}
getFoldersForScope={getFoldersForScope}
getSessionFolderId={getSessionFolderId}
removeSessionFromFolder={removeSessionFromFolder}
addSessionToFolder={addSessionToFolder}
createFolderAndStartRename={stableCreateFolderAndStartRename}
openContextPanelTab={openContextPanelTab}
handleDeleteSession={stableHandleDeleteSession}
mobileVariant={mobileVariant}
alwaysShowActions={alwaysShowSidebarActions}
renderSessionNode={renderSessionNode}
secondaryMeta={secondaryMeta}
renderContext={renderContext}
subtreeContainsEditing={renderExtras?.subtreeContainsEditing ?? EMPTY_SUBTREE_SET}
menuOpenSessionId={renderExtras?.menuOpenSessionId ?? null}
nodeStructureKey={renderExtras?.nodeStructureKey ?? ''}
childRenderExtrasFor={renderExtras?.childRenderExtrasFor}
/>
),
);
const toggleCollapsedGroup = React.useCallback((key: string) => {
resetGroupSessionLimit(key);
setCollapsedGroups((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
}, [resetGroupSessionLimit]);
const prVisualStateByDirectoryBranch = React.useMemo(() => {
const result = new Map<string, PrIndicator>();
for (const [key, summary] of prVisualSummaryMap) {
const displayKey = prLookup.displayKeyByLookupKey.get(key);
if (!displayKey) {
continue;
}
result.set(displayKey, {
visualState: summary.visualState as PrVisualState,
number: summary.number,
url: summary.url,
state: summary.prState as 'open' | 'closed' | 'merged',
draft: summary.draft,
title: summary.title,
base: summary.base,
head: summary.head,
checks: summary.checks as PrIndicator['checks'],
canMerge: summary.canMerge,
mergeableState: summary.mergeableState,
repo: summary.repo,
});
}
return result;
}, [prLookup.displayKeyByLookupKey, prVisualSummaryMap]);
const renderGroupSessions = React.useCallback(
(
group: SessionGroup,
groupKey: string,
projectId?: string | null,
hideGroupLabel?: boolean,
dragHandleProps?: SortableDragHandleProps | null,
compactBodyPadding?: boolean,
scrollContainerRef?: React.RefObject<HTMLElement | null>,
) => (
<SessionGroupSection
group={group}
groupKey={groupKey}
projectId={projectId}
hideGroupLabel={hideGroupLabel}
compactBodyPadding={compactBodyPadding}
hasSessionSearchQuery={hasSessionSearchQuery}
normalizedSessionSearchQuery={normalizedSessionSearchQuery}
groupSearchDataByGroup={groupSearchDataByGroup}
visibleSessionCount={visibleSessionCountByGroup.get(groupKey)}
collapsedGroups={collapsedGroups}
hideDirectoryControls={hideDirectoryControls}
collapsedFolderIds={collapsedFolderIds}
toggleFolderCollapse={toggleFolderCollapse}
renameFolder={renameFolder}
deleteFolder={deleteFolder}
showDeletionDialog={showDeletionDialog}
setDeleteFolderConfirm={setDeleteFolderConfirm}
renderSessionNode={renderSessionNode}
projectRepoStatus={projectRepoStatus}
lastRepoStatus={lastRepoStatusRef.current}
showMoreGroupSessions={showMoreGroupSessions}
resetGroupSessionLimit={resetGroupSessionLimit}
mobileVariant={mobileVariant}
alwaysShowActions={alwaysShowSidebarActions}
activeProjectId={activeProjectId}
setActiveProjectIdOnly={setActiveProjectIdOnly}
setActiveMainTab={setActiveMainTab}
setSessionSwitcherOpen={setSessionSwitcherOpen}
openNewSessionDraft={openNewSessionDraftFromTree}
addSessionToFolder={addSessionToFolder}
createFolderAndStartRename={stableCreateFolderAndStartRename}
renamingFolderId={renamingFolderId}
renameFolderDraft={renameFolderDraft}
setRenameFolderDraft={setRenameFolderDraft}
setRenamingFolderId={setRenamingFolderId}
pinnedSessionIds={pinnedSessionIds}
expandedParents={projectExpandedParents}
sessionOrderIndex={sessionOrderIndex}
editingId={editingId}
editTitle={editTitle}
openSidebarMenuKey={openSidebarMenuKey}
prVisualStateByDirectoryBranch={prVisualStateByDirectoryBranch}
onToggleCollapsedGroup={toggleCollapsedGroup}
dragHandleProps={dragHandleProps}
scrollContainerRef={scrollContainerRef}
/>
),
[
hasSessionSearchQuery,
normalizedSessionSearchQuery,
groupSearchDataByGroup,
visibleSessionCountByGroup,
collapsedGroups,
hideDirectoryControls,
collapsedFolderIds,
toggleFolderCollapse,
renameFolder,
deleteFolder,
showDeletionDialog,
renderSessionNode,
projectRepoStatus,
showMoreGroupSessions,
resetGroupSessionLimit,
mobileVariant,
alwaysShowSidebarActions,
activeProjectId,
setActiveProjectIdOnly,
setActiveMainTab,
setSessionSwitcherOpen,
openNewSessionDraftFromTree,
addSessionToFolder,
stableCreateFolderAndStartRename,
renamingFolderId,
renameFolderDraft,
pinnedSessionIds,
projectExpandedParents,
sessionOrderIndex,
editingId,
editTitle,
openSidebarMenuKey,
prVisualStateByDirectoryBranch,
toggleCollapsedGroup,
],
);
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 {
selectionModeEnabled,
hasSelection,
selectedIdsSize,
bulkScopeIsArchived,
derivedSelectionScope,
bulkScopeFolders,
bulkCanRemoveFromFolder,
handleToggleSelectionMode,
handleExitSelectionMode,
handleBulkMoveToFolder,
handleBulkCreateFolderAndMove,
handleBulkRemoveFromFolder,
handleBulkDelete,
confirmBulkDelete,
} = useSidebarBulkActions({
isInlineEditing,
showDeletionDialog,
foldersMap,
addSessionsToFolder,
removeSessionsFromFolders,
createFolderAndStartRename,
archiveSessions,
deleteSessions,
setBulkDeleteConfirm,
});
const handleOpenMultiRunFromHeader = React.useCallback(() => {
setActiveMainTab('chat');
if (mobileVariant) {
setSessionSwitcherOpen(false);
}
openMultiRunLauncher();
}, [mobileVariant, openMultiRunLauncher, setActiveMainTab, setSessionSwitcherOpen]);
const handleOpenNewSessionDraftFromHeader = React.useCallback(() => {
setActiveMainTab('chat');
if (mobileVariant) {
setSessionSwitcherOpen(false);
}
openNewSessionDraft();
}, [mobileVariant, openNewSessionDraft, setActiveMainTab, setSessionSwitcherOpen]);
return (
<div
ref={sessionSearchContainerRef}
className={cn(
'relative flex h-full flex-col text-foreground overflow-x-hidden',
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}
handleOpenDirectoryDialog={handleOpenDirectoryDialog}
openNewSessionDraft={handleOpenNewSessionDraftFromHeader}
canOpenMultiRun={projects.length > 0}
openMultiRunLauncher={handleOpenMultiRunFromHeader}
headerActionIconClass={headerActionIconClass}
headerActionButtonClass={headerActionButtonClass}
isSessionSearchOpen={isSessionSearchOpen}
setIsSessionSearchOpen={setIsSessionSearchOpen}
sessionSearchInputRef={sessionSearchInputRef}
sessionSearchQuery={sessionSearchQuery}
setSessionSearchQuery={setSessionSearchQuery}
hasSessionSearchQuery={hasSessionSearchQuery}
searchMatchCount={searchMatchCount}
collapseAllProjects={collapseAllProjects}
expandAllProjects={expandAllProjects}
openScheduledTasksDialog={() => setScheduledTasksDialogOpen(true)}
selectionModeEnabled={selectionModeEnabled}
onToggleSelectionMode={handleToggleSelectionMode}
/>
{isVisible ? <SidebarProjectsList
topContent={topContent}
hasSharedSessions={hasActivitySectionItems}
sectionsForRender={sectionsForSidebarRender}
projectSections={projectSections}
activeProjectId={activeProjectId}
showOnlyMainWorkspace={showOnlyMainWorkspace}
hasSessionSearchQuery={hasSessionSearchQuery}
emptyState={emptyState}
searchEmptyState={searchEmptyState}
renderGroupSessions={renderGroupSessions}
homeDirectory={homeDirectory}
collapsedProjects={collapsedProjects}
hideDirectoryControls={hideDirectoryControls}
projectRepoStatus={projectRepoStatus}
isDesktopShellRuntime={isDesktopShellRuntime}
stuckProjectHeaders={stuckProjectHeaders}
mobileVariant={mobileVariant}
alwaysShowActions={alwaysShowSidebarActions}
toggleProject={toggleProject}
setActiveProjectIdOnly={setActiveProjectIdOnly}
setActiveMainTab={setActiveMainTab}
setSessionSwitcherOpen={setSessionSwitcherOpen}
openNewSessionDraft={openNewSessionDraftFromTree}
openNewWorktreeDialog={openNewWorktreeDialog}
openProjectEditDialog={setEditingProjectDialogId}
removeProject={removeProject}
projectHeaderSentinelRefs={projectHeaderSentinelRefs}
reorderProjects={reorderProjects}
projectSortOrder={projectSortOrder}
getOrderedGroups={getOrderedGroups}
setGroupOrderByProject={setGroupOrderByProject}
openSidebarMenuKey={openSidebarMenuKey}
setOpenSidebarMenuKey={setOpenSidebarMenuKey}
isInlineEditing={isInlineEditing}
/> : null}
{selectionModeEnabled && hasSelection ? (
<BulkActionBar
selectedCount={selectedIdsSize}
scopeKey={derivedSelectionScope}
scopeFolders={bulkScopeFolders}
archivedBucket={bulkScopeIsArchived}
onMoveToFolder={handleBulkMoveToFolder}
onCreateFolderAndMove={handleBulkCreateFolderAndMove}
onRemoveFromFolder={handleBulkRemoveFromFolder}
canRemoveFromFolder={bulkCanRemoveFromFolder}
onDelete={handleBulkDelete}
onDone={handleExitSelectionMode}
/>
) : null}
<SidebarFooter
onOpenSettings={handleOpenSettings}
onOpenShortcuts={toggleHelpDialog}
onOpenAbout={() => setAboutDialogOpen(true)}
onOpenUpdate={handleOpenUpdateDialog}
showRuntimeButtons={!isVSCode}
showUpdateButton={showSidebarUpdateButton}
/>
<UpdateDialog
open={updateDialogOpen}
onOpenChange={setUpdateDialogOpen}
info={updateStore.info}
downloading={updateStore.downloading}
downloaded={updateStore.downloaded}
progress={updateStore.progress}
error={updateStore.error}
onDownload={updateStore.downloadUpdate}
onRestart={updateStore.restartToUpdate}
runtimeType={updateStore.runtimeType}
/>
<ProjectEditDialog
open={Boolean(editingProject)}
onOpenChange={(open) => {
if (!open) {
setEditingProjectDialogId(null);
}
}}
project={editingProject}
onSave={handleSaveProjectEdit}
/>
<NewWorktreeDialog
open={newWorktreeDialogOpen}
onOpenChange={setNewWorktreeDialogOpen}
onWorktreeCreated={(worktreePath, options) => {
setActiveMainTab('chat');
if (mobileVariant) {
setSessionSwitcherOpen(false);
}
if (options?.sessionId) {
setCurrentSession(options.sessionId, worktreePath);
return;
}
openNewSessionDraft({ directoryOverride: worktreePath, preserveDirectoryOverride: true });
}}
/>
<ScheduledTasksDialog />
<SessionDeleteConfirmDialog
value={deleteSessionConfirm}
setValue={setDeleteSessionConfirm}
showDeletionDialog={showDeletionDialog}
setShowDeletionDialog={setShowDeletionDialog}
onConfirm={confirmDeleteSession}
/>
<FolderDeleteConfirmDialog
value={deleteFolderConfirm}
setValue={setDeleteFolderConfirm}
onConfirm={confirmDeleteFolder}
/>
<BulkSessionDeleteConfirmDialog
value={bulkDeleteConfirm}
setValue={setBulkDeleteConfirm}
showDeletionDialog={showDeletionDialog}
setShowDeletionDialog={setShowDeletionDialog}
onConfirm={confirmBulkDelete}
/>
</div>
);
};
export const SessionSidebar = React.memo(SessionSidebarComponent);