Files
openchamber/packages/ui/src/components/session/SessionSidebar.tsx
T
Bohdan Triapitsyn c83986cd94 feat: improve project directory picker
Adds command-palette style project browsing
Keeps native Finder picker as optional desktop action
Supports disabled existing projects and create-and-add paths
2026-04-27 15:45:14 +03:00

1772 lines
65 KiB
TypeScript

import React from 'react';
import type { Session } from '@opencode-ai/sdk/v2';
import { RiLayoutLeftLine } from '@remixicon/react';
import { toast } from '@/components/ui';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useI18n } from '@/lib/i18n';
import { isDesktopShell } from '@/lib/desktop';
import { isDesktopWindowFullscreen as getDesktopWindowFullscreen, onDesktopWindowResized, startDesktopWindowDrag } from '@/lib/desktopNative';
import { sessionEvents } from '@/lib/sessionEvents';
import { formatDirectoryName, cn } from '@/lib/utils';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useAllLiveSessions, useAllSessionStatuses } from '@/sync/sync-context';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useSync } from '@/sync/use-sync';
import { useSessionPrefetch } from './sidebar/hooks/useSessionPrefetch';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useUIStore } from '@/stores/useUIStore';
import { getSafeStorage } 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 { useProjectSessionSelection } 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 { useSessionFolderCleanup } from './sidebar/hooks/useSessionFolderCleanup';
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 { useUpdateStore } from '@/stores/useUpdateStore';
import { useShallow } from 'zustand/react/shallow';
import { listProjectWorktrees } from '@/lib/worktrees/worktreeManager';
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 { useSessionMultiSelectStore } from '@/stores/useSessionMultiSelectStore';
import { type SessionGroup, type SessionNode } from './sidebar/types';
import {
type ActiveNowEntry,
addActiveNowSession,
deriveActiveNowSessions,
deriveLiveActiveNowSessions,
persistActiveNowEntries,
pruneActiveNowEntries,
readActiveNowEntries,
} from './sidebar/activitySections';
import {
compareSessionsByPinnedAndTime,
formatProjectLabel,
normalizePath,
} from './sidebar/utils';
import { refreshGlobalSessions, resolveGlobalSessionDirectory, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
import { subscribeOpenchamberEvents } from '@/lib/openchamberEvents';
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';
const SESSION_EXPANDED_STORAGE_KEY = 'oc.sessions.expandedParents';
const SESSION_PINNED_STORAGE_KEY = 'oc.sessions.pinned';
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[]>,
): Set<string> => {
const directories = new Set<string>();
for (const project of projects) {
const normalized = normalizePath(project.path)?.toLowerCase();
if (normalized) directories.add(normalized);
}
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>): boolean => {
if (session.time?.archived) return true;
const directory = normalizePath(resolveGlobalSessionDirectory(session))?.toLowerCase();
if (!directory) return true;
if (knownDirectories.size === 0) return true;
return knownDirectories.has(directory);
};
interface SessionSidebarProps {
mobileVariant?: boolean;
onSessionSelected?: (sessionId: string) => void;
allowReselect?: boolean;
hideDirectoryControls?: boolean;
showOnlyMainWorkspace?: boolean;
}
export const SessionSidebar: React.FC<SessionSidebarProps> = ({
mobileVariant = false,
onSessionSelected,
allowReselect = false,
hideDirectoryControls = false,
showOnlyMainWorkspace = false,
}) => {
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 [directoryStatus] = React.useState<Map<string, 'unknown' | 'exists' | 'missing'>>(
() => new Map(),
);
const safeStorage = React.useMemo(() => getSafeStorage(), []);
const [activeNowEntries, setActiveNowEntries] = React.useState<ActiveNowEntry[]>(() => readActiveNowEntries(safeStorage));
const [collapsedProjects, setCollapsedProjects] = React.useState<Set<string>>(new Set());
const [projectRepoStatus, setProjectRepoStatus] = React.useState<Map<string, boolean | null>>(new Map());
const [expandedSessionGroups, setExpandedSessionGroups] = React.useState<Set<string>>(new Set());
const [newWorktreeDialogOpen, setNewWorktreeDialogOpen] = React.useState(false);
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, setPinnedSessionIds] = React.useState<Set<string>>(() => {
try {
const raw = getSafeStorage().getItem(SESSION_PINNED_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 [collapsedGroups, setCollapsedGroups] = React.useState<Set<string>>(() => {
try {
const raw = getSafeStorage().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 = getSafeStorage().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 [activeSessionByProject, setActiveSessionByProject] = React.useState<Map<string, string>>(() => {
try {
const raw = getSafeStorage().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 [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 setDirectory = useDirectoryStore((state) => state.setDirectory);
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 toggleSidebar = useUIStore((state) => state.toggleSidebar);
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 cleanupSessions = useSessionFoldersStore((state) => state.cleanupSessions);
const getSessionFolderId = useSessionFoldersStore((state) => state.getSessionFolderId);
useSessionSearchEffects({
isSessionSearchOpen,
setIsSessionSearchOpen,
sessionSearchInputRef,
sessionSearchContainerRef,
});
const gitBranches = useGitAllBranches();
const sync = useSync();
const liveSessions = useAllLiveSessions();
const liveSessionStatuses = useAllSessionStatuses();
const hasLoadedGlobalSessions = useGlobalSessionsStore((state) => state.hasLoaded);
const globalActiveSessions = useGlobalSessionsStore((state) => state.activeSessions);
const archivedSessions = useGlobalSessionsStore((state) => state.archivedSessions);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const newSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open));
const 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);
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),
[availableWorktreesByProject, projects],
);
const sessions = React.useMemo(() => {
const liveById = new Map(liveSessions.map((session) => [session.id, session]));
const merged = globalActiveSessions.map((session) => liveById.get(session.id) ?? session);
const seenIds = new Set(merged.map((session) => session.id));
liveSessions.forEach((session) => {
if (seenIds.has(session.id)) {
return;
}
merged.push(session);
});
return merged.filter((session) => isKnownActiveSessionDirectory(session, knownSessionDirectories));
}, [globalActiveSessions, knownSessionDirectories, liveSessions]);
const syncSessionStructureSignature = React.useMemo(
() => liveSessions
.map((session) => {
const directory = normalizePath((session as Session & { directory?: string | null }).directory ?? null) ?? '';
return `${session.id}:${session.title ?? ''}:${session.time?.archived ? 1 : 0}:${directory}`;
})
.join('|'),
[liveSessions],
);
const syncSessionsSnapshotRef = React.useRef<Session[]>(liveSessions);
React.useEffect(() => {
syncSessionsSnapshotRef.current = liveSessions;
}, [syncSessionStructureSignature, liveSessions]);
React.useEffect(() => {
let cancelled = false;
const discoverWorktrees = async () => {
const projectEntries = useProjectsStore.getState().projects;
if (projectEntries.length === 0) return;
const worktreesByProject = new Map<string, WorktreeMetadata[]>();
const allWorktrees: WorktreeMetadata[] = [];
await Promise.all(
projectEntries.map(async (project) => {
const projectPath = normalizePath(project.path);
if (!projectPath) return;
try {
// Use store-cached isGitRepo when available; fall back to direct check for initial worktree discovery
const cachedIsGitRepo = useGitStore.getState().directories.get(projectPath)?.isGitRepo;
const isGitRepo = cachedIsGitRepo ?? await import('@/lib/gitApi').then(m => m.checkIsGitRepository(projectPath));
if (!isGitRepo) return;
const worktrees = await listProjectWorktrees({ id: project.id, path: projectPath });
if (cancelled || worktrees.length === 0) return;
worktreesByProject.set(projectPath, worktrees);
allWorktrees.push(...worktrees);
} catch {
// ignore discovery errors
}
}),
);
if (cancelled) return;
useSessionUIStore.setState({
availableWorktrees: allWorktrees,
availableWorktreesByProject: worktreesByProject,
});
};
void refreshGlobalSessions(syncSessionsSnapshotRef.current);
void discoverWorktrees();
return () => {
cancelled = true;
};
}, [currentDirectory, syncSessionStructureSignature, projects]);
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 [isDesktopWindowFullscreen, setIsDesktopWindowFullscreen] = React.useState(false);
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
const isMacPlatform = React.useMemo(() => {
if (typeof navigator === 'undefined') {
return false;
}
return /Macintosh|Mac OS X/.test(navigator.userAgent || '');
}, []);
const isWebRuntime = !mobileVariant && !isVSCode && !isDesktopShellRuntime;
const showDesktopSidebarChrome = !mobileVariant && !isVSCode && !isWebRuntime;
const desktopSidebarTopPaddingClass = isDesktopShellRuntime && isMacPlatform && !isDesktopWindowFullscreen ? 'pl-[5.5rem]' : 'pl-3';
const desktopSidebarToggleButtonClass = 'app-region-no-drag inline-flex h-8 w-8 items-center justify-center rounded-md typography-ui-label font-medium text-foreground transition-colors hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50';
React.useEffect(() => {
if (!isDesktopShellRuntime || !isMacPlatform) {
setIsDesktopWindowFullscreen(false);
return;
}
let disposed = false;
let unlistenResize: (() => void) | null = null;
const syncFullscreenState = async () => {
try {
const fullscreen = await getDesktopWindowFullscreen();
if (!disposed) {
setIsDesktopWindowFullscreen(fullscreen);
}
} catch {
if (!disposed) {
setIsDesktopWindowFullscreen(false);
}
}
};
const attach = async () => {
try {
unlistenResize = onDesktopWindowResized(() => {
void syncFullscreenState();
});
} catch {
// Ignore listener setup failures; fallback state remains false.
}
};
void syncFullscreenState();
void attach();
return () => {
disposed = true;
if (unlistenResize) {
unlistenResize();
}
};
}, [isDesktopShellRuntime, isMacPlatform]);
const handleDesktopSidebarDragStart = React.useCallback(async (event: React.MouseEvent) => {
const target = event.target as HTMLElement;
if (target.closest('.app-region-no-drag')) {
return;
}
if (target.closest('button, a, input, select, textarea')) {
return;
}
if (event.button !== 0) {
return;
}
if (!isDesktopShellRuntime) {
return;
}
await startDesktopWindowDrag();
}, [isDesktopShellRuntime]);
const {
buildGroupSearchText,
filterSessionNodesForSearch,
buildGroupedSessions,
} = useSessionGrouping({
homeDirectory,
worktreeMetadata,
pinnedSessionIds,
gitBranches,
isVSCode,
});
const { scheduleCollapsedProjectsPersist } = useSidebarPersistence({
isVSCode,
hasLoadedGlobalSessions,
safeStorage,
keys: {
sessionExpanded: SESSION_EXPANDED_STORAGE_KEY,
projectCollapse: PROJECT_COLLAPSE_STORAGE_KEY,
sessionPinned: SESSION_PINNED_STORAGE_KEY,
groupOrder: GROUP_ORDER_STORAGE_KEY,
projectActiveSession: PROJECT_ACTIVE_SESSION_STORAGE_KEY,
groupCollapse: GROUP_COLLAPSE_STORAGE_KEY,
},
sessions,
pinnedSessionIds,
setPinnedSessionIds,
groupOrderByProject,
activeSessionByProject,
collapsedGroups,
setExpandedParents,
setCollapsedProjects,
});
const togglePinnedSession = React.useCallback((sessionId: string) => {
setPinnedSessionIds((prev) => {
const next = new Set(prev);
if (next.has(sessionId)) {
next.delete(sessionId);
} else {
next.add(sessionId);
}
return next;
});
}, []);
const sortedSessions = React.useMemo(() => {
return [...sessions].sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds));
}, [sessions, pinnedSessionIds]);
const sessionOrderIndex = React.useMemo(
() => new Map(sortedSessions.map((session, index) => [session.id, index])),
[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 = (
<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>
);
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 }) => {
if (!editingProjectDialogId) {
return;
}
updateProjectMeta(editingProjectDialogId, data);
setEditingProjectDialogId(null);
}, [editingProjectDialogId, updateProjectMeta]);
const openNewWorktreeDialog = React.useCallback(() => {
setNewWorktreeDialogOpen(true);
}, []);
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({
activeProjectId,
currentDirectory,
currentSessionId,
mobileVariant,
allowReselect,
onSessionSelected,
isSessionSearchOpen,
sessionSearchQuery,
setSessionSearchQuery,
setIsSessionSearchOpen,
setActiveProjectIdOnly,
setDirectory,
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();
}, []);
// Auto-expand parent session when navigating to a subagent (child) session
React.useEffect(() => {
if (!currentSessionId) return;
const current = sessions.find((s) => s.id === currentSessionId);
const parentID = (current as Session & { parentID?: string | null })?.parentID;
if (!parentID) return;
setExpandedParents((prev) => {
if (prev.has(parentID)) return prev;
const next = new Set(prev);
next.add(parentID);
try {
safeStorage.setItem(SESSION_EXPANDED_STORAGE_KEY, JSON.stringify(Array.from(next)));
} catch { /* ignored */ }
return next;
});
}, [currentSessionId, sessions, safeStorage]);
const toggleParent = React.useCallback((sessionId: string) => {
setExpandedParents((prev) => {
const next = new Set(prev);
if (next.has(sessionId)) {
next.delete(sessionId);
} else {
next.add(sessionId);
}
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 toggleGroupSessionLimit = React.useCallback((groupId: string) => {
setExpandedSessionGroups((prev) => {
const next = new Set(prev);
if (next.has(groupId)) {
next.delete(groupId);
} else {
next.add(groupId);
}
return next;
});
}, []);
const collapseAllProjects = React.useCallback(() => {
ignoreIntersectionUntil.current = Date.now() + 150;
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;
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;
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, 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;
}>;
}, [projects]);
const normalizedProjectPaths = React.useMemo(
() => normalizedProjects.map((project) => project.normalizedPath),
[normalizedProjects],
);
const { github } = useRuntimeAPIs();
const githubAuthStatus = useGitHubAuthStore((state) => state.status);
const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
const gitRepoStatus = useGitRepoStatusMap(normalizedProjectPaths);
const ensurePrStatusEntry = useGitHubPrStatusStore((state) => state.ensureEntry);
const setPrStatusParams = useGitHubPrStatusStore((state) => state.setParams);
const refreshPrStatusTargets = useGitHubPrStatusStore((state) => state.refreshTargets);
useProjectRepoStatus({
normalizedProjects,
gitRepoStatus,
setProjectRepoStatus,
setProjectRootBranches,
});
const isSessionsLoading = useSessionUIStore((state) => state.isLoading);
useSessionFolderCleanup({
isSessionsLoading,
sessions,
archivedSessions,
normalizedProjects,
isVSCode,
availableWorktreesByProject,
cleanupSessions,
});
const { getSessionsForProject, getArchivedSessionsForProject } = useProjectSessionLists({
isVSCode,
sessions,
archivedSessions,
availableWorktreesByProject,
});
useArchivedAutoFolders({
normalizedProjects,
sessions,
archivedSessions,
availableWorktreesByProject,
isVSCode,
isSessionsLoading,
foldersMap,
createFolder,
addSessionToFolder,
cleanupSessions,
});
// 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 {
projectSections,
groupSearchDataByGroup,
sectionsForRender,
searchMatchCount,
} = useSessionSidebarSections({
normalizedProjects,
getSessionsForProject,
getArchivedSessionsForProject,
availableWorktreesByProject,
projectRepoStatus,
projectRootBranches,
lastRepoStatus: lastRepoStatusRef.current,
buildGroupedSessions,
hasSessionSearchQuery,
normalizedSessionSearchQuery,
filterSessionNodesForSearch,
buildGroupSearchText,
foldersMap,
});
const searchEmptyState = (
<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>
);
const reserveHeaderActionsSpace = true;
const { currentSessionDirectory } = useProjectSessionSelection({
projectSections,
activeProjectId,
activeSessionByProject,
setActiveSessionByProject,
currentSessionId,
handleSessionSelect,
newSessionDraftOpen,
mobileVariant,
openNewSessionDraft,
setActiveMainTab,
setSessionSwitcherOpen,
sessions,
worktreeMetadata,
});
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 secondaryMeta = group.branch && group.branch !== projectLabel
? { projectLabel, branchLabel: group.branch }
: { projectLabel, branchLabel: null };
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(
() => deriveActiveNowSessions(activeNowEntries, new Map(sessions.map((session) => [session.id, session]))),
[activeNowEntries, sessions],
);
const liveActiveSessions = React.useMemo(
() => deriveLiveActiveNowSessions(sessions, liveSessionStatuses),
[liveSessionStatuses, sessions],
);
React.useEffect(() => {
if (liveActiveSessions.length === 0) {
return;
}
setActiveNowEntries((prev) => {
const next = liveActiveSessions.reduce((entries, session) => addActiveNowSession(entries, session.id), prev);
if (next === prev) {
return prev;
}
persistActiveNowEntries(safeStorage, next);
return next;
});
}, [liveActiveSessions, safeStorage]);
React.useEffect(() => {
const allKnownSessionsById = new Map<string, Session>();
[...sessions, ...archivedSessions].forEach((session) => {
allKnownSessionsById.set(session.id, session);
});
const pruned = pruneActiveNowEntries(activeNowEntries, allKnownSessionsById);
if (pruned.length === activeNowEntries.length && pruned.every((entry, index) => entry.sessionId === activeNowEntries[index]?.sessionId)) {
return;
}
setActiveNowEntries(pruned);
persistActiveNowEntries(safeStorage, pruned);
}, [activeNowEntries, archivedSessions, safeStorage, sessions]);
// Prefetch is wired below, after recentSessionIds is computed.
const activitySections = React.useMemo(() => {
const toItem = (session: Session) => {
const existing = sessionSidebarMetaById.get(session.id);
const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null);
return {
node: existing?.node ?? { session, children: [], worktree: null },
projectId: existing?.projectId ?? null,
groupDirectory: existing?.groupDirectory ?? sessionDirectory,
secondaryMeta: existing?.secondaryMeta ?? null,
};
};
return [
{ key: 'active-now' as const, title: t('sessions.sidebar.activity.recentTitle'), items: activeNowSessions.map(toItem) },
];
}, [activeNowSessions, sessionSidebarMetaById, t]);
const recentSessionIds = React.useMemo(() => {
return new Set(activitySections.flatMap((section) => section.items.map((item) => item.node.session.id)));
}, [activitySections]);
const recentSessionIdsList = React.useMemo(() => [...recentSessionIds], [recentSessionIds]);
useSessionPrefetch({
currentSessionId,
sortedSessions,
recentSessionIds: recentSessionIdsList,
loadMessages: sync.syncSession,
});
const sectionsForSidebarRender = React.useMemo(() => {
if (!isVSCode || hasSessionSearchQuery || recentSessionIds.size === 0) {
return sectionsForRender;
}
const filterNodes = (nodes: SessionNode[]): SessionNode[] => {
return nodes.reduce<SessionNode[]>((acc, node) => {
if (recentSessionIds.has(node.session.id)) {
return acc;
}
const filteredChildren = filterNodes(node.children);
if (filteredChildren.length === node.children.length) {
acc.push(node);
return acc;
}
acc.push({
...node,
children: filteredChildren,
});
return acc;
}, []);
};
return sectionsForRender.map((section) => ({
...section,
groups: section.groups.map((group) => ({
...group,
sessions: filterNodes(group.sessions),
})),
}));
}, [isVSCode, hasSessionSearchQuery, recentSessionIds, sectionsForRender]);
const prLookupKeys = React.useMemo(() => {
const keys = new Set<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;
}
keys.add(getGitHubPrStatusKey(directory, branch));
});
});
return [...keys];
}, [gitBranches, sectionsForSidebarRender]);
const prVisualSummaryMap = usePrVisualSummaryByKeys(prLookupKeys);
React.useEffect(() => {
if (!githubAuthChecked || !githubAuthStatus?.connected || !github) {
return;
}
const missingTargets: Array<{ directory: string; branch: string; remoteName?: string | null }> = [];
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];
if (!entry || !entry.isInitialStatusResolved) {
missingTargets.push({ directory, branch });
}
});
});
if (missingTargets.length === 0) {
return;
}
const uniqueTargets = new Map<string, { directory: string; branch: string; remoteName?: string | null }>();
missingTargets.forEach((target) => {
const key = getGitHubPrStatusKey(target.directory, target.branch, target.remoteName ?? null);
if (!uniqueTargets.has(key)) {
uniqueTargets.set(key, target);
}
});
uniqueTargets.forEach((target, key) => {
ensurePrStatusEntry(key);
setPrStatusParams(key, {
directory: target.directory,
branch: target.branch,
remoteName: target.remoteName ?? null,
canShow: true,
github,
githubAuthChecked,
githubConnected: githubAuthStatus.connected,
});
});
void refreshPrStatusTargets([...uniqueTargets.values()], {
force: true,
silent: true,
markInitialResolved: true,
});
}, [
collapsedProjects,
ensurePrStatusEntry,
github,
githubAuthChecked,
githubAuthStatus?.connected,
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({
isDesktopShellRuntime,
projectSections,
projectHeaderSentinelRefs,
});
const renderSessionNode = React.useCallback(
(
node: SessionNode,
depth = 0,
groupDirectory?: string | null,
projectId?: string | null,
archivedBucket = false,
secondaryMeta?: { projectLabel?: string | null; branchLabel?: string | null } | null,
renderContext: 'project' | 'recent' = 'project',
): React.ReactNode => (
<SessionNodeItem
node={node}
depth={depth}
groupDirectory={groupDirectory}
projectId={projectId}
archivedBucket={archivedBucket}
directoryStatus={directoryStatus}
currentSessionId={currentSessionId}
pinnedSessionIds={pinnedSessionIds}
expandedParents={expandedParents}
hasSessionSearchQuery={hasSessionSearchQuery}
normalizedSessionSearchQuery={normalizedSessionSearchQuery}
notifyOnSubtasks={notifyOnSubtasks}
editingId={editingId}
setEditingId={setEditingId}
editTitle={editTitle}
setEditTitle={setEditTitle}
handleSaveEdit={handleSaveEdit}
handleCancelEdit={handleCancelEdit}
toggleParent={toggleParent}
handleSessionSelect={handleSessionSelect}
handleSessionDoubleClick={handleSessionDoubleClick}
togglePinnedSession={togglePinnedSession}
handleShareSession={handleShareSession}
copiedSessionId={copiedSessionId}
handleCopyShareUrl={handleCopyShareUrl}
handleUnshareSession={handleUnshareSession}
openSidebarMenuKey={openSidebarMenuKey}
setOpenSidebarMenuKey={setOpenSidebarMenuKey}
renamingFolderId={renamingFolderId}
getFoldersForScope={getFoldersForScope}
getSessionFolderId={getSessionFolderId}
removeSessionFromFolder={removeSessionFromFolder}
addSessionToFolder={addSessionToFolder}
createFolderAndStartRename={createFolderAndStartRename}
openContextPanelTab={openContextPanelTab}
handleDeleteSession={handleDeleteSession}
mobileVariant={mobileVariant}
renderSessionNode={renderSessionNode}
secondaryMeta={secondaryMeta}
renderContext={renderContext}
/>
),
[
directoryStatus,
currentSessionId,
pinnedSessionIds,
expandedParents,
hasSessionSearchQuery,
normalizedSessionSearchQuery,
notifyOnSubtasks,
editingId,
setEditingId,
editTitle,
setEditTitle,
handleSaveEdit,
handleCancelEdit,
toggleParent,
handleSessionSelect,
handleSessionDoubleClick,
togglePinnedSession,
handleShareSession,
copiedSessionId,
handleCopyShareUrl,
handleUnshareSession,
openSidebarMenuKey,
setOpenSidebarMenuKey,
renamingFolderId,
getFoldersForScope,
getSessionFolderId,
removeSessionFromFolder,
addSessionToFolder,
createFolderAndStartRename,
openContextPanelTab,
handleDeleteSession,
mobileVariant,
],
);
const toggleCollapsedGroup = React.useCallback((key: string) => {
setCollapsedGroups((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
}, []);
const prVisualStateByDirectoryBranch = React.useMemo(() => {
const result = new Map<string, PrIndicator>();
for (const [key, summary] of prVisualSummaryMap) {
result.set(key, {
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;
}, [prVisualSummaryMap]);
const renderGroupSessions = React.useCallback(
(group: SessionGroup, groupKey: string, projectId?: string | null, hideGroupLabel?: boolean, dragHandleProps?: SortableDragHandleProps | null, compactBodyPadding?: boolean) => (
<SessionGroupSection
group={group}
groupKey={groupKey}
projectId={projectId}
hideGroupLabel={hideGroupLabel}
compactBodyPadding={compactBodyPadding}
hasSessionSearchQuery={hasSessionSearchQuery}
normalizedSessionSearchQuery={normalizedSessionSearchQuery}
groupSearchDataByGroup={groupSearchDataByGroup}
expandedSessionGroups={expandedSessionGroups}
collapsedGroups={collapsedGroups}
hideDirectoryControls={hideDirectoryControls}
collapsedFolderIds={collapsedFolderIds}
toggleFolderCollapse={toggleFolderCollapse}
renameFolder={renameFolder}
deleteFolder={deleteFolder}
showDeletionDialog={showDeletionDialog}
setDeleteFolderConfirm={setDeleteFolderConfirm}
renderSessionNode={renderSessionNode}
currentSessionDirectory={currentSessionDirectory}
projectRepoStatus={projectRepoStatus}
lastRepoStatus={lastRepoStatusRef.current}
toggleGroupSessionLimit={toggleGroupSessionLimit}
mobileVariant={mobileVariant}
activeProjectId={activeProjectId}
setActiveProjectIdOnly={setActiveProjectIdOnly}
setActiveMainTab={setActiveMainTab}
setSessionSwitcherOpen={setSessionSwitcherOpen}
openNewSessionDraft={openNewSessionDraft}
addSessionToFolder={addSessionToFolder}
createFolderAndStartRename={createFolderAndStartRename}
renamingFolderId={renamingFolderId}
renameFolderDraft={renameFolderDraft}
setRenameFolderDraft={setRenameFolderDraft}
setRenamingFolderId={setRenamingFolderId}
pinnedSessionIds={pinnedSessionIds}
sessionOrderIndex={sessionOrderIndex}
prVisualStateByDirectoryBranch={prVisualStateByDirectoryBranch}
onToggleCollapsedGroup={toggleCollapsedGroup}
dragHandleProps={dragHandleProps}
/>
),
[
hasSessionSearchQuery,
normalizedSessionSearchQuery,
groupSearchDataByGroup,
expandedSessionGroups,
collapsedGroups,
hideDirectoryControls,
collapsedFolderIds,
toggleFolderCollapse,
renameFolder,
deleteFolder,
showDeletionDialog,
renderSessionNode,
currentSessionDirectory,
projectRepoStatus,
toggleGroupSessionLimit,
mobileVariant,
activeProjectId,
setActiveProjectIdOnly,
setActiveMainTab,
setSessionSwitcherOpen,
openNewSessionDraft,
addSessionToFolder,
createFolderAndStartRename,
renamingFolderId,
renameFolderDraft,
pinnedSessionIds,
sessionOrderIndex,
prVisualStateByDirectoryBranch,
toggleCollapsedGroup,
],
);
const topContent = !hasSessionSearchQuery ? (
<SidebarActivitySections
sections={activitySections}
renderSessionNode={renderSessionNode}
/>
) : null;
const isInlineEditing = Boolean(renamingFolderId || editingId || editingProjectDialogId);
const selectionModeEnabled = useSessionMultiSelectStore((state) => state.enabled);
const selectedIds = useSessionMultiSelectStore((state) => state.selectedIds);
const selectionScopeKey = useSessionMultiSelectStore((state) => state.scopeKey);
const multiSelectStoreApi = useSessionMultiSelectStore;
const handleToggleSelectionMode = React.useCallback(() => {
useSessionMultiSelectStore.getState().toggleMode();
}, []);
const handleExitSelectionMode = React.useCallback(() => {
useSessionMultiSelectStore.getState().disable();
}, []);
const bulkScopeIsArchived = React.useMemo(() => {
if (selectedIds.size === 0) return false;
if (typeof document === 'undefined') return false;
let sawActive = false;
let sawArchived = false;
for (const id of selectedIds) {
const rows = document.querySelectorAll<HTMLElement>(`[data-session-row="${CSS.escape(id)}"]`);
for (const row of rows) {
if (row.getAttribute('data-session-archived') === '1') sawArchived = true;
else sawActive = true;
}
}
return sawArchived && !sawActive;
}, [selectedIds]);
const derivedSelectionScope = React.useMemo(() => {
if (selectionScopeKey) return selectionScopeKey;
if (selectedIds.size === 0) return null;
if (typeof document === 'undefined') return null;
for (const id of selectedIds) {
const row = document.querySelector<HTMLElement>(`[data-session-row="${CSS.escape(id)}"]`);
const scope = row?.getAttribute('data-session-scope');
if (scope && scope.length > 0) return scope;
}
return null;
}, [selectedIds, selectionScopeKey]);
const bulkScopeFolders = React.useMemo(() => {
if (!derivedSelectionScope) return [];
return foldersMap[derivedSelectionScope] ?? [];
}, [foldersMap, derivedSelectionScope]);
const bulkCanRemoveFromFolder = React.useMemo(() => {
if (!derivedSelectionScope || selectedIds.size === 0) return false;
const scopeFolders = foldersMap[derivedSelectionScope] ?? [];
for (const folder of scopeFolders) {
for (const id of folder.sessionIds) {
if (selectedIds.has(id)) return true;
}
}
return false;
}, [foldersMap, derivedSelectionScope, selectedIds]);
const handleBulkMoveToFolder = React.useCallback((folderId: string) => {
if (!derivedSelectionScope || selectedIds.size === 0) return;
addSessionsToFolder(derivedSelectionScope, folderId, Array.from(selectedIds));
}, [addSessionsToFolder, selectedIds, derivedSelectionScope]);
const handleBulkCreateFolderAndMove = React.useCallback(() => {
if (!derivedSelectionScope || selectedIds.size === 0) return;
const newFolder = createFolderAndStartRename(derivedSelectionScope);
if (!newFolder) return;
addSessionsToFolder(derivedSelectionScope, newFolder.id, Array.from(selectedIds));
}, [addSessionsToFolder, createFolderAndStartRename, selectedIds, derivedSelectionScope]);
const handleBulkRemoveFromFolder = React.useCallback(() => {
if (!derivedSelectionScope || selectedIds.size === 0) return;
removeSessionsFromFolders(derivedSelectionScope, Array.from(selectedIds));
}, [removeSessionsFromFolders, selectedIds, derivedSelectionScope]);
const executeBulkDelete = React.useCallback(async () => {
const ids = Array.from(selectedIds);
if (ids.length === 0) return;
if (bulkScopeIsArchived) {
const { deletedIds, failedIds } = await deleteSessions(ids);
if (deletedIds.length > 0) {
toast.success(deletedIds.length === 1
? t('sessions.sidebar.bulkActions.deletedSingle', { count: deletedIds.length })
: t('sessions.sidebar.bulkActions.deletedPlural', { count: deletedIds.length }));
}
if (failedIds.length > 0) {
toast.error(failedIds.length === 1
? t('sessions.sidebar.bulkActions.failedDeleteSingle', { count: failedIds.length })
: t('sessions.sidebar.bulkActions.failedDeletePlural', { count: failedIds.length }));
}
} else {
const { archivedIds, failedIds } = await archiveSessions(ids);
if (archivedIds.length > 0) {
toast.success(archivedIds.length === 1
? t('sessions.sidebar.bulkActions.archivedSingle', { count: archivedIds.length })
: t('sessions.sidebar.bulkActions.archivedPlural', { count: archivedIds.length }));
}
if (failedIds.length > 0) {
toast.error(failedIds.length === 1
? t('sessions.sidebar.bulkActions.failedArchiveSingle', { count: failedIds.length })
: t('sessions.sidebar.bulkActions.failedArchivePlural', { count: failedIds.length }));
}
}
useSessionMultiSelectStore.getState().clear();
}, [archiveSessions, bulkScopeIsArchived, deleteSessions, selectedIds, t]);
const handleBulkDelete = React.useCallback(() => {
const count = selectedIds.size;
if (count === 0) return;
if (!showDeletionDialog) {
void executeBulkDelete();
return;
}
setBulkDeleteConfirm({ sessionCount: count, archivedBucket: bulkScopeIsArchived });
}, [bulkScopeIsArchived, executeBulkDelete, selectedIds, showDeletionDialog]);
const confirmBulkDelete = React.useCallback(async () => {
setBulkDeleteConfirm(null);
await executeBulkDelete();
}, [executeBulkDelete]);
React.useEffect(() => {
if (!selectionModeEnabled) return;
const isMac = typeof navigator !== 'undefined' && /Macintosh|Mac OS X/.test(navigator.userAgent || '');
const listener = (event: KeyboardEvent) => {
if (isInlineEditing) return;
const target = event.target as HTMLElement | null;
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) {
return;
}
const modifier = isMac ? event.metaKey : event.ctrlKey;
if (event.key === 'Escape') {
event.preventDefault();
useSessionMultiSelectStore.getState().disable();
return;
}
if (modifier && event.key === 'Backspace') {
event.preventDefault();
handleBulkDelete();
return;
}
if (modifier && (event.key === 'a' || event.key === 'A')) {
const rows = typeof document !== 'undefined'
? Array.from(document.querySelectorAll<HTMLElement>('[data-session-row]'))
: [];
if (rows.length === 0) return;
event.preventDefault();
const currentScope = multiSelectStoreApi.getState().scopeKey;
const targetScope = currentScope
?? rows[0]?.getAttribute('data-session-scope')
?? null;
const scopeFilter = (el: HTMLElement): boolean => {
if (!targetScope) return true;
return el.getAttribute('data-session-scope') === targetScope;
};
const ids = rows
.filter(scopeFilter)
.map((el) => el.getAttribute('data-session-row'))
.filter((id): id is string => typeof id === 'string' && id.length > 0);
if (ids.length === 0) return;
multiSelectStoreApi.getState().replaceAll(ids, targetScope || null);
}
};
window.addEventListener('keydown', listener);
return () => window.removeEventListener('keydown', listener);
}, [handleBulkDelete, isInlineEditing, multiSelectStoreApi, selectionModeEnabled]);
const handleSidebarNewSession = React.useCallback(() => {
setActiveMainTab('chat');
if (mobileVariant) {
setSessionSwitcherOpen(false);
}
openNewSessionDraft();
}, [mobileVariant, openNewSessionDraft, setActiveMainTab, setSessionSwitcherOpen]);
const handleOpenMultiRunFromHeader = React.useCallback(() => {
setActiveMainTab('chat');
if (mobileVariant) {
setSessionSwitcherOpen(false);
}
openMultiRunLauncher();
}, [mobileVariant, openMultiRunLauncher, setActiveMainTab, setSessionSwitcherOpen]);
return (
<div
ref={sessionSearchContainerRef}
className={cn(
'relative flex h-full flex-col text-foreground overflow-x-hidden',
mobileVariant ? '' : 'bg-transparent',
)}
>
{showDesktopSidebarChrome ? (
<div
onMouseDown={handleDesktopSidebarDragStart}
className={cn(
'app-region-drag flex h-[var(--oc-header-height,56px)] flex-shrink-0 items-center pr-3',
desktopSidebarTopPaddingClass,
)}
>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={toggleSidebar}
className={desktopSidebarToggleButtonClass}
aria-label={t('sessions.sidebar.header.actions.closeSessions')}
>
<RiLayoutLeftLine className="h-[18px] w-[18px]" />
</button>
</TooltipTrigger>
<TooltipContent>
<p>{t('sessions.sidebar.header.actions.closeSessions')}</p>
</TooltipContent>
</Tooltip>
</div>
) : null}
<SidebarHeader
hideDirectoryControls={hideDirectoryControls}
handleOpenDirectoryDialog={handleOpenDirectoryDialog}
handleNewSession={handleSidebarNewSession}
canOpenMultiRun={projects.length > 0}
openMultiRunLauncher={handleOpenMultiRunFromHeader}
headerActionIconClass={headerActionIconClass}
reserveHeaderActionsSpace={reserveHeaderActionsSpace}
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}
showSidebarToggle={isWebRuntime}
onToggleSidebar={toggleSidebar}
/>
<SidebarProjectsList
topContent={topContent}
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}
toggleProject={toggleProject}
setActiveProjectIdOnly={setActiveProjectIdOnly}
setActiveMainTab={setActiveMainTab}
setSessionSwitcherOpen={setSessionSwitcherOpen}
openNewSessionDraft={openNewSessionDraft}
openNewWorktreeDialog={openNewWorktreeDialog}
openProjectEditDialog={setEditingProjectDialogId}
removeProject={removeProject}
projectHeaderSentinelRefs={projectHeaderSentinelRefs}
reorderProjects={reorderProjects}
getOrderedGroups={getOrderedGroups}
setGroupOrderByProject={setGroupOrderByProject}
openSidebarMenuKey={openSidebarMenuKey}
setOpenSidebarMenuKey={setOpenSidebarMenuKey}
isInlineEditing={isInlineEditing}
/>
{selectionModeEnabled && selectedIds.size > 0 ? (
<BulkActionBar
selectedCount={selectedIds.size}
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}
/>
{editingProject ? (
<ProjectEditDialog
open={Boolean(editingProject)}
onOpenChange={(open) => {
if (!open) {
setEditingProjectDialogId(null);
}
}}
projectId={editingProject.id}
projectName={editingProject.label || formatDirectoryName(editingProject.path, homeDirectory)}
projectPath={editingProject.path}
initialIcon={editingProject.icon}
initialColor={editingProject.color}
initialIconBackground={editingProject.iconBackground}
onSave={handleSaveProjectEdit}
/>
) : null}
<NewWorktreeDialog
open={newWorktreeDialogOpen}
onOpenChange={setNewWorktreeDialogOpen}
onWorktreeCreated={(worktreePath, options) => {
setActiveMainTab('chat');
if (mobileVariant) {
setSessionSwitcherOpen(false);
}
if (options?.sessionId) {
setCurrentSession(options.sessionId);
return;
}
openNewSessionDraft({ directoryOverride: worktreePath });
}}
/>
<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>
);
};