Files
openchamber/packages/ui/src/components/session/SessionSidebar.tsx
T

1105 lines
41 KiB
TypeScript
Raw Normal View History

2025-12-07 19:32:53 +02:00
import React from 'react';
2026-01-03 13:39:59 +02:00
import type { Session } from '@opencode-ai/sdk/v2';
import { toast } from '@/components/ui';
import { isDesktopLocalOriginActive, isDesktopShell, isTauriShell } from '@/lib/desktop';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
2025-12-07 19:32:53 +02:00
import { sessionEvents } from '@/lib/sessionEvents';
import { formatDirectoryName, cn } from '@/lib/utils';
2025-12-07 19:32:53 +02:00
import { useSessionStore } from '@/stores/useSessionStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
2026-01-06 21:31:04 +02:00
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useUIStore } from '@/stores/useUIStore';
import { useConfigStore } from '@/stores/useConfigStore';
import type { GitHubPullRequestStatus } from '@/lib/api/types';
2025-12-07 19:32:53 +02:00
import { getSafeStorage } from '@/stores/utils/safeStorage';
import { createWorktreeSession } from '@/lib/worktreeSessionCreator';
import { useGitStore } from '@/stores/useGitStore';
import { useDeviceInfo } from '@/lib/device';
import { isVSCodeRuntime } from '@/lib/desktop';
import { NewWorktreeDialog } from './NewWorktreeDialog';
import { ProjectNotesTodoPanel } from './ProjectNotesTodoPanel';
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 { useSessionPrefetch } from './sidebar/hooks/useSessionPrefetch';
import { useDirectoryStatusProbe } from './sidebar/hooks/useDirectoryStatusProbe';
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 { useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore';
import { SessionGroupSection } from './sidebar/SessionGroupSection';
import { SidebarHeader } from './sidebar/SidebarHeader';
import { SidebarProjectsList } from './sidebar/SidebarProjectsList';
import { SessionNodeItem } from './sidebar/SessionNodeItem';
import {
FolderDeleteConfirmDialog,
SessionDeleteConfirmDialog,
type DeleteFolderConfirmState,
type DeleteSessionConfirmState,
} from './sidebar/ConfirmDialogs';
import { type SessionGroup, type SessionNode } from './sidebar/types';
import {
compareSessionsByPinnedAndTime,
formatProjectLabel,
normalizePath,
} from './sidebar/utils';
2026-01-06 21:31:04 +02:00
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';
2025-12-07 19:32:53 +02:00
const SESSION_EXPANDED_STORAGE_KEY = 'oc.sessions.expandedParents';
const SESSION_PINNED_STORAGE_KEY = 'oc.sessions.pinned';
2025-12-07 19:32:53 +02:00
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 getPrVisualState = (status: GitHubPullRequestStatus | null): PrVisualState | null => {
const pr = status?.pr;
if (!pr) {
2025-12-07 19:32:53 +02:00
return null;
}
if (pr.state === 'merged') {
return 'merged';
}
if (pr.state === 'closed') {
return 'closed';
}
if (pr.draft) {
return 'draft';
}
const checksFailed = status?.checks?.state === 'failure';
const mergeableState = typeof pr.mergeableState === 'string' ? pr.mergeableState : '';
const notMergeable = pr.mergeable === false || mergeableState === 'blocked' || mergeableState === 'dirty';
if (checksFailed || notMergeable) {
return 'blocked';
}
return 'open';
};
const getPrVisualPriority = (state: PrVisualState): number => {
switch (state) {
case 'open':
return 5;
case 'blocked':
return 4;
case 'draft':
return 3;
case 'merged':
return 2;
case 'closed':
return 1;
default:
return 0;
}
2026-01-06 21:31:04 +02:00
};
2025-12-07 19:32:53 +02:00
interface SessionSidebarProps {
mobileVariant?: boolean;
2025-12-13 16:34:17 +02:00
onSessionSelected?: (sessionId: string) => void;
allowReselect?: boolean;
hideDirectoryControls?: boolean;
hideProjectSelector?: boolean;
showOnlyMainWorkspace?: boolean;
2025-12-07 19:32:53 +02:00
}
2025-12-13 16:34:17 +02:00
export const SessionSidebar: React.FC<SessionSidebarProps> = ({
mobileVariant = false,
onSessionSelected,
allowReselect = false,
hideDirectoryControls = false,
hideProjectSelector = true,
showOnlyMainWorkspace = false,
2025-12-13 16:34:17 +02:00
}) => {
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);
2025-12-07 19:32:53 +02:00
const [editingId, setEditingId] = React.useState<string | null>(null);
const [editTitle, setEditTitle] = React.useState('');
const [editingProjectId, setEditingProjectId] = React.useState<string | null>(null);
const [editProjectTitle, setEditProjectTitle] = React.useState('');
2025-12-07 19:32:53 +02:00
const [expandedParents, setExpandedParents] = React.useState<Set<string>>(new Set());
const [directoryStatus, setDirectoryStatus] = React.useState<Map<string, 'unknown' | 'exists' | 'missing'>>(
() => new Map(),
);
const safeStorage = React.useMemo(() => getSafeStorage(), []);
2026-01-06 21:31:04 +02:00
const [collapsedProjects, setCollapsedProjects] = React.useState<Set<string>>(new Set());
2026-01-06 21:31:04 +02:00
const [projectRepoStatus, setProjectRepoStatus] = React.useState<Map<string, boolean | null>>(new Map());
2025-12-07 19:32:53 +02:00
const [expandedSessionGroups, setExpandedSessionGroups] = React.useState<Set<string>>(new Set());
2026-01-06 21:31:04 +02:00
const [hoveredProjectId, setHoveredProjectId] = React.useState<string | null>(null);
const [newWorktreeDialogOpen, setNewWorktreeDialogOpen] = React.useState(false);
const [projectNotesPanelOpen, setProjectNotesPanelOpen] = React.useState(false);
const [openMenuSessionId, setOpenMenuSessionId] = 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 [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 [isProjectRenameInline, setIsProjectRenameInline] = React.useState(false);
const [projectRenameDraft, setProjectRenameDraft] = React.useState('');
const [projectRootBranches, setProjectRootBranches] = React.useState<Map<string, string>>(new Map());
2026-01-06 21:31:04 +02:00
const projectHeaderSentinelRefs = React.useRef<Map<string, HTMLDivElement | null>>(new Map());
const ignoreIntersectionUntil = React.useRef<number>(0);
2025-12-07 19:32:53 +02:00
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
2026-01-06 21:31:04 +02:00
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
2025-12-07 19:32:53 +02:00
const setDirectory = useDirectoryStore((state) => state.setDirectory);
2026-01-06 21:31:04 +02:00
const projects = useProjectsStore((state) => state.projects);
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
const addProject = useProjectsStore((state) => state.addProject);
const removeProject = useProjectsStore((state) => state.removeProject);
const setActiveProjectIdOnly = useProjectsStore((state) => state.setActiveProjectIdOnly);
const renameProject = useProjectsStore((state) => state.renameProject);
2026-01-06 21:31:04 +02:00
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
const openContextPanelTab = useUIStore((state) => state.openContextPanelTab);
const deviceInfo = useDeviceInfo();
const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen);
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 settingsAutoCreateWorktree = useConfigStore((state) => state.settingsAutoCreateWorktree);
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 removeSessionFromFolder = useSessionFoldersStore((state) => state.removeSessionFromFolder);
const toggleFolderCollapse = useSessionFoldersStore((state) => state.toggleFolderCollapse);
const cleanupSessions = useSessionFoldersStore((state) => state.cleanupSessions);
const getSessionFolderId = useSessionFoldersStore((state) => state.getSessionFolderId);
useSessionSearchEffects({
isSessionSearchOpen,
setIsSessionSearchOpen,
sessionSearchInputRef,
sessionSearchContainerRef,
});
const gitDirectories = useGitStore((state) => state.directories);
2026-01-06 21:31:04 +02:00
const sessions = useSessionStore((state) => state.sessions);
const archivedSessions = useSessionStore((state) => state.archivedSessions);
2026-01-06 21:31:04 +02:00
const sessionsByDirectory = useSessionStore((state) => state.sessionsByDirectory);
2025-12-07 19:32:53 +02:00
const currentSessionId = useSessionStore((state) => state.currentSessionId);
const newSessionDraftOpen = useSessionStore((state) => Boolean(state.newSessionDraft?.open));
2025-12-07 19:32:53 +02:00
const setCurrentSession = useSessionStore((state) => state.setCurrentSession);
const loadMessages = useSessionStore((state) => state.loadMessages);
2025-12-07 19:32:53 +02:00
const updateSessionTitle = useSessionStore((state) => state.updateSessionTitle);
const shareSession = useSessionStore((state) => state.shareSession);
const unshareSession = useSessionStore((state) => state.unshareSession);
const sessionMemoryState = useSessionStore((state) => state.sessionMemoryState);
const sessionStatus = useSessionStore((state) => state.sessionStatus);
const sessionAttentionStates = useSessionStore((state) => state.sessionAttentionStates);
2026-01-06 21:31:04 +02:00
const permissions = useSessionStore((state) => state.permissions);
2025-12-07 19:32:53 +02:00
const worktreeMetadata = useSessionStore((state) => state.worktreeMetadata);
2026-01-06 21:31:04 +02:00
const availableWorktreesByProject = useSessionStore((state) => state.availableWorktreesByProject);
const getSessionsByDirectory = useSessionStore((state) => state.getSessionsByDirectory);
const openNewSessionDraft = useSessionStore((state) => state.openNewSessionDraft);
const prStatusEntries = useGitHubPrStatusStore((state) => state.entries);
2025-12-07 19:32:53 +02:00
const tauriIpcAvailable = React.useMemo(() => isTauriShell(), []);
const isDesktopShellRuntime = React.useMemo(() => isDesktopShell(), []);
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
const {
buildGroupSearchText,
filterSessionNodesForSearch,
buildGroupedSessions,
} = useSessionGrouping({
homeDirectory,
worktreeMetadata,
pinnedSessionIds,
gitDirectories,
isVSCode,
});
2025-12-07 19:32:53 +02:00
const { scheduleCollapsedProjectsPersist } = useSidebarPersistence({
isVSCode,
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]);
useSessionPrefetch({
currentSessionId,
sortedSessions,
loadMessages,
});
2025-12-07 19:32:53 +02:00
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)));
2025-12-07 19:32:53 +02:00
return map;
}, [sortedSessions, pinnedSessionIds]);
2025-12-07 19:32:53 +02:00
useDirectoryStatusProbe({
sortedSessions,
projects,
directoryStatus,
setDirectoryStatus,
});
2025-12-07 19:32:53 +02:00
const emptyState = (
<div className="py-6 text-center text-muted-foreground">
<p className="typography-ui-label font-semibold">No sessions yet</p>
<p className="typography-meta mt-1">Create your first session to start coding.</p>
</div>
);
const handleSaveProjectEdit = React.useCallback(() => {
if (editingProjectId && editProjectTitle.trim()) {
renameProject(editingProjectId, editProjectTitle.trim());
setEditingProjectId(null);
setEditProjectTitle('');
}
}, [editingProjectId, editProjectTitle, renameProject]);
const handleCancelProjectEdit = React.useCallback(() => {
setEditingProjectId(null);
setEditProjectTitle('');
}, []);
2025-12-07 19:32:53 +02:00
const deleteSession = useSessionStore((state) => state.deleteSession);
const deleteSessions = useSessionStore((state) => state.deleteSessions);
const archiveSession = useSessionStore((state) => state.archiveSession);
const archiveSessions = useSessionStore((state) => state.archiveSessions);
2025-12-07 19:32:53 +02:00
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]);
2025-12-07 19:32:53 +02:00
const handleOpenDirectoryDialog = React.useCallback(() => {
if (!tauriIpcAvailable || !isDesktopLocalOriginActive()) {
2025-12-07 19:32:53 +02:00
sessionEvents.requestDirectoryDialog();
return;
2025-12-07 19:32:53 +02:00
}
import('@/lib/desktop')
.then(({ requestDirectoryAccess }) => requestDirectoryAccess(''))
.then((result) => {
if (result.success && result.path) {
const added = addProject(result.path, { id: result.projectId });
if (!added) {
toast.error('Failed to add project', {
description: 'Please select a valid directory.',
});
}
} else if (result.error && result.error !== 'Directory selection cancelled') {
toast.error('Failed to select directory', {
description: result.error,
});
}
})
.catch((error) => {
console.error('Desktop: Error selecting directory:', error);
toast.error('Failed to select directory');
});
}, [addProject, tauriIpcAvailable]);
2026-01-06 21:31:04 +02:00
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, 'New folder', parentId);
setRenamingFolderId(newFolder.id);
setRenameFolderDraft(newFolder.name);
return newFolder;
2026-01-06 21:31:04 +02:00
},
[collapsedFolderIds, toggleFolderCollapse, createFolder],
2026-01-06 21:31:04 +02:00
);
2025-12-07 19:32:53 +02:00
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;
});
}, []);
2026-01-06 21:31:04 +02:00
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);
}
2026-01-06 21:31:04 +02:00
return next;
});
}, [isVSCode, safeStorage, scheduleCollapsedProjectsPersist]);
2026-01-06 21:31:04 +02:00
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;
}>;
}, [projects]);
useProjectRepoStatus({
projects,
normalizedProjects,
normalizePath,
gitDirectories,
setProjectRepoStatus,
setProjectRootBranches,
});
const isSessionsLoading = useSessionStore((state) => state.isLoading);
useSessionFolderCleanup({
isSessionsLoading,
sessions,
archivedSessions,
normalizedProjects,
isVSCode,
availableWorktreesByProject,
cleanupSessions,
});
const { getSessionsForProject, getArchivedSessionsForProject } = useProjectSessionLists({
isVSCode,
sessions,
archivedSessions,
sessionsByDirectory,
getSessionsByDirectory,
availableWorktreesByProject,
});
2026-01-06 21:31:04 +02:00
useArchivedAutoFolders({
normalizedProjects,
sessions,
archivedSessions,
availableWorktreesByProject,
isVSCode,
isSessionsLoading,
foldersMap,
createFolder,
addSessionToFolder,
cleanupSessions,
});
2026-01-06 21:31:04 +02:00
// 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,
activeProjectId,
getSessionsForProject,
getArchivedSessionsForProject,
availableWorktreesByProject,
projectRepoStatus,
projectRootBranches,
lastRepoStatus: lastRepoStatusRef.current,
buildGroupedSessions,
hasSessionSearchQuery,
normalizedSessionSearchQuery,
filterSessionNodesForSearch,
buildGroupSearchText,
getFoldersForScope,
});
const searchEmptyState = (
<div className="py-6 text-center text-muted-foreground">
<p className="typography-ui-label font-semibold">No matching sessions</p>
<p className="typography-meta mt-1">Try a different title, branch, folder, or path.</p>
</div>
);
const activeProjectForHeader = React.useMemo(
() => normalizedProjects.find((project) => project.id === activeProjectId) ?? normalizedProjects[0] ?? null,
[normalizedProjects, activeProjectId],
);
const activeProjectRefForHeader = React.useMemo(
() => (activeProjectForHeader
? {
id: activeProjectForHeader.id,
path: activeProjectForHeader.normalizedPath,
}
: null),
[activeProjectForHeader],
);
const activeProjectIsRepo = React.useMemo(
() => (activeProjectForHeader ? Boolean(projectRepoStatus.get(activeProjectForHeader.id)) : false),
[activeProjectForHeader, projectRepoStatus],
);
// Only flip to false once the new project's status is actually resolved (present in map)
const stableActiveProjectIsRepo = activeProjectForHeader && projectRepoStatus.has(activeProjectForHeader.id)
? activeProjectIsRepo
: lastRepoStatusRef.current;
const reserveHeaderActionsSpace = Boolean(activeProjectForHeader);
const useMobileNotesPanel = mobileVariant || deviceInfo.isMobile;
React.useEffect(() => {
if (!activeProjectForHeader) {
setProjectNotesPanelOpen(false);
}
}, [activeProjectForHeader]);
const { currentSessionDirectory } = useProjectSessionSelection({
projectSections,
activeProjectId,
activeSessionByProject,
setActiveSessionByProject,
currentSessionId,
handleSessionSelect,
isVSCode,
newSessionDraftOpen,
mobileVariant,
openNewSessionDraft,
setActiveMainTab,
setSessionSwitcherOpen,
sessions,
worktreeMetadata,
});
const { getOrderedGroups } = useGroupOrdering(groupOrderByProject);
const handleStartInlineProjectRename = React.useCallback(() => {
if (!activeProjectForHeader) {
return;
}
setProjectRenameDraft(formatProjectLabel(
activeProjectForHeader.label?.trim()
|| formatDirectoryName(activeProjectForHeader.normalizedPath, homeDirectory)
|| activeProjectForHeader.normalizedPath,
));
setIsProjectRenameInline(true);
}, [activeProjectForHeader, homeDirectory]);
const handleSaveInlineProjectRename = React.useCallback(() => {
if (!activeProjectForHeader) {
return;
}
const trimmed = projectRenameDraft.trim();
if (!trimmed) {
return;
}
renameProject(activeProjectForHeader.id, trimmed);
setIsProjectRenameInline(false);
}, [activeProjectForHeader, projectRenameDraft, renameProject]);
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 addProjectButtonClass = cn(
'inline-flex cursor-pointer items-center justify-center rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 disabled:cursor-not-allowed',
mobileVariant
? 'h-8 w-8 text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50'
: 'h-8 w-8 text-foreground hover:bg-interactive-hover',
!isDesktopShellRuntime && 'bg-transparent hover:bg-sidebar/40',
);
2026-01-06 21:31:04 +02:00
const stuckProjectHeaders = useStickyProjectHeaders({
isDesktopShellRuntime,
projectSections,
projectHeaderSentinelRefs,
});
2025-12-07 19:32:53 +02:00
const renderSessionNode = React.useCallback(
(
node: SessionNode,
depth = 0,
groupDirectory?: string | null,
projectId?: string | null,
archivedBucket = false,
): React.ReactNode => (
<SessionNodeItem
node={node}
depth={depth}
groupDirectory={groupDirectory}
projectId={projectId}
archivedBucket={archivedBucket}
directoryStatus={directoryStatus}
sessionMemoryState={sessionMemoryState as Map<string, { isZombie?: boolean }>}
currentSessionId={currentSessionId}
pinnedSessionIds={pinnedSessionIds}
expandedParents={expandedParents}
hasSessionSearchQuery={hasSessionSearchQuery}
normalizedSessionSearchQuery={normalizedSessionSearchQuery}
sessionAttentionStates={sessionAttentionStates as Map<string, { needsAttention?: boolean }>}
notifyOnSubtasks={notifyOnSubtasks}
sessionStatus={sessionStatus as Map<string, { type?: string }> | undefined}
permissions={permissions as Map<string, unknown[]>}
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}
openMenuSessionId={openMenuSessionId}
setOpenMenuSessionId={setOpenMenuSessionId}
renamingFolderId={renamingFolderId}
getFoldersForScope={getFoldersForScope}
getSessionFolderId={getSessionFolderId}
removeSessionFromFolder={removeSessionFromFolder}
addSessionToFolder={addSessionToFolder}
createFolderAndStartRename={createFolderAndStartRename}
openContextPanelTab={openContextPanelTab}
handleDeleteSession={handleDeleteSession}
mobileVariant={mobileVariant}
renderSessionNode={renderSessionNode}
/>
),
2025-12-07 19:32:53 +02:00
[
directoryStatus,
sessionMemoryState,
currentSessionId,
pinnedSessionIds,
expandedParents,
hasSessionSearchQuery,
normalizedSessionSearchQuery,
sessionAttentionStates,
notifyOnSubtasks,
sessionStatus,
permissions,
2025-12-07 19:32:53 +02:00
editingId,
setEditingId,
2025-12-07 19:32:53 +02:00
editTitle,
setEditTitle,
2025-12-07 19:32:53 +02:00
handleSaveEdit,
handleCancelEdit,
toggleParent,
handleSessionSelect,
handleSessionDoubleClick,
togglePinnedSession,
2025-12-07 19:32:53 +02:00
handleShareSession,
copiedSessionId,
2025-12-07 19:32:53 +02:00
handleCopyShareUrl,
handleUnshareSession,
openMenuSessionId,
setOpenMenuSessionId,
renamingFolderId,
getFoldersForScope,
getSessionFolderId,
removeSessionFromFolder,
addSessionToFolder,
createFolderAndStartRename,
openContextPanelTab,
handleDeleteSession,
mobileVariant,
2025-12-07 19:32:53 +02:00
],
);
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>();
Object.values(prStatusEntries).forEach((entry) => {
const directory = normalizePath(entry.params?.directory ?? entry.identity?.directory ?? null);
const branch = entry.params?.branch?.trim() ?? entry.identity?.branch?.trim();
if (!directory || !branch) {
return;
}
const state = getPrVisualState(entry.status ?? null);
const pr = entry.status?.pr;
if (!state || !pr?.number) {
return;
}
const key = `${directory}::${branch}`;
const nextIndicator: PrIndicator = {
visualState: state,
number: pr.number,
url: typeof pr.url === 'string' && pr.url.trim().length > 0 ? pr.url : null,
state: pr.state,
draft: Boolean(pr.draft),
title: typeof pr.title === 'string' && pr.title.trim().length > 0 ? pr.title : null,
base: typeof pr.base === 'string' && pr.base.trim().length > 0 ? pr.base : null,
head: typeof pr.head === 'string' && pr.head.trim().length > 0 ? pr.head : null,
checks: entry.status?.checks
? {
state: entry.status.checks.state,
total: entry.status.checks.total,
success: entry.status.checks.success,
failure: entry.status.checks.failure,
pending: entry.status.checks.pending,
}
: null,
canMerge: typeof entry.status?.canMerge === 'boolean' ? entry.status.canMerge : null,
mergeableState: typeof pr.mergeableState === 'string' ? pr.mergeableState : null,
repo: entry.status?.repo
? {
owner: entry.status.repo.owner,
repo: entry.status.repo.repo,
}
: null,
};
const existing = result.get(key);
if (!existing || getPrVisualPriority(nextIndicator.visualState) > getPrVisualPriority(existing.visualState)) {
result.set(key, nextIndicator);
}
});
return result;
}, [prStatusEntries]);
const renderGroupSessions = React.useCallback(
(group: SessionGroup, groupKey: string, projectId?: string | null, hideGroupLabel?: boolean) => (
<SessionGroupSection
group={group}
groupKey={groupKey}
projectId={projectId}
hideGroupLabel={hideGroupLabel}
hasSessionSearchQuery={hasSessionSearchQuery}
normalizedSessionSearchQuery={normalizedSessionSearchQuery}
groupSearchDataByGroup={groupSearchDataByGroup}
expandedSessionGroups={expandedSessionGroups}
collapsedGroups={collapsedGroups}
hideDirectoryControls={hideDirectoryControls}
getFoldersForScope={getFoldersForScope}
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}
prVisualStateByDirectoryBranch={prVisualStateByDirectoryBranch}
onToggleCollapsedGroup={toggleCollapsedGroup}
/>
),
[
hasSessionSearchQuery,
normalizedSessionSearchQuery,
groupSearchDataByGroup,
expandedSessionGroups,
collapsedGroups,
hideDirectoryControls,
getFoldersForScope,
collapsedFolderIds,
toggleFolderCollapse,
renameFolder,
deleteFolder,
showDeletionDialog,
renderSessionNode,
currentSessionDirectory,
projectRepoStatus,
toggleGroupSessionLimit,
mobileVariant,
activeProjectId,
setActiveProjectIdOnly,
setActiveMainTab,
setSessionSwitcherOpen,
openNewSessionDraft,
addSessionToFolder,
createFolderAndStartRename,
renamingFolderId,
renameFolderDraft,
pinnedSessionIds,
prVisualStateByDirectoryBranch,
toggleCollapsedGroup,
],
2026-01-06 21:31:04 +02:00
);
2025-12-07 19:32:53 +02:00
return (
<div
ref={sessionSearchContainerRef}
2025-12-07 19:32:53 +02:00
className={cn(
'flex h-full flex-col text-foreground overflow-x-hidden',
mobileVariant ? '' : 'bg-transparent',
2025-12-07 19:32:53 +02:00
)}
>
<SidebarHeader
hideDirectoryControls={hideDirectoryControls}
hideProjectSelector={hideProjectSelector}
activeProjectForHeader={activeProjectForHeader}
homeDirectory={homeDirectory}
normalizedProjects={normalizedProjects}
activeProjectId={activeProjectId}
setActiveProjectIdOnly={setActiveProjectIdOnly}
isProjectRenameInline={isProjectRenameInline}
setIsProjectRenameInline={setIsProjectRenameInline}
handleStartInlineProjectRename={handleStartInlineProjectRename}
handleSaveInlineProjectRename={handleSaveInlineProjectRename}
projectRenameDraft={projectRenameDraft}
setProjectRenameDraft={setProjectRenameDraft}
removeProject={removeProject}
handleOpenDirectoryDialog={handleOpenDirectoryDialog}
addProjectButtonClass={addProjectButtonClass}
headerActionIconClass={headerActionIconClass}
reserveHeaderActionsSpace={reserveHeaderActionsSpace}
stableActiveProjectIsRepo={stableActiveProjectIsRepo}
useMobileNotesPanel={useMobileNotesPanel}
projectNotesPanelOpen={projectNotesPanelOpen}
setProjectNotesPanelOpen={setProjectNotesPanelOpen}
activeProjectRefForHeader={activeProjectRefForHeader}
openMultiRunLauncher={openMultiRunLauncher}
headerActionButtonClass={headerActionButtonClass}
setNewWorktreeDialogOpen={setNewWorktreeDialogOpen}
setActiveMainTab={setActiveMainTab}
isSessionSearchOpen={isSessionSearchOpen}
setIsSessionSearchOpen={setIsSessionSearchOpen}
sessionSearchInputRef={sessionSearchInputRef}
sessionSearchQuery={sessionSearchQuery}
setSessionSearchQuery={setSessionSearchQuery}
hasSessionSearchQuery={hasSessionSearchQuery}
searchMatchCount={searchMatchCount}
/>
2025-12-07 19:32:53 +02:00
<SidebarProjectsList
sectionsForRender={sectionsForRender}
projectSections={projectSections}
activeProjectId={activeProjectId}
showOnlyMainWorkspace={showOnlyMainWorkspace}
hasSessionSearchQuery={hasSessionSearchQuery}
emptyState={emptyState}
searchEmptyState={searchEmptyState}
renderGroupSessions={renderGroupSessions}
homeDirectory={homeDirectory}
collapsedProjects={collapsedProjects}
hideDirectoryControls={hideDirectoryControls}
projectRepoStatus={projectRepoStatus}
hoveredProjectId={hoveredProjectId}
setHoveredProjectId={setHoveredProjectId}
isDesktopShellRuntime={isDesktopShellRuntime}
stuckProjectHeaders={stuckProjectHeaders}
mobileVariant={mobileVariant}
toggleProject={toggleProject}
setActiveProjectIdOnly={setActiveProjectIdOnly}
setActiveMainTab={setActiveMainTab}
setSessionSwitcherOpen={setSessionSwitcherOpen}
openNewSessionDraft={openNewSessionDraft}
createWorktreeSession={createWorktreeSession}
openMultiRunLauncher={openMultiRunLauncher}
setEditingProjectId={setEditingProjectId}
setEditProjectTitle={setEditProjectTitle}
editingProjectId={editingProjectId}
editProjectTitle={editProjectTitle}
handleSaveProjectEdit={handleSaveProjectEdit}
handleCancelProjectEdit={handleCancelProjectEdit}
removeProject={removeProject}
projectHeaderSentinelRefs={projectHeaderSentinelRefs}
settingsAutoCreateWorktree={settingsAutoCreateWorktree}
getOrderedGroups={getOrderedGroups}
setGroupOrderByProject={setGroupOrderByProject}
/>
2026-01-06 21:31:04 +02:00
<NewWorktreeDialog
open={newWorktreeDialogOpen}
onOpenChange={setNewWorktreeDialogOpen}
onWorktreeCreated={(worktreePath, options) => {
setActiveMainTab('chat');
if (mobileVariant) {
setSessionSwitcherOpen(false);
}
if (options?.sessionId) {
setCurrentSession(options.sessionId);
return;
}
openNewSessionDraft({ directoryOverride: worktreePath });
}}
/>
{useMobileNotesPanel ? (
<MobileOverlayPanel
open={projectNotesPanelOpen}
onClose={() => setProjectNotesPanelOpen(false)}
title="Project notes"
>
<ProjectNotesTodoPanel
projectRef={activeProjectRefForHeader}
canCreateWorktree={stableActiveProjectIsRepo}
onActionComplete={() => setProjectNotesPanelOpen(false)}
className="p-0"
/>
</MobileOverlayPanel>
) : null}
<SessionDeleteConfirmDialog
value={deleteSessionConfirm}
setValue={setDeleteSessionConfirm}
showDeletionDialog={showDeletionDialog}
setShowDeletionDialog={setShowDeletionDialog}
onConfirm={confirmDeleteSession}
/>
<FolderDeleteConfirmDialog
value={deleteFolderConfirm}
setValue={setDeleteFolderConfirm}
onConfirm={confirmDeleteFolder}
/>
2025-12-07 19:32:53 +02:00
</div>
);
};