Files
openchamber/packages/ui/src/components/session/SessionSidebar.tsx
T
Bohdan Triapitsyn d0a5538bff feat: tune session sidebar tooltip timing
Adds a shared sidebar tooltip provider with delayed open and instant close behavior
Removes per-button tooltip delays so sidebar actions feel more consistent
Aligns session sidebar hover interactions with the opencode-style tooltip experience
2026-08-30 18:03:29 +03:00

758 lines
30 KiB
TypeScript

import React from 'react';
import { toast } from '@/components/ui';
import { useI18n } from '@/lib/i18n';
import { useDeviceInfo } from '@/lib/device';
import { isDesktopShell, isVSCodeRuntime } from '@/lib/desktop';
import { sessionEvents } from '@/lib/sessionEvents';
import { cn } from '@/lib/utils';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useUIStore } from '@/stores/useUIStore';
import { getDeferredSafeStorage } from '@/stores/utils/safeStorage';
import { useGitStore, useGitAllBranches, useGitRepoStatusMap } from '@/stores/useGitStore';
import { TooltipProvider } from '@/components/ui/tooltip';
import { NewWorktreeDialog } from './NewWorktreeDialog';
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
import { useSessionSearchEffects } from './sidebar/shell/useSessionSearchEffects';
import { useSessionProjectViewState } from './sidebar/projects/useSessionProjectViewState';
import { useProjectRepoStatus } from './sidebar/projects/useProjectRepoStatus';
import { ProjectEditDialog } from '@/components/layout/ProjectEditDialog';
import { UpdateDialog } from '@/components/ui/UpdateDialog';
import { SidebarHeader } from './sidebar/shell/SidebarHeader';
import { SidebarNav } from './sidebar/shell/SidebarNav';
import { SidebarFooter } from './sidebar/shell/SidebarFooter';
import { SessionProjectCollection } from './sidebar/list/SessionProjectCollection';
import { useUpdateStore } from '@/stores/useUpdateStore';
import { useShallow } from 'zustand/react/shallow';
import {
listProjectWorktrees,
partitionWorktreesByRegisteredProject,
worktreeMapsEqual,
} from '@/lib/worktrees/worktreeManager';
import { checkIsGitRepository } from '@/lib/gitApi';
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
import { normalizePath } from './sidebar/utils';
import { recordWorktreesSeen } from './sidebar/projects/worktreeFirstSeen';
import { getRuntimeKey } from '@/lib/runtime-switch';
import { streamPerfCount, streamPerfMark } from '@/stores/utils/streamDebug';
import { runBackgroundNetworkTask } from '@/lib/background-network';
import { buildKnownSessionDirectories } from './sidebar/list/sessionListDirectories';
import { z } from 'zod';
import { subscribeOpenchamberEvents } from '@/lib/openchamberEvents';
import {
commitDiscoveredRawWorktreesByProject,
ensureRawWorktreesByProjectScope,
startSessionWorktreeMenuLoad,
type RawWorktreesByProjectScope,
type StartSessionWorktreeMenuLoadArgs,
} from './sidebar/sessionWorktreeMenu';
import { resolveProjectRef } from '@/lib/worktreeSessionCreator';
const PROJECT_ACTIVE_SESSION_STORAGE_KEY = 'oc.sessions.activeSessionByProject';
const EMPTY_STRING_ARRAY: string[] = [];
const activeSessionByProjectSchema = z.record(z.string(), z.string().min(1).catch(''));
interface SessionSidebarProps {
isVisible?: boolean;
mobileVariant?: boolean;
onSessionSelected?: (sessionId: string) => void;
allowReselect?: boolean;
hideDirectoryControls?: boolean;
showOnlyMainWorkspace?: boolean;
}
const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
isVisible = true,
mobileVariant = false,
onSessionSelected,
allowReselect = false,
hideDirectoryControls = false,
showOnlyMainWorkspace = false,
}) => {
streamPerfMark('react.session_sidebar_render');
streamPerfCount('ui.session_sidebar.render');
streamPerfCount(`ui.session_sidebar.render.${mobileVariant ? 'mobile' : 'desktop'}`);
streamPerfCount(`ui.session_sidebar.render.${isVisible ? 'visible' : 'hidden'}`);
const { t } = useI18n();
const [isSessionSearchOpen, setIsSessionSearchOpen] = React.useState(false);
const [sessionSearchQuery, setSessionSearchQuery] = React.useState('');
// Reported by the session list below: the header cannot see what matched.
const [searchMatchCount, setSearchMatchCount] = React.useState(0);
const sessionSearchContainerRef = React.useRef<HTMLDivElement | null>(null);
const sessionSearchInputRef = React.useRef<HTMLInputElement | null>(null);
const [editingProjectDialogId, setEditingProjectDialogId] = React.useState<string | null>(null);
const safeStorage = React.useMemo(() => getDeferredSafeStorage(), []);
const [projectRepoStatus, setProjectRepoStatus] = React.useState<Map<string, boolean | null>>(new Map());
const newWorktreeDialogOpen = useUIStore((state) => state.isNewWorktreeDialogOpen);
const setNewWorktreeDialogOpen = useUIStore((state) => state.setNewWorktreeDialogOpen);
const [updateDialogOpen, setUpdateDialogOpen] = React.useState(false);
const initialActiveSessionByProject = React.useMemo<Map<string, string>>(() => {
try {
const raw = safeStorage.getItem(PROJECT_ACTIVE_SESSION_STORAGE_KEY);
if (!raw) {
return new Map();
}
const parsed = activeSessionByProjectSchema.safeParse(JSON.parse(raw));
if (!parsed.success) return new Map();
const next = new Map<string, string>();
Object.entries(parsed.data).forEach(([projectId, sessionId]) => {
if (sessionId) next.set(projectId, sessionId);
});
return next;
} catch {
return new Map();
}
}, [safeStorage]);
const persistActiveSessionByProject = React.useCallback((value: Map<string, string>) => {
try {
safeStorage.setItem(PROJECT_ACTIVE_SESSION_STORAGE_KEY, JSON.stringify(Object.fromEntries(value.entries())));
} catch { /* ignored */ }
}, [safeStorage]);
const [projectRootBranches, setProjectRootBranches] = React.useState<Map<string, string>>(new Map());
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
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 setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
const toggleHelpDialog = useUIStore((state) => state.toggleHelpDialog);
const setAboutDialogOpen = useUIStore((state) => state.setAboutDialogOpen);
const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen);
const setScheduledTasksDialogOpen = useUIStore((state) => state.setScheduledTasksDialogOpen);
const setArchivePageOpen = useUIStore((state) => state.setArchivePageOpen);
const setWorktreesPageProjectId = useUIStore((state) => state.setWorktreesPageProjectId);
const openMultiRunLauncher = useUIStore((state) => state.openMultiRunLauncher);
const notifyOnSubtasks = useUIStore((state) => state.notifyOnSubtasks);
const debouncedSessionSearchQuery = useDebouncedValue(sessionSearchQuery, 120);
const normalizedSessionSearchQuery = React.useMemo(
() => debouncedSessionSearchQuery.trim().toLowerCase(),
[debouncedSessionSearchQuery],
);
const hasSessionSearchQuery = normalizedSessionSearchQuery.length > 0;
useSessionSearchEffects({
enabled: isVisible,
isSessionSearchOpen,
setIsSessionSearchOpen,
sessionSearchInputRef,
sessionSearchContainerRef,
});
const gitBranches = useGitAllBranches(isVisible);
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
// 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 knownSessionDirectories = React.useMemo(
() => buildKnownSessionDirectories(projects, availableWorktreesByProject, { includeWorktrees: !isVSCode }),
[availableWorktreesByProject, isVSCode, projects],
);
// The sidebar tree's +-buttons (project / group / folder) open a draft but,
// unlike selecting an existing session, don't navigate. VS Code's compact view
// is driven by the openchamber:navigate event, so switch to chat explicitly
// (a no-op in the expanded side-by-side layout, which is always showing chat).
const openNewSessionDraftFromTree = React.useCallback<typeof openNewSessionDraft>((options) => {
// Starting a draft always leaves any full-page surface, even when a
// draft was already open (no store transition fires in that case).
useUIStore.getState().closeMainSurfaces();
openNewSessionDraft(options);
if (isVSCode) {
window.dispatchEvent(new CustomEvent('openchamber:navigate', { detail: { view: 'chat' } }));
}
}, [isVSCode, openNewSessionDraft]);
const updateStore = useUpdateStore(useShallow((s) => ({
checkForUpdates: s.checkForUpdates,
available: s.available,
runtimeType: s.runtimeType,
info: s.info,
downloading: s.downloading,
downloaded: s.downloaded,
progress: s.progress,
error: s.error,
downloadUpdate: s.downloadUpdate,
restartToUpdate: s.restartToUpdate,
})));
const runtimeKey = getRuntimeKey();
const projectWorktreeDiscoveryKey = React.useMemo(
() => `${runtimeKey}|${projects
.map((project) => `${project.id}:${normalizePath(project.path) ?? ''}`)
.join('|')}`,
[projects, runtimeKey],
);
const [resolvedWorktreeTopologyKey, setResolvedWorktreeTopologyKey] = React.useState<string | null>(
isVSCode ? projectWorktreeDiscoveryKey : null,
);
const [worktreeDiscoveryRevision, requestWorktreeDiscovery] = React.useReducer((revision) => revision + 1, 0);
const isWorktreeTopologyLoading = !isVSCode && resolvedWorktreeTopologyKey !== projectWorktreeDiscoveryKey;
const [unresolvedWorktreeProjectPaths, setUnresolvedWorktreeProjectPaths] = React.useState<ReadonlySet<string>>(new Set());
const rawWorktreesByProjectRef = React.useRef<RawWorktreesByProjectScope>({
runtimeKey: null,
revision: 0,
worktreesByProject: new Map(),
});
React.useEffect(() => {
let cancelled = false;
const discoverWorktrees = async () => {
const discoveryRuntimeKey = runtimeKey;
const projectEntries = useProjectsStore.getState().projects;
if (projectEntries.length === 0 || isVSCode) {
if (!cancelled) {
rawWorktreesByProjectRef.current = {
runtimeKey: null,
revision: 0,
worktreesByProject: new Map(),
};
setUnresolvedWorktreeProjectPaths(new Set());
setResolvedWorktreeTopologyKey(projectWorktreeDiscoveryKey);
}
return;
}
const knownPublishedWorktreesByProject = useSessionUIStore.getState().availableWorktreesByProject;
const seededRawScope = ensureRawWorktreesByProjectScope({
rawWorktreesByProjectRef,
publishedWorktreesByProject: knownPublishedWorktreesByProject,
runtimeKey: discoveryRuntimeKey,
});
const capturedRawRevision = seededRawScope.revision;
const worktreesByProject = new Map(seededRawScope.worktreesByProject);
const unresolvedProjectPaths = new Set<string>();
// Constrain fanout: previously `Promise.all(projects.map(...))` could
// spawn dozens of concurrent `git worktree list` and
// `checkIsGitRepository` calls on cold start, each touching the
// worktree process. Concurrency=3 keeps startup latency low while
// bounding peak worktree-process load.
const worktreeConcurrency = 3;
let cursor = 0;
const workers = Array.from({ length: worktreeConcurrency }, async () => {
while (true) {
const nextIndex = cursor;
cursor += 1;
if (nextIndex >= projectEntries.length) return;
const project = projectEntries[nextIndex];
const projectPath = normalizePath(project.path);
if (!projectPath) continue;
try {
const worktrees = await runBackgroundNetworkTask(async () => {
// Use store-cached isGitRepo when available; fall back to
// a direct check for projects the Git store hasn't seen yet.
const cachedIsGitRepo = useGitStore.getState().directories.get(projectPath)?.isGitRepo;
const isGitRepo = cachedIsGitRepo ?? await checkIsGitRepository(projectPath);
if (!isGitRepo) return null;
return listProjectWorktrees({ id: project.id, path: projectPath });
});
if (worktrees === null) {
worktreesByProject.delete(projectPath);
continue;
}
if (cancelled) return;
if (worktrees.length === 0) {
worktreesByProject.delete(projectPath);
} else {
worktreesByProject.set(projectPath, worktrees);
}
} catch {
// Keep last-known worktrees when a project is temporarily unavailable.
unresolvedProjectPaths.add(projectPath);
}
}
});
await Promise.all(workers);
if (cancelled || getRuntimeKey() !== discoveryRuntimeKey) return;
const activeProjectPaths = new Set(projectEntries.map((project) => normalizePath(project.path)).filter(Boolean));
for (const projectPath of worktreesByProject.keys()) {
if (!activeProjectPaths.has(projectPath)) {
worktreesByProject.delete(projectPath);
}
}
const committed = commitDiscoveredRawWorktreesByProject({
rawWorktreesByProjectRef,
runtimeKey: discoveryRuntimeKey,
capturedRevision: capturedRawRevision,
nextRawWorktreesByProject: worktreesByProject,
publishedWorktreesByProject: knownPublishedWorktreesByProject,
partitionWorktreesByRegisteredProject,
projects: projectEntries,
worktreeMapsEqual,
recordWorktreesSeen,
publishTopology: (next) => {
useSessionUIStore.setState(next);
},
requestRediscovery: () => {
requestWorktreeDiscovery();
},
now: () => Date.now(),
});
if (!committed) {
return;
}
setUnresolvedWorktreeProjectPaths(unresolvedProjectPaths);
setResolvedWorktreeTopologyKey(projectWorktreeDiscoveryKey);
};
void discoverWorktrees();
return () => {
cancelled = true;
};
}, [isVSCode, projectWorktreeDiscoveryKey, runtimeKey, worktreeDiscoveryRevision]);
React.useEffect(() => {
if (isVSCode) return;
return subscribeOpenchamberEvents((event) => {
if (event.type === 'session-created') requestWorktreeDiscovery();
});
}, [isVSCode]);
const isDesktopShellRuntime = React.useMemo(() => isDesktopShell(), []);
const { isTablet } = useDeviceInfo();
const alwaysShowSidebarActions = mobileVariant || isTablet;
const emptyState = React.useMemo(() => (
<div className="py-6 text-center text-muted-foreground">
<p className="typography-ui-label font-semibold">{t('sessions.sidebar.empty.noSessions.title')}</p>
<p className="typography-meta mt-1">{t('sessions.sidebar.empty.noSessions.description')}</p>
</div>
), [t]);
const editingProject = React.useMemo(
() => projects.find((project) => project.id === editingProjectDialogId) ?? null,
[projects, editingProjectDialogId],
);
const handleSaveProjectEdit = React.useCallback((data: {
label: string;
icon: string | null;
color: string | null;
iconBackground: string | null;
defaultModel: string | null;
}) => {
if (!editingProjectDialogId) {
return;
}
updateProjectMeta(editingProjectDialogId, {
label: data.label,
icon: data.icon,
color: data.color,
iconBackground: data.iconBackground,
defaultModel: data.defaultModel ?? null,
});
}, [editingProjectDialogId, updateProjectMeta]);
const openNewWorktreeDialog = React.useCallback(() => {
setNewWorktreeDialogOpen(true);
}, [setNewWorktreeDialogOpen]);
const handleOpenUpdateDialog = React.useCallback(() => {
const current = useUpdateStore.getState();
if (current.available && current.info) {
setUpdateDialogOpen(true);
return;
}
void updateStore.checkForUpdates().then(() => {
const { available, error } = useUpdateStore.getState();
if (error) {
toast.error(t('sessions.sidebar.updateCheck.errorTitle'), { description: error });
return;
}
if (!available) {
toast.success(t('sessions.sidebar.updateCheck.latestVersion'));
return;
}
setUpdateDialogOpen(true);
});
}, [t, updateStore]);
const handleOpenSettings = React.useCallback(() => {
if (mobileVariant) {
setSessionSwitcherOpen(false);
}
setSettingsDialogOpen(true);
}, [mobileVariant, setSessionSwitcherOpen, setSettingsDialogOpen]);
const showSidebarUpdateButton =
updateStore.available &&
(updateStore.runtimeType === 'desktop' || updateStore.runtimeType === 'web');
const handleOpenDirectoryDialog = React.useCallback(() => {
sessionEvents.requestDirectoryDialog();
}, []);
const normalizedProjects = React.useMemo(() => {
return projects.flatMap((project) => {
const normalizedPath = normalizePath(project.path);
if (!normalizedPath) return [];
return [{
id: project.id,
path: project.path,
label: project.label,
normalizedPath,
icon: project.icon ?? undefined,
color: project.color ?? undefined,
iconImage: project.iconImage ?? undefined,
iconBackground: project.iconBackground ?? undefined,
addedAt: project.addedAt,
lastOpenedAt: project.lastOpenedAt,
sidebarCollapsed: project.sidebarCollapsed,
}];
});
}, [projects]);
const normalizedProjectPaths = React.useMemo(
() => normalizedProjects.map((project) => project.normalizedPath),
[normalizedProjects],
);
const gitRepoStatus = useGitRepoStatusMap(isVisible ? normalizedProjectPaths : EMPTY_STRING_ARRAY);
useProjectRepoStatus({
enabled: isVisible,
normalizedProjects,
gitRepoStatus,
setProjectRepoStatus,
setProjectRootBranches,
});
const isSessionsLoading = useSessionUIStore((state) => state.isLoading);
// 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 showArchivedSessions = useSessionDisplayStore((state) => state.showArchivedSessions);
const projectSortOrder = useSessionDisplayStore((state) => state.projectSortOrder);
const stickyZoneHeaders = useSessionDisplayStore((state) => state.stickyZoneHeaders);
const manualProjectOrder = useProjectsStore((state) => state.manualProjectOrder);
const sidebarRenderSources = {
isVisible,
mobileVariant,
onSessionSelected,
allowReselect,
hideDirectoryControls,
showOnlyMainWorkspace,
t,
isTablet,
projects,
activeProjectId,
manualProjectOrder,
worktreeMetadata,
availableWorktreesByProject,
gitBranches,
gitRepoStatus,
updateStore,
showArchivedSessions,
projectSortOrder,
projectRepoStatus,
projectRootBranches,
resolvedWorktreeTopologyKey,
unresolvedWorktreeProjectPaths,
isSessionSearchOpen,
sessionSearchQuery,
editingProjectDialogId,
updateDialogOpen,
};
const previousSidebarRenderSourcesRef = React.useRef<typeof sidebarRenderSources | null>(null);
const previousSidebarRenderSources = previousSidebarRenderSourcesRef.current;
if (previousSidebarRenderSources) {
let attributed = false;
// SAFETY: Object.keys is constrained to the immediately constructed object's own keys.
for (const source of Object.keys(sidebarRenderSources) as Array<keyof typeof sidebarRenderSources>) {
if (!Object.is(previousSidebarRenderSources[source], sidebarRenderSources[source])) {
streamPerfCount(`ui.session_sidebar.source.${source}`);
attributed = true;
}
}
if (!attributed) {
streamPerfCount('ui.session_sidebar.source.parent_or_context');
}
}
previousSidebarRenderSourcesRef.current = sidebarRenderSources;
const sortedProjects = React.useMemo(() => {
const list = [...normalizedProjects];
switch (projectSortOrder) {
case 'a-z':
list.sort((a, b) => {
const aLabel = (a.label || a.path).toLowerCase();
const bLabel = (b.label || b.path).toLowerCase();
return aLabel.localeCompare(bLabel);
});
break;
case 'z-a':
list.sort((a, b) => {
const aLabel = (a.label || a.path).toLowerCase();
const bLabel = (b.label || b.path).toLowerCase();
return bLabel.localeCompare(aLabel);
});
break;
case 'date-added':
list.sort((a, b) => (b.addedAt ?? 0) - (a.addedAt ?? 0));
break;
case 'recent':
list.sort((a, b) => (b.lastOpenedAt ?? 0) - (a.lastOpenedAt ?? 0));
break;
case 'manual': {
const orderMap = new Map(manualProjectOrder.map((id, i) => [id, i]));
list.sort((a, b) => {
const ai = orderMap.get(a.id) ?? Infinity;
const bi = orderMap.get(b.id) ?? Infinity;
return ai - bi;
});
break;
}
}
return list;
}, [normalizedProjects, projectSortOrder, manualProjectOrder]);
const projectView = useSessionProjectViewState({ isVSCode, projects: sortedProjects });
const searchEmptyState = React.useMemo(() => (
<div className="py-6 text-center text-muted-foreground">
<p className="typography-ui-label font-semibold">{t('sessions.sidebar.empty.noMatches.title')}</p>
<p className="typography-meta mt-1">{t('sessions.sidebar.empty.noMatches.description')}</p>
</div>
), [t]);
// Web/desktop route archived sessions to the Archive page; only the VS Code
// compact webview keeps inline archived buckets behind its toggle.
const showInlineArchived = isVSCode && showArchivedSessions;
// 'by-worktree' renders the worktree-grouped sections (parallel-work
// overview); 'flat' renders the merged per-project list. VS Code has no
// worktree groups, so both resolve to the same shape — use flat there.
const sessionGroupingMode = useSessionDisplayStore((state) => state.sessionGroupingMode);
const useGroupedSections = sessionGroupingMode === 'by-worktree' && !isVSCode;
const 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 handleOpenMultiRunFromHeader = React.useCallback(() => {
if (mobileVariant) {
setSessionSwitcherOpen(false);
}
openMultiRunLauncher();
}, [mobileVariant, openMultiRunLauncher, setSessionSwitcherOpen]);
const handleSessionWorktreeMenuLoad = React.useCallback((args: StartSessionWorktreeMenuLoadArgs) => {
const resolvedProject = args.projectId
? (projects.find((candidate) => candidate.id === args.projectId) ?? null)
: (args.sourceDirectory ? resolveProjectRef(args.sourceDirectory) : null);
return startSessionWorktreeMenuLoad(args, {
projects,
getCurrentProjects: () => useProjectsStore.getState().projects,
rawWorktreesByProjectRef,
getPublishedWorktreesByProject: () => useSessionUIStore.getState().availableWorktreesByProject,
resolveProject: (directory) => resolveProjectRef(directory),
listProjectWorktrees,
partitionWorktreesByRegisteredProject,
worktreeMapsEqual,
recordWorktreesSeen,
publishTopology: (next) => {
useSessionUIStore.setState(next);
},
getRuntimeKey,
now: () => Date.now(),
projectRootBranch: resolvedProject ? (projectRootBranches.get(resolvedProject.id) ?? null) : null,
});
}, [projectRootBranches, projects]);
const handleOpenNewSessionDraftFromHeader = React.useCallback(() => {
useUIStore.getState().closeMainSurfaces();
if (mobileVariant) {
setSessionSwitcherOpen(false);
}
openNewSessionDraft();
}, [mobileVariant, openNewSessionDraft, setSessionSwitcherOpen]);
return (
// One shared tooltip provider for the whole sidebar, matching the opencode
// sidebar feel: 400ms before the first tooltip opens, instant close on
// leave, and grouping — moving between rows within 600ms hands the tooltip
// over to the next row without replaying the open delay or exit/enter
// animation.
<TooltipProvider delay={400} closeDelay={0} timeout={300}>
<div
ref={sessionSearchContainerRef}
className={cn(
'relative flex h-full flex-col text-foreground overflow-x-hidden',
mobileVariant ? '' : 'bg-transparent',
)}
>
{!hideDirectoryControls && !isVSCode ? (
<SidebarNav onNewSession={handleOpenNewSessionDraftFromHeader} />
) : null}
<SidebarHeader
hideDirectoryControls={hideDirectoryControls}
showProjectDisplayControls={!isVSCode}
showRecentControls={!isVSCode}
handleOpenDirectoryDialog={handleOpenDirectoryDialog}
onOpenScheduled={() => {
if (mobileVariant) setSessionSwitcherOpen(false);
setScheduledTasksDialogOpen(true);
}}
onOpenMultiRun={handleOpenMultiRunFromHeader}
canOpenMultiRun={projects.length > 0}
onOpenArchive={() => {
if (mobileVariant) setSessionSwitcherOpen(false);
setArchivePageOpen(true);
}}
headerActionIconClass={headerActionIconClass}
headerActionButtonClass={headerActionButtonClass}
isSessionSearchOpen={isSessionSearchOpen}
setIsSessionSearchOpen={setIsSessionSearchOpen}
sessionSearchInputRef={sessionSearchInputRef}
sessionSearchQuery={sessionSearchQuery}
setSessionSearchQuery={setSessionSearchQuery}
hasSessionSearchQuery={hasSessionSearchQuery}
searchMatchCount={searchMatchCount}
collapseAllProjects={projectView.actions.collapseAllProjects}
expandAllProjects={projectView.actions.expandAllProjects}
/>
<SessionProjectCollection
topology={{
projects: sortedProjects,
availableWorktreesByProject,
knownDirectories: knownSessionDirectories,
isVSCode,
worktreeMetadata,
gitBranches,
projectRepoStatus,
projectRootBranches,
lastRepoStatus: lastRepoStatusRef.current,
}}
view={{
isVisible,
hasSessionSearchQuery,
normalizedSessionSearchQuery,
activeProjectId,
showInlineArchived,
useGroupedSections,
homeDirectory,
mobileVariant,
hideDirectoryControls,
showOnlyMainWorkspace,
isDesktopShellRuntime,
stickyZoneHeaders,
projectSortOrder,
emptyState,
searchEmptyState,
isSessionsLoading,
isWorktreeTopologyLoading,
unresolvedWorktreeProjectPaths,
projectView: projectView.state,
onSearchMatchCountChange: setSearchMatchCount,
}}
actions={{
rowActions: {
allowReselect,
onSessionSelected,
isSessionSearchOpen,
sessionSearchQuery,
setSessionSearchQuery,
setIsSessionSearchOpen,
},
alwaysShowActions: alwaysShowSidebarActions,
notifyOnSubtasks,
setActiveProjectIdOnly,
setSessionSwitcherOpen,
openNewSessionDraft: openNewSessionDraftFromTree,
openNewWorktreeDialog,
openWorktreesPage: (projectId) => {
if (mobileVariant) setSessionSwitcherOpen(false);
setWorktreesPageProjectId(projectId);
},
openProjectEditDialog: setEditingProjectDialogId,
removeProject,
reorderProjects,
startSessionWorktreeMenuLoad: handleSessionWorktreeMenuLoad,
initialActiveSessionByProject,
persistActiveSessionByProject,
projectViewActions: projectView.actions,
}}
/>
<SidebarFooter
onOpenSettings={handleOpenSettings}
onOpenShortcuts={toggleHelpDialog}
onOpenAbout={() => setAboutDialogOpen(true)}
onOpenUpdate={handleOpenUpdateDialog}
showRuntimeButtons={!isVSCode}
showUpdateButton={showSidebarUpdateButton}
/>
<UpdateDialog
open={updateDialogOpen}
onOpenChange={setUpdateDialogOpen}
info={updateStore.info}
downloading={updateStore.downloading}
downloaded={updateStore.downloaded}
progress={updateStore.progress}
error={updateStore.error}
onDownload={updateStore.downloadUpdate}
onRestart={updateStore.restartToUpdate}
runtimeType={updateStore.runtimeType}
/>
<ProjectEditDialog
open={Boolean(editingProject)}
onOpenChange={(open) => {
if (!open) {
setEditingProjectDialogId(null);
}
}}
project={editingProject}
onSave={handleSaveProjectEdit}
/>
<NewWorktreeDialog
open={newWorktreeDialogOpen}
onOpenChange={setNewWorktreeDialogOpen}
onWorktreeCreated={(worktreePath, options) => {
useUIStore.getState().closeMainSurfaces();
if (mobileVariant) {
setSessionSwitcherOpen(false);
}
if (options?.sessionId) {
setCurrentSession(options.sessionId, worktreePath);
return;
}
openNewSessionDraft({ directoryOverride: worktreePath, preserveDirectoryOverride: true });
}}
/>
</div>
</TooltipProvider>
);
};
export const SessionSidebar = React.memo(SessionSidebarComponent);