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

903 lines
42 KiB
TypeScript
Raw Normal View History

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; tanstack-virtual will measure precisely via the row ref.
const ARCHIVED_ROW_ESTIMATE_PX = 28;
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
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 { DroppableFolderWrapper, SessionFolderDndScope } from './sessionFolderDnd';
import type { SortableDragHandleProps } from './sortableItems';
import type { GroupSearchData, SessionGroup, SessionNode } from './types';
import { compareSessionsByPinnedAndTime, isBranchDifferentFromLabel, normalizePath, renderHighlightedText } from './utils';
import type { SessionFolder } from '@/stores/useSessionFoldersStore';
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
import { openExternalUrl } from '@/lib/url';
2026-06-12 12:38:31 +03:00
import { isVSCodeRuntime } from '@/lib/desktop';
import { useI18n } from '@/lib/i18n';
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) => React.ReactNode;
currentSessionDirectory: string | null;
projectRepoStatus: Map<string, boolean | null>;
lastRepoStatus: boolean;
showMoreGroupSessions: (groupKey: string, currentVisibleCount: number) => void;
resetGroupSessionLimit: (groupKey: string) => void;
mobileVariant: boolean;
2026-05-03 17:19:55 +03:00
alwaysShowActions: boolean;
activeProjectId: string | null;
setActiveProjectIdOnly: (id: string) => void;
setActiveMainTab: (tab: MainTab) => void;
setSessionSwitcherOpen: (open: boolean) => void;
openNewSessionDraft: (options?: { 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>;
sessionOrderIndex: Map<string, number>;
prVisualStateByDirectoryBranch: Map<string, {
visualState: 'draft' | 'open' | 'blocked' | 'merged' | 'closed';
number: number;
url: string | null;
state: 'open' | 'closed' | 'merged';
draft: boolean;
title: string | null;
base: string | null;
head: string | null;
checks: {
state: 'success' | 'failure' | 'pending' | 'unknown';
total: number;
success: number;
failure: number;
pending: number;
} | null;
canMerge: boolean | null;
mergeableState: string | null;
repo: {
owner: string;
repo: string;
} | null;
}>;
onToggleCollapsedGroup: (groupKey: string) => void;
dragHandleProps?: SortableDragHandleProps | null;
compactBodyPadding?: boolean;
};
export function SessionGroupSection(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,
projectRepoStatus,
lastRepoStatus,
showMoreGroupSessions,
resetGroupSessionLimit,
mobileVariant,
2026-05-03 17:19:55 +03:00
alwaysShowActions,
activeProjectId,
setActiveProjectIdOnly,
setActiveMainTab,
setSessionSwitcherOpen,
openNewSessionDraft,
addSessionToFolder,
createFolderAndStartRename,
renamingFolderId,
renameFolderDraft,
setRenameFolderDraft,
setRenamingFolderId,
pinnedSessionIds,
sessionOrderIndex,
prVisualStateByDirectoryBranch,
onToggleCollapsedGroup,
dragHandleProps,
compactBodyPadding = false,
} = 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 compareSessionsByPinnedAndTime(a.session, b.session, pinnedSessionIds);
}, [pinnedSessionIds, sessionOrderIndex]);
const searchData = hasSessionSearchQuery ? groupSearchDataByGroup.get(group) : null;
const displayMode = useSessionDisplayStore((state) => state.displayMode);
const foldersMap = useSessionFoldersStore((state) => state.foldersMap);
2026-06-12 12:38:31 +03:00
// VS Code always uses the expanded layout (see SessionNodeItem).
const isMinimalMode = displayMode === 'minimal' && !isVSCodeRuntime();
const isCollapsed = hasSessionSearchQuery ? false : collapsedGroups.has(groupKey);
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);
const scopeFolders = React.useMemo(
() => folderScopeKey ? (foldersMap[folderScopeKey] ?? []) : [],
[folderScopeKey, 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) => {
const nodes = folder.sessionIds
.map((sid) => nodeBySessionId.get(sid))
.filter((n): n is SessionNode => Boolean(n))
.sort(compareSessionNodes);
return { folder, 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 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 the archived bucket once it grows past a threshold. The
// archived list is the only group that can routinely hit hundreds or
// thousands of rows (projects accumulate archived sessions over time);
// every other group renders eagerly because they're small. All hooks
// below MUST stay above the search-empty early-return so they fire in
// the same order every render — rules-of-hooks.
const shouldVirtualizeArchived = group.isArchivedBucket === true
&& !hasSessionSearchQuery
&& visibleSessions.length >= ARCHIVED_VIRTUALIZE_THRESHOLD;
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.
// tanstack-virtual reads scrollMargin from useVirtualizer 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);
// Find the nearest scrolling ancestor by walking up the DOM. The sidebar
// routes its scroll through `ScrollableOverlay` higher up the tree;
// threading a ref through every intermediate component would be invasive
// for this single use case.
const archivedVirtualizer = useVirtualizer({
count: visibleSessions.length,
getScrollElement: () => archivedScrollEl,
estimateSize: () => ARCHIVED_ROW_ESTIMATE_PX,
overscan: 8,
enabled: shouldVirtualizeArchived && archivedScrollEl !== null,
scrollMargin: archivedScrollMargin,
});
// Resolve the scrolling ancestor and measure the virtual container's offset
// from its content origin, both on every render. The container ref is null
// while the archived bucket is collapsed (the body isn't mounted), so a
// dep-gated effect that only fires when shouldVirtualizeArchived flips
// would miss the eventual mount and leave the scroll element null forever.
// Running on every render lets us pick up the container as soon as
// expanding the bucket mounts it; the cached scroll element is reused as
// long as it still contains the container. Both state setters compare
// before writing, so a stable layout produces no state churn.
// eslint-disable-next-line react-hooks/exhaustive-deps
React.useLayoutEffect(() => {
if (!shouldVirtualizeArchived) {
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;
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);
// setState triggers a re-render; bail out and let the next pass
// measure the margin against the fresh element.
return;
}
}
if (!scrollEl) return;
const offset = container.getBoundingClientRect().top
- scrollEl.getBoundingClientRect().top
+ scrollEl.scrollTop;
setArchivedScrollMargin((prev) => (Math.abs(prev - offset) < 1 ? prev : offset));
});
// Re-measure when the sidebar's scroll container resizes (e.g. window
// resize, sidebar width change). Mirrors the pattern in ChangesSection.
React.useEffect(() => {
if (!shouldVirtualizeArchived || !archivedScrollEl) return;
const observer = new ResizeObserver(() => {
archivedVirtualizer.measure();
});
observer.observe(archivedScrollEl);
return () => observer.disconnect();
}, [shouldVirtualizeArchived, archivedScrollEl, archivedVirtualizer]);
const archivedTotalSize = archivedVirtualizer.getTotalSize();
// Read virtual rows directly in render rather than via useMemo. The
// virtualizer instance is a stable reference across renders, and on a
// pure scroll event neither it nor `archivedTotalSize` change — only the
// virtualizer's internal scroll offset does. Memoizing here would return
// stale rows after every scroll. tanstack-virtual v3 expects callers to
// read getVirtualItems() inline; it's cheap and returns [] when the
// virtualizer is disabled.
const archivedVirtualRows = shouldVirtualizeArchived && archivedScrollEl !== null
? archivedVirtualizer.getVirtualItems()
: [];
if (hasSessionSearchQuery && !groupMatchesSearch && rootFolders.length === 0 && ungroupedSessions.length === 0) {
return null;
}
const collectGroupSessions = (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;
};
const allGroupSessions = collectGroupSessions(sourceGroupNodes);
const isGitProject = projectId && projectRepoStatus.has(projectId)
? Boolean(projectRepoStatus.get(projectId))
: lastRepoStatus;
const groupDirectoryKey = normalizePath(group.directory ?? null);
const groupBranchKey = group.branch?.trim() ?? null;
const prIndicator = groupDirectoryKey && groupBranchKey
? (prVisualStateByDirectoryBranch.get(`${groupDirectoryKey}::${groupBranchKey}`) ?? null)
: null;
const showInlinePrTitle = Boolean(prIndicator && group.branch);
const showBranchSubtitle = !prIndicator && !group.isMain && Boolean(group.branch);
const prVisualState = prIndicator?.visualState ?? null;
const checksSummary = prIndicator && prIndicator.state === 'open' && prIndicator.checks
? t('sessions.sidebar.group.pr.checksPassed', {
success: prIndicator.checks.success,
total: prIndicator.checks.total,
})
: null;
const checksTail = prIndicator && prIndicator.state === 'open' && prIndicator.checks
? [
prIndicator.checks.failure > 0
? t('sessions.sidebar.group.pr.failingCount', { count: prIndicator.checks.failure })
: null,
prIndicator.checks.pending > 0
? t('sessions.sidebar.group.pr.pendingCount', { count: prIndicator.checks.pending })
: null,
].filter((item): item is string => Boolean(item)).join(', ')
: null;
const mergeabilityLabel = prIndicator && prIndicator.state === 'open'
? (prIndicator.mergeableState === 'blocked' || prIndicator.mergeableState === 'dirty'
? t('sessions.sidebar.group.pr.conflictsOrBlocked')
: (prIndicator.mergeableState === 'clean' || prIndicator.canMerge === true ? t('sessions.sidebar.group.pr.mergeable') : null))
: null;
const mergeStateLabel = prIndicator && prIndicator.state === 'open' && prIndicator.mergeableState
? t('sessions.sidebar.group.pr.mergeState', { state: prIndicator.mergeableState })
: null;
const baseBranchLabel = prIndicator?.base ?? null;
const headBranchLabel = prIndicator?.head ?? null;
const statusLine = (() => {
if (!prIndicator) {
return group.branch && isBranchDifferentFromLabel(group.branch, group.label)
? { label: group.branch, color: null as string | null }
: null;
}
switch (prIndicator.visualState) {
case 'merged':
return { label: t('sessions.sidebar.group.pr.status.merged'), color: 'var(--pr-merged)' };
case 'open':
return (prIndicator.canMerge === true || prIndicator.mergeableState === 'clean' || prIndicator.checks?.state === 'success')
? { label: t('sessions.sidebar.group.pr.status.readyToMerge'), color: 'var(--pr-open)' }
: { label: t('sessions.sidebar.group.pr.status.open'), color: 'var(--pr-open)' };
case 'blocked':
return {
label: prIndicator.mergeableState === 'dirty'
? t('sessions.sidebar.group.pr.status.mergeConflicts')
: t('sessions.sidebar.group.pr.status.mergeBlocked'),
color: 'var(--pr-blocked)',
};
case 'draft':
return { label: t('sessions.sidebar.group.pr.status.draft'), color: 'var(--pr-draft)' };
case 'closed':
return { label: t('sessions.sidebar.group.pr.status.closed'), color: 'var(--pr-closed)' };
default:
return null;
}
})();
const branchIconColor = statusLine?.color ?? (prVisualState ? `var(--pr-${prVisualState})` : undefined);
const handlePrLinkClick = (event: React.MouseEvent<HTMLElement>) => {
event.preventDefault();
event.stopPropagation();
const url = prIndicator?.url;
if (!url) {
return;
}
void openExternalUrl(url);
};
const renderOneFolderItem = (folder: SessionFolder, nodes: SessionNode[], depth: number): React.ReactNode => {
const directSubFolders = allFoldersForGroup.filter(({ folder: f }) => f.parentId === folder.id);
const subFolderItems = directSubFolders.length > 0
? <>{directSubFolders.map(({ folder: sf, nodes: sn }) => renderOneFolderItem(sf, sn, depth + 1))}</>
: undefined;
const collectFolderSessions = (targetFolderId: string): Session[] => {
const directNodes = allFoldersForGroup.find(({ folder: candidate }) => candidate.id === targetFolderId)?.nodes ?? [];
const childFolders = allFoldersForGroup.filter(({ folder: candidate }) => candidate.parentId === targetFolderId);
return [
...collectGroupSessions(directNodes),
...childFolders.flatMap(({ folder: child }) => collectFolderSessions(child.id)),
];
};
const folderSessionsForDelete = group.isArchivedBucket ? collectFolderSessions(folder.id) : [];
return (
<DroppableFolderWrapper key={folder.id} folderId={folder.id}>
{(droppableRef, isDropTarget) => (
<SessionFolderItem
folder={folder}
sessions={nodes}
subFolderItems={subFolderItems}
isCollapsed={hasSessionSearchQuery ? false : collapsedFolderIds.has(folder.id)}
onToggle={() => toggleFolderCollapse(folder.id)}
onRename={(name) => {
if (folderScopeKey) renameFolder(folderScopeKey, 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 (!folderScopeKey) return;
if (!showDeletionDialog) {
deleteFolder(folderScopeKey, folder.id);
return;
}
const subFolderCount = allFoldersForGroup.filter(({ folder: f }) => f.parentId === folder.id).length;
const sessionCount = nodes.length;
setDeleteFolderConfirm({
scopeKey: folderScopeKey,
folderId: folder.id,
folderName: folder.name,
subFolderCount,
sessionCount,
});
}}
renderSessionNode={renderSessionNode}
groupDirectory={group.directory}
projectId={projectId}
mobileVariant={mobileVariant}
2026-05-03 17:19:55 +03:00
alwaysShowActions={alwaysShowActions}
isRenaming={renamingFolderId === folder.id}
renameDraft={renamingFolderId === folder.id ? renameFolderDraft : undefined}
onRenameDraftChange={(value) => setRenameFolderDraft(value)}
onRenameSave={() => {
const trimmed = renameFolderDraft.trim();
if (trimmed && folderScopeKey) {
renameFolder(folderScopeKey, folder.id, trimmed);
}
setRenamingFolderId(null);
setRenameFolderDraft('');
}}
onRenameCancel={() => {
setRenamingFolderId(null);
setRenameFolderDraft('');
}}
droppableRef={droppableRef}
isDropTarget={isDropTarget}
depth={depth}
onNewSession={() => {
if (projectId && projectId !== activeProjectId) setActiveProjectIdOnly(projectId);
setActiveMainTab('chat');
if (mobileVariant) setSessionSwitcherOpen(false);
openNewSessionDraft({ directoryOverride: group.directory, targetFolderId: folder.id });
}}
onNewSubFolder={depth === 0 ? () => {
if (!folderScopeKey) return;
createFolderAndStartRename(folderScopeKey, folder.id);
} : undefined}
hideActions={false}
archivedBucket={group.isArchivedBucket === true}
/>
)}
</DroppableFolderWrapper>
);
};
const renderFolderItems = () => rootFolders.map(({ folder, nodes }) => renderOneFolderItem(folder, nodes, 0));
const hasWorktreeDeleteAction = Boolean(!group.isMain && group.worktree);
2026-05-03 17:19:55 +03:00
const groupHeaderRightPadding = alwaysShowActions
? (hasWorktreeDeleteAction ? 'pr-14' : 'pr-7')
: isMinimalMode
? (hasWorktreeDeleteAction
? 'pr-2 group-hover/gh:pr-14 group-focus-within/gh:pr-14'
: 'pr-2')
: (hasWorktreeDeleteAction
? 'pr-5 group-hover/gh:pr-14 group-focus-within/gh:pr-14'
: 'pr-5');
const body = (
<SessionFolderDndScope
scopeKey={folderScopeKey}
hasFolders={allFoldersForGroup.length > 0}
onSessionDroppedOnFolder={(sessionId, folderId) => {
if (folderScopeKey) addSessionToFolder(folderScopeKey, folderId, sessionId);
}}
>
{renderFolderItems()}
{shouldVirtualizeArchived ? (
<div
ref={archivedVirtualContainerRef}
style={{
position: 'relative',
// Reserve scroll height for all archived rows so the parent
// scroll thumb reflects the full list. Individual rows are
// absolutely positioned by translateY.
height: archivedTotalSize > 0 ? archivedTotalSize : undefined,
width: '100%',
}}
>
{archivedVirtualRows.map((virtualRow) => {
const node = visibleSessions[virtualRow.index];
if (!node) return null;
return (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={archivedVirtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
// virtualRow.start is in scroll-element coordinates (offset by
// scrollMargin). Subtract scrollMargin to position within the
// container, which itself starts at scrollMargin in the scroll
// element.
transform: `translateY(${virtualRow.start - archivedScrollMargin}px)`,
}}
>
{renderSessionNode(node, 0, group.directory, projectId, true)}
</div>
);
})}
</div>
) : (
visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true))
)}
{totalSessions === 0 && allFoldersForGroup.length === 0 ? (
<div className="py-1 text-left typography-micro text-muted-foreground">
{group.isArchivedBucket
? t('sessions.sidebar.group.empty.noArchivedSessions')
: t('sessions.sidebar.group.empty.noSessionsInWorkspace')}
</div>
) : null}
{remainingCount > 0 ? (
<button
type="button"
onClick={() => showMoreGroupSessions(groupKey, visibleSessions.length)}
className="mt-0.5 flex items-center justify-start rounded-md px-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 px-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>
);
const groupBodyPaddingClass = compactBodyPadding ? 'pb-2 pl-1' : 'pb-3 pl-4';
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(
'min-w-0 flex flex-1 items-start gap-1 overflow-hidden pl-0.5 transition-[padding] cursor-grab active:cursor-grabbing',
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">
{showInlinePrTitle && prIndicator ? (
<span className="inline-flex min-w-0 max-w-full items-center">
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex shrink-0 items-center gap-1 leading-none align-middle">
<span className="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center">
<Icon name="git-branch"
2026-05-03 17:19:55 +03:00
className={cn('h-3.5 w-3.5 shrink-0', alwaysShowActions ? 'hidden' : 'group-hover/gh:hidden')}
style={branchIconColor ? { color: branchIconColor } : undefined}
/>
2026-05-03 17:19:55 +03:00
<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>
{prIndicator.url ? (
<button
type="button"
className="inline-flex shrink-0 items-center leading-none"
onMouseDown={(event) => event.stopPropagation()}
onClick={handlePrLinkClick}
>
#{prIndicator.number}
</button>
) : (
<span className="inline-flex shrink-0 items-center leading-none">#{prIndicator.number}</span>
)}
</span>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6} align="start" className="max-w-sm">
<div className="space-y-1 text-xs">
{(baseBranchLabel || headBranchLabel) ? (
<div className="text-muted-foreground truncate">
{baseBranchLabel && headBranchLabel ? (
<>
<span>{baseBranchLabel}</span>
<Icon name="arrow-left-long" className="mx-0.5 inline h-3 w-3 align-[-2px]" />
<span>{headBranchLabel}</span>
</>
) : (
<span>{baseBranchLabel ?? headBranchLabel ?? ''}</span>
)}
</div>
) : null}
{mergeStateLabel ? <div className="text-muted-foreground truncate">{mergeStateLabel}</div> : null}
{(mergeabilityLabel || checksSummary) ? (
<div className="text-muted-foreground truncate">
{mergeabilityLabel ?? ''}
{mergeabilityLabel && checksSummary ? ' • ' : ''}
{checksSummary ?? ''}
{checksTail ? ` (${checksTail})` : ''}
</div>
) : null}
</div>
</TooltipContent>
</Tooltip>
<span className="ml-1 min-w-0 flex-1 truncate leading-none align-middle">{group.branch}</span>
</span>
) : 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')} />
2026-05-03 17:19:55 +03:00
<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>
</span>
) : (!group.isMain || group.worktree) ? (
<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="git-branch"
2026-05-03 17:19:55 +03:00
className={cn('h-3.5 w-3.5 shrink-0 text-muted-foreground', alwaysShowActions ? 'hidden' : 'group-hover/gh:hidden')}
style={branchIconColor ? { color: branchIconColor } : undefined}
/>
2026-05-03 17:19:55 +03:00
<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>
</span>
) : (
renderHighlightedText(group.label, normalizedSessionSearchQuery)
)}
</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" />
) : (!group.isMain || isGitProject) ? (
showInlinePrTitle && prIndicator ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex h-3.5 w-3.5 flex-shrink-0 items-center justify-center">
<Icon name="git-branch" className="h-3.5 w-3.5 text-muted-foreground"
style={branchIconColor ? { color: branchIconColor } : undefined}/>
</span>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6} align="start" className="max-w-sm">
<div className="space-y-1 text-xs">
{(baseBranchLabel || headBranchLabel) ? (
<div className="text-muted-foreground truncate">
{baseBranchLabel && headBranchLabel ? (
<>
<span>{baseBranchLabel}</span>
<Icon name="arrow-left-long" className="mx-0.5 inline h-3 w-3 align-[-2px]" />
<span>{headBranchLabel}</span>
</>
) : (
<span>{baseBranchLabel ?? headBranchLabel ?? ''}</span>
)}
</div>
) : null}
{mergeStateLabel ? <div className="text-muted-foreground truncate">{mergeStateLabel}</div> : null}
{(mergeabilityLabel || checksSummary) ? (
<div className="text-muted-foreground truncate">
{mergeabilityLabel ?? ''}
{mergeabilityLabel && checksSummary ? ' • ' : ''}
{checksSummary ?? ''}
{checksTail ? ` (${checksTail})` : ''}
</div>
) : null}
</div>
</TooltipContent>
</Tooltip>
) : (
<Icon name="git-branch" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground"
style={branchIconColor ? { color: branchIconColor } : undefined}/>
)
) : null}
<span
className={cn('min-w-0 truncate text-[11px] font-medium', !statusLine.color && 'text-muted-foreground/80')}
style={statusLine.color ? { color: statusLine.color } : undefined}
>
{statusLine.label}
</span>
</span>
) : null}
</div>
</div>
{group.isArchivedBucket && allGroupSessions.length > 0 ? (
2026-05-03 17:19:55 +03:00
<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 ? (
2026-05-03 17:19:55 +03:00
<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 ? (
2026-05-03 17:19:55 +03:00
<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({ 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>
);
}