Recompute focus/visibility staleness at event time, retry closed/merged sidebar associations on the no-PR cadence, and assert refresh failures preserve prior PR status. Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
2011 lines
78 KiB
TypeScript
2011 lines
78 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 { TooltipProvider } from '@/components/ui/tooltip';
|
|
import { NewWorktreeDialog } from './NewWorktreeDialog';
|
|
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
|
|
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
|
|
import { useArchivedAutoFolders } from './sidebar/hooks/useArchivedAutoFolders';
|
|
import { useGroupOrdering } from './sidebar/hooks/useGroupOrdering';
|
|
import { useSessionSidebarSections } from './sidebar/hooks/useSessionSidebarSections';
|
|
import { ProjectSessionSelectionEffect } from './sidebar/hooks/useProjectSessionSelection';
|
|
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 { ProjectEditDialog } from '@/components/layout/ProjectEditDialog';
|
|
import { UpdateDialog } from '@/components/ui/UpdateDialog';
|
|
import { SessionGroupSection } from './sidebar/SessionGroupSection';
|
|
import { SidebarHeader } from './sidebar/SidebarHeader';
|
|
import { SidebarNav } from './sidebar/SidebarNav';
|
|
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,
|
|
partitionWorktreesByRegisteredProject,
|
|
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 {
|
|
formatProjectLabel,
|
|
normalizePath,
|
|
selectExpandedParentKeysForContext,
|
|
toggleExpandedParentKey,
|
|
} from './sidebar/utils';
|
|
import {
|
|
compareSessionsByLifecycleOrder,
|
|
EMPTY_SESSION_ORDER_RANKS,
|
|
orderSessionsByLifecycleScopes,
|
|
useSessionOrderingStore,
|
|
} from '@/sync/session-ordering';
|
|
import { useGlobalSessionStatusStore } from '@/sync/global-session-status';
|
|
import {
|
|
refreshGlobalSessions,
|
|
refreshGlobalSessionsForDirectories,
|
|
getSessionStructuralSignature,
|
|
resolveGlobalSessionDirectory,
|
|
useGlobalSessionsStore,
|
|
} from '@/stores/useGlobalSessionsStore';
|
|
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
|
import { useNotificationStore } from '@/sync/notification-store';
|
|
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
|
import { getGitHubPrStatusKey, useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore';
|
|
import { subscribeOpenchamberEvents } from '@/lib/openchamberEvents';
|
|
import { buildSessionBootstrapDemands } from './sidebar/sessionBootstrapDemands';
|
|
import { recordWorktreesSeen } from './sidebar/worktreeFirstSeen';
|
|
import { getRuntimeKey } from '@/lib/runtime-switch';
|
|
import { streamPerfCount, streamPerfMark } from '@/stores/utils/streamDebug';
|
|
import { runBackgroundNetworkTask } from '@/lib/background-network';
|
|
|
|
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';
|
|
|
|
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;
|
|
};
|
|
|
|
// Aggregated activity/attention dot for a collapsed project header. Only
|
|
// mounted while the project is collapsed, so the per-status-event scans stay
|
|
// rare and bounded by the project's directory count.
|
|
const ProjectAggregateStatusIndicator: React.FC<{ directories: Array<string | null> }> = ({ directories }) => {
|
|
const { t } = useI18n();
|
|
const directorySet = React.useMemo(() => {
|
|
const set = new Set<string>();
|
|
directories.forEach((directory) => {
|
|
const normalized = normalizePath(directory)?.toLowerCase();
|
|
if (normalized) set.add(normalized);
|
|
});
|
|
return set;
|
|
}, [directories]);
|
|
const hasBusySession = useGlobalSessionStatusStore(React.useCallback((state) => {
|
|
for (const entry of state.statusById.values()) {
|
|
if (entry.status.type !== 'busy' && entry.status.type !== 'retry') continue;
|
|
const directory = normalizePath(entry.directory)?.toLowerCase();
|
|
if (directory && directorySet.has(directory)) return true;
|
|
}
|
|
return false;
|
|
}, [directorySet]));
|
|
const hasUnseenNotification = useNotificationStore(React.useCallback((state) => {
|
|
for (const [directory, count] of Object.entries(state.index.project.unseenCount)) {
|
|
if (!count) continue;
|
|
const normalized = normalizePath(directory)?.toLowerCase();
|
|
if (normalized && directorySet.has(normalized)) return true;
|
|
}
|
|
return false;
|
|
}, [directorySet]));
|
|
|
|
// Aggregate header: dot only. A collapsed project can hold several running
|
|
// turns, so a single elapsed counter would have nothing to count.
|
|
if (hasBusySession) {
|
|
return (
|
|
<span
|
|
className="h-1.5 w-1.5 rounded-full bg-primary"
|
|
aria-label={t('sessions.sidebar.session.status.active')}
|
|
title={t('sessions.sidebar.session.status.active')}
|
|
/>
|
|
);
|
|
}
|
|
if (hasUnseenNotification) {
|
|
return (
|
|
<span
|
|
className="h-1.5 w-1.5 rounded-full bg-[var(--status-info)]"
|
|
aria-label={t('sessions.sidebar.session.status.unread')}
|
|
title={t('sessions.sidebar.session.status.unread')}
|
|
/>
|
|
);
|
|
}
|
|
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 [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 sessionOrderRanks = useSessionOrderingStore(React.useCallback(
|
|
(state) => isVisible ? state.rankById : EMPTY_SESSION_ORDER_RANKS,
|
|
[isVisible],
|
|
));
|
|
const activeSessionIds = useGlobalSessionStatusStore(useShallow(
|
|
(state) => isVisible ? [...state.statusById.keys()].sort() : EMPTY_STRING_ARRAY,
|
|
));
|
|
const activeSessionIdSet = React.useMemo(() => new Set(activeSessionIds), [activeSessionIds]);
|
|
const unreadSessionIds = useNotificationStore(useShallow(
|
|
(state) => isVisible
|
|
? Object.entries(state.index.session.unseenCount)
|
|
.filter(([, count]) => count > 0)
|
|
.map(([sessionId]) => sessionId)
|
|
.sort()
|
|
: EMPTY_STRING_ARRAY,
|
|
));
|
|
const unreadSessionIdSet = React.useMemo(() => new Set(unreadSessionIds), [unreadSessionIds]);
|
|
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 setArchivePageOpen = useUIStore((state) => state.setArchivePageOpen);
|
|
const setWorktreesPageProjectId = useUIStore((state) => state.setWorktreesPageProjectId);
|
|
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) => {
|
|
// Starting a draft always leaves any full-page surface, even when a
|
|
// draft was already open (no store transition fires in that case).
|
|
useUIStore.getState().closeMainSurfaces();
|
|
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 [worktreeDiscoveryRevision, requestWorktreeDiscovery] = React.useReducer((revision) => revision + 1, 0);
|
|
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 knownWorktreesByProject = useSessionUIStore.getState().availableWorktreesByProject;
|
|
const worktreesByProject = new Map(knownWorktreesByProject);
|
|
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 {
|
|
const worktrees = await runBackgroundNetworkTask(async () => {
|
|
// Use store-cached isGitRepo when available; fall back to
|
|
// a direct check for projects the Git store hasn't seen yet.
|
|
const cachedIsGitRepo = useGitStore.getState().directories.get(projectPath)?.isGitRepo;
|
|
const isGitRepo = cachedIsGitRepo ?? await checkIsGitRepository(projectPath);
|
|
if (!isGitRepo) return null;
|
|
return listProjectWorktrees({ id: project.id, path: projectPath });
|
|
});
|
|
if (worktrees === null) {
|
|
worktreesByProject.delete(projectPath);
|
|
continue;
|
|
}
|
|
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 partitionedWorktreesByProject = partitionWorktreesByRegisteredProject(projectEntries, worktreesByProject);
|
|
const allWorktrees = [...partitionedWorktreesByProject.values()].flat();
|
|
// Newly appearing worktrees sort to the top of their project's
|
|
// worktree list (see worktreeFirstSeen.ts).
|
|
recordWorktreesSeen(allWorktrees.map((worktree) => worktree.path), Date.now());
|
|
|
|
// Skip update if nothing changed — see worktreeMapsEqual JSDoc.
|
|
if (!worktreeMapsEqual(partitionedWorktreesByProject, knownWorktreesByProject)) {
|
|
useSessionUIStore.setState({
|
|
availableWorktrees: allWorktrees,
|
|
availableWorktreesByProject: partitionedWorktreesByProject,
|
|
});
|
|
}
|
|
setUnresolvedWorktreeProjectPaths(unresolvedProjectPaths);
|
|
setResolvedWorktreeTopologyKey(projectWorktreeDiscoveryKey);
|
|
};
|
|
|
|
void discoverWorktrees();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [isVSCode, projectWorktreeDiscoveryKey, runtimeKey, worktreeDiscoveryRevision]);
|
|
|
|
React.useEffect(() => {
|
|
let refreshTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
let needsGlobalRefresh = false;
|
|
const sessionDirectories = new Set<string>();
|
|
const unsubscribe = subscribeOpenchamberEvents((event) => {
|
|
if (event.type === 'scheduled-task-ran') {
|
|
needsGlobalRefresh = true;
|
|
} else if (event.type === 'session-created') {
|
|
sessionDirectories.add(event.directory);
|
|
requestWorktreeDiscovery();
|
|
} else {
|
|
// Browser control events carry no session state; nothing to refresh.
|
|
return;
|
|
}
|
|
if (refreshTimeout) {
|
|
clearTimeout(refreshTimeout);
|
|
}
|
|
refreshTimeout = setTimeout(() => {
|
|
refreshTimeout = null;
|
|
if (needsGlobalRefresh) {
|
|
needsGlobalRefresh = false;
|
|
sessionDirectories.clear();
|
|
void refreshGlobalSessions(syncSessionsSnapshotRef.current);
|
|
return;
|
|
}
|
|
const directories = [...sessionDirectories];
|
|
sessionDirectories.clear();
|
|
if (directories.length > 0) {
|
|
void refreshGlobalSessionsForDirectories(directories, 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,
|
|
sessionOrderRanks,
|
|
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 orderedSessions = React.useMemo(() => {
|
|
return orderSessionsByLifecycleScopes(sessions, pinnedSessionIds, sessionOrderRanks);
|
|
}, [pinnedSessionIds, sessionOrderRanks, sessions]);
|
|
|
|
// Reuse the index while the ordered IDs stay unchanged.
|
|
// Without this, a fresh `orderedSessions` 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(
|
|
() => orderedSessions.map((session) => session.id).join('|'),
|
|
[orderedSessions],
|
|
);
|
|
|
|
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(orderedSessions.map((session, index) => [session.id, index]));
|
|
sessionOrderIndexRef.current = { signature: sessionOrderSignature, map: next };
|
|
return next;
|
|
}, [orderedSessions, sessionOrderSignature]);
|
|
|
|
const childrenMap = React.useMemo(() => {
|
|
const map = new Map<string, Session[]>();
|
|
orderedSessions.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) => compareSessionsByLifecycleOrder(a, b, pinnedSessionIds, sessionOrderRanks)));
|
|
return map;
|
|
}, [orderedSessions, pinnedSessionIds, sessionOrderRanks]);
|
|
|
|
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 unarchiveSession = useSessionUIStore((state) => state.unarchiveSession);
|
|
const unarchiveSessions = useSessionUIStore((state) => state.unarchiveSessions);
|
|
|
|
const {
|
|
copiedSessionId,
|
|
handleSessionSelect,
|
|
handleSessionDoubleClick,
|
|
handleSaveEdit,
|
|
handleCancelEdit,
|
|
handleShareSession,
|
|
handleCopyShareUrl,
|
|
handleCopySessionId,
|
|
handleUnshareSession,
|
|
handleDeleteSession,
|
|
handleRestoreSession,
|
|
confirmDeleteSession,
|
|
} = useSessionActions({
|
|
mobileVariant,
|
|
allowReselect,
|
|
onSessionSelected,
|
|
isSessionSearchOpen,
|
|
sessionSearchQuery,
|
|
setSessionSearchQuery,
|
|
setIsSessionSearchOpen,
|
|
setActiveMainTab,
|
|
setSessionSwitcherOpen,
|
|
setCurrentSession,
|
|
updateSessionTitle,
|
|
shareSession,
|
|
unshareSession,
|
|
deleteSession,
|
|
deleteSessions,
|
|
archiveSession,
|
|
archiveSessions,
|
|
unarchiveSession,
|
|
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 stableHandleCopySessionId = useStableRenderCallback(handleCopySessionId);
|
|
const stableHandleUnshareSession = useStableRenderCallback(handleUnshareSession);
|
|
const stableHandleDeleteSession = useStableRenderCallback(handleDeleteSession);
|
|
const stableHandleRestoreSession = useStableRenderCallback(handleRestoreSession);
|
|
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;
|
|
});
|
|
}, []);
|
|
|
|
// Collapse/expand covers both levels: projects and their worktree groups.
|
|
const projectSectionsRef = React.useRef<typeof projectSections>([]);
|
|
|
|
const collapseAllProjects = React.useCallback(() => {
|
|
ignoreIntersectionUntil.current = Date.now() + 150;
|
|
setVisibleSessionCountByGroup(new Map());
|
|
setCollapsedGroups(() => {
|
|
const allGroupKeys = new Set<string>();
|
|
projectSectionsRef.current.forEach((section) => {
|
|
section.groups.forEach((group) => {
|
|
if (!group.isMain) allGroupKeys.add(`${section.project.id}:${group.id}`);
|
|
});
|
|
});
|
|
return allGroupKeys;
|
|
});
|
|
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());
|
|
setCollapsedGroups(new Set());
|
|
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 stickyZoneHeaders = useSessionDisplayStore((state) => state.stickyZoneHeaders);
|
|
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,
|
|
sessionOrderRanks,
|
|
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,
|
|
};
|
|
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,
|
|
flatSectionsForRender,
|
|
searchMatchCount,
|
|
} = useSessionSidebarSections({
|
|
normalizedProjects: sortedProjects,
|
|
getSessionsForProject,
|
|
getArchivedSessionsForProject,
|
|
availableWorktreesByProject,
|
|
projectRepoStatus,
|
|
projectRootBranches,
|
|
lastRepoStatus: lastRepoStatusRef.current,
|
|
buildGroupedSessions,
|
|
hasSessionSearchQuery,
|
|
normalizedSessionSearchQuery,
|
|
filterSessionNodesForSearch,
|
|
buildGroupSearchText,
|
|
foldersMap,
|
|
});
|
|
|
|
projectSectionsRef.current = projectSections;
|
|
|
|
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 recentSessions = React.useMemo(() => {
|
|
if (!showRecentSection || isVSCode) {
|
|
return [];
|
|
}
|
|
|
|
return deriveRecentSessions(sessions, activeSessionIdSet)
|
|
.sort((a, b) => compareSessionsByLifecycleOrder(a, b, pinnedSessionIds, sessionOrderRanks));
|
|
}, [activeSessionIdSet, isVSCode, pinnedSessionIds, sessionOrderRanks, sessions, showRecentSection]);
|
|
|
|
// Prefetch is wired below, after recentSessions 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 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 },
|
|
];
|
|
}, [filterSessionNodesForSearch, hasSessionSearchQuery, isVSCode, normalizedSessionSearchQuery, recentSessions, sessionSidebarMetaById, showRecentSection, t]);
|
|
|
|
const hasActivitySectionItems = React.useMemo(
|
|
() => activitySections.some((section) => section.items.length > 0),
|
|
[activitySections],
|
|
);
|
|
|
|
|
|
// Web/desktop route archived sessions to the Archive page; only the VS Code
|
|
// compact webview keeps inline archived buckets behind its toggle.
|
|
const showInlineArchived = isVSCode && showArchivedSessions;
|
|
// 'by-worktree' renders the worktree-grouped sections (parallel-work
|
|
// overview); 'flat' renders the merged per-project list. VS Code has no
|
|
// worktree groups, so both resolve to the same shape — use flat there.
|
|
const sessionGroupingMode = useSessionDisplayStore((state) => state.sessionGroupingMode);
|
|
const useGroupedSections = sessionGroupingMode === 'by-worktree' && !isVSCode;
|
|
const sectionsForSidebarRender = React.useMemo(() => {
|
|
const source = useGroupedSections ? sectionsForRender : flatSectionsForRender;
|
|
return showInlineArchived
|
|
? source
|
|
: source.map((section) => (
|
|
section.groups.some((group) => group.isArchivedBucket)
|
|
? { ...section, groups: section.groups.filter((group) => !group.isArchivedBucket) }
|
|
: section
|
|
));
|
|
}, [flatSectionsForRender, sectionsForRender, showInlineArchived, useGroupedSections]);
|
|
|
|
// Discover/refresh PR status for expanded projects' worktree branches so
|
|
// session rows can tint their branch marker and show PR state in tooltips.
|
|
// The data source is the worktree-grouped projectSections (data layer), not
|
|
// the flat display sections.
|
|
const retriedNoPrStatusKeysRef = React.useRef<Set<string>>(new Set());
|
|
React.useEffect(() => {
|
|
if (!isVisible || !githubAuthChecked || !githubAuthStatus?.connected || !github) {
|
|
return;
|
|
}
|
|
|
|
const targetsByKey = new Map<string, { directory: string; branch: string }>();
|
|
const now = Date.now();
|
|
|
|
projectSections.forEach((section) => {
|
|
if (collapsedProjects.has(section.project.id)) {
|
|
return;
|
|
}
|
|
|
|
section.groups.forEach((group) => {
|
|
if (group.isArchivedBucket || group.isMain) {
|
|
return;
|
|
}
|
|
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 prState = entry?.status?.pr?.state;
|
|
const isTerminalPr = prState === 'closed' || prState === 'merged';
|
|
// Closed/merged associations are not live branch status — retry them on
|
|
// the same cadence as missing PRs so a newer open PR can appear.
|
|
const hasLivePr = Boolean(entry?.status?.pr) && !isTerminalPr;
|
|
const retryKey = `${directory}::${branch}`;
|
|
const noPrLastCheckedAt = Math.max(entry?.lastRefreshAt ?? 0, entry?.lastDiscoveryPollAt ?? 0);
|
|
const shouldRetryNoPr = Boolean(
|
|
entry?.isInitialStatusResolved
|
|
&& !hasLivePr
|
|
&& (
|
|
!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,
|
|
projectSections,
|
|
refreshPrStatusTargets,
|
|
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 && stickyZoneHeaders,
|
|
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}
|
|
handleCopySessionId={stableHandleCopySessionId}
|
|
handleUnshareSession={stableHandleUnshareSession}
|
|
openSidebarMenuKey={openSidebarMenuKey}
|
|
setOpenSidebarMenuKey={setOpenSidebarMenuKey}
|
|
renamingFolderId={renamingFolderId}
|
|
getFoldersForScope={getFoldersForScope}
|
|
getSessionFolderId={getSessionFolderId}
|
|
removeSessionFromFolder={removeSessionFromFolder}
|
|
addSessionToFolder={addSessionToFolder}
|
|
createFolderAndStartRename={stableCreateFolderAndStartRename}
|
|
openContextPanelTab={openContextPanelTab}
|
|
handleDeleteSession={stableHandleDeleteSession}
|
|
handleRestoreSession={stableHandleRestoreSession}
|
|
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}
|
|
/>
|
|
),
|
|
);
|
|
|
|
// Selection scope is the project id; bulk folder actions need the project's
|
|
// directory scopes (root + worktrees) to resolve folders across worktrees.
|
|
const folderScopesByProject = React.useMemo(() => {
|
|
const map = new Map<string, Array<{ scopeKey: string; directory: string | null }>>();
|
|
flatSectionsForRender.forEach((section) => {
|
|
const flatGroup = section.groups.find((group) => !group.isArchivedBucket);
|
|
if (flatGroup?.folderScopes && flatGroup.folderScopes.length > 0) {
|
|
map.set(section.project.id, flatGroup.folderScopes);
|
|
}
|
|
});
|
|
return map;
|
|
}, [flatSectionsForRender]);
|
|
|
|
const renderProjectStatusIndicator = React.useCallback((_projectId: string, groups: SessionGroup[]) => {
|
|
const directories: Array<string | null> = [];
|
|
groups.forEach((group) => {
|
|
if (group.isArchivedBucket) return;
|
|
directories.push(group.directory);
|
|
group.folderScopes?.forEach((scope) => directories.push(scope.directory));
|
|
});
|
|
return <ProjectAggregateStatusIndicator directories={directories} />;
|
|
}, []);
|
|
|
|
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 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}
|
|
dragHandleProps={dragHandleProps}
|
|
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}
|
|
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}
|
|
activeActivitySessionIds={activeSessionIdSet}
|
|
unreadActivitySessionIds={unreadSessionIdSet}
|
|
notifyOnSubtasks={notifyOnSubtasks}
|
|
onToggleCollapsedGroup={toggleCollapsedGroup}
|
|
scrollContainerRef={scrollContainerRef}
|
|
/>
|
|
),
|
|
[
|
|
hasSessionSearchQuery,
|
|
normalizedSessionSearchQuery,
|
|
groupSearchDataByGroup,
|
|
visibleSessionCountByGroup,
|
|
collapsedGroups,
|
|
hideDirectoryControls,
|
|
collapsedFolderIds,
|
|
toggleFolderCollapse,
|
|
renameFolder,
|
|
deleteFolder,
|
|
showDeletionDialog,
|
|
renderSessionNode,
|
|
showMoreGroupSessions,
|
|
resetGroupSessionLimit,
|
|
mobileVariant,
|
|
alwaysShowSidebarActions,
|
|
activeProjectId,
|
|
setActiveProjectIdOnly,
|
|
setActiveMainTab,
|
|
setSessionSwitcherOpen,
|
|
openNewSessionDraftFromTree,
|
|
addSessionToFolder,
|
|
stableCreateFolderAndStartRename,
|
|
renamingFolderId,
|
|
renameFolderDraft,
|
|
pinnedSessionIds,
|
|
projectExpandedParents,
|
|
sessionOrderIndex,
|
|
editingId,
|
|
editTitle,
|
|
openSidebarMenuKey,
|
|
activeSessionIdSet,
|
|
unreadSessionIdSet,
|
|
notifyOnSubtasks,
|
|
toggleCollapsedGroup,
|
|
],
|
|
);
|
|
|
|
const topContent = React.useMemo(
|
|
() => (!isVSCode && showRecentSection && !hasSessionSearchQuery) ? (
|
|
<SidebarActivitySections
|
|
sections={activitySections}
|
|
renderSessionNode={renderSessionNode}
|
|
editingId={editingId}
|
|
openSidebarMenuKey={openSidebarMenuKey}
|
|
expansionState={recentExpandedParents}
|
|
variant="section"
|
|
isDesktopShellRuntime={isDesktopShellRuntime}
|
|
/>
|
|
) : null,
|
|
[activitySections, editingId, hasSessionSearchQuery, isDesktopShellRuntime, 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,
|
|
handleBulkRestore,
|
|
confirmBulkDelete,
|
|
} = useSidebarBulkActions({
|
|
isInlineEditing,
|
|
showDeletionDialog,
|
|
foldersMap,
|
|
folderScopesByProject,
|
|
addSessionsToFolder,
|
|
removeSessionsFromFolders,
|
|
createFolderAndStartRename,
|
|
archiveSessions,
|
|
unarchiveSessions,
|
|
deleteSessions,
|
|
setBulkDeleteConfirm,
|
|
});
|
|
const handleOpenMultiRunFromHeader = React.useCallback(() => {
|
|
setActiveMainTab('chat');
|
|
if (mobileVariant) {
|
|
setSessionSwitcherOpen(false);
|
|
}
|
|
openMultiRunLauncher();
|
|
}, [mobileVariant, openMultiRunLauncher, setActiveMainTab, setSessionSwitcherOpen]);
|
|
|
|
const handleOpenNewSessionDraftFromHeader = React.useCallback(() => {
|
|
useUIStore.getState().closeMainSurfaces();
|
|
setActiveMainTab('chat');
|
|
if (mobileVariant) {
|
|
setSessionSwitcherOpen(false);
|
|
}
|
|
openNewSessionDraft();
|
|
}, [mobileVariant, openNewSessionDraft, setActiveMainTab, setSessionSwitcherOpen]);
|
|
|
|
return (
|
|
// One shared tooltip provider for the whole sidebar: session tooltips open
|
|
// instantly, and moving between rows hands the tooltip over (grouping)
|
|
// instead of replaying the exit/enter animation for each row.
|
|
// closeDelay bridges the small gap between rows: the tooltip survives the
|
|
// pointer crossing row margins, and the grouping timeout hands it over to
|
|
// the next row without an exit/enter cycle.
|
|
<TooltipProvider delay={0} closeDelay={150} timeout={600}>
|
|
<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}
|
|
sessionOwnerBySessionId={sessionOwnership.bySessionId}
|
|
/>
|
|
<SessionPrefetchEffect
|
|
enabled={isVisible}
|
|
sortedSessions={orderedSessions}
|
|
recentSessions={recentSessions}
|
|
prefetchSession={sync.prefetchSession}
|
|
/>
|
|
{!hideDirectoryControls && !isVSCode ? (
|
|
<SidebarNav onNewSession={handleOpenNewSessionDraftFromHeader} />
|
|
) : null}
|
|
|
|
<SidebarHeader
|
|
hideDirectoryControls={hideDirectoryControls}
|
|
showRecentControls={!isVSCode}
|
|
handleOpenDirectoryDialog={handleOpenDirectoryDialog}
|
|
onOpenScheduled={() => {
|
|
if (mobileVariant) setSessionSwitcherOpen(false);
|
|
setScheduledTasksDialogOpen(true);
|
|
}}
|
|
onOpenMultiRun={handleOpenMultiRunFromHeader}
|
|
canOpenMultiRun={projects.length > 0}
|
|
onOpenArchive={() => {
|
|
if (mobileVariant) setSessionSwitcherOpen(false);
|
|
setArchivePageOpen(true);
|
|
}}
|
|
headerActionIconClass={headerActionIconClass}
|
|
headerActionButtonClass={headerActionButtonClass}
|
|
isSessionSearchOpen={isSessionSearchOpen}
|
|
setIsSessionSearchOpen={setIsSessionSearchOpen}
|
|
sessionSearchInputRef={sessionSearchInputRef}
|
|
sessionSearchQuery={sessionSearchQuery}
|
|
setSessionSearchQuery={setSessionSearchQuery}
|
|
hasSessionSearchQuery={hasSessionSearchQuery}
|
|
searchMatchCount={searchMatchCount}
|
|
collapseAllProjects={collapseAllProjects}
|
|
expandAllProjects={expandAllProjects}
|
|
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}
|
|
stickyZoneHeaders={stickyZoneHeaders}
|
|
stuckProjectHeaders={stuckProjectHeaders}
|
|
mobileVariant={mobileVariant}
|
|
alwaysShowActions={alwaysShowSidebarActions}
|
|
toggleProject={toggleProject}
|
|
setActiveProjectIdOnly={setActiveProjectIdOnly}
|
|
setActiveMainTab={setActiveMainTab}
|
|
setSessionSwitcherOpen={setSessionSwitcherOpen}
|
|
openNewSessionDraft={openNewSessionDraftFromTree}
|
|
openNewWorktreeDialog={openNewWorktreeDialog}
|
|
openWorktreesPage={(projectId) => {
|
|
if (mobileVariant) setSessionSwitcherOpen(false);
|
|
setWorktreesPageProjectId(projectId);
|
|
}}
|
|
openProjectEditDialog={setEditingProjectDialogId}
|
|
removeProject={removeProject}
|
|
projectHeaderSentinelRefs={projectHeaderSentinelRefs}
|
|
reorderProjects={reorderProjects}
|
|
projectSortOrder={projectSortOrder}
|
|
getOrderedGroups={getOrderedGroups}
|
|
setGroupOrderByProject={setGroupOrderByProject}
|
|
renderProjectStatusIndicator={renderProjectStatusIndicator}
|
|
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}
|
|
onRestore={handleBulkRestore}
|
|
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 });
|
|
}}
|
|
/>
|
|
|
|
<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>
|
|
</TooltipProvider>
|
|
);
|
|
};
|
|
|
|
export const SessionSidebar = React.memo(SessionSidebarComponent);
|