Files
openchamber/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx
T

1263 lines
57 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import type { Session } from '@opencode-ai/sdk/v2';
// Archived buckets routinely grow into the hundreds/thousands; virtualize
// when we cross this row count so the DOM stays bounded.
const ARCHIVED_VIRTUALIZE_THRESHOLD = 50;
// Compact rows in the archived bucket without nested subagents render
// around 24-32px; virtua measures mounted rows and uses this as the initial hint.
const ARCHIVED_ROW_ESTIMATE_PX = 28;
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Button } from '@/components/ui/button';
import { Icon } from "@/components/icon/Icon";
import { cn } from '@/lib/utils';
import { sessionEvents } from '@/lib/sessionEvents';
import type { MainTab } from '@/stores/useUIStore';
import { SessionFolderItem } from '../SessionFolderItem';
import type { SortableDragHandleProps } from './sortableItems';
import { DroppableFolderWrapper, SessionFolderDndScope } from './sessionFolderDnd';
import type { GroupSearchData, SessionGroup, SessionNode } from './types';
import { isBranchDifferentFromLabel, normalizePath, renderHighlightedText } from './utils';
import { compareSessionsByLifecycleOrder, EMPTY_SESSION_ORDER_RANKS } from '@/sync/session-ordering';
import {
collectSubtreeContainingId,
computeNodeStructureKey,
nodeHasPinnedMembershipChange,
nodeContainsSessionId,
resolveMenuOpenSessionId,
selectFolderRootNodes,
} from './sessionNodeItemUtils';
import type { SessionNodeRenderExtras } from './sessionNodeItemUtils';
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
import { getGitHubPrStatusKey, usePrVisualSummary } from '@/stores/useGitHubPrStatusStore';
import { useI18n } from '@/lib/i18n';
import { useChildStoreManager } from '@/sync/sync-context';
import { canRequestNativeDirectoryAccess, requestDirectoryAccess } from '@/lib/desktop';
import { CollapsedActivityIndicator } from './collapsedActivityIndicator';
import {
getSessionNodesActivityState,
mergeCollapsedActivityStates,
type CollapsedActivityState,
} from './collapsedActivityState';
type DeleteFolderConfirm = {
scopeKey: string;
folderId: string;
folderName: string;
subFolderCount: number;
sessionCount: number;
} | null;
type Props = {
group: SessionGroup;
groupKey: string;
projectId?: string | null;
hideGroupLabel?: boolean;
hasSessionSearchQuery: boolean;
normalizedSessionSearchQuery: string;
groupSearchDataByGroup: WeakMap<SessionGroup, GroupSearchData>;
visibleSessionCount?: number;
collapsedGroups: Set<string>;
hideDirectoryControls: boolean;
collapsedFolderIds: Set<string>;
toggleFolderCollapse: (folderId: string) => void;
renameFolder: (scopeKey: string, folderId: string, name: string) => void;
deleteFolder: (scopeKey: string, folderId: string) => void;
showDeletionDialog: boolean;
setDeleteFolderConfirm: React.Dispatch<React.SetStateAction<DeleteFolderConfirm>>;
renderSessionNode: (
node: SessionNode,
depth?: number,
groupDirectory?: string | null,
projectId?: string | null,
archivedBucket?: boolean,
secondaryMeta?: { projectLabel?: string | null; branchLabel?: string | null } | null,
renderContext?: 'project' | 'recent',
renderExtras?: SessionNodeRenderExtras,
) => React.ReactNode;
showMoreGroupSessions: (groupKey: string, currentVisibleCount: number) => void;
resetGroupSessionLimit: (groupKey: string) => void;
mobileVariant: boolean;
alwaysShowActions: boolean;
activeProjectId: string | null;
setActiveProjectIdOnly: (id: string) => void;
setActiveMainTab: (tab: MainTab) => void;
setSessionSwitcherOpen: (open: boolean) => void;
openNewSessionDraft: (options?: { selectedProjectId?: string | null; directoryOverride?: string | null; targetFolderId?: string }) => void;
addSessionToFolder: (scopeKey: string, folderId: string, sessionId: string) => void;
createFolderAndStartRename: (scopeKey: string, parentId?: string | null) => { id: string } | null;
renamingFolderId: string | null;
renameFolderDraft: string;
setRenameFolderDraft: React.Dispatch<React.SetStateAction<string>>;
setRenamingFolderId: React.Dispatch<React.SetStateAction<string | null>>;
pinnedSessionIds: Set<string>;
expandedParents: Set<string>;
sessionOrderIndex: Map<string, number>;
editingId: string | null;
editTitle: string;
openSidebarMenuKey: string | null;
activeActivitySessionIds: Set<string>;
unreadActivitySessionIds: Set<string>;
notifyOnSubtasks: boolean;
onToggleCollapsedGroup: (groupKey: string) => void;
dragHandleProps?: SortableDragHandleProps | null;
compactBodyPadding?: boolean;
/**
* Optional scroll container ref threaded from the outer ScrollableOverlay.
* When provided, the virtualization effect can resolve the scrolling
* ancestor synchronously and skip the getComputedStyle walk on every
* render of an expanded archived bucket.
*/
scrollContainerRef?: React.RefObject<HTMLElement | null>;
};
const groupContainsSessionId = (group: SessionGroup, sessionId: string | null): boolean => {
if (!sessionId) return false;
return group.sessions.some((node) => nodeContainsSessionId(node, sessionId));
};
const groupHasPinnedMembershipChange = (
group: SessionGroup,
prevPinnedSessionIds: Set<string>,
nextPinnedSessionIds: Set<string>,
): boolean => {
return group.sessions.some((node) => nodeHasPinnedMembershipChange(
node,
node,
prevPinnedSessionIds,
nextPinnedSessionIds,
group.directory,
group.directory,
));
};
const groupHasSessionOrderChange = (
group: SessionGroup,
prevSessionOrderIndex: Map<string, number>,
nextSessionOrderIndex: Map<string, number>,
): boolean => {
const visit = (node: SessionNode): boolean => {
const sessionId = node.session.id;
if (prevSessionOrderIndex.get(sessionId) !== nextSessionOrderIndex.get(sessionId)) return true;
return node.children.some(visit);
};
return group.sessions.some(visit);
};
const groupHasActivityMembershipChange = (
group: SessionGroup,
prevSessionIds: Set<string>,
nextSessionIds: Set<string>,
): boolean => {
const visit = (node: SessionNode): boolean => {
if (prevSessionIds.has(node.session.id) !== nextSessionIds.has(node.session.id)) return true;
return node.children.some(visit);
};
return group.sessions.some(visit);
};
const groupHasAnyActivityMembership = (group: SessionGroup, sessionIds: Set<string>): boolean => {
const visit = (node: SessionNode): boolean => {
if (sessionIds.has(node.session.id)) return true;
return node.children.some(visit);
};
return group.sessions.some(visit);
};
const groupHasExpansionMembershipChange = (
group: SessionGroup,
prevExpandedParents: Set<string>,
nextExpandedParents: Set<string>,
): boolean => {
const bucketTag = group.isArchivedBucket ? 'archived' : 'active';
const visit = (node: SessionNode): boolean => {
const key = `project:${bucketTag}:${node.session.id}`;
if (prevExpandedParents.has(key) !== nextExpandedParents.has(key)) return true;
return node.children.some(visit);
};
return group.sessions.some(visit);
};
const areGroupPropsEqual = (prev: Props, next: Props): boolean => {
// Bail on Object.is for the props that drive the most work: the group
// itself, its key, and the group-level chrome. These change rarely and
// any change should force a re-render of this group.
if (prev.group !== next.group) return false;
if (prev.groupKey !== next.groupKey) return false;
if (prev.projectId !== next.projectId) return false;
if (prev.hideGroupLabel !== next.hideGroupLabel) return false;
if (prev.compactBodyPadding !== next.compactBodyPadding) return false;
if (prev.groupSearchDataByGroup !== next.groupSearchDataByGroup) return false;
if (prev.visibleSessionCount !== next.visibleSessionCount) return false;
if (prev.collapsedGroups !== next.collapsedGroups
&& prev.collapsedGroups.has(prev.groupKey) !== next.collapsedGroups.has(next.groupKey)) {
return false;
}
if (prev.pinnedSessionIds !== next.pinnedSessionIds
&& groupHasPinnedMembershipChange(next.group, prev.pinnedSessionIds, next.pinnedSessionIds)) {
return false;
}
if (prev.expandedParents !== next.expandedParents
&& groupHasExpansionMembershipChange(next.group, prev.expandedParents, next.expandedParents)) {
return false;
}
if (prev.sessionOrderIndex !== next.sessionOrderIndex
&& groupHasSessionOrderChange(next.group, prev.sessionOrderIndex, next.sessionOrderIndex)) {
return false;
}
if (prev.editingId !== next.editingId
&& (groupContainsSessionId(prev.group, prev.editingId) || groupContainsSessionId(next.group, next.editingId))) {
return false;
}
if (prev.editTitle !== next.editTitle
&& (groupContainsSessionId(prev.group, prev.editingId) || groupContainsSessionId(next.group, next.editingId))) {
return false;
}
if (prev.openSidebarMenuKey !== next.openSidebarMenuKey) {
const prevMenuSessionId = resolveMenuOpenSessionId(prev.group.sessions, prev.openSidebarMenuKey, 'project', Boolean(prev.group.isArchivedBucket));
const nextMenuSessionId = resolveMenuOpenSessionId(next.group.sessions, next.openSidebarMenuKey, 'project', Boolean(next.group.isArchivedBucket));
if (prevMenuSessionId || nextMenuSessionId) return false;
}
if (prev.activeActivitySessionIds !== next.activeActivitySessionIds
&& groupHasActivityMembershipChange(next.group, prev.activeActivitySessionIds, next.activeActivitySessionIds)) {
return false;
}
if (prev.unreadActivitySessionIds !== next.unreadActivitySessionIds
&& groupHasActivityMembershipChange(next.group, prev.unreadActivitySessionIds, next.unreadActivitySessionIds)) {
return false;
}
if (prev.notifyOnSubtasks !== next.notifyOnSubtasks
&& groupHasAnyActivityMembership(next.group, next.unreadActivitySessionIds)) {
return false;
}
// Other props are typically stable references from the parent. Default
// to reference equality (the cheap path) and only re-render when the
// parent actually swapped something.
return (
prev.hasSessionSearchQuery === next.hasSessionSearchQuery
&& prev.normalizedSessionSearchQuery === next.normalizedSessionSearchQuery
&& prev.hideDirectoryControls === next.hideDirectoryControls
&& prev.collapsedFolderIds === next.collapsedFolderIds
&& prev.toggleFolderCollapse === next.toggleFolderCollapse
&& prev.renameFolder === next.renameFolder
&& prev.deleteFolder === next.deleteFolder
&& prev.showDeletionDialog === next.showDeletionDialog
&& prev.setDeleteFolderConfirm === next.setDeleteFolderConfirm
&& prev.renderSessionNode === next.renderSessionNode
&& prev.showMoreGroupSessions === next.showMoreGroupSessions
&& prev.resetGroupSessionLimit === next.resetGroupSessionLimit
&& prev.mobileVariant === next.mobileVariant
&& prev.alwaysShowActions === next.alwaysShowActions
&& prev.activeProjectId === next.activeProjectId
&& prev.setActiveProjectIdOnly === next.setActiveProjectIdOnly
&& prev.setActiveMainTab === next.setActiveMainTab
&& prev.setSessionSwitcherOpen === next.setSessionSwitcherOpen
&& prev.openNewSessionDraft === next.openNewSessionDraft
&& prev.addSessionToFolder === next.addSessionToFolder
&& prev.createFolderAndStartRename === next.createFolderAndStartRename
&& prev.renamingFolderId === next.renamingFolderId
&& prev.renameFolderDraft === next.renameFolderDraft
&& prev.setRenameFolderDraft === next.setRenameFolderDraft
&& prev.setRenamingFolderId === next.setRenamingFolderId
&& prev.onToggleCollapsedGroup === next.onToggleCollapsedGroup
&& prev.dragHandleProps === next.dragHandleProps
&& prev.scrollContainerRef === next.scrollContainerRef
);
};
function SessionGroupSectionBase(props: Props): React.ReactNode {
const { t } = useI18n();
const {
group,
groupKey,
projectId,
hideGroupLabel,
hasSessionSearchQuery,
normalizedSessionSearchQuery,
groupSearchDataByGroup,
visibleSessionCount,
collapsedGroups,
hideDirectoryControls,
collapsedFolderIds,
toggleFolderCollapse,
renameFolder,
deleteFolder,
showDeletionDialog,
setDeleteFolderConfirm,
renderSessionNode,
showMoreGroupSessions,
resetGroupSessionLimit,
mobileVariant,
alwaysShowActions,
activeProjectId,
setActiveProjectIdOnly,
setActiveMainTab,
setSessionSwitcherOpen,
openNewSessionDraft,
addSessionToFolder,
createFolderAndStartRename,
renamingFolderId,
renameFolderDraft,
setRenameFolderDraft,
setRenamingFolderId,
pinnedSessionIds,
expandedParents,
sessionOrderIndex,
editingId,
openSidebarMenuKey,
activeActivitySessionIds,
unreadActivitySessionIds,
notifyOnSubtasks,
onToggleCollapsedGroup,
dragHandleProps,
compactBodyPadding = false,
scrollContainerRef,
} = props;
const compareSessionNodes = React.useCallback((a: SessionNode, b: SessionNode) => {
const aIndex = sessionOrderIndex.get(a.session.id);
const bIndex = sessionOrderIndex.get(b.session.id);
if (aIndex !== undefined || bIndex !== undefined) {
if (aIndex === undefined) return 1;
if (bIndex === undefined) return -1;
if (aIndex !== bIndex) return aIndex - bIndex;
}
return compareSessionsByLifecycleOrder(a.session, b.session, pinnedSessionIds, EMPTY_SESSION_ORDER_RANKS);
}, [pinnedSessionIds, sessionOrderIndex]);
const searchData = hasSessionSearchQuery ? groupSearchDataByGroup.get(group) : null;
const foldersMap = useSessionFoldersStore((state) => state.foldersMap);
const isCollapsed = hasSessionSearchQuery ? false : collapsedGroups.has(groupKey);
// PR state for the worktree sub-header (grouped display mode).
const groupPrKey = React.useMemo(() => {
if (group.isMain || group.isArchivedBucket || hideGroupLabel) return null;
const directory = normalizePath(group.directory ?? null);
const branch = group.branch?.trim();
return directory && branch ? getGitHubPrStatusKey(directory, branch) : null;
}, [group.branch, group.directory, group.isArchivedBucket, group.isMain, hideGroupLabel]);
const groupPrSummary = usePrVisualSummary(groupPrKey);
const groupPrColor = groupPrSummary ? `var(--pr-${groupPrSummary.visualState})` : undefined;
const childStores = useChildStoreManager();
const bootstrapDirectories = React.useMemo(() => {
const directories = group.folderScopes?.map((scope) => normalizePath(scope.directory))
?? [normalizePath(group.directory ?? null)];
return [...new Set(directories.filter((directory): directory is string => Boolean(directory)))];
}, [group.directory, group.folderScopes]);
React.useSyncExternalStore(
React.useCallback(
(notify) => bootstrapDirectories.length > 0 ? childStores.subscribeBootstrap(notify) : () => undefined,
[bootstrapDirectories.length, childStores],
),
React.useCallback(
() => bootstrapDirectories.map((directory) => (
`${directory}\u0000${childStores.getBootstrapState(directory) ?? ''}\u0000${childStores.getBootstrapFailure(directory) ?? ''}`
)).join('\u0001'),
[bootstrapDirectories, childStores],
),
React.useCallback(() => '', []),
);
const bootstrapLoading = bootstrapDirectories.some((directory) => {
const state = childStores.getBootstrapState(directory);
return state === 'queued' || state === 'running';
});
const failedBootstrapDirectory = bootstrapDirectories.find(
(directory) => childStores.getBootstrapState(directory) === 'failed',
) ?? null;
const bootstrapFailure = failedBootstrapDirectory
? childStores.getBootstrapFailure(failedBootstrapDirectory)
: undefined;
const canGrantBootstrapAccess = bootstrapFailure === 'os-permission' && canRequestNativeDirectoryAccess();
const [isRequestingBootstrapAccess, setIsRequestingBootstrapAccess] = React.useState(false);
const retryFailedBootstrap = React.useCallback(() => {
if (!failedBootstrapDirectory) return;
childStores.requestBootstrap({
directory: failedBootstrapDirectory,
priority: isCollapsed ? 'visible' : 'expanded',
reason: group.isMain ? 'project-expanded' : 'worktree-expanded',
force: true,
});
}, [childStores, failedBootstrapDirectory, group.isMain, isCollapsed]);
const grantFailedBootstrapAccess = React.useCallback(async () => {
if (!failedBootstrapDirectory || !canGrantBootstrapAccess || isRequestingBootstrapAccess) return;
setIsRequestingBootstrapAccess(true);
try {
const result = await requestDirectoryAccess(failedBootstrapDirectory);
if (result.success) retryFailedBootstrap();
} finally {
setIsRequestingBootstrapAccess(false);
}
}, [canGrantBootstrapAccess, failedBootstrapDirectory, isRequestingBootstrapAccess, retryFailedBootstrap]);
const maxVisible = hideDirectoryControls ? 10 : 5;
const nonArchivedVisibleCount = Math.max(maxVisible, visibleSessionCount ?? maxVisible);
const groupMatchesSearch = hasSessionSearchQuery ? searchData?.groupMatches === true : false;
const shouldFilterGroupContents = hasSessionSearchQuery;
const sourceGroupNodes = React.useMemo(
() => [...(shouldFilterGroupContents ? (searchData?.filteredNodes ?? []) : group.sessions)]
.sort(compareSessionNodes),
[compareSessionNodes, group.sessions, searchData?.filteredNodes, shouldFilterGroupContents],
);
const folderScopeKey = group.folderScopeKey ?? normalizePath(group.directory ?? null);
// Merged flat groups list every contributing scope; single-scope groups
// (archived buckets, VS Code workspaces) fall back to folderScopeKey.
const folderScopes = React.useMemo<Array<{ scopeKey: string; directory: string | null }>>(() => {
if (group.folderScopes && group.folderScopes.length > 0) return group.folderScopes;
return folderScopeKey ? [{ scopeKey: folderScopeKey, directory: group.directory ?? null }] : [];
}, [folderScopeKey, group.directory, group.folderScopes]);
const scopeFolders = React.useMemo(
() => folderScopes.flatMap(({ scopeKey, directory }) =>
(foldersMap[scopeKey] ?? []).map((folder) => ({ folder, scopeKey, scopeDirectory: directory }))),
[folderScopes, foldersMap]
);
const nodeBySessionId = React.useMemo(() => {
const map = new Map<string, SessionNode>();
const collectNodeLookup = (nodes: SessionNode[]) => {
nodes.forEach((node) => {
map.set(node.session.id, node);
if (node.children.length > 0) {
collectNodeLookup(node.children);
}
});
};
collectNodeLookup(sourceGroupNodes);
return map;
}, [sourceGroupNodes]);
const allFoldersForGroupBase = React.useMemo(() => scopeFolders.map(({ folder, scopeKey, scopeDirectory }) => {
const nodes = selectFolderRootNodes(folder.sessionIds, nodeBySessionId).sort(compareSessionNodes);
return { folder, scopeKey, scopeDirectory, nodes };
}), [scopeFolders, nodeBySessionId, compareSessionNodes]);
const allFoldersForGroup = React.useMemo(() => {
const folderMapById = new Map(allFoldersForGroupBase.map((entry) => [entry.folder.id, entry]));
const childFolderIdsByParentId = new Map<string, string[]>();
for (const { folder } of allFoldersForGroupBase) {
if (!folder.parentId) continue;
const existing = childFolderIdsByParentId.get(folder.parentId);
if (existing) {
existing.push(folder.id);
} else {
childFolderIdsByParentId.set(folder.parentId, [folder.id]);
}
}
const keepByFolderId = new Map<string, boolean>();
const shouldKeepFolder = (folderId: string): boolean => {
const cached = keepByFolderId.get(folderId);
if (cached !== undefined) return cached;
const entry = folderMapById.get(folderId);
if (!entry) {
keepByFolderId.set(folderId, false);
return false;
}
const childFolderIds = childFolderIdsByParentId.get(folderId) ?? [];
// For archived buckets, hide folders with no sessions unless descendants have content.
if (group.isArchivedBucket && entry.nodes.length === 0) {
const hasContentInChildren = childFolderIds.some((childId) => shouldKeepFolder(childId));
keepByFolderId.set(folderId, hasContentInChildren);
return hasContentInChildren;
}
if (!hasSessionSearchQuery) {
keepByFolderId.set(folderId, true);
return true;
}
const folderMatches = entry.folder.name.toLowerCase().includes(normalizedSessionSearchQuery);
if (folderMatches || entry.nodes.length > 0) {
keepByFolderId.set(folderId, true);
return true;
}
const hasMatchingChildren = childFolderIds.some((childId) => shouldKeepFolder(childId));
keepByFolderId.set(folderId, hasMatchingChildren);
return hasMatchingChildren;
};
return allFoldersForGroupBase.filter(({ folder }) => shouldKeepFolder(folder.id));
}, [allFoldersForGroupBase, group.isArchivedBucket, hasSessionSearchQuery, normalizedSessionSearchQuery]);
const sessionIdsInFolders = React.useMemo(() => new Set(allFoldersForGroup.flatMap((f) => f.folder.sessionIds)), [allFoldersForGroup]);
const ungroupedSessions = React.useMemo(() => sourceGroupNodes.filter((node) => !sessionIdsInFolders.has(node.session.id)), [sourceGroupNodes, sessionIdsInFolders]);
const rootFolders = React.useMemo(() => allFoldersForGroup.filter(({ folder }) => !folder.parentId), [allFoldersForGroup]);
const childFoldersByParentId = React.useMemo(() => {
const map = new Map<string, typeof allFoldersForGroup>();
allFoldersForGroup.forEach((entry) => {
if (!entry.folder.parentId) return;
const children = map.get(entry.folder.parentId) ?? [];
children.push(entry);
map.set(entry.folder.parentId, children);
});
return map;
}, [allFoldersForGroup]);
const folderActivityStateById = React.useMemo(() => {
const foldersById = new Map(allFoldersForGroup.map((entry) => [entry.folder.id, entry] as const));
const result = new Map<string, CollapsedActivityState>();
const visit = (folderId: string, seen: Set<string>): CollapsedActivityState => {
const cached = result.get(folderId);
if (cached !== undefined) return cached;
if (seen.has(folderId)) return null;
seen.add(folderId);
const entry = foldersById.get(folderId);
let state = entry
? getSessionNodesActivityState(entry.nodes, activeActivitySessionIds, unreadActivitySessionIds, notifyOnSubtasks)
: null;
for (const child of childFoldersByParentId.get(folderId) ?? []) {
state = mergeCollapsedActivityStates(state, visit(child.folder.id, seen));
if (state === 'active') break;
}
result.set(folderId, state);
return state;
};
allFoldersForGroup.forEach(({ folder }) => visit(folder.id, new Set()));
return result;
}, [activeActivitySessionIds, allFoldersForGroup, childFoldersByParentId, notifyOnSubtasks, unreadActivitySessionIds]);
// Precompute the per-row "subtree contains editing session" lookup once per
// render. The previous design walked the
// node tree inside SessionNodeItem.areEqual for every row, which is O(M^2)
// across the whole sidebar. These sets let areEqual answer with a single
// Set.has lookup, so the cost is O(M) once per SessionGroupSection render.
const renderContextForGroup = 'project' as const;
const subtreeContainsEditing = React.useMemo(() => {
const set = new Set<string>();
collectSubtreeContainingId(sourceGroupNodes, editingId, set);
allFoldersForGroup.forEach(({ nodes }) => {
collectSubtreeContainingId(nodes, editingId, set);
});
return set;
}, [sourceGroupNodes, allFoldersForGroup, editingId]);
const menuOpenSessionId = React.useMemo(() => {
if (!openSidebarMenuKey) return null;
const fromSource = resolveMenuOpenSessionId(sourceGroupNodes, openSidebarMenuKey, renderContextForGroup, Boolean(group.isArchivedBucket));
if (fromSource) return fromSource;
for (const { nodes } of allFoldersForGroup) {
const id = resolveMenuOpenSessionId(nodes, openSidebarMenuKey, renderContextForGroup, Boolean(group.isArchivedBucket));
if (id) return id;
}
return null;
}, [openSidebarMenuKey, sourceGroupNodes, allFoldersForGroup, group.isArchivedBucket]);
const buildNodeStructureKeyByNode = React.useCallback((nodes: SessionNode[]): WeakMap<SessionNode, string> => {
const map = new WeakMap<SessionNode, string>();
const visit = (node: SessionNode): void => {
map.set(node, computeNodeStructureKey(node));
for (const child of node.children) {
visit(child);
}
};
nodes.forEach(visit);
return map;
}, []);
const nodeStructureKeyBySourceNode = React.useMemo(
() => buildNodeStructureKeyByNode(sourceGroupNodes),
[buildNodeStructureKeyByNode, sourceGroupNodes],
);
const nodeStructureKeyByFolderNode = React.useMemo(
() => {
const map = new WeakMap<SessionNode, string>();
allFoldersForGroup.forEach(({ nodes }) => {
nodes.forEach((node) => map.set(node, computeNodeStructureKey(node)));
});
return map;
},
[allFoldersForGroup],
);
const resolveNodeStructureKey = React.useCallback((node: SessionNode): string => {
return nodeStructureKeyBySourceNode.get(node) ?? nodeStructureKeyByFolderNode.get(node) ?? '';
}, [nodeStructureKeyBySourceNode, nodeStructureKeyByFolderNode]);
const childRenderExtrasFor = React.useCallback((child: SessionNode) => ({
subtreeContainsEditing,
menuOpenSessionId,
nodeStructureKey: resolveNodeStructureKey(child),
}), [subtreeContainsEditing, menuOpenSessionId, resolveNodeStructureKey]);
const totalSessions = ungroupedSessions.length;
const visibleSessions = group.isArchivedBucket
? ungroupedSessions
: hasSessionSearchQuery
? ungroupedSessions
: ungroupedSessions.slice(0, nonArchivedVisibleCount);
const remainingCount = totalSessions - visibleSessions.length;
const canShowLess = !group.isArchivedBucket && !hasSessionSearchQuery && totalSessions > maxVisible && remainingCount === 0;
// Virtualize archived buckets, which can grow into the thousands. Active
// groups retain normal flow because their incremental Show more control and
// the shared ancestor scroller cannot expose an unmounted virtual tail.
// Hooks below MUST stay above the search-empty early-return so they fire in
// the same order every render — rules-of-hooks.
const shouldVirtualize = group.isArchivedBucket === true
&& !hasSessionSearchQuery
&& visibleSessions.length >= ARCHIVED_VIRTUALIZE_THRESHOLD;
// Check if any parent node is expanded - expanded parents render their
// children inline, making them much taller than the fixed estimate.
// When expanded parents exist, increase bufferSize to cover the extra height.
const bucketTag = group.isArchivedBucket ? 'archived' : 'active';
const hasExpandedParent = shouldVirtualize && visibleSessions.some((node) => {
if (node.children.length === 0) return false;
const expansionKey = `project:${bucketTag}:${node.session.id}`;
return expandedParents.has(expansionKey);
});
const archivedVirtualContainerRef = React.useRef<HTMLDivElement | null>(null);
const [archivedScrollEl, setArchivedScrollEl] = React.useState<HTMLElement | null>(null);
// Offset of the virtual container from the scroll element's content origin.
// virtua reads startMargin from Virtualizer options and uses it
// to translate scrollTop into container-relative coordinates. Without this,
// when the scroll element is an ancestor (the sidebar's ScrollableOverlay),
// the virtualizer assumes the container starts at the top of the scroll
// element and renders rows in the wrong subset / position.
const [archivedScrollMargin, setArchivedScrollMargin] = React.useState(0);
// Resolve the scrolling ancestor. When the parent has threaded a
// `scrollContainerRef` (Layer 1.4), use it directly to skip the
// `getComputedStyle` walk on every render of an expanded archived
// bucket — the walk is one of the more expensive operations in the
// hot path because it forces a style recalc on every parent up the
// tree. Fall back to the legacy walk only when the ref is missing.
//
// We also still re-run when the archive flips between expanded/collapsed,
// and on a ResizeObserver-driven layout change of the container, so a
// dep-gated effect that only fires when shouldVirtualizeArchived flips
// would miss the eventual mount and leave the scroll element null.
const [, setLayoutVersion] = React.useState(0);
React.useEffect(() => {
if (!shouldVirtualize) return;
const container = archivedVirtualContainerRef.current;
if (!container) return;
if (typeof ResizeObserver === 'undefined') return;
const ro = new ResizeObserver(() => setLayoutVersion((v) => v + 1));
ro.observe(container);
return () => ro.disconnect();
}, [shouldVirtualize]);
// eslint-disable-next-line react-hooks/exhaustive-deps
React.useLayoutEffect(() => {
if (!shouldVirtualize) {
if (archivedScrollEl !== null) setArchivedScrollEl(null);
if (archivedScrollMargin !== 0) setArchivedScrollMargin(0);
return;
}
const container = archivedVirtualContainerRef.current;
if (!container) {
// Bucket still collapsed — body not mounted. We'll re-run on the
// render that mounts it.
return;
}
let scrollEl: HTMLElement | null = archivedScrollEl;
const providedScrollEl = scrollContainerRef?.current ?? null;
if (providedScrollEl && providedScrollEl.contains(container)) {
scrollEl = providedScrollEl;
if (scrollEl !== archivedScrollEl) {
setArchivedScrollEl(scrollEl);
return;
}
} else if (!scrollEl || !scrollEl.contains(container)) {
// Walk up to find the nearest scrolling ancestor. Only happens on
// first mount or if the DOM tree restructured.
let el: HTMLElement | null = container.parentElement;
while (el) {
const style = window.getComputedStyle(el);
if (style.overflowY === 'auto' || style.overflowY === 'scroll') {
scrollEl = el;
break;
}
el = el.parentElement;
}
if (scrollEl !== archivedScrollEl) {
setArchivedScrollEl(scrollEl);
return;
}
}
if (!scrollEl) return;
const offset = container.getBoundingClientRect().top
- scrollEl.getBoundingClientRect().top
+ scrollEl.scrollTop;
setArchivedScrollMargin((prev) => (Math.abs(prev - offset) < 1 ? prev : offset));
});
// The scroll element is an ANCESTOR of this section (the sidebar's
// ScrollableOverlay), so scrollMargin translates its scrollTop into
// container-relative coordinates — the tanstack equivalent of virtua's
// startMargin this replaces.
// Enable ONLY once the ancestor scroll element is resolved. While the
// virtualizer is disabled the core resets its cached scroll offset, so the
// first enabled read takes initialOffset() from the LIVE scrollTop below —
// making the core's attach-time scrollTo target the current position (a
// visual no-op) instead of a stale 0 that reset the sidebar to the top.
// The core only learns the offset from scroll events after that, so this
// initial seeding is what makes the first render window correct too.
const virtualizerReady = shouldVirtualize && archivedScrollEl !== null;
const sessionVirtualizer = useVirtualizer<HTMLElement, HTMLDivElement>({
count: visibleSessions.length,
enabled: virtualizerReady,
getScrollElement: () => archivedScrollEl,
initialOffset: () => archivedScrollEl?.scrollTop ?? 0,
estimateSize: () => ARCHIVED_ROW_ESTIMATE_PX,
// Expanded parents render children inline and dwarf the row estimate;
// widen the window so their extra height stays covered.
overscan: hasExpandedParent ? 20 : 8,
scrollMargin: archivedScrollMargin,
getItemKey: (index) => visibleSessions[index]?.session.id ?? index,
});
// Hooks below MUST stay above the search-empty early-return so they
// fire in the same order every render — rules-of-hooks.
const collectGroupSessions = React.useCallback((nodes: SessionNode[]): Session[] => {
const collected: Session[] = [];
const visit = (list: SessionNode[]) => {
list.forEach((node) => {
collected.push(node.session);
if (node.children.length > 0) visit(node.children);
});
};
visit(nodes);
return collected;
}, []);
// Flat list of all sessions in this group (including nested children).
// Used by both the "delete all archived" button and the "delete worktree"
// button. Memoize so the recursive flatten only runs when the underlying
// source group nodes change, not on every render.
const allGroupSessions = React.useMemo(
() => collectGroupSessions(sourceGroupNodes),
[collectGroupSessions, sourceGroupNodes],
);
// Precompute the per-folder "delete all sessions in folder" list once
// per render. The previous design ran a recursive `collectFolderSessions`
// walk inside each folder's render, which is O(F × (S + F)) per group
// render. With F=50 folders and S=200 archived sessions this is
// significant; the precompute makes it O(F + S) once.
const folderSessionsForDeleteById = React.useMemo(() => {
if (!group.isArchivedBucket) return new Map<string, Session[]>();
const result = new Map<string, Session[]>();
const childIdsByParentId = new Map<string, string[]>();
for (const { folder } of allFoldersForGroup) {
if (!folder.parentId) continue;
const existing = childIdsByParentId.get(folder.parentId) ?? [];
existing.push(folder.id);
childIdsByParentId.set(folder.parentId, existing);
}
const visit = (targetFolderId: string, seen: Set<string>): Session[] => {
if (seen.has(targetFolderId)) return [];
seen.add(targetFolderId);
const directEntry = allFoldersForGroup.find(({ folder: candidate }) => candidate.id === targetFolderId);
const collected: Session[] = directEntry ? collectGroupSessions(directEntry.nodes) : [];
const childIds = childIdsByParentId.get(targetFolderId) ?? [];
for (const childId of childIds) {
collected.push(...visit(childId, seen));
}
return collected;
};
for (const { folder } of allFoldersForGroup) {
result.set(folder.id, visit(folder.id, new Set()));
}
return result;
}, [allFoldersForGroup, collectGroupSessions, group.isArchivedBucket]);
if (hasSessionSearchQuery && !groupMatchesSearch && rootFolders.length === 0 && ungroupedSessions.length === 0) {
return null;
}
const showBranchSubtitle = !group.isMain && Boolean(group.branch);
const statusLine = group.branch && isBranchDifferentFromLabel(group.branch, group.label)
? { label: group.branch, color: null as string | null }
: null;
const groupActivityState = isCollapsed
? getSessionNodesActivityState(sourceGroupNodes, activeActivitySessionIds, unreadActivitySessionIds, notifyOnSubtasks)
: null;
const groupActivityIndicator = groupActivityState ? (
<CollapsedActivityIndicator
state={groupActivityState}
activeLabel={t('sessions.sidebar.session.status.active')}
unreadLabel={t('sessions.sidebar.session.status.unread')}
/>
) : null;
type FolderEntry = (typeof allFoldersForGroup)[number];
const renderOneFolderItem = (entry: FolderEntry, displayName: string): React.ReactNode => {
const { folder, scopeKey, scopeDirectory, nodes } = entry;
const folderSessionsForDelete = folderSessionsForDeleteById.get(folder.id) ?? [];
const isFolderCollapsed = hasSessionSearchQuery ? false : collapsedFolderIds.has(folder.id);
return (
<DroppableFolderWrapper key={folder.id} folderId={folder.id}>
{(droppableRef, isDropTarget) => (
<SessionFolderItem
folder={folder}
displayName={displayName}
sessions={nodes}
isCollapsed={isFolderCollapsed}
collapsedActivityState={isFolderCollapsed ? (folderActivityStateById.get(folder.id) ?? null) : null}
onToggle={() => toggleFolderCollapse(folder.id)}
onRename={(name) => {
renameFolder(scopeKey, folder.id, name);
}}
onDelete={() => {
if (group.isArchivedBucket) {
// Delete sessions in the folder
// Empty folders are auto-hidden by useArchivedAutoFolders
sessionEvents.requestDelete({
sessions: folderSessionsForDelete,
mode: 'session',
});
return;
}
if (!showDeletionDialog) {
deleteFolder(scopeKey, folder.id);
return;
}
const subFolderCount = allFoldersForGroup.filter(({ folder: f }) => f.parentId === folder.id).length;
const sessionCount = nodes.length;
setDeleteFolderConfirm({
scopeKey,
folderId: folder.id,
folderName: folder.name,
subFolderCount,
sessionCount,
});
}}
renderSessionNode={renderSessionNode}
getRenderExtras={resolveNodeStructureKey
? (node) => ({
subtreeContainsEditing,
menuOpenSessionId,
nodeStructureKey: resolveNodeStructureKey(node),
childRenderExtrasFor,
})
: undefined}
groupDirectory={scopeDirectory ?? group.directory}
projectId={projectId}
mobileVariant={mobileVariant}
alwaysShowActions={alwaysShowActions}
isRenaming={renamingFolderId === folder.id}
renameDraft={renamingFolderId === folder.id ? renameFolderDraft : undefined}
onRenameDraftChange={(value) => setRenameFolderDraft(value)}
onRenameSave={() => {
const trimmed = renameFolderDraft.trim();
if (trimmed) {
renameFolder(scopeKey, folder.id, trimmed);
}
setRenamingFolderId(null);
setRenameFolderDraft('');
}}
onRenameCancel={() => {
setRenamingFolderId(null);
setRenameFolderDraft('');
}}
droppableRef={droppableRef}
isDropTarget={isDropTarget}
depth={0}
onNewSession={() => {
if (projectId && projectId !== activeProjectId) setActiveProjectIdOnly(projectId);
setActiveMainTab('chat');
if (mobileVariant) setSessionSwitcherOpen(false);
openNewSessionDraft({ selectedProjectId: projectId, directoryOverride: scopeDirectory ?? group.directory, targetFolderId: folder.id });
}}
hideActions={false}
archivedBucket={group.isArchivedBucket === true}
/>
)}
</DroppableFolderWrapper>
);
};
// Folders render flat: nested folders keep their data-model parent link but
// display at the same level with a "Parent / Child" path label, so sessions
// never gain extra indentation. Collapsing a folder hides its whole subtree.
const renderFolderItems = () => {
const childEntriesByParentId = new Map<string, FolderEntry[]>();
for (const entry of allFoldersForGroup) {
const parentId = entry.folder.parentId;
if (!parentId) continue;
const existing = childEntriesByParentId.get(parentId);
if (existing) existing.push(entry);
else childEntriesByParentId.set(parentId, [entry]);
}
const out: React.ReactNode[] = [];
const visit = (entry: FolderEntry, parentPath: string) => {
const displayName = parentPath ? `${parentPath} / ${entry.folder.name}` : entry.folder.name;
out.push(renderOneFolderItem(entry, displayName));
const isFolderCollapsed = !hasSessionSearchQuery && collapsedFolderIds.has(entry.folder.id);
if (isFolderCollapsed) return;
(childEntriesByParentId.get(entry.folder.id) ?? []).forEach((child) => visit(child, displayName));
};
rootFolders.forEach((entry) => visit(entry, ''));
return out;
};
// Reserve room for the hover-revealed header actions (new draft + delete
// worktree) so they never overlap the label / PR badge.
const hasWorktreeDeleteAction = Boolean(!group.isMain && group.worktree);
const groupHeaderRightPadding = alwaysShowActions
? (hasWorktreeDeleteAction ? 'pr-14' : 'pr-7')
: (hasWorktreeDeleteAction
? 'pr-2 group-hover/gh:pr-14 group-focus-within/gh:pr-14'
: 'pr-2 group-hover/gh:pr-7 group-focus-within/gh:pr-7');
const bootstrapFailureNotice = failedBootstrapDirectory ? (
<span className="inline-flex flex-wrap items-center gap-1.5">
{bootstrapFailure === 'os-permission'
? t('sessions.sidebar.group.empty.permissionDenied')
: t('sessions.sidebar.group.empty.loadFailed')}
{canGrantBootstrapAccess ? (
<Button
variant="link"
size="xs"
className="h-auto p-0 typography-micro"
disabled={isRequestingBootstrapAccess}
onClick={() => void grantFailedBootstrapAccess()}
>
{t('sessions.sidebar.group.empty.grantAccess')}
</Button>
) : null}
<Button
variant="link"
size="xs"
className="h-auto p-0 typography-micro"
onClick={retryFailedBootstrap}
>
{t('sessions.sidebar.group.empty.retry')}
</Button>
</span>
) : null;
const body = (
<SessionFolderDndScope
scopeKey={folderScopes[0]?.scopeKey ?? folderScopeKey}
hasFolders={allFoldersForGroup.length > 0}
onSessionDroppedOnFolder={(sessionId, folderId) => {
const targetEntry = allFoldersForGroup.find(({ folder }) => folder.id === folderId);
if (!targetEntry) return;
// Clear membership in other scopes first — the store only dedupes
// within one scope, and a session must live in a single folder.
const foldersStore = useSessionFoldersStore.getState();
for (const { scopeKey } of folderScopes) {
if (scopeKey === targetEntry.scopeKey) continue;
if (foldersStore.getSessionFolderId(scopeKey, sessionId)) {
foldersStore.removeSessionFromFolder(scopeKey, sessionId);
}
}
addSessionToFolder(targetEntry.scopeKey, folderId, sessionId);
}}
>
{renderFolderItems()}
{shouldVirtualize ? (
<div ref={archivedVirtualContainerRef}>
{!virtualizerReady ? (
// At most one pre-paint frame: this wrapper must exist for the
// layout effect to resolve the ancestor scroll element, which
// re-renders synchronously before paint. Rendering the plain rows
// meanwhile keeps the container's height real so the scroller
// never collapses/clamps during the flip.
visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true, undefined, 'project', {
subtreeContainsEditing,
menuOpenSessionId,
nodeStructureKey: resolveNodeStructureKey(node),
childRenderExtrasFor,
}))
) : (
<div style={{ height: sessionVirtualizer.getTotalSize(), position: 'relative' }}>
{/* Absolutely positioned rows (canonical tanstack layout): with
variable-height rows, flow-stacking can drift from the computed
total height until measurements settle and overlap the content
below the group. Per-item offsets cannot drift. item.start
includes scrollMargin (ancestor-scroll offset), so subtract it. */}
{sessionVirtualizer.getVirtualItems().map((item) => {
const node = visibleSessions[item.index];
if (!node) return null;
return (
<div
key={node.session.id}
data-index={item.index}
ref={sessionVirtualizer.measureElement}
// Rows carry my-0.5 (2px), which COLLAPSES to 2px between
// neighbors in normal flow but cannot collapse across
// isolated virtualized wrappers — spacing doubles to 4px the
// moment virtualization kicks in. Replace the row margin
// with 1px per side (no collapse, 1+1 = the same visual 2px
// gap). The [data-session-row] selector reaches the row
// through the dnd/context-menu wrappers at any depth and
// keeps nested child rows consistent too.
className="[&_[data-session-row]]:my-px"
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${item.start - archivedScrollMargin}px)`,
}}
>
{renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true, undefined, 'project', {
subtreeContainsEditing,
menuOpenSessionId,
nodeStructureKey: resolveNodeStructureKey(node),
childRenderExtrasFor,
})}
</div>
);
})}
</div>
)}
</div>
) : (
visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true, undefined, 'project', {
subtreeContainsEditing,
menuOpenSessionId,
nodeStructureKey: resolveNodeStructureKey(node),
childRenderExtrasFor,
}))
)}
{totalSessions === 0 && allFoldersForGroup.length === 0 ? (
// pl-[26px] lines the text up with the worktree sub-header label
// (gutter + icon + gap).
<div className="py-1 pl-[26px] text-left typography-micro text-muted-foreground">
{group.isArchivedBucket
? t('sessions.sidebar.group.empty.noArchivedSessions')
: bootstrapLoading
? (
<span className="inline-flex items-center gap-1.5">
<Icon name="loader-4" className="size-3 animate-spin" />
{t('sessions.sidebar.group.empty.loadingSessions')}
</span>
)
: bootstrapFailureNotice
? bootstrapFailureNotice
: t('sessions.sidebar.group.empty.noSessionsInWorkspace')}
</div>
) : null}
{totalSessions > 0 && bootstrapFailureNotice ? (
<div className="py-1 pl-[26px] text-left typography-micro text-status-error">
{bootstrapFailureNotice}
</div>
) : null}
{remainingCount > 0 ? (
<button
type="button"
onClick={() => showMoreGroupSessions(groupKey, visibleSessions.length)}
className="mt-0.5 flex items-center justify-start rounded-md pl-[26px] pr-1.5 py-0.5 text-left text-xs text-muted-foreground/70 leading-tight hover:text-foreground hover:underline"
>
{t('sessions.sidebar.group.showMore')}
</button>
) : null}
{canShowLess ? (
<button
type="button"
onClick={() => resetGroupSessionLimit(groupKey)}
className="mt-0.5 flex items-center justify-start rounded-md pl-[26px] pr-1.5 py-0.5 text-left text-xs text-muted-foreground/70 leading-tight hover:text-foreground hover:underline"
>
{t('sessions.sidebar.group.showFewer')}
</button>
) : null}
</SessionFolderDndScope>
);
// Rows own their left gutter (aligned with the zone-header text), so the
// group body adds no extra indentation.
void compactBodyPadding;
// Folder nesting is legacy-only: existing sub-folders keep working (path
// labels), but the UI no longer offers creating new ones.
void createFolderAndStartRename;
const groupBodyPaddingClass = 'pb-2';
if (hideGroupLabel) {
return <div className="oc-group"><div className={cn('oc-group-body', groupBodyPaddingClass)}>{body}</div></div>;
}
return (
<div className="oc-group">
<div
className={cn('group/gh relative flex items-start justify-between gap-1 py-1 min-w-0 rounded-md', 'cursor-pointer')}
onClick={() => onToggleCollapsedGroup(groupKey)}
role="button"
tabIndex={0}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onToggleCollapsedGroup(groupKey);
}
}}
aria-label={isCollapsed
? t('sessions.sidebar.group.expandAria', { label: group.label })
: t('sessions.sidebar.group.collapseAria', { label: group.label })}
aria-expanded={!isCollapsed}
>
<div
ref={dragHandleProps?.setActivatorNodeRef}
className={cn(
// pl-1.5 lines the branch icon up with the project-zone header
// icon (container pl-2.5 + 6px = band pl-4 past its -ml-2.5).
'min-w-0 flex flex-1 items-start gap-1 overflow-hidden pl-1.5 transition-[padding]',
groupHeaderRightPadding,
)}
{...(dragHandleProps?.listeners ?? {})}
>
<div className="min-w-0 flex flex-1 flex-col justify-center gap-0.5 overflow-hidden">
<p className="text-[14px] font-normal truncate text-foreground/92">
{group.isArchivedBucket ? (
<span className="inline-flex min-w-0 max-w-full items-center gap-1">
<span className="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center">
<Icon name="archive" className={cn('h-3.5 w-3.5 shrink-0 text-muted-foreground', alwaysShowActions ? 'hidden' : 'group-hover/gh:hidden')} />
<span className={cn(
'text-muted-foreground h-3.5 w-3.5 items-center justify-center',
alwaysShowActions ? 'inline-flex' : 'hidden group-hover/gh:inline-flex',
)}>
{isCollapsed ? <Icon name="arrow-right-s" className="h-3.5 w-3.5" /> : <Icon name="arrow-down-s" className="h-3.5 w-3.5" />}
</span>
</span>
<span className="min-w-0 flex-1 truncate">{renderHighlightedText(group.label, normalizedSessionSearchQuery)}</span>
{groupActivityIndicator}
</span>
) : (!group.isMain || group.worktree) ? (
// Worktree sub-header in the flat visual language: slim
// folder-style row with a PR-tinted branch icon and PR badge.
<span className="flex w-full min-w-0 items-center gap-1.5">
<span className="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center">
<Icon name="git-branch"
className={cn('h-3.5 w-3.5 shrink-0', !groupPrColor && 'text-muted-foreground', alwaysShowActions ? 'hidden' : 'group-hover/gh:hidden')}
style={groupPrColor ? { color: groupPrColor } : undefined}
/>
<span className={cn(
'text-muted-foreground h-3.5 w-3.5 items-center justify-center',
alwaysShowActions ? 'inline-flex' : 'hidden group-hover/gh:inline-flex',
)}>
{isCollapsed ? <Icon name="arrow-right-s" className="h-3.5 w-3.5" /> : <Icon name="arrow-down-s" className="h-3.5 w-3.5" />}
</span>
</span>
<span className="min-w-0 truncate typography-ui-label font-semibold text-muted-foreground">
{renderHighlightedText(group.label, normalizedSessionSearchQuery)}
</span>
{groupActivityIndicator}
{groupPrSummary ? (
<span
className="ml-auto flex-shrink-0 text-[0.72rem] font-medium leading-none"
style={groupPrColor ? { color: groupPrColor } : undefined}
>
#{groupPrSummary.number}
</span>
) : null}
</span>
) : (
<span className="inline-flex min-w-0 max-w-full items-center gap-1">
<span className="min-w-0 truncate">{renderHighlightedText(group.label, normalizedSessionSearchQuery)}</span>
{groupActivityIndicator}
</span>
)}
</p>
{showBranchSubtitle && statusLine ? (
<span className="inline-flex min-w-0 items-center gap-1.5 leading-tight">
{group.isArchivedBucket ? (
<Icon name="archive" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
) : (
<Icon name="git-branch" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
)}
<span className="min-w-0 truncate text-[11px] font-medium text-muted-foreground/80">
{statusLine.label}
</span>
</span>
) : null}
</div>
</div>
{group.isArchivedBucket && allGroupSessions.length > 0 ? (
<div className={cn('absolute right-0.5 top-1/2 -translate-y-1/2 z-10 transition-opacity', alwaysShowActions ? 'opacity-100' : 'opacity-0 group-hover/gh:opacity-100 group-focus-within/gh:opacity-100')}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
sessionEvents.requestDelete({
sessions: allGroupSessions,
mode: 'session',
});
}}
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-destructive hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label={t('sessions.sidebar.group.actions.deleteArchivedInGroupAria', { label: group.label })}
>
<Icon name="delete-bin" className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}><p>{t('sessions.sidebar.group.actions.deleteArchivedSessions')}</p></TooltipContent>
</Tooltip>
</div>
) : null}
{group.directory && !group.isMain && group.worktree ? (
<div className={cn('absolute right-7 top-1/2 -translate-y-1/2 z-10 transition-opacity', alwaysShowActions ? 'opacity-100' : 'opacity-0 group-hover/gh:opacity-100 group-focus-within/gh:opacity-100')}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
sessionEvents.requestDelete({
sessions: allGroupSessions,
mode: 'worktree',
worktree: group.worktree,
});
}}
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-destructive hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label={t('sessions.sidebar.group.actions.deleteGroupAria', { label: group.label })}
>
<Icon name="delete-bin" className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}><p>{t('sessions.sidebar.group.actions.deleteWorktree')}</p></TooltipContent>
</Tooltip>
</div>
) : null}
{group.directory ? (
<div className={cn('absolute right-0.5 top-1/2 -translate-y-1/2 z-10 transition-opacity', alwaysShowActions ? 'opacity-100' : 'opacity-0 group-hover/gh:opacity-100 group-focus-within/gh:opacity-100')}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
if (projectId && projectId !== activeProjectId) setActiveProjectIdOnly(projectId);
setActiveMainTab('chat');
if (mobileVariant) setSessionSwitcherOpen(false);
openNewSessionDraft({ selectedProjectId: projectId, directoryOverride: group.directory });
}}
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label={t('sessions.sidebar.group.actions.newDraftInGroupAria', { label: group.label })}
>
<Icon name="add" className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}><p>{t('sessions.sidebar.project.actions.newDraftSession')}</p></TooltipContent>
</Tooltip>
</div>
) : null}
</div>
{!isCollapsed ? <div className={cn('oc-group-body', groupBodyPaddingClass)}>{body}</div> : null}
</div>
);
}
export const SessionGroupSection = React.memo(SessionGroupSectionBase, areGroupPropsEqual);