2026-03-06 17:22:59 +02:00
|
|
|
|
import React from 'react';
|
2026-06-15 03:29:40 +03:00
|
|
|
|
import { Virtualizer } from 'virtua';
|
2026-03-06 17:22:59 +02:00
|
|
|
|
import type { Session } from '@opencode-ai/sdk/v2';
|
2026-05-18 22:07:12 +07:00
|
|
|
|
|
|
|
|
|
|
// 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;
|
2026-06-18 08:43:16 +11:00
|
|
|
|
// Active/worktree groups can also grow large (a single worktree with 80+
|
|
|
|
|
|
// sessions), and unlike the archive they're interactive from the start.
|
|
|
|
|
|
// Virtualize eagerly for non-archived groups to keep the rendered row
|
|
|
|
|
|
// count bounded. With overscan ~8 the visible behavior is identical.
|
|
|
|
|
|
const ACTIVE_VIRTUALIZE_THRESHOLD = 30;
|
2026-05-18 22:07:12 +07:00
|
|
|
|
// Compact rows in the archived bucket without nested subagents render
|
2026-06-15 03:29:40 +03:00
|
|
|
|
// around 24-32px; virtua measures mounted rows and uses this as the initial hint.
|
2026-05-18 22:07:12 +07:00
|
|
|
|
const ARCHIVED_ROW_ESTIMATE_PX = 28;
|
2026-03-06 17:22:59 +02:00
|
|
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
2026-05-13 13:26:15 +03:00
|
|
|
|
import { Icon } from "@/components/icon/Icon";
|
2026-03-06 17:22:59 +02:00
|
|
|
|
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';
|
2026-03-20 01:01:03 +02:00
|
|
|
|
import type { SortableDragHandleProps } from './sortableItems';
|
2026-03-06 17:22:59 +02:00
|
|
|
|
import type { GroupSearchData, SessionGroup, SessionNode } from './types';
|
|
|
|
|
|
import { compareSessionsByPinnedAndTime, isBranchDifferentFromLabel, normalizePath, renderHighlightedText } from './utils';
|
2026-06-18 08:43:16 +11:00
|
|
|
|
import {
|
|
|
|
|
|
collectSubtreeContainingId,
|
|
|
|
|
|
computeNodeStructureKey,
|
|
|
|
|
|
nodeContainsSessionId,
|
|
|
|
|
|
resolveMenuOpenSessionId,
|
|
|
|
|
|
} from './sessionNodeItemUtils';
|
|
|
|
|
|
import type { SessionNodeRenderExtras } from './sessionNodeItemUtils';
|
2026-03-06 17:22:59 +02:00
|
|
|
|
import type { SessionFolder } from '@/stores/useSessionFoldersStore';
|
2026-04-27 13:42:52 +03:00
|
|
|
|
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
|
2026-03-20 01:01:03 +02:00
|
|
|
|
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
2026-03-20 19:11:45 +08:00
|
|
|
|
import { openExternalUrl } from '@/lib/url';
|
2026-06-12 12:38:31 +03:00
|
|
|
|
import { isVSCodeRuntime } from '@/lib/desktop';
|
2026-04-26 14:03:39 +03:00
|
|
|
|
import { useI18n } from '@/lib/i18n';
|
2026-03-06 17:22:59 +02:00
|
|
|
|
|
|
|
|
|
|
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>;
|
2026-06-02 13:25:28 +03:00
|
|
|
|
visibleSessionCount?: number;
|
2026-03-06 17:22:59 +02:00
|
|
|
|
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>>;
|
2026-06-18 08:43:16 +11:00
|
|
|
|
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;
|
2026-03-06 17:22:59 +02:00
|
|
|
|
projectRepoStatus: Map<string, boolean | null>;
|
|
|
|
|
|
lastRepoStatus: boolean;
|
2026-06-02 13:25:28 +03:00
|
|
|
|
showMoreGroupSessions: (groupKey: string, currentVisibleCount: number) => void;
|
|
|
|
|
|
resetGroupSessionLimit: (groupKey: string) => void;
|
2026-03-06 17:22:59 +02:00
|
|
|
|
mobileVariant: boolean;
|
2026-05-03 17:19:55 +03:00
|
|
|
|
alwaysShowActions: boolean;
|
2026-03-06 17:22:59 +02:00
|
|
|
|
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>;
|
2026-06-18 08:43:16 +11:00
|
|
|
|
expandedParents: Set<string>;
|
2026-04-04 02:19:55 +03:00
|
|
|
|
sessionOrderIndex: Map<string, number>;
|
2026-06-18 08:43:16 +11:00
|
|
|
|
currentSessionId: string | null;
|
|
|
|
|
|
editingId: string | null;
|
|
|
|
|
|
editTitle: string;
|
|
|
|
|
|
openSidebarMenuKey: string | null;
|
|
|
|
|
|
liveSessionById: Map<string, Session>;
|
2026-03-06 17:22:59 +02:00
|
|
|
|
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;
|
2026-03-20 01:01:03 +02:00
|
|
|
|
dragHandleProps?: SortableDragHandleProps | null;
|
2026-03-23 23:51:55 +02:00
|
|
|
|
compactBodyPadding?: boolean;
|
2026-06-18 08:43:16 +11:00
|
|
|
|
/**
|
|
|
|
|
|
* 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>;
|
2026-03-06 17:22:59 +02:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-18 08:43:16 +11:00
|
|
|
|
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 => {
|
|
|
|
|
|
const visit = (node: SessionNode): boolean => {
|
|
|
|
|
|
const sessionId = node.session.id;
|
|
|
|
|
|
if (prevPinnedSessionIds.has(sessionId) !== nextPinnedSessionIds.has(sessionId)) return true;
|
|
|
|
|
|
return node.children.some(visit);
|
|
|
|
|
|
};
|
|
|
|
|
|
return group.sessions.some(visit);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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 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 groupHasResolvedSessionChange = (
|
|
|
|
|
|
group: SessionGroup,
|
|
|
|
|
|
prevLiveSessionById: Map<string, Session>,
|
|
|
|
|
|
nextLiveSessionById: Map<string, Session>,
|
|
|
|
|
|
): boolean => {
|
|
|
|
|
|
const visit = (node: SessionNode): boolean => {
|
|
|
|
|
|
const sessionId = node.session.id;
|
|
|
|
|
|
if ((prevLiveSessionById.get(sessionId) ?? node.session) !== (nextLiveSessionById.get(sessionId) ?? node.session)) {
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
return node.children.some(visit);
|
|
|
|
|
|
};
|
|
|
|
|
|
return group.sessions.some(visit);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const getProjectRepoStatusValue = (props: Props): boolean | null | undefined => {
|
|
|
|
|
|
if (!props.projectId) return undefined;
|
|
|
|
|
|
return props.projectRepoStatus.has(props.projectId)
|
|
|
|
|
|
? props.projectRepoStatus.get(props.projectId)
|
|
|
|
|
|
: undefined;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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.projectRepoStatus !== next.projectRepoStatus
|
|
|
|
|
|
&& getProjectRepoStatusValue(prev) !== getProjectRepoStatusValue(next)) {
|
|
|
|
|
|
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.currentSessionId !== next.currentSessionId
|
|
|
|
|
|
&& (groupContainsSessionId(prev.group, prev.currentSessionId) || groupContainsSessionId(next.group, next.currentSessionId))) {
|
|
|
|
|
|
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.liveSessionById !== next.liveSessionById
|
|
|
|
|
|
&& groupHasResolvedSessionChange(next.group, prev.liveSessionById, next.liveSessionById)) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Per-row / per-state props. The PR-visual-state map flips frequently
|
|
|
|
|
|
// during bootstrap but a single group's value is usually stable, so we
|
|
|
|
|
|
// compare only the value this group actually consumes instead of the
|
|
|
|
|
|
// whole map reference.
|
|
|
|
|
|
if (prev.prVisualStateByDirectoryBranch !== next.prVisualStateByDirectoryBranch) {
|
|
|
|
|
|
const prevVal = prev.group?.directory && prev.group?.branch
|
|
|
|
|
|
? prev.prVisualStateByDirectoryBranch.get(`${prev.group.directory}::${prev.group.branch.trim()}`)
|
|
|
|
|
|
: undefined;
|
|
|
|
|
|
const nextVal = next.group?.directory && next.group?.branch
|
|
|
|
|
|
? next.prVisualStateByDirectoryBranch.get(`${next.group.directory}::${next.group.branch.trim()}`)
|
|
|
|
|
|
: undefined;
|
|
|
|
|
|
if (!Object.is(prevVal, nextVal)) 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.lastRepoStatus === next.lastRepoStatus
|
|
|
|
|
|
&& 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 {
|
2026-04-26 14:03:39 +03:00
|
|
|
|
const { t } = useI18n();
|
2026-03-06 17:22:59 +02:00
|
|
|
|
const {
|
|
|
|
|
|
group,
|
|
|
|
|
|
groupKey,
|
|
|
|
|
|
projectId,
|
|
|
|
|
|
hideGroupLabel,
|
|
|
|
|
|
hasSessionSearchQuery,
|
|
|
|
|
|
normalizedSessionSearchQuery,
|
|
|
|
|
|
groupSearchDataByGroup,
|
2026-06-02 13:25:28 +03:00
|
|
|
|
visibleSessionCount,
|
2026-03-06 17:22:59 +02:00
|
|
|
|
collapsedGroups,
|
|
|
|
|
|
hideDirectoryControls,
|
|
|
|
|
|
collapsedFolderIds,
|
|
|
|
|
|
toggleFolderCollapse,
|
|
|
|
|
|
renameFolder,
|
|
|
|
|
|
deleteFolder,
|
|
|
|
|
|
showDeletionDialog,
|
|
|
|
|
|
setDeleteFolderConfirm,
|
|
|
|
|
|
renderSessionNode,
|
|
|
|
|
|
projectRepoStatus,
|
|
|
|
|
|
lastRepoStatus,
|
2026-06-02 13:25:28 +03:00
|
|
|
|
showMoreGroupSessions,
|
|
|
|
|
|
resetGroupSessionLimit,
|
2026-03-06 17:22:59 +02:00
|
|
|
|
mobileVariant,
|
2026-05-03 17:19:55 +03:00
|
|
|
|
alwaysShowActions,
|
2026-03-06 17:22:59 +02:00
|
|
|
|
activeProjectId,
|
|
|
|
|
|
setActiveProjectIdOnly,
|
|
|
|
|
|
setActiveMainTab,
|
|
|
|
|
|
setSessionSwitcherOpen,
|
|
|
|
|
|
openNewSessionDraft,
|
|
|
|
|
|
addSessionToFolder,
|
|
|
|
|
|
createFolderAndStartRename,
|
|
|
|
|
|
renamingFolderId,
|
|
|
|
|
|
renameFolderDraft,
|
|
|
|
|
|
setRenameFolderDraft,
|
|
|
|
|
|
setRenamingFolderId,
|
|
|
|
|
|
pinnedSessionIds,
|
2026-06-24 06:24:11 +11:00
|
|
|
|
expandedParents,
|
2026-04-04 02:19:55 +03:00
|
|
|
|
sessionOrderIndex,
|
2026-06-18 08:43:16 +11:00
|
|
|
|
currentSessionId,
|
|
|
|
|
|
editingId,
|
|
|
|
|
|
openSidebarMenuKey,
|
2026-03-06 17:22:59 +02:00
|
|
|
|
prVisualStateByDirectoryBranch,
|
|
|
|
|
|
onToggleCollapsedGroup,
|
2026-03-20 01:01:03 +02:00
|
|
|
|
dragHandleProps,
|
2026-03-23 23:51:55 +02:00
|
|
|
|
compactBodyPadding = false,
|
2026-06-18 08:43:16 +11:00
|
|
|
|
scrollContainerRef,
|
2026-03-06 17:22:59 +02:00
|
|
|
|
} = props;
|
|
|
|
|
|
|
2026-04-04 02:19:55 +03:00
|
|
|
|
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]);
|
|
|
|
|
|
|
2026-03-06 17:22:59 +02:00
|
|
|
|
const searchData = hasSessionSearchQuery ? groupSearchDataByGroup.get(group) : null;
|
2026-03-20 01:01:03 +02:00
|
|
|
|
const displayMode = useSessionDisplayStore((state) => state.displayMode);
|
2026-04-27 13:42:52 +03:00
|
|
|
|
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();
|
2026-03-06 17:22:59 +02:00
|
|
|
|
const isCollapsed = hasSessionSearchQuery ? false : collapsedGroups.has(groupKey);
|
|
|
|
|
|
const maxVisible = hideDirectoryControls ? 10 : 5;
|
2026-06-02 13:25:28 +03:00
|
|
|
|
const nonArchivedVisibleCount = Math.max(maxVisible, visibleSessionCount ?? maxVisible);
|
2026-03-06 17:22:59 +02:00
|
|
|
|
const groupMatchesSearch = hasSessionSearchQuery ? searchData?.groupMatches === true : false;
|
|
|
|
|
|
const shouldFilterGroupContents = hasSessionSearchQuery;
|
2026-04-04 02:19:55 +03:00
|
|
|
|
const sourceGroupNodes = React.useMemo(
|
|
|
|
|
|
() => [...(shouldFilterGroupContents ? (searchData?.filteredNodes ?? []) : group.sessions)]
|
|
|
|
|
|
.sort(compareSessionNodes),
|
|
|
|
|
|
[compareSessionNodes, group.sessions, searchData?.filteredNodes, shouldFilterGroupContents],
|
|
|
|
|
|
);
|
2026-03-06 17:22:59 +02:00
|
|
|
|
const folderScopeKey = group.folderScopeKey ?? normalizePath(group.directory ?? null);
|
2026-04-26 16:24:07 +03:00
|
|
|
|
const scopeFolders = React.useMemo(
|
2026-04-27 13:42:52 +03:00
|
|
|
|
() => folderScopeKey ? (foldersMap[folderScopeKey] ?? []) : [],
|
|
|
|
|
|
[folderScopeKey, foldersMap]
|
2026-04-26 16:24:07 +03:00
|
|
|
|
);
|
2026-03-06 17:22:59 +02:00
|
|
|
|
|
2026-04-26 16:24:07 +03:00
|
|
|
|
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]);
|
2026-03-06 17:22:59 +02:00
|
|
|
|
|
2026-04-26 16:24:07 +03:00
|
|
|
|
const allFoldersForGroupBase = React.useMemo(() => scopeFolders.map((folder) => {
|
2026-03-06 17:22:59 +02:00
|
|
|
|
const nodes = folder.sessionIds
|
|
|
|
|
|
.map((sid) => nodeBySessionId.get(sid))
|
|
|
|
|
|
.filter((n): n is SessionNode => Boolean(n))
|
2026-04-04 02:19:55 +03:00
|
|
|
|
.sort(compareSessionNodes);
|
2026-03-06 17:22:59 +02:00
|
|
|
|
return { folder, nodes };
|
2026-04-26 16:24:07 +03:00
|
|
|
|
}), [scopeFolders, nodeBySessionId, compareSessionNodes]);
|
2026-03-06 17:22:59 +02:00
|
|
|
|
|
2026-04-12 04:25:33 +08:00
|
|
|
|
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;
|
|
|
|
|
|
};
|
2026-03-06 17:22:59 +02:00
|
|
|
|
|
2026-04-12 04:25:33 +08:00
|
|
|
|
return allFoldersForGroupBase.filter(({ folder }) => shouldKeepFolder(folder.id));
|
|
|
|
|
|
}, [allFoldersForGroupBase, group.isArchivedBucket, hasSessionSearchQuery, normalizedSessionSearchQuery]);
|
2026-03-06 17:22:59 +02:00
|
|
|
|
|
2026-04-26 16:24:07 +03:00
|
|
|
|
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]);
|
2026-03-06 17:22:59 +02:00
|
|
|
|
|
2026-06-18 08:43:16 +11:00
|
|
|
|
// Precompute per-row "subtree contains active session" and "subtree contains
|
|
|
|
|
|
// editing session" lookups 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 subtreeContainsActive = React.useMemo(() => {
|
|
|
|
|
|
const set = new Set<string>();
|
|
|
|
|
|
collectSubtreeContainingId(sourceGroupNodes, currentSessionId, set);
|
|
|
|
|
|
allFoldersForGroup.forEach(({ nodes }) => {
|
|
|
|
|
|
collectSubtreeContainingId(nodes, currentSessionId, set);
|
|
|
|
|
|
});
|
|
|
|
|
|
return set;
|
|
|
|
|
|
}, [sourceGroupNodes, allFoldersForGroup, currentSessionId]);
|
|
|
|
|
|
|
|
|
|
|
|
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) => ({
|
|
|
|
|
|
subtreeContainsActive,
|
|
|
|
|
|
subtreeContainsEditing,
|
|
|
|
|
|
menuOpenSessionId,
|
|
|
|
|
|
nodeStructureKey: resolveNodeStructureKey(child),
|
|
|
|
|
|
}), [subtreeContainsActive, subtreeContainsEditing, menuOpenSessionId, resolveNodeStructureKey]);
|
|
|
|
|
|
|
2026-03-06 17:22:59 +02:00
|
|
|
|
const totalSessions = ungroupedSessions.length;
|
|
|
|
|
|
const visibleSessions = group.isArchivedBucket
|
|
|
|
|
|
? ungroupedSessions
|
|
|
|
|
|
: hasSessionSearchQuery
|
|
|
|
|
|
? ungroupedSessions
|
2026-06-02 13:25:28 +03:00
|
|
|
|
: ungroupedSessions.slice(0, nonArchivedVisibleCount);
|
2026-03-06 17:22:59 +02:00
|
|
|
|
const remainingCount = totalSessions - visibleSessions.length;
|
2026-06-02 13:25:28 +03:00
|
|
|
|
const canShowLess = !group.isArchivedBucket && !hasSessionSearchQuery && totalSessions > maxVisible && remainingCount === 0;
|
2026-03-06 17:22:59 +02:00
|
|
|
|
|
2026-06-18 08:43:16 +11:00
|
|
|
|
// Virtualize large groups. Archived buckets grow into the hundreds or
|
|
|
|
|
|
// thousands of rows; active/worktree groups can also hit 80+ sessions
|
|
|
|
|
|
// when a single worktree accumulates over time. Both paths share the
|
|
|
|
|
|
// same virtua Virtualizer; the threshold just controls when we mount
|
|
|
|
|
|
// it. The visible behavior is identical because virtua uses overscan
|
|
|
|
|
|
// (8) for the buffer zone. All hooks below MUST stay above the
|
|
|
|
|
|
// search-empty early-return so they fire in the same order every
|
|
|
|
|
|
// render — rules-of-hooks.
|
2026-05-18 22:07:12 +07:00
|
|
|
|
const shouldVirtualizeArchived = group.isArchivedBucket === true
|
|
|
|
|
|
&& !hasSessionSearchQuery
|
|
|
|
|
|
&& visibleSessions.length >= ARCHIVED_VIRTUALIZE_THRESHOLD;
|
2026-06-18 08:43:16 +11:00
|
|
|
|
const shouldVirtualizeActive = group.isArchivedBucket !== true
|
|
|
|
|
|
&& !hasSessionSearchQuery
|
|
|
|
|
|
&& visibleSessions.length >= ACTIVE_VIRTUALIZE_THRESHOLD;
|
|
|
|
|
|
const shouldVirtualize = shouldVirtualizeArchived || shouldVirtualizeActive;
|
2026-05-18 22:07:12 +07:00
|
|
|
|
|
2026-06-24 06:24:11 +11:00
|
|
|
|
// 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);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-18 22:07:12 +07:00
|
|
|
|
const archivedVirtualContainerRef = React.useRef<HTMLDivElement | null>(null);
|
2026-06-15 03:29:40 +03:00
|
|
|
|
const archivedScrollRef = React.useRef<HTMLElement | null>(null);
|
2026-05-18 22:07:12 +07:00
|
|
|
|
const [archivedScrollEl, setArchivedScrollEl] = React.useState<HTMLElement | null>(null);
|
|
|
|
|
|
// Offset of the virtual container from the scroll element's content origin.
|
2026-06-15 03:29:40 +03:00
|
|
|
|
// virtua reads startMargin from Virtualizer options and uses it
|
2026-05-18 22:07:12 +07:00
|
|
|
|
// 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);
|
|
|
|
|
|
|
2026-06-18 08:43:16 +11:00
|
|
|
|
// 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
|
2026-05-18 22:07:12 +07:00
|
|
|
|
// dep-gated effect that only fires when shouldVirtualizeArchived flips
|
2026-06-18 08:43:16 +11:00
|
|
|
|
// 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]);
|
|
|
|
|
|
|
2026-05-18 22:07:12 +07:00
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
|
React.useLayoutEffect(() => {
|
2026-06-18 08:43:16 +11:00
|
|
|
|
if (!shouldVirtualize) {
|
2026-05-18 22:07:12 +07:00
|
|
|
|
if (archivedScrollEl !== null) setArchivedScrollEl(null);
|
2026-06-15 03:29:40 +03:00
|
|
|
|
archivedScrollRef.current = null;
|
2026-05-18 22:07:12 +07:00
|
|
|
|
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;
|
2026-06-18 08:43:16 +11:00
|
|
|
|
const providedScrollEl = scrollContainerRef?.current ?? null;
|
|
|
|
|
|
if (providedScrollEl && providedScrollEl.contains(container)) {
|
|
|
|
|
|
scrollEl = providedScrollEl;
|
|
|
|
|
|
if (scrollEl !== archivedScrollEl) {
|
|
|
|
|
|
archivedScrollRef.current = scrollEl;
|
|
|
|
|
|
setArchivedScrollEl(scrollEl);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (!scrollEl || !scrollEl.contains(container)) {
|
2026-05-18 22:07:12 +07:00
|
|
|
|
// 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) {
|
2026-06-15 03:29:40 +03:00
|
|
|
|
archivedScrollRef.current = scrollEl;
|
2026-05-18 22:07:12 +07:00
|
|
|
|
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));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-06-18 08:43:16 +11:00
|
|
|
|
// 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[] => {
|
2026-03-06 17:22:59 +02:00
|
|
|
|
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;
|
2026-06-18 08:43:16 +11:00
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-06-29 08:42:13 +11:00
|
|
|
|
// 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.
|
2026-06-18 08:43:16 +11:00
|
|
|
|
const allGroupSessions = React.useMemo(
|
2026-06-29 08:42:13 +11:00
|
|
|
|
() => collectGroupSessions(sourceGroupNodes),
|
|
|
|
|
|
[collectGroupSessions, sourceGroupNodes],
|
2026-06-18 08:43:16 +11:00
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
2026-03-06 17:22:59 +02:00
|
|
|
|
|
|
|
|
|
|
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);
|
2026-03-20 01:01:03 +02:00
|
|
|
|
const showBranchSubtitle = !prIndicator && !group.isMain && Boolean(group.branch);
|
2026-03-06 17:22:59 +02:00
|
|
|
|
const prVisualState = prIndicator?.visualState ?? null;
|
|
|
|
|
|
const checksSummary = prIndicator && prIndicator.state === 'open' && prIndicator.checks
|
2026-04-26 14:03:39 +03:00
|
|
|
|
? t('sessions.sidebar.group.pr.checksPassed', {
|
|
|
|
|
|
success: prIndicator.checks.success,
|
|
|
|
|
|
total: prIndicator.checks.total,
|
|
|
|
|
|
})
|
2026-03-06 17:22:59 +02:00
|
|
|
|
: null;
|
|
|
|
|
|
const checksTail = prIndicator && prIndicator.state === 'open' && prIndicator.checks
|
|
|
|
|
|
? [
|
2026-04-26 14:03:39 +03:00
|
|
|
|
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,
|
2026-03-06 17:22:59 +02:00
|
|
|
|
].filter((item): item is string => Boolean(item)).join(', ')
|
|
|
|
|
|
: null;
|
|
|
|
|
|
const mergeabilityLabel = prIndicator && prIndicator.state === 'open'
|
2026-03-11 23:52:31 +02:00
|
|
|
|
? (prIndicator.mergeableState === 'blocked' || prIndicator.mergeableState === 'dirty'
|
2026-04-26 14:03:39 +03:00
|
|
|
|
? t('sessions.sidebar.group.pr.conflictsOrBlocked')
|
|
|
|
|
|
: (prIndicator.mergeableState === 'clean' || prIndicator.canMerge === true ? t('sessions.sidebar.group.pr.mergeable') : null))
|
2026-03-06 17:22:59 +02:00
|
|
|
|
: null;
|
|
|
|
|
|
const mergeStateLabel = prIndicator && prIndicator.state === 'open' && prIndicator.mergeableState
|
2026-04-26 14:03:39 +03:00
|
|
|
|
? t('sessions.sidebar.group.pr.mergeState', { state: prIndicator.mergeableState })
|
2026-03-06 17:22:59 +02:00
|
|
|
|
: null;
|
|
|
|
|
|
const baseBranchLabel = prIndicator?.base ?? null;
|
|
|
|
|
|
const headBranchLabel = prIndicator?.head ?? null;
|
2026-03-20 01:01:03 +02:00
|
|
|
|
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':
|
2026-04-26 14:03:39 +03:00
|
|
|
|
return { label: t('sessions.sidebar.group.pr.status.merged'), color: 'var(--pr-merged)' };
|
2026-03-20 01:01:03 +02:00
|
|
|
|
case 'open':
|
|
|
|
|
|
return (prIndicator.canMerge === true || prIndicator.mergeableState === 'clean' || prIndicator.checks?.state === 'success')
|
2026-04-26 14:03:39 +03:00
|
|
|
|
? { label: t('sessions.sidebar.group.pr.status.readyToMerge'), color: 'var(--pr-open)' }
|
|
|
|
|
|
: { label: t('sessions.sidebar.group.pr.status.open'), color: 'var(--pr-open)' };
|
2026-03-20 01:01:03 +02:00
|
|
|
|
case 'blocked':
|
|
|
|
|
|
return {
|
2026-04-26 14:03:39 +03:00
|
|
|
|
label: prIndicator.mergeableState === 'dirty'
|
|
|
|
|
|
? t('sessions.sidebar.group.pr.status.mergeConflicts')
|
|
|
|
|
|
: t('sessions.sidebar.group.pr.status.mergeBlocked'),
|
2026-03-20 01:01:03 +02:00
|
|
|
|
color: 'var(--pr-blocked)',
|
|
|
|
|
|
};
|
|
|
|
|
|
case 'draft':
|
2026-04-26 14:03:39 +03:00
|
|
|
|
return { label: t('sessions.sidebar.group.pr.status.draft'), color: 'var(--pr-draft)' };
|
2026-03-20 01:01:03 +02:00
|
|
|
|
case 'closed':
|
2026-04-26 14:03:39 +03:00
|
|
|
|
return { label: t('sessions.sidebar.group.pr.status.closed'), color: 'var(--pr-closed)' };
|
2026-03-20 01:01:03 +02:00
|
|
|
|
default:
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
})();
|
|
|
|
|
|
const branchIconColor = statusLine?.color ?? (prVisualState ? `var(--pr-${prVisualState})` : undefined);
|
2026-03-06 17:22:59 +02:00
|
|
|
|
const handlePrLinkClick = (event: React.MouseEvent<HTMLElement>) => {
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
|
const url = prIndicator?.url;
|
2026-03-20 19:11:45 +08:00
|
|
|
|
if (!url) {
|
2026-03-06 17:22:59 +02:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-03-20 19:11:45 +08:00
|
|
|
|
void openExternalUrl(url);
|
2026-03-06 17:22:59 +02:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
2026-06-18 08:43:16 +11:00
|
|
|
|
const folderSessionsForDelete = folderSessionsForDeleteById.get(folder.id) ?? [];
|
2026-03-06 17:22:59 +02:00
|
|
|
|
|
|
|
|
|
|
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={() => {
|
2026-03-20 01:01:03 +02:00
|
|
|
|
if (group.isArchivedBucket) {
|
2026-04-12 04:25:33 +08:00
|
|
|
|
// Delete sessions in the folder
|
|
|
|
|
|
// Empty folders are auto-hidden by useArchivedAutoFolders
|
2026-03-20 01:01:03 +02:00
|
|
|
|
sessionEvents.requestDelete({
|
|
|
|
|
|
sessions: folderSessionsForDelete,
|
|
|
|
|
|
mode: 'session',
|
|
|
|
|
|
});
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-03-06 17:22:59 +02:00
|
|
|
|
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}
|
2026-06-18 08:43:16 +11:00
|
|
|
|
getRenderExtras={resolveNodeStructureKey
|
|
|
|
|
|
? (node) => ({
|
|
|
|
|
|
subtreeContainsActive,
|
|
|
|
|
|
subtreeContainsEditing,
|
|
|
|
|
|
menuOpenSessionId,
|
|
|
|
|
|
nodeStructureKey: resolveNodeStructureKey(node),
|
|
|
|
|
|
childRenderExtrasFor,
|
|
|
|
|
|
})
|
|
|
|
|
|
: undefined}
|
2026-03-06 17:22:59 +02:00
|
|
|
|
groupDirectory={group.directory}
|
|
|
|
|
|
projectId={projectId}
|
|
|
|
|
|
mobileVariant={mobileVariant}
|
2026-05-03 17:19:55 +03:00
|
|
|
|
alwaysShowActions={alwaysShowActions}
|
2026-03-06 17:22:59 +02:00
|
|
|
|
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}
|
2026-03-20 01:01:03 +02:00
|
|
|
|
hideActions={false}
|
2026-03-06 17:22:59 +02:00
|
|
|
|
archivedBucket={group.isArchivedBucket === true}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</DroppableFolderWrapper>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const renderFolderItems = () => rootFolders.map(({ folder, nodes }) => renderOneFolderItem(folder, nodes, 0));
|
2026-03-20 01:01:03 +02:00
|
|
|
|
const hasWorktreeDeleteAction = Boolean(!group.isMain && group.worktree);
|
2026-05-03 17:19:55 +03:00
|
|
|
|
const groupHeaderRightPadding = alwaysShowActions
|
2026-03-20 01:01:03 +02:00
|
|
|
|
? (hasWorktreeDeleteAction ? 'pr-14' : 'pr-7')
|
|
|
|
|
|
: isMinimalMode
|
|
|
|
|
|
? (hasWorktreeDeleteAction
|
2026-03-23 23:51:55 +02:00
|
|
|
|
? 'pr-2 group-hover/gh:pr-14 group-focus-within/gh:pr-14'
|
|
|
|
|
|
: 'pr-2')
|
2026-03-20 01:01:03 +02:00
|
|
|
|
: (hasWorktreeDeleteAction
|
|
|
|
|
|
? 'pr-5 group-hover/gh:pr-14 group-focus-within/gh:pr-14'
|
|
|
|
|
|
: 'pr-5');
|
2026-03-06 17:22:59 +02:00
|
|
|
|
|
|
|
|
|
|
const body = (
|
|
|
|
|
|
<SessionFolderDndScope
|
|
|
|
|
|
scopeKey={folderScopeKey}
|
|
|
|
|
|
hasFolders={allFoldersForGroup.length > 0}
|
|
|
|
|
|
onSessionDroppedOnFolder={(sessionId, folderId) => {
|
|
|
|
|
|
if (folderScopeKey) addSessionToFolder(folderScopeKey, folderId, sessionId);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{renderFolderItems()}
|
2026-06-18 08:43:16 +11:00
|
|
|
|
{shouldVirtualize ? (
|
2026-06-15 03:29:40 +03:00
|
|
|
|
<div ref={archivedVirtualContainerRef}>
|
|
|
|
|
|
<Virtualizer
|
|
|
|
|
|
data={visibleSessions}
|
|
|
|
|
|
itemSize={ARCHIVED_ROW_ESTIMATE_PX}
|
2026-06-24 06:24:11 +11:00
|
|
|
|
bufferSize={hasExpandedParent ? ARCHIVED_ROW_ESTIMATE_PX * 20 : ARCHIVED_ROW_ESTIMATE_PX * 8}
|
2026-06-15 03:29:40 +03:00
|
|
|
|
scrollRef={archivedScrollRef}
|
|
|
|
|
|
startMargin={archivedScrollMargin}
|
|
|
|
|
|
>
|
2026-06-18 08:43:16 +11:00
|
|
|
|
{(node) => renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true, undefined, 'project', {
|
|
|
|
|
|
subtreeContainsActive,
|
|
|
|
|
|
subtreeContainsEditing,
|
|
|
|
|
|
menuOpenSessionId,
|
|
|
|
|
|
nodeStructureKey: resolveNodeStructureKey(node),
|
|
|
|
|
|
childRenderExtrasFor,
|
|
|
|
|
|
}) as React.ReactElement}
|
2026-06-15 03:29:40 +03:00
|
|
|
|
</Virtualizer>
|
2026-05-18 22:07:12 +07:00
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
2026-06-18 08:43:16 +11:00
|
|
|
|
visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true, undefined, 'project', {
|
|
|
|
|
|
subtreeContainsActive,
|
|
|
|
|
|
subtreeContainsEditing,
|
|
|
|
|
|
menuOpenSessionId,
|
|
|
|
|
|
nodeStructureKey: resolveNodeStructureKey(node),
|
|
|
|
|
|
childRenderExtrasFor,
|
|
|
|
|
|
}))
|
2026-05-18 22:07:12 +07:00
|
|
|
|
)}
|
2026-03-06 17:22:59 +02:00
|
|
|
|
{totalSessions === 0 && allFoldersForGroup.length === 0 ? (
|
|
|
|
|
|
<div className="py-1 text-left typography-micro text-muted-foreground">
|
2026-04-26 14:03:39 +03:00
|
|
|
|
{group.isArchivedBucket
|
|
|
|
|
|
? t('sessions.sidebar.group.empty.noArchivedSessions')
|
|
|
|
|
|
: t('sessions.sidebar.group.empty.noSessionsInWorkspace')}
|
2026-03-06 17:22:59 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
2026-06-02 13:25:28 +03:00
|
|
|
|
{remainingCount > 0 ? (
|
2026-03-06 17:22:59 +02:00
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
2026-06-02 13:25:28 +03:00
|
|
|
|
onClick={() => showMoreGroupSessions(groupKey, visibleSessions.length)}
|
2026-03-06 17:22:59 +02:00
|
|
|
|
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"
|
|
|
|
|
|
>
|
2026-06-02 13:25:28 +03:00
|
|
|
|
{t('sessions.sidebar.group.showMore')}
|
2026-03-06 17:22:59 +02:00
|
|
|
|
</button>
|
|
|
|
|
|
) : null}
|
2026-06-02 13:25:28 +03:00
|
|
|
|
{canShowLess ? (
|
2026-03-06 17:22:59 +02:00
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
2026-06-02 13:25:28 +03:00
|
|
|
|
onClick={() => resetGroupSessionLimit(groupKey)}
|
2026-03-06 17:22:59 +02:00
|
|
|
|
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"
|
|
|
|
|
|
>
|
2026-04-26 14:03:39 +03:00
|
|
|
|
{t('sessions.sidebar.group.showFewer')}
|
2026-03-06 17:22:59 +02:00
|
|
|
|
</button>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</SessionFolderDndScope>
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-03-23 23:51:55 +02:00
|
|
|
|
const groupBodyPaddingClass = compactBodyPadding ? 'pb-2 pl-1' : 'pb-3 pl-4';
|
|
|
|
|
|
|
2026-03-06 17:22:59 +02:00
|
|
|
|
if (hideGroupLabel) {
|
2026-03-23 23:51:55 +02:00
|
|
|
|
return <div className="oc-group"><div className={cn('oc-group-body', groupBodyPaddingClass)}>{body}</div></div>;
|
2026-03-06 17:22:59 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="oc-group">
|
|
|
|
|
|
<div
|
2026-03-20 01:01:03 +02:00
|
|
|
|
className={cn('group/gh relative flex items-start justify-between gap-1 py-1 min-w-0 rounded-md', 'cursor-pointer')}
|
2026-03-06 17:22:59 +02:00
|
|
|
|
onClick={() => onToggleCollapsedGroup(groupKey)}
|
|
|
|
|
|
role="button"
|
|
|
|
|
|
tabIndex={0}
|
|
|
|
|
|
onKeyDown={(event) => {
|
|
|
|
|
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
onToggleCollapsedGroup(groupKey);
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
2026-04-26 14:03:39 +03:00
|
|
|
|
aria-label={isCollapsed
|
|
|
|
|
|
? t('sessions.sidebar.group.expandAria', { label: group.label })
|
|
|
|
|
|
: t('sessions.sidebar.group.collapseAria', { label: group.label })}
|
2026-03-20 01:01:03 +02:00
|
|
|
|
aria-expanded={!isCollapsed}
|
2026-03-06 17:22:59 +02:00
|
|
|
|
>
|
2026-03-20 01:01:03 +02:00
|
|
|
|
<div
|
|
|
|
|
|
ref={dragHandleProps?.setActivatorNodeRef}
|
|
|
|
|
|
className={cn(
|
2026-03-23 23:51:55 +02:00
|
|
|
|
'min-w-0 flex flex-1 items-start gap-1 overflow-hidden pl-0.5 transition-[padding] cursor-grab active:cursor-grabbing',
|
2026-03-20 01:01:03 +02:00
|
|
|
|
groupHeaderRightPadding,
|
|
|
|
|
|
)}
|
|
|
|
|
|
{...(dragHandleProps?.listeners ?? {})}
|
|
|
|
|
|
>
|
2026-03-23 23:51:55 +02:00
|
|
|
|
<div className="min-w-0 flex flex-1 flex-col justify-center gap-0.5 overflow-hidden">
|
2026-03-20 01:01:03 +02:00
|
|
|
|
<p className="text-[14px] font-normal truncate text-foreground/92">
|
2026-03-06 17:22:59 +02:00
|
|
|
|
{showInlinePrTitle && prIndicator ? (
|
2026-03-20 01:01:03 +02:00
|
|
|
|
<span className="inline-flex min-w-0 max-w-full items-center">
|
2026-03-06 17:22:59 +02:00
|
|
|
|
<Tooltip>
|
|
|
|
|
|
<TooltipTrigger asChild>
|
2026-03-20 01:01:03 +02:00
|
|
|
|
<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">
|
2026-05-13 13:26:15 +03:00
|
|
|
|
<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')}
|
2026-03-20 01:01:03 +02:00
|
|
|
|
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',
|
|
|
|
|
|
)}>
|
2026-05-13 13:26:15 +03:00
|
|
|
|
{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" />}
|
2026-03-20 01:01:03 +02:00
|
|
|
|
</span>
|
|
|
|
|
|
</span>
|
2026-03-06 17:22:59 +02:00
|
|
|
|
{prIndicator.url ? (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
2026-03-20 01:01:03 +02:00
|
|
|
|
className="inline-flex shrink-0 items-center leading-none"
|
2026-03-06 17:22:59 +02:00
|
|
|
|
onMouseDown={(event) => event.stopPropagation()}
|
|
|
|
|
|
onClick={handlePrLinkClick}
|
|
|
|
|
|
>
|
|
|
|
|
|
#{prIndicator.number}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
) : (
|
2026-03-20 01:01:03 +02:00
|
|
|
|
<span className="inline-flex shrink-0 items-center leading-none">#{prIndicator.number}</span>
|
2026-03-06 17:22:59 +02:00
|
|
|
|
)}
|
|
|
|
|
|
</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>
|
2026-05-13 13:26:15 +03:00
|
|
|
|
<Icon name="arrow-left-long" className="mx-0.5 inline h-3 w-3 align-[-2px]" />
|
2026-03-06 17:22:59 +02:00
|
|
|
|
<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>
|
2026-03-20 01:01:03 +02:00
|
|
|
|
<span className="ml-1 min-w-0 flex-1 truncate leading-none align-middle">{group.branch}</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
) : group.isArchivedBucket ? (
|
2026-03-23 23:51:55 +02:00
|
|
|
|
<span className="inline-flex min-w-0 max-w-full items-center gap-1">
|
2026-03-20 01:01:03 +02:00
|
|
|
|
<span className="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center">
|
2026-05-13 13:26:15 +03:00
|
|
|
|
<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',
|
|
|
|
|
|
)}>
|
2026-05-13 13:26:15 +03:00
|
|
|
|
{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" />}
|
2026-03-20 01:01:03 +02:00
|
|
|
|
</span>
|
|
|
|
|
|
</span>
|
2026-03-23 23:51:55 +02:00
|
|
|
|
<span className="min-w-0 flex-1 truncate">{renderHighlightedText(group.label, normalizedSessionSearchQuery)}</span>
|
2026-03-20 01:01:03 +02:00
|
|
|
|
</span>
|
|
|
|
|
|
) : (!group.isMain || group.worktree) ? (
|
2026-03-23 23:51:55 +02:00
|
|
|
|
<span className="inline-flex min-w-0 max-w-full items-center gap-1">
|
2026-03-20 01:01:03 +02:00
|
|
|
|
<span className="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center">
|
2026-05-13 13:26:15 +03:00
|
|
|
|
<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')}
|
2026-03-20 01:01:03 +02:00
|
|
|
|
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',
|
|
|
|
|
|
)}>
|
2026-05-13 13:26:15 +03:00
|
|
|
|
{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" />}
|
2026-03-20 01:01:03 +02:00
|
|
|
|
</span>
|
|
|
|
|
|
</span>
|
2026-03-23 23:51:55 +02:00
|
|
|
|
<span className="min-w-0 flex-1 truncate">{renderHighlightedText(group.label, normalizedSessionSearchQuery)}</span>
|
2026-03-20 01:01:03 +02:00
|
|
|
|
</span>
|
2026-03-06 17:22:59 +02:00
|
|
|
|
) : (
|
|
|
|
|
|
renderHighlightedText(group.label, normalizedSessionSearchQuery)
|
|
|
|
|
|
)}
|
|
|
|
|
|
</p>
|
2026-03-20 01:01:03 +02:00
|
|
|
|
{showBranchSubtitle && statusLine ? (
|
|
|
|
|
|
<span className="inline-flex min-w-0 items-center gap-1.5 leading-tight">
|
|
|
|
|
|
{group.isArchivedBucket ? (
|
2026-05-13 13:26:15 +03:00
|
|
|
|
<Icon name="archive" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
2026-03-20 01:01:03 +02:00
|
|
|
|
) : (!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">
|
2026-05-13 13:26:15 +03:00
|
|
|
|
<Icon name="git-branch" className="h-3.5 w-3.5 text-muted-foreground"
|
|
|
|
|
|
style={branchIconColor ? { color: branchIconColor } : undefined}/>
|
2026-03-20 01:01:03 +02:00
|
|
|
|
</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>
|
2026-05-13 13:26:15 +03:00
|
|
|
|
<Icon name="arrow-left-long" className="mx-0.5 inline h-3 w-3 align-[-2px]" />
|
2026-03-20 01:01:03 +02:00
|
|
|
|
<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>
|
|
|
|
|
|
) : (
|
2026-05-13 13:26:15 +03:00
|
|
|
|
<Icon name="git-branch" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground"
|
|
|
|
|
|
style={branchIconColor ? { color: branchIconColor } : undefined}/>
|
2026-03-20 01:01:03 +02:00
|
|
|
|
)
|
|
|
|
|
|
) : 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>
|
2026-03-06 17:22:59 +02:00
|
|
|
|
</span>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-03-20 01:01:03 +02:00
|
|
|
|
{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')}>
|
2026-03-20 01:01:03 +02:00
|
|
|
|
<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"
|
2026-04-26 14:03:39 +03:00
|
|
|
|
aria-label={t('sessions.sidebar.group.actions.deleteArchivedInGroupAria', { label: group.label })}
|
2026-03-20 01:01:03 +02:00
|
|
|
|
>
|
2026-05-13 13:26:15 +03:00
|
|
|
|
<Icon name="delete-bin" className="h-4 w-4" />
|
2026-03-20 01:01:03 +02:00
|
|
|
|
</button>
|
|
|
|
|
|
</TooltipTrigger>
|
2026-04-26 14:03:39 +03:00
|
|
|
|
<TooltipContent side="bottom" sideOffset={4}><p>{t('sessions.sidebar.group.actions.deleteArchivedSessions')}</p></TooltipContent>
|
2026-03-20 01:01:03 +02:00
|
|
|
|
</Tooltip>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
2026-03-06 17:22:59 +02:00
|
|
|
|
{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')}>
|
2026-03-06 17:22:59 +02:00
|
|
|
|
<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"
|
2026-04-26 14:03:39 +03:00
|
|
|
|
aria-label={t('sessions.sidebar.group.actions.deleteGroupAria', { label: group.label })}
|
2026-03-06 17:22:59 +02:00
|
|
|
|
>
|
2026-05-13 13:26:15 +03:00
|
|
|
|
<Icon name="delete-bin" className="h-4 w-4" />
|
2026-03-06 17:22:59 +02:00
|
|
|
|
</button>
|
|
|
|
|
|
</TooltipTrigger>
|
2026-04-26 14:03:39 +03:00
|
|
|
|
<TooltipContent side="bottom" sideOffset={4}><p>{t('sessions.sidebar.group.actions.deleteWorktree')}</p></TooltipContent>
|
2026-03-06 17:22:59 +02:00
|
|
|
|
</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')}>
|
2026-03-06 17:22:59 +02:00
|
|
|
|
<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"
|
2026-04-26 14:03:39 +03:00
|
|
|
|
aria-label={t('sessions.sidebar.group.actions.newDraftInGroupAria', { label: group.label })}
|
2026-03-20 01:01:03 +02:00
|
|
|
|
>
|
2026-05-13 13:26:15 +03:00
|
|
|
|
<Icon name="add" className="h-4 w-4" />
|
2026-03-20 01:01:03 +02:00
|
|
|
|
</button>
|
|
|
|
|
|
</TooltipTrigger>
|
2026-04-26 14:03:39 +03:00
|
|
|
|
<TooltipContent side="bottom" sideOffset={4}><p>{t('sessions.sidebar.project.actions.newDraftSession')}</p></TooltipContent>
|
2026-03-20 01:01:03 +02:00
|
|
|
|
</Tooltip>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
2026-03-06 17:22:59 +02:00
|
|
|
|
</div>
|
2026-03-23 23:51:55 +02:00
|
|
|
|
{!isCollapsed ? <div className={cn('oc-group-body', groupBodyPaddingClass)}>{body}</div> : null}
|
2026-03-06 17:22:59 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-06-18 08:43:16 +11:00
|
|
|
|
|
|
|
|
|
|
export const SessionGroupSection = React.memo(SessionGroupSectionBase, areGroupPropsEqual);
|