= ({
};
}, []);
- const displayDirectory = React.useMemo(
- () => formatDirectoryName(currentDirectory, homeDirectory),
- [currentDirectory, homeDirectory],
- );
-
- const directoryTooltip = React.useMemo(
- () => formatPathForDisplay(currentDirectory, homeDirectory),
- [currentDirectory, homeDirectory],
- );
const emptyState = (
@@ -342,11 +589,20 @@ export const SessionSidebar: React.FC = ({
);
const handleSessionSelect = React.useCallback(
- (sessionId: string, disabled?: boolean) => {
+ (sessionId: string, sessionDirectory?: string | null, disabled?: boolean, projectId?: string | null) => {
if (disabled) {
return;
}
+ if (projectId && projectId !== activeProjectId) {
+ // Important: avoid switching to the project root first (that can select the wrong session).
+ setActiveProjectIdOnly(projectId);
+ }
+
+ if (sessionDirectory && sessionDirectory !== currentDirectory) {
+ setDirectory(sessionDirectory, { showOverlay: false });
+ }
+
if (mobileVariant) {
setActiveMainTab('chat');
setSessionSwitcherOpen(false);
@@ -360,12 +616,16 @@ export const SessionSidebar: React.FC = ({
onSessionSelected?.(sessionId);
},
[
+ activeProjectId,
allowReselect,
+ currentDirectory,
currentSessionId,
mobileVariant,
onSessionSelected,
setActiveMainTab,
+ setActiveProjectIdOnly,
setCurrentSession,
+ setDirectory,
setSessionSwitcherOpen,
],
);
@@ -473,19 +733,28 @@ export const SessionSidebar: React.FC = ({
);
const handleCreateSessionInGroup = React.useCallback(
- (directory: string | null) => {
+ (directory: string | null, projectId?: string | null) => {
+ if (projectId && projectId !== activeProjectId) {
+ setActiveProject(projectId);
+ }
setActiveMainTab('chat');
if (mobileVariant) {
setSessionSwitcherOpen(false);
}
openNewSessionDraft({ directoryOverride: directory ?? null });
},
- [openNewSessionDraft, setActiveMainTab, setSessionSwitcherOpen, mobileVariant],
+ [activeProjectId, openNewSessionDraft, setActiveMainTab, setActiveProject, setSessionSwitcherOpen, mobileVariant],
);
- const handleOpenWorktreeManager = React.useCallback(() => {
- sessionEvents.requestCreate({ worktreeMode: 'create' });
- }, []);
+ const handleOpenWorktreeManager = React.useCallback(
+ (projectId?: string | null) => {
+ if (projectId && projectId !== activeProjectId) {
+ setActiveProjectIdOnly(projectId);
+ }
+ sessionEvents.requestCreate({ worktreeMode: 'create', projectId: projectId ?? null });
+ },
+ [activeProjectId, setActiveProjectIdOnly],
+ );
const handleOpenDirectoryDialog = React.useCallback(() => {
if (isDesktopRuntime && window.opencodeDesktop?.requestDirectoryAccess) {
@@ -493,7 +762,12 @@ export const SessionSidebar: React.FC = ({
.requestDirectoryAccess('')
.then((result) => {
if (result.success && result.path) {
- setDirectory(result.path, { showOverlay: true });
+ 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,
@@ -507,7 +781,22 @@ export const SessionSidebar: React.FC = ({
} else {
sessionEvents.requestDirectoryDialog();
}
- }, [isDesktopRuntime, setDirectory]);
+ }, [addProject, isDesktopRuntime]);
+
+ const confirmPendingProjectClose = React.useCallback(() => {
+ const pending = pendingProjectClose;
+ if (!pending) {
+ return;
+ }
+
+ removeProject(pending.id);
+ setPendingProjectClose(null);
+ toast.success('Project closed', { description: pending.label });
+ }, [pendingProjectClose, removeProject]);
+
+ const cancelPendingProjectClose = React.useCallback(() => {
+ setPendingProjectClose(null);
+ }, []);
const toggleParent = React.useCallback((sessionId: string) => {
setExpandedParents((prev) => {
@@ -535,113 +824,134 @@ export const SessionSidebar: React.FC = ({
[childrenMap],
);
- const groupedSessions = React.useMemo(() => {
- const groups = new Map();
- const normalizedProjectRoot = normalizePath(projectRoot ?? null);
- const worktreeByPath = new Map();
- const existingWorktreePaths = new Set();
- availableWorktrees.forEach((meta) => {
- if (meta.path) {
- const normalized = normalizePath(meta.path) ?? meta.path;
- existingWorktreePaths.add(normalized);
- worktreeByPath.set(normalized, meta);
- }
- });
+ const buildGroupedSessions = React.useCallback(
+ (projectSessions: Session[], projectRoot: string | null, availableWorktrees: WorktreeMetadata[]) => {
+ const groups = new Map();
+ const normalizedProjectRoot = normalizePath(projectRoot ?? null);
+ const sortedProjectSessions = [...projectSessions].sort((a, b) => (b.time?.created || 0) - (a.time?.created || 0));
- const ensureGroup = (session: Session) => {
- const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null);
-
- const sessionWorktreeMeta = worktreeMetadata.get(session.id);
- const sessionWorktreeExists = sessionWorktreeMeta?.path
- ? existingWorktreePaths.has(normalizePath(sessionWorktreeMeta.path) ?? sessionWorktreeMeta.path)
- : false;
- const worktree =
- (sessionWorktreeExists ? sessionWorktreeMeta : null) ??
- (sessionDirectory ? worktreeByPath.get(sessionDirectory) ?? null : null);
- const isMain =
- !worktree &&
- ((sessionDirectory && normalizedProjectRoot
- ? sessionDirectory === normalizedProjectRoot
- : !sessionDirectory && Boolean(normalizedProjectRoot)));
- const key = isMain ? 'main' : worktree?.path ?? sessionDirectory ?? session.id;
- const directory = worktree?.path ?? sessionDirectory ?? normalizedProjectRoot ?? null;
- if (!groups.has(key)) {
- const label = isMain
- ? 'Main workspace'
- : worktree?.label || worktree?.branch || formatDirectoryName(directory || '', homeDirectory) || 'Worktree';
- const description = worktree?.relativePath
- ? formatPathForDisplay(worktree.relativePath, homeDirectory)
- : directory
- ? formatPathForDisplay(directory, homeDirectory)
- : null;
- groups.set(key, {
- id: key,
- label,
- description,
- isMain,
- worktree,
- directory,
- sessions: [],
- });
- }
- return groups.get(key)!;
- };
-
- const roots = sortedSessions.filter((session) => {
- const parentID = (session as Session & { parentID?: string | null }).parentID;
- if (!parentID) {
- return true;
- }
- return !sessionMap.has(parentID);
- });
-
- roots.forEach((session) => {
- const group = ensureGroup(session);
- const node = buildNode(session);
- group.sessions.push(node);
- });
-
- if (!groups.has('main')) {
- groups.set('main', {
- id: 'main',
- label: 'Main workspace',
- description: normalizedProjectRoot ? formatPathForDisplay(normalizedProjectRoot, homeDirectory) : null,
- isMain: true,
- worktree: null,
- directory: normalizedProjectRoot,
- sessions: [],
+ const sessionMap = new Map(sortedProjectSessions.map((session) => [session.id, session]));
+ const childrenMap = new Map();
+ sortedProjectSessions.forEach((session) => {
+ const parentID = (session as Session & { parentID?: string | null }).parentID;
+ if (!parentID) {
+ return;
+ }
+ const collection = childrenMap.get(parentID) ?? [];
+ collection.push(session);
+ childrenMap.set(parentID, collection);
});
- }
+ childrenMap.forEach((list) => list.sort((a, b) => (b.time?.created || 0) - (a.time?.created || 0)));
- worktreeByPath.forEach((meta, path) => {
- const key = meta.path;
- if (!groups.has(key)) {
- groups.set(key, {
- id: key,
- label: meta.label || meta.branch || formatDirectoryName(path, homeDirectory) || 'Worktree',
- description: meta.relativePath
- ? formatPathForDisplay(meta.relativePath, homeDirectory)
- : formatPathForDisplay(path, homeDirectory),
- isMain: false,
- worktree: meta,
- directory: path,
+ const buildProjectNode = (session: Session): SessionNode => {
+ const children = childrenMap.get(session.id) ?? [];
+ return {
+ session,
+ children: children.map((child) => buildProjectNode(child)),
+ };
+ };
+
+ const worktreeByPath = new Map();
+ availableWorktrees.forEach((meta) => {
+ if (meta.path) {
+ const normalized = normalizePath(meta.path) ?? meta.path;
+ worktreeByPath.set(normalized, meta);
+ }
+ });
+
+ const ensureGroup = (session: Session) => {
+ const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null);
+
+ const sessionWorktreeMeta = worktreeMetadata.get(session.id) ?? null;
+ const worktree =
+ sessionWorktreeMeta ??
+ (sessionDirectory ? worktreeByPath.get(sessionDirectory) ?? null : null);
+ const isMain =
+ !worktree &&
+ ((sessionDirectory && normalizedProjectRoot
+ ? sessionDirectory === normalizedProjectRoot
+ : !sessionDirectory && Boolean(normalizedProjectRoot)));
+ const key = isMain ? 'main' : worktree?.path ?? sessionDirectory ?? session.id;
+ const directory = worktree?.path ?? sessionDirectory ?? normalizedProjectRoot ?? null;
+ if (!groups.has(key)) {
+ const label = isMain
+ ? 'Main workspace'
+ : worktree?.label || worktree?.branch || formatDirectoryName(directory || '', homeDirectory) || 'Worktree';
+ const description = worktree?.relativePath
+ ? formatPathForDisplay(worktree.relativePath, homeDirectory)
+ : directory
+ ? formatPathForDisplay(directory, homeDirectory)
+ : null;
+ groups.set(key, {
+ id: key,
+ label,
+ description,
+ isMain,
+ worktree,
+ directory,
+ sessions: [],
+ });
+ }
+ return groups.get(key)!;
+ };
+
+ const roots = sortedProjectSessions.filter((session) => {
+ const parentID = (session as Session & { parentID?: string | null }).parentID;
+ if (!parentID) {
+ return true;
+ }
+ return !sessionMap.has(parentID);
+ });
+
+ roots.forEach((session) => {
+ const group = ensureGroup(session);
+ const node = buildProjectNode(session);
+ group.sessions.push(node);
+ });
+
+ if (!groups.has('main')) {
+ groups.set('main', {
+ id: 'main',
+ label: 'Main workspace',
+ description: normalizedProjectRoot ? formatPathForDisplay(normalizedProjectRoot, homeDirectory) : null,
+ isMain: true,
+ worktree: null,
+ directory: normalizedProjectRoot,
sessions: [],
});
}
- });
- groups.forEach((group) => {
- group.sessions.sort((a, b) => (b.session.time?.created || 0) - (a.session.time?.created || 0));
- });
+ worktreeByPath.forEach((meta, path) => {
+ const key = meta.path;
+ if (!groups.has(key)) {
+ groups.set(key, {
+ id: key,
+ label: meta.label || meta.branch || formatDirectoryName(path, homeDirectory) || 'Worktree',
+ description: meta.relativePath
+ ? formatPathForDisplay(meta.relativePath, homeDirectory)
+ : formatPathForDisplay(path, homeDirectory),
+ isMain: false,
+ worktree: meta,
+ directory: path,
+ sessions: [],
+ });
+ }
+ });
- return Array.from(groups.values()).sort((a, b) => {
- if (a.isMain !== b.isMain) {
- return a.isMain ? -1 : 1;
- }
- return (a.label || '').localeCompare(b.label || '');
- });
- }, [sortedSessions, worktreeMetadata, availableWorktrees, projectRoot, homeDirectory, buildNode, sessionMap]);
+ groups.forEach((group) => {
+ group.sessions.sort((a, b) => (b.session.time?.created || 0) - (a.session.time?.created || 0));
+ });
+
+ return Array.from(groups.values()).sort((a, b) => {
+ if (a.isMain !== b.isMain) {
+ return a.isMain ? -1 : 1;
+ }
+ return (a.label || '').localeCompare(b.label || '');
+ });
+ },
+ [homeDirectory, worktreeMetadata]
+ );
const toggleGroup = React.useCallback((groupId: string) => {
setCollapsedGroups((prev) => {
@@ -670,12 +980,87 @@ export const SessionSidebar: React.FC = ({
});
}, []);
+ 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 */ }
+ return next;
+ });
+ }, [safeStorage]);
+
+ 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]);
+
+ const getSessionsForProject = React.useCallback(
+ (project: { normalizedPath: string }) => {
+ const worktreesForProject = availableWorktreesByProject.get(project.normalizedPath) ?? [];
+ const directories = [
+ project.normalizedPath,
+ ...worktreesForProject
+ .map((meta) => normalizePath(meta.path) ?? meta.path)
+ .filter((value): value is string => Boolean(value)),
+ ];
+
+ const seen = new Set();
+ const collected: Session[] = [];
+
+ directories.forEach((directory) => {
+ const sessionsForDirectory = sessionsByDirectory.get(directory) ?? getSessionsByDirectory(directory);
+ sessionsForDirectory.forEach((session) => {
+ if (seen.has(session.id)) {
+ return;
+ }
+ seen.add(session.id);
+ collected.push(session);
+ });
+ });
+
+ return collected;
+ },
+ [availableWorktreesByProject, getSessionsByDirectory, sessionsByDirectory],
+ );
+
+ const projectSections = React.useMemo(() => {
+ return normalizedProjects.map((project) => {
+ const projectSessions = getSessionsForProject(project);
+ const worktreesForProject = availableWorktreesByProject.get(project.normalizedPath) ?? [];
+ const groups = buildGroupedSessions(projectSessions, project.normalizedPath, worktreesForProject);
+ return {
+ project,
+ groups,
+ };
+ });
+ }, [normalizedProjects, getSessionsForProject, buildGroupedSessions, availableWorktreesByProject]);
+
// Track when sticky headers become "stuck" using sentinel elements
React.useEffect(() => {
if (!isDesktopRuntime) return;
const observer = new IntersectionObserver(
(entries) => {
+ // Ignore intersection events shortly after collapse/expand
+ if (Date.now() < ignoreIntersectionUntil.current) return;
+
entries.forEach((entry) => {
const groupId = (entry.target as HTMLElement).dataset.groupId;
if (!groupId) return;
@@ -692,18 +1077,55 @@ export const SessionSidebar: React.FC = ({
});
});
},
+ { threshold: 0, rootMargin: '-96px 0px 0px 0px' }
+ );
+
+ // Small delay to let DOM settle after collapse/expand
+ const timeoutId = setTimeout(() => {
+ headerSentinelRefs.current.forEach((el) => {
+ if (el) observer.observe(el);
+ });
+ }, 50);
+
+ return () => {
+ clearTimeout(timeoutId);
+ observer.disconnect();
+ };
+ }, [isDesktopRuntime, projectSections, collapsedProjects]);
+
+ // Track when project sticky headers become "stuck"
+ React.useEffect(() => {
+ if (!isDesktopRuntime) return;
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ entries.forEach((entry) => {
+ const projectId = (entry.target as HTMLElement).dataset.projectId;
+ if (!projectId) return;
+
+ setStuckProjectHeaders((prev) => {
+ const next = new Set(prev);
+ if (!entry.isIntersecting) {
+ next.add(projectId);
+ } else {
+ next.delete(projectId);
+ }
+ return next;
+ });
+ });
+ },
{ threshold: 0 }
);
- headerSentinelRefs.current.forEach((el) => {
+ projectHeaderSentinelRefs.current.forEach((el) => {
if (el) observer.observe(el);
});
return () => observer.disconnect();
- }, [isDesktopRuntime, groupedSessions]);
+ }, [isDesktopRuntime, projectSections]);
const renderSessionNode = React.useCallback(
- (node: SessionNode, depth = 0, groupDirectory?: string | null): React.ReactNode => {
+ (node: SessionNode, depth = 0, groupDirectory?: string | null, projectId?: string | null): React.ReactNode => {
const session = node.session;
const sessionDirectory =
normalizePath((session as Session & { directory?: string | null }).directory ?? null) ??
@@ -796,6 +1218,7 @@ export const SessionSidebar: React.FC = ({
const phase = sessionActivityPhase?.get(session.id) ?? 'idle';
const isStreaming = phase === 'busy' || phase === 'cooldown';
+ const pendingPermissionCount = permissions.get(session.id)?.length ?? 0;
const streamingIndicator = (() => {
if (!memoryState) return null;
@@ -819,7 +1242,7 @@ export const SessionSidebar: React.FC = ({
{}
@@ -963,7 +1397,7 @@ export const SessionSidebar: React.FC = ({