perf(ui): streamline session sidebar state

This commit is contained in:
c_w_xiaohei
2026-08-26 00:42:36 +08:00
parent 3b7d5e1c34
commit 26dbc2f309
84 changed files with 5159 additions and 2809 deletions
@@ -0,0 +1,227 @@
import { describe, expect, mock, test } from 'bun:test';
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { I18nProvider } from '@/lib/i18n';
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
import { useUIStore } from '@/stores/useUIStore';
import type { SessionFolder } from '@/stores/useSessionFoldersStore';
import type { Session } from '@opencode-ai/sdk/v2';
import type { SessionGroupSectionProps } from './SessionGroupSection';
import { SessionProjectScroller } from './SessionProjectScroller';
import { RecentSessionSection } from '../recent/RecentSessionSection';
import { SidebarActivitySections } from '../recent/SidebarActivitySections';
import { installHookTestDom } from '../test-utils/testDom';
type FolderCallbacks = {
onRename: (name: string) => void;
onDelete: () => void;
};
type RowPropsCapture = Pick<SessionGroupSectionProps,
| 'allowReselect'
| 'onSessionSelected'
| 'isSessionSearchOpen'
| 'sessionSearchQuery'
| 'deleteSessionConfirm'
| 'copiedSessionId'
| 'setCopiedSessionId'
>;
type ExpectNever<T extends never> = T;
type RowDomainCallback =
| 'handleSaveEdit'
| 'handleCancelEdit'
| 'handleSessionSelect'
| 'handleSessionDoubleClick'
| 'handleShareSession'
| 'handleCopyShareUrl'
| 'handleCopySessionId'
| 'handleUnshareSession'
| 'handleDeleteSession'
| 'handleRestoreSession';
// Structural group contracts must not expose the row domain action surface.
type _SessionGroupSectionHasNoRowDomainCallbacks = ExpectNever<Extract<keyof SessionGroupSectionProps, RowDomainCallback>>;
type _SessionProjectScrollerHasNoRowDomainCallbacks = ExpectNever<Extract<keyof React.ComponentProps<typeof SessionProjectScroller>, RowDomainCallback>>;
type _RecentSessionSectionHasNoRowDomainCallbacks = ExpectNever<Extract<keyof React.ComponentProps<typeof RecentSessionSection>, RowDomainCallback>>;
type _SidebarActivitySectionsHasNoRowDomainCallbacks = ExpectNever<Extract<keyof React.ComponentProps<typeof SidebarActivitySections>, RowDomainCallback>>;
let folderCallbacks: FolderCallbacks | null = null;
let rowPropsCapture: RowPropsCapture | null = null;
mock.module('../../SessionFolderItem', () => ({
SessionFolderItem: (props: FolderCallbacks) => {
folderCallbacks = props;
return null;
},
}));
mock.module('../folders/sessionFolderDnd', () => ({
DroppableFolderWrapper: ({ children }: { children: (ref: () => void, isOver: boolean) => React.ReactNode }) => <>{children(() => undefined, false)}</>,
SessionFolderDndScope: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
mock.module('@/sync/sync-context', () => ({
setActiveSession: () => undefined,
useChildStoreManager: () => ({
subscribeBootstrap: () => () => undefined,
getBootstrapState: () => null,
getBootstrapFailure: () => undefined,
requestBootstrap: () => undefined,
}),
useDirectoryStore: () => null,
useGlobalSessionStatus: () => null,
useSessionPermissions: () => null,
useSessionQuestionCount: () => 0,
useSyncSDK: () => null,
useSyncDirectory: () => null,
buildSessionMessageRecordsSnapshot: () => [],
}));
mock.module('../sessions/collapsedActivityIndicator', () => ({
CollapsedSessionActivityIndicator: () => null,
useCollapsedSessionActivityState: () => null,
}));
mock.module('../sessions/SessionTreeItem', () => ({
SessionTreeItem: (props: RowPropsCapture) => {
rowPropsCapture = props;
return null;
},
}));
const { SessionGroupSection } = await import('./SessionGroupSection');
const folder: SessionFolder = {
id: 'folder-a',
name: 'Initial folder',
parentId: null,
sessionIds: [],
createdAt: 1,
};
const group: SessionGroupSectionProps['group'] = {
id: 'main',
label: 'Main',
branch: null,
description: null,
isMain: true,
worktree: null,
directory: '/workspace',
folderScopeKey: '/workspace',
sessions: [],
};
const groupWithSession: SessionGroupSectionProps['group'] = {
...group,
// SAFETY: SessionGroupSection only reads the fixture session's id in this test.
sessions: [{ session: { id: 'session-a' } as Session, children: [], worktree: null }],
};
const createProps = (): SessionGroupSectionProps => ({
group,
groupKey: 'project:main',
projectId: 'project',
hideGroupLabel: true,
hasSessionSearchQuery: false,
normalizedSessionSearchQuery: '',
groupSearchDataByGroup: new WeakMap(),
collapsedGroups: new Set(),
hideDirectoryControls: false,
showMoreGroupSessions: () => undefined,
resetGroupSessionLimit: () => undefined,
mobileVariant: false,
alwaysShowActions: false,
activeProjectId: 'project',
setActiveProjectIdOnly: () => undefined,
setActiveMainTab: () => undefined,
setSessionSwitcherOpen: () => undefined,
openNewSessionDraft: () => undefined,
pinnedSessionIds: new Set(),
sessionOrderIndex: new Map(),
notifyOnSubtasks: false,
expandedParents: new Set(),
editingId: null,
editTitle: '',
copiedSessionId: null,
openSidebarMenuKey: null,
setEditingId: () => undefined,
setEditTitle: () => undefined,
toggleParent: () => undefined,
setOpenSidebarMenuKey: () => undefined,
startFolderRename: () => undefined,
allowReselect: false,
isSessionSearchOpen: false,
sessionSearchQuery: '',
setSessionSearchQuery: () => undefined,
setIsSessionSearchOpen: () => undefined,
deleteSessionConfirm: null,
setDeleteSessionConfirm: () => undefined,
setCopiedSessionId: () => undefined,
onToggleCollapsedGroup: () => undefined,
folderRename: null,
setFolderRenameDraft: () => undefined,
clearFolderRename: () => undefined,
});
describe('SessionGroupSection public behavior', () => {
test('routes rendered folder rename and delete actions to the owning folder store', async () => {
const dom = installHookTestDom();
const root = createRoot(dom.container);
const originalFolders = useSessionFoldersStore.getState();
const originalUi = useUIStore.getState();
useSessionFoldersStore.setState({ foldersMap: { '/workspace': [folder] } });
useUIStore.setState({ showDeletionDialog: false });
try {
await act(async () => root.render(<I18nProvider><SessionGroupSection {...createProps()} /></I18nProvider>));
expect(folderCallbacks).not.toBeNull();
await act(async () => folderCallbacks?.onRename('Renamed folder'));
expect(useSessionFoldersStore.getState().foldersMap['/workspace']?.[0]?.name).toBe('Renamed folder');
await act(async () => folderCallbacks?.onDelete());
expect(useSessionFoldersStore.getState().foldersMap['/workspace']).toEqual([]);
} finally {
await act(async () => root.unmount());
useSessionFoldersStore.setState(originalFolders, true);
useUIStore.setState(originalUi, true);
folderCallbacks = null;
dom.restore();
}
});
test('propagates confirmation, search/navigation, and copy ownership changes to rendered rows', async () => {
const dom = installHookTestDom();
const root = createRoot(dom.container);
const firstSelected = () => undefined;
const nextSelected = () => undefined;
const firstCopied = () => undefined;
const nextCopied = () => undefined;
const initialProps = createProps();
try {
await act(async () => root.render(<I18nProvider><SessionGroupSection {...initialProps} group={groupWithSession} onSessionSelected={firstSelected} setCopiedSessionId={firstCopied} /></I18nProvider>));
expect(rowPropsCapture?.onSessionSelected).toBe(firstSelected);
expect(rowPropsCapture?.sessionSearchQuery).toBe('');
expect(rowPropsCapture?.deleteSessionConfirm).toBeNull();
expect(rowPropsCapture?.copiedSessionId).toBeNull();
expect(rowPropsCapture?.setCopiedSessionId).toBe(firstCopied);
// SAFETY: the confirmation is only forwarded by identity to the row mock.
const confirmation = { session: { id: 'session-a' } as Session, descendantCount: 0, descendantIds: [], archivedBucket: false };
await act(async () => root.render(<I18nProvider><SessionGroupSection {...initialProps} group={groupWithSession} allowReselect onSessionSelected={nextSelected} isSessionSearchOpen sessionSearchQuery="search" deleteSessionConfirm={confirmation} copiedSessionId="session-a" setCopiedSessionId={nextCopied} /></I18nProvider>));
expect(rowPropsCapture?.allowReselect).toBe(true);
expect(rowPropsCapture?.onSessionSelected).toBe(nextSelected);
expect(rowPropsCapture?.isSessionSearchOpen).toBe(true);
expect(rowPropsCapture?.sessionSearchQuery).toBe('search');
expect(rowPropsCapture?.deleteSessionConfirm).toBe(confirmation);
expect(rowPropsCapture?.copiedSessionId).toBe('session-a');
expect(rowPropsCapture?.setCopiedSessionId).toBe(nextCopied);
} finally {
await act(async () => root.unmount());
rowPropsCapture = null;
dom.restore();
}
});
});
@@ -0,0 +1,58 @@
import { describe, expect, test } from 'bun:test';
import type { SessionFolder } from '@/stores/useSessionFoldersStore';
import { normalizeFolderRoots, selectFolderIdsForProjection } from '../sessions/sessionNodeItemUtils';
const folder = (id: string, parentId: string | null = null, sessionIds: string[] = []): SessionFolder => ({
id,
name: id,
parentId,
sessionIds,
createdAt: 1,
});
describe('normalizeFolderRoots', () => {
test('returns cycle and orphan folders as deterministic fallback roots without duplication', () => {
const folders = [
folder('cycle-a', 'cycle-b', ['session-a']),
folder('cycle-b', 'cycle-a'),
folder('orphan', 'missing-parent'),
folder('root'),
];
expect(normalizeFolderRoots(folders).map((entry) => entry.id))
.toEqual(['orphan', 'root', 'cycle-a']);
});
test('keeps normal nested folder root order unchanged', () => {
const folders = [folder('root-a'), folder('child-a', 'root-a'), folder('root-b')];
expect(normalizeFolderRoots(folders).map((entry) => entry.id)).toEqual(['root-a', 'root-b']);
});
});
describe('selectFolderIdsForProjection', () => {
const malformedFolders = [
{ id: 'cycle-a', name: 'cycle-a', parentId: 'cycle-b', nodeCount: 0 },
{ id: 'cycle-b', name: 'cycle-b', parentId: 'cycle-a', nodeCount: 1 },
{ id: 'orphan', name: 'orphan', parentId: 'missing-parent', nodeCount: 0 },
];
test('keeps malformed empty and nonempty folders in every projection mode', () => {
for (const archivedBucket of [false, true]) {
for (const searchQuery of ['', 'does-not-match']) {
expect([...selectFolderIdsForProjection(malformedFolders, { archivedBucket, searchQuery })])
.toEqual(['cycle-a', 'cycle-b', 'orphan']);
}
}
});
test('keeps normal archived/search nesting semantics', () => {
const folders = [
{ id: 'root', name: 'root', parentId: null, nodeCount: 0 },
{ id: 'child', name: 'matching-child', parentId: 'root', nodeCount: 1 },
];
expect([...selectFolderIdsForProjection(folders, { archivedBucket: true, searchQuery: 'matching' })])
.toEqual(['root', 'child']);
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,70 @@
import { describe, expect, test } from 'bun:test';
import { buildGroupRenderDescriptors } from './sessionProjectRender';
import type { SessionGroup } from '../types';
const makeGroup = (id: string, overrides: Partial<SessionGroup> = {}): SessionGroup => ({
id,
label: id,
branch: null,
description: null,
isMain: id === 'main',
worktree: null,
directory: '/workspace',
sessions: [],
...overrides,
});
describe('buildGroupRenderDescriptors', () => {
test('renders the main group and archived bucket for the main workspace', () => {
const section = {
project: { id: 'project-a', normalizedPath: '/workspace' },
groups: [makeGroup('main'), makeGroup('archived', { isArchivedBucket: true })],
};
expect(buildGroupRenderDescriptors(section, { mainWorkspaceOnly: true })).toEqual([
{
group: section.groups[0],
groupKey: 'project-a:main',
projectId: 'project-a',
hideGroupLabel: true,
},
{
group: section.groups[1],
groupKey: 'project-a:archived',
projectId: 'project-a',
hideGroupLabel: false,
},
]);
});
test('renders the primary group without a label and nested groups with labels', () => {
const section = {
project: { id: 'project-a', normalizedPath: '/workspace' },
groups: [makeGroup('main'), makeGroup('feature')],
};
expect(buildGroupRenderDescriptors(section, { mainWorkspaceOnly: false })).toEqual([
{
group: section.groups[0],
groupKey: 'project-a:main',
projectId: 'project-a',
hideGroupLabel: true,
},
{
group: section.groups[1],
groupKey: 'project-a:feature',
projectId: 'project-a',
hideGroupLabel: false,
},
]);
});
test('keeps labels when a flat section has no main group', () => {
const section = {
project: { id: 'project-a', normalizedPath: '/workspace' },
groups: [makeGroup('feature', { isMain: false }), makeGroup('other', { isMain: false })],
};
expect(buildGroupRenderDescriptors(section, { mainWorkspaceOnly: false }).map((descriptor) => descriptor.hideGroupLabel)).toEqual([false, false]);
});
});
@@ -0,0 +1,404 @@
import React from 'react';
import {
DndContext,
DragOverlay,
KeyboardSensor,
PointerSensor,
closestCenter,
useSensor,
useSensors,
} from '@dnd-kit/core';
import { SortableContext, arrayMove, sortableKeyboardCoordinates, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { formatDirectoryName, formatPathForDisplay, cn } from '@/lib/utils';
import type { SessionGroup } from '../types';
import { ProjectHeaderIdentity, SortableGroupItem, SortableProjectItem } from './sortableItems';
import { SessionGroupSection, type SessionGroupSectionProps } from './SessionGroupSection';
import { buildGroupRenderDescriptors, type ProjectSection } from './sessionProjectRender';
import { formatProjectLabel } from '../utils';
import { useI18n } from '@/lib/i18n';
import type { ProjectSortOrder } from '@/stores/useSessionDisplayStore';
import { streamPerfCount } from '@/stores/utils/streamDebug';
import { Icon } from '@/components/icon/Icon';
type SessionProjectScrollerState = Pick<SessionGroupSectionProps,
| 'editingId'
| 'openSidebarMenuKey'
| 'setOpenSidebarMenuKey'
> & {
visibleSessionCountByGroup: Map<string, number>;
};
type SessionProjectScrollerGroupProps = Pick<SessionGroupSectionProps,
| 'hasSessionSearchQuery'
| 'normalizedSessionSearchQuery'
| 'groupSearchDataByGroup'
| 'collapsedGroups'
| 'hideDirectoryControls'
| 'mobileVariant'
| 'alwaysShowActions'
| 'notifyOnSubtasks'
| 'expandedParents'
| 'editTitle'
| 'copiedSessionId'
| 'folderRename'
| 'setFolderRenameDraft'
| 'clearFolderRename'
| 'setEditingId'
| 'setEditTitle'
| 'toggleParent'
| 'allowReselect'
| 'onSessionSelected'
| 'isSessionSearchOpen'
| 'sessionSearchQuery'
| 'setSessionSearchQuery'
| 'setIsSessionSearchOpen'
| 'deleteSessionConfirm'
| 'setDeleteSessionConfirm'
| 'startFolderRename'
| 'setCopiedSessionId'
> & {
activeProjectId: string | null;
pinnedSessionIds: Set<string>;
sessionOrderIndex: Map<string, number>;
};
type SessionProjectScrollerGroupActions = Pick<SessionGroupSectionProps,
| 'showMoreGroupSessions'
| 'resetGroupSessionLimit'
| 'setActiveProjectIdOnly'
| 'setSessionSwitcherOpen'
| 'openNewSessionDraft'
| 'onToggleCollapsedGroup'
>;
type SessionProjectScrollerModel = {
topContent?: React.ReactNode;
hasSharedSessions?: boolean;
sectionsForRender: ProjectSection[];
projectSections: ProjectSection[];
activeProjectId: string | null;
emptyState: React.ReactNode;
searchEmptyState: React.ReactNode;
projectRepoStatus: Map<string, boolean | null>;
stuckProjectHeaders: Set<string>;
projectHeaderSentinelRefs: React.MutableRefObject<Map<string, HTMLDivElement | null>>;
state: SessionProjectScrollerState;
groupProps: SessionProjectScrollerGroupProps;
};
type SessionProjectScrollerView = {
homeDirectory: string | null;
collapsedProjects: Set<string>;
showOnlyMainWorkspace: boolean;
hasSessionSearchQuery: boolean;
normalizedSessionSearchQuery: string;
hideDirectoryControls: boolean;
isDesktopShellRuntime: boolean;
stickyZoneHeaders: boolean;
mobileVariant: boolean;
alwaysShowActions: boolean;
projectSortOrder: ProjectSortOrder;
};
type SessionProjectScrollerActions = {
group: SessionProjectScrollerGroupActions;
toggleProject: (id: string) => void;
setActiveProjectIdOnly: (id: string) => void;
setSessionSwitcherOpen: (open: boolean) => void;
openNewSessionDraft: (options?: { selectedProjectId?: string | null; directoryOverride?: string | null }) => void;
openNewWorktreeDialog: () => void;
openWorktreesPage: (id: string) => void;
openProjectEditDialog: (id: string) => void;
removeProject: (id: string) => void;
reorderProjects: (fromIndex: number, toIndex: number) => void;
setGroupOrderByProject: React.Dispatch<React.SetStateAction<Map<string, string[]>>>;
renderProjectStatusIndicator?: (projectId: string, groups: SessionGroup[]) => React.ReactNode;
};
type Props = {
model: SessionProjectScrollerModel;
view: SessionProjectScrollerView;
actions: SessionProjectScrollerActions;
};
const TOP_FADE_MAX_SIZE = 48;
const TOP_FADE_MIN_SIZE = 32;
const TOP_FADE_CLEAR_MAX_SIZE = 24;
const getProjectLabel = (project: ProjectSection['project'], homeDirectory: string | null): string => (
formatProjectLabel(
project.label?.trim()
|| formatDirectoryName(project.normalizedPath, homeDirectory)
|| project.normalizedPath,
)
);
function SessionProjectScrollerComponent(props: Props): React.ReactNode {
streamPerfCount('ui.sidebar_projects_list.render');
const { t } = useI18n();
const { model, view, actions } = props;
const isInlineEditing = model.state.editingId !== null;
const enableStickyFade = view.isDesktopShellRuntime && view.stickyZoneHeaders;
const projectSensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const groupSensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
);
// Threaded into SessionGroupSection so the archived-bucket virtualizer
// can resolve the scrolling ancestor synchronously (no getComputedStyle
// walk) and skip the cost of a style recalc on every render.
const scrollContainerRef = React.useRef<HTMLElement | null>(null);
// Keep per-scroll measurements out of React state so the interaction guard
// can read the current fade boundary without rerendering the sidebar.
const topFadeSizeRef = React.useRef(0);
// Update the compositor-owned mask on every scroll, but cross the React
// render boundary only when the sticky identity overlay appears or hides.
const syncTopFade = React.useCallback((scroller: HTMLElement) => {
const hasTopScroll = scroller.scrollTop > 1;
const topFadeSize = hasTopScroll
? Math.min(TOP_FADE_MIN_SIZE + scroller.scrollTop, TOP_FADE_MAX_SIZE)
: 0;
topFadeSizeRef.current = topFadeSize;
scroller.style.setProperty('--scroll-shadow-top-size', `${topFadeSize}px`);
scroller.style.setProperty(
'--scroll-shadow-top-clear-size',
`${Math.min(Math.max(topFadeSize - 8, 0), TOP_FADE_CLEAR_MAX_SIZE)}px`,
);
}, []);
const blockObscuredInteraction = React.useCallback((
event: React.MouseEvent<HTMLDivElement> | React.PointerEvent<HTMLDivElement>,
) => {
// SAFETY: React's mouse and pointer events are dispatched from Elements.
if ((event.target as Element).closest('[data-overlay-scrollbar-thumb], [data-sidebar-sticky-header]')) return;
const eventY = event.clientY - event.currentTarget.getBoundingClientRect().top;
if (eventY >= topFadeSizeRef.current) return;
event.preventDefault();
event.stopPropagation();
}, []);
const hasProjectScroller = model.projectSections.length > 0 && model.sectionsForRender.length > 0;
React.useLayoutEffect(() => {
if (enableStickyFade && hasProjectScroller && scrollContainerRef.current) {
syncTopFade(scrollContainerRef.current);
}
}, [enableStickyFade, hasProjectScroller, syncTopFade]);
let stuckProject: ProjectSection['project'] | null = null;
for (const section of model.projectSections) {
if (model.stuckProjectHeaders.has(section.project.id)) {
stuckProject = section.project;
}
}
// The IntersectionObserver reports the stuck header asynchronously, a frame or
// two after the (synchronous) mask has already hidden the real header — which
// otherwise leaves a one-frame gap where the title blinks out with no crisp
// replacement. Seed the overlay with the topmost rendered project so it is
// ready in the same frame; the observer then corrects it. When shared sessions
// lead the list, the Recent fallback below owns the top instead of a project.
const leadingProject =
stuckProject ?? (model.hasSharedSessions ? null : model.sectionsForRender[0]?.project ?? null);
const leadingProjectLabel = leadingProject ? getProjectLabel(leadingProject, view.homeDirectory) : null;
if (model.projectSections.length === 0) {
return <ScrollableOverlay useScrollShadow scrollShadowSize={96} outerClassName="flex-1 min-h-0" className={cn('space-y-1 pb-1 pl-2.5 pr-2', view.mobileVariant ? '' : '')}>{model.topContent}{model.emptyState}</ScrollableOverlay>;
}
if (model.sectionsForRender.length === 0) {
return <ScrollableOverlay useScrollShadow scrollShadowSize={96} outerClassName="flex-1 min-h-0" className={cn('space-y-1 pb-1 pl-2.5 pr-2', view.mobileVariant ? '' : '')}>{model.searchEmptyState}</ScrollableOverlay>;
}
return (
// [overflow-anchor:none] — the browser's native scroll anchoring otherwise
// latches onto content BELOW a growing session group (e.g. the "Show more"
// button) and holds it in place, which makes newly revealed sessions look
// like they insert upward. With anchoring off, scrollTop stays put and new
// rows appear below naturally.
<div
className="oc-sticky-fade-root relative flex min-h-0 flex-1"
onPointerDownCapture={enableStickyFade ? blockObscuredInteraction : undefined}
onClickCapture={enableStickyFade ? blockObscuredInteraction : undefined}
onContextMenuCapture={enableStickyFade ? blockObscuredInteraction : undefined}
>
<ScrollableOverlay
ref={scrollContainerRef}
useScrollShadow
hideTopScrollShadow={!enableStickyFade}
scrollShadowSize={96}
outerClassName="flex-1 min-h-0"
className={cn('oc-sidebar-scroller oc-sticky-fade-scroller space-y-1.5 pb-1 pl-2.5 pr-2 [overflow-anchor:none]', view.mobileVariant ? '' : '')}
// SAFETY: the custom property is the only dynamic CSS declaration here.
style={enableStickyFade ? { '--scroll-shadow-top-size': '0px' } as React.CSSProperties : undefined}
onScroll={enableStickyFade ? (event) => syncTopFade(event.currentTarget) : undefined}
>
{model.topContent}
{view.showOnlyMainWorkspace ? (
<div className="space-y-[0.6rem] py-1">
{(() => {
const activeSection = model.sectionsForRender.find((section) => section.project.id === model.activeProjectId) ?? model.sectionsForRender[0];
if (!activeSection) {
return view.hasSessionSearchQuery ? model.searchEmptyState : model.emptyState;
}
const descriptors = buildGroupRenderDescriptors(activeSection, { mainWorkspaceOnly: true });
if (!descriptors.length) {
return <div className="py-1 text-left typography-micro text-muted-foreground">{t('sessions.sidebar.empty.noSessions.title')}</div>;
}
return descriptors.map(({ group, groupKey, projectId, hideGroupLabel }) => {
return (
<React.Fragment key={groupKey}>
<SessionGroupSection {...model.groupProps} {...actions.group} editingId={model.state.editingId} openSidebarMenuKey={model.state.openSidebarMenuKey} setOpenSidebarMenuKey={model.state.setOpenSidebarMenuKey} group={group} groupKey={groupKey} projectId={projectId} hideGroupLabel={hideGroupLabel} visibleSessionCount={model.state.visibleSessionCountByGroup.get(groupKey)} compactBodyPadding scrollContainerRef={scrollContainerRef} />
</React.Fragment>
);
});
})()}
</div>
) : (
<DndContext
sensors={projectSensors}
collisionDetection={closestCenter}
onDragEnd={(event) => {
if (isInlineEditing) return;
// Drag only allowed in manual sort mode - indices from visual order don't match store order in other modes
if (view.projectSortOrder !== 'manual') return;
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = model.sectionsForRender.findIndex((section) => section.project.id === active.id);
const newIndex = model.sectionsForRender.findIndex((section) => section.project.id === over.id);
if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) return;
actions.reorderProjects(oldIndex, newIndex);
}}
>
<SortableContext items={model.sectionsForRender.map((section) => section.project.id)} strategy={verticalListSortingStrategy}>
{model.sectionsForRender.map((section) => {
const project = section.project;
const projectKey = project.id;
const projectLabel = getProjectLabel(project, view.homeDirectory);
const projectDescription = formatPathForDisplay(project.normalizedPath, view.homeDirectory);
const isCollapsed = view.collapsedProjects.has(projectKey);
const isRepo = model.projectRepoStatus.get(projectKey);
return (
<SortableProjectItem
key={projectKey}
id={projectKey}
disabled={view.projectSortOrder !== 'manual'}
projectLabel={projectLabel}
projectDescription={projectDescription}
projectIcon={project.icon}
projectColor={project.color}
projectIconImage={project.iconImage}
projectIconBackground={project.iconBackground}
isCollapsed={isCollapsed}
isRepo={Boolean(isRepo)}
isDesktopShell={view.isDesktopShellRuntime}
hideDirectoryControls={view.hideDirectoryControls}
mobileVariant={view.mobileVariant}
alwaysShowActions={view.alwaysShowActions}
statusIndicator={isCollapsed ? actions.renderProjectStatusIndicator?.(projectKey, section.groups) : null}
openSidebarMenuKey={model.state.openSidebarMenuKey}
setOpenSidebarMenuKey={model.state.setOpenSidebarMenuKey}
onToggle={() => actions.toggleProject(projectKey)}
onNewSession={() => {
if (projectKey !== model.activeProjectId) actions.setActiveProjectIdOnly(projectKey);
if (view.mobileVariant) actions.setSessionSwitcherOpen(false);
actions.openNewSessionDraft({
selectedProjectId: projectKey,
directoryOverride: project.normalizedPath,
});
}}
onNewWorktreeSession={() => {
if (projectKey !== model.activeProjectId) actions.setActiveProjectIdOnly(projectKey);
actions.openNewWorktreeDialog();
}}
onManageWorktrees={() => actions.openWorktreesPage(projectKey)}
onRenameStart={() => actions.openProjectEditDialog(projectKey)}
onClose={() => actions.removeProject(projectKey)}
sentinelRef={(el) => { model.projectHeaderSentinelRefs.current.set(projectKey, el); }}
showCreateButtons
>
{!isCollapsed ? (
<div className="space-y-0 pt-0.5 pb-0.5">
{(() => {
const orderedGroups = section.groups;
const rootGroup = orderedGroups.find((group) => group.isMain) ?? null;
const nestedGroups = rootGroup
? orderedGroups.filter((group) => group.id !== rootGroup.id)
: orderedGroups;
return (
<DndContext
sensors={groupSensors}
collisionDetection={closestCenter}
onDragEnd={(event) => {
if (isInlineEditing) return;
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = nestedGroups.findIndex((item) => item.id === active.id);
const newIndex = nestedGroups.findIndex((item) => item.id === over.id);
if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) return;
const nextNested = arrayMove(nestedGroups, oldIndex, newIndex).map((item) => item.id);
const next = rootGroup ? [rootGroup.id, ...nextNested] : nextNested;
actions.setGroupOrderByProject((prev) => {
const map = new Map(prev);
map.set(projectKey, next);
return map;
});
}}
>
{/* Root/flat sessions render directly under the
project zone header; worktree and archived
groups keep their own slim sortable sub-header. */}
{rootGroup ? <SessionGroupSection {...model.groupProps} {...actions.group} editingId={model.state.editingId} openSidebarMenuKey={model.state.openSidebarMenuKey} setOpenSidebarMenuKey={model.state.setOpenSidebarMenuKey} group={rootGroup} groupKey={`${projectKey}:${rootGroup.id}`} projectId={projectKey} hideGroupLabel visibleSessionCount={model.state.visibleSessionCountByGroup.get(`${projectKey}:${rootGroup.id}`)} scrollContainerRef={scrollContainerRef} /> : null}
<SortableContext items={nestedGroups.map((group) => group.id)} strategy={verticalListSortingStrategy}>
{nestedGroups.map((group) => {
const groupKey = `${projectKey}:${group.id}`;
return (
<SortableGroupItem key={group.id} id={group.id} disabled={isInlineEditing}>
{(dragHandleProps) => <SessionGroupSection {...model.groupProps} {...actions.group} editingId={model.state.editingId} openSidebarMenuKey={model.state.openSidebarMenuKey} setOpenSidebarMenuKey={model.state.setOpenSidebarMenuKey} group={group} groupKey={groupKey} projectId={projectKey} visibleSessionCount={model.state.visibleSessionCountByGroup.get(groupKey)} dragHandleProps={dragHandleProps} scrollContainerRef={scrollContainerRef} />}
</SortableGroupItem>
);
})}
</SortableContext>
<DragOverlay dropAnimation={null} />
</DndContext>
);
})()}
</div>
) : null}
</SortableProjectItem>
);
})}
</SortableContext>
<DragOverlay dropAnimation={null} />
</DndContext>
)}
</ScrollableOverlay>
{enableStickyFade && (leadingProject || model.hasSharedSessions) ? (
<div
className="oc-sticky-fade-overlay pointer-events-none absolute inset-x-0 top-0 z-30 flex items-center gap-1.5 py-1 pl-4 pr-5"
aria-hidden="true"
>
{leadingProject && leadingProjectLabel ? (
<ProjectHeaderIdentity
id={leadingProject.id}
projectLabel={leadingProjectLabel}
projectIcon={leadingProject.icon}
projectColor={leadingProject.color}
projectIconImage={leadingProject.iconImage}
projectIconBackground={leadingProject.iconBackground}
/>
) : (
<>
<Icon name="history" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground/80" />
<span className="truncate text-[14px] font-semibold lowercase text-foreground">
{t('sessions.sidebar.activity.recentTitle')}
</span>
</>
)}
</div>
) : null}
</div>
);
}
export const SessionProjectScroller = React.memo(SessionProjectScrollerComponent);
@@ -0,0 +1,47 @@
import type { SessionGroup } from '../types';
export type ProjectSection = {
project: {
id: string;
label?: string;
normalizedPath: string;
icon?: string;
color?: string;
iconImage?: { mime: string; updatedAt: number; source: 'custom' | 'auto' };
iconBackground?: string;
};
groups: SessionGroup[];
};
type GroupRenderDescriptor = {
group: SessionGroup;
groupKey: string;
projectId: string;
hideGroupLabel: boolean;
};
export const buildGroupRenderDescriptors = (
section: ProjectSection,
options: { mainWorkspaceOnly: boolean },
): GroupRenderDescriptor[] => {
const primaryGroup = section.groups.find((group) => group.isMain && group.sessions.length > 0)
?? section.groups.find((group) => group.sessions.length > 0)
?? section.groups.find((group) => group.isMain)
?? section.groups[0];
if (!primaryGroup) return [];
const archivedGroup = section.groups.find((group) => group.isArchivedBucket && group.id !== primaryGroup.id);
const groups = options.mainWorkspaceOnly
? [primaryGroup, ...(archivedGroup ? [archivedGroup] : [])]
: [
...(section.groups.find((group) => group.isMain) ? [section.groups.find((group) => group.isMain)!] : []),
...section.groups.filter((group) => !group.isMain),
];
return groups.map((group) => ({
group,
groupKey: `${section.project.id}:${group.id}`,
projectId: section.project.id,
hideGroupLabel: options.mainWorkspaceOnly ? group.id === primaryGroup.id : group.isMain,
}));
};
@@ -0,0 +1,448 @@
import React from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu';
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
import { Icon } from '@/components/icon/Icon';
import { cn } from '@/lib/utils';
import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, ProjectIconImage } from '@/lib/projectMeta';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
import { useI18n } from '@/lib/i18n';
export type SortableDragHandleProps = {
listeners: ReturnType<typeof useSortable>['listeners'];
setActivatorNodeRef: ReturnType<typeof useSortable>['setActivatorNodeRef'];
};
type ProjectIdentityProps = {
id: string;
projectLabel: string;
projectIcon?: string;
projectColor?: string;
projectIconImage?: { mime: string; updatedAt: number; source: 'custom' | 'auto' };
projectIconBackground?: string;
};
type ProjectHeaderIdentityProps = ProjectIdentityProps & {
isCollapsed?: boolean;
alwaysShowActions?: boolean;
};
export const ProjectHeaderIdentity: React.FC<ProjectHeaderIdentityProps> = ({
id,
projectLabel,
projectIcon,
projectColor,
projectIconImage,
projectIconBackground,
isCollapsed,
alwaysShowActions = false,
}) => {
const { currentTheme } = useThemeSystem();
const projectIconName = projectIcon ? PROJECT_ICON_MAP[projectIcon] : null;
const iconColor = projectColor ? (PROJECT_COLOR_MAP[projectColor] ?? null) : null;
const hasCollapseControl = isCollapsed !== undefined;
const iconVisibilityClassName = hasCollapseControl
? (alwaysShowActions ? 'hidden' : 'group-hover/project:hidden group-focus-within/project:hidden')
: undefined;
return (
<>
<span className="inline-flex h-3.5 w-3.5 flex-shrink-0 items-center justify-center">
{hasCollapseControl ? (
<span className={cn(
'h-3.5 w-3.5 items-center justify-center text-muted-foreground',
alwaysShowActions ? 'inline-flex' : 'hidden group-hover/project:inline-flex group-focus-within/project:inline-flex',
)}>
<Icon name={isCollapsed ? 'arrow-right-s' : 'arrow-down-s'} className="h-3.5 w-3.5" />
</span>
) : null}
{projectIconImage ? (
<span
className={cn(
'h-3.5 w-3.5 items-center justify-center overflow-hidden rounded-[3px]',
hasCollapseControl && alwaysShowActions ? 'hidden' : 'inline-flex',
iconVisibilityClassName,
)}
style={projectIconBackground ? { backgroundColor: projectIconBackground } : undefined}
>
<ProjectIconImage
project={{ id, iconImage: projectIconImage }}
options={{
themeVariant: currentTheme.metadata.variant,
iconColor: currentTheme.colors.surface.foreground,
}}
className="h-full w-full object-contain"
fallback={projectIconName ? (
<Icon name={projectIconName} className="h-3.5 w-3.5" style={iconColor ? { color: iconColor } : undefined} />
) : (
<Icon name="folder" className="h-3.5 w-3.5 text-muted-foreground/80" style={iconColor ? { color: iconColor } : undefined} />
)}
/>
</span>
) : projectIconName ? (
<Icon name={projectIconName} className={cn('h-3.5 w-3.5', iconVisibilityClassName)} style={iconColor ? { color: iconColor } : undefined} />
) : (
<Icon name="folder" className={cn('h-3.5 w-3.5 text-muted-foreground/80', iconVisibilityClassName)} style={iconColor ? { color: iconColor } : undefined} />
)}
</span>
<span className="truncate text-[14px] font-semibold lowercase text-foreground">{projectLabel}</span>
</>
);
};
export interface SortableProjectItemProps extends ProjectIdentityProps {
disabled?: boolean;
projectDescription: string;
isCollapsed: boolean;
isRepo: boolean;
isDesktopShell: boolean;
hideDirectoryControls: boolean;
mobileVariant: boolean;
alwaysShowActions: boolean;
onToggle: () => void;
onNewSession: () => void;
onNewWorktreeSession?: () => void;
onManageWorktrees?: () => void;
onRenameStart: () => void;
onClose: () => void;
sentinelRef: (el: HTMLDivElement | null) => void;
children?: React.ReactNode;
showCreateButtons?: boolean;
hideHeader?: boolean;
/** Aggregated activity/attention indicator shown while the project is collapsed. */
statusIndicator?: React.ReactNode;
openSidebarMenuKey: string | null;
setOpenSidebarMenuKey: (key: string | null) => void;
}
export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
id,
disabled = false,
projectLabel,
projectDescription,
projectIcon,
projectColor,
projectIconImage,
projectIconBackground,
isCollapsed,
isRepo,
isDesktopShell,
hideDirectoryControls,
alwaysShowActions,
onToggle,
onNewSession,
onNewWorktreeSession,
onManageWorktrees,
onRenameStart,
onClose,
sentinelRef,
children,
showCreateButtons = true,
hideHeader = false,
statusIndicator = null,
openSidebarMenuKey,
setOpenSidebarMenuKey,
}) => {
const { t } = useI18n();
const stickyZoneHeaders = useSessionDisplayStore((state) => state.stickyZoneHeaders);
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id, disabled });
const suppressNextToggleRef = React.useRef(false);
const menuInstanceKey = `project:${id}`;
const isMenuOpen = openSidebarMenuKey === menuInstanceKey;
const [isContextMenuOpen, setIsContextMenuOpen] = React.useState(false);
const handleMenuOpenChange = React.useCallback((open: boolean) => {
if (open) setIsContextMenuOpen(false);
setOpenSidebarMenuKey(open ? menuInstanceKey : null);
}, [menuInstanceKey, setOpenSidebarMenuKey]);
const renderProjectMenuItems = (Item: React.ElementType) => (
<>
{showCreateButtons && !isRepo && !hideDirectoryControls && onNewSession && (
<Item onClick={onNewSession}>
<Icon name="add" className="mr-1.5 h-4 w-4" />
{t('sessions.sidebar.project.actions.newSession')}
</Item>
)}
{isRepo && !hideDirectoryControls && onManageWorktrees && (
<Item onClick={onManageWorktrees}>
<Icon name="node-tree" className="mr-1.5 h-4 w-4" />
{t('sessions.sidebar.project.actions.manageWorktrees')}
</Item>
)}
<Item onClick={onRenameStart}>
<Icon name="pencil-ai" className="mr-1.5 h-4 w-4" />
{t('sessions.sidebar.project.actions.edit')}
</Item>
<Item onClick={onClose} className="text-destructive focus:text-destructive">
<Icon name="close" className="mr-1.5 h-4 w-4" />
{t('sessions.sidebar.project.actions.closeProject')}
</Item>
</>
);
const handleMenuTriggerClick = React.useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
}, []);
const handleMenuTriggerPointerDown = React.useCallback((event: React.PointerEvent<HTMLButtonElement>) => {
event.stopPropagation();
}, []);
const handleMenuTriggerMouseDown = React.useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
}, []);
const handleToggleMouseDown = React.useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
if (event.button === 2 || (event.button === 0 && event.ctrlKey)) {
suppressNextToggleRef.current = true;
}
}, []);
const handleToggleClick = React.useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
// Drop mouse-click focus so hover-revealed chrome (chevron, actions)
// hides again on mouse-leave instead of sticking via :focus-within.
// Keyboard users keep their focus-visible ring (blur only fires here
// for pointer interactions that produced a click).
event.currentTarget.blur();
if (suppressNextToggleRef.current) {
suppressNextToggleRef.current = false;
return;
}
onToggle();
}, [onToggle]);
return (
<div
ref={setNodeRef}
style={{ transform: CSS.Transform.toString(transform), transition }}
className={cn('relative', isDragging && 'opacity-30')}
>
{!hideHeader ? (
<>
{isDesktopShell && (
<div
ref={sentinelRef}
data-project-id={id}
className="absolute top-0 h-px w-full pointer-events-none"
aria-hidden="true"
/>
)}
<ContextMenu open={isContextMenuOpen} onOpenChange={setIsContextMenuOpen}>
<ContextMenuTrigger
render={
// Sticky zone header: this trigger div is a direct child of
// the project wrapper (which spans header + sessions), so it
// can stick for the whole zone.
// Full-bleed band: pull past the list container's padding so
// the section band spans the entire sidebar width (ref: edge-
// to-edge section headers, not rounded pills).
<div
className={cn(
'-ml-2.5 -mr-2 text-left group/project select-none',
stickyZoneHeaders && 'sticky top-0 z-20 bg-sidebar',
)}
data-sidebar-sticky-header={stickyZoneHeaders ? 'true' : undefined}
onContextMenu={(event) => {
// VS Code hides project actions entirely (hideDirectoryControls).
if (hideDirectoryControls) return;
event.preventDefault();
setIsContextMenuOpen(true);
}}
/>
}
>
<div
className="relative flex items-center gap-1 py-1 pl-4 pr-3.5"
{...attributes}
>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onMouseDown={handleToggleMouseDown}
onClick={handleToggleClick}
{...listeners}
className={cn(
'flex-1 min-w-0 flex items-center gap-1.5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 rounded-md cursor-grab active:cursor-grabbing transition-[padding]',
isRepo && !hideDirectoryControls
? (alwaysShowActions ? 'pr-20' : 'pr-7 group-hover/project:pr-20 group-focus-within/project:pr-20')
: (alwaysShowActions ? 'pr-14' : 'pr-7 group-hover/project:pr-14 group-focus-within/project:pr-14'),
)}
>
<ProjectHeaderIdentity
id={id}
projectLabel={projectLabel}
projectIcon={projectIcon}
projectColor={projectColor}
projectIconImage={projectIconImage}
projectIconBackground={projectIconBackground}
isCollapsed={isCollapsed}
alwaysShowActions={alwaysShowActions}
/>
{statusIndicator ? (
<span className="ml-1 inline-flex flex-shrink-0 items-center">{statusIndicator}</span>
) : null}
</button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{projectDescription}
</TooltipContent>
</Tooltip>
<div className={cn(
'absolute top-1/2 z-10 flex -translate-y-1/2 items-center gap-1',
showCreateButtons ? 'right-7' : 'right-0.5',
)}>
{showCreateButtons && isRepo && !hideDirectoryControls && onNewWorktreeSession ? (
<Tooltip delayDuration={500}>
<TooltipTrigger asChild>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onNewWorktreeSession();
}}
className={cn(
'inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 hover:text-foreground transition-opacity',
alwaysShowActions ? 'opacity-100' : 'opacity-0 pointer-events-none group-hover/project:opacity-100 group-hover/project:pointer-events-auto group-focus-within/project:opacity-100 group-focus-within/project:pointer-events-auto',
)}
aria-label={t('sessions.sidebar.project.actions.newWorktree')}
>
<Icon name="node-tree" className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
<p>{t('sessions.sidebar.project.actions.newWorktreeEllipsis')}</p>
</TooltipContent>
</Tooltip>
) : null}
{!hideDirectoryControls ? (
<DropdownMenu
open={isMenuOpen}
onOpenChange={handleMenuOpenChange}
>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
'inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-opacity focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 hover:text-foreground',
isMenuOpen
? 'opacity-100 pointer-events-auto'
: alwaysShowActions
? 'opacity-100'
: 'opacity-0 pointer-events-none group-hover/project:opacity-100 group-hover/project:pointer-events-auto group-focus-within/project:opacity-100 group-focus-within/project:pointer-events-auto',
)}
aria-label={t('sessions.sidebar.project.actions.projectMenu')}
onPointerDown={handleMenuTriggerPointerDown}
onMouseDown={handleMenuTriggerMouseDown}
onClick={handleMenuTriggerClick}
>
<Icon name="more-2" className="h-3.5 w-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[180px]">
{renderProjectMenuItems(DropdownMenuItem)}
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
{showCreateButtons && onNewSession ? (
<div className="absolute right-0.5 top-1/2 z-10 -translate-y-1/2">
<Tooltip delayDuration={500}>
<TooltipTrigger asChild>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onNewSession();
}}
className={cn(
'inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 transition-opacity',
alwaysShowActions ? 'opacity-100' : 'opacity-0 pointer-events-none group-hover/project:opacity-100 group-hover/project:pointer-events-auto group-focus-within/project:opacity-100 group-focus-within/project:pointer-events-auto',
)}
aria-label={isRepo
? t('sessions.sidebar.project.actions.newDraftSession')
: t('sessions.sidebar.project.actions.newSession')}
>
<Icon name="add" className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
<p>{isRepo
? t('sessions.sidebar.project.actions.newDraftSession')
: t('sessions.sidebar.project.actions.newSession')}</p>
</TooltipContent>
</Tooltip>
</div>
) : null}
</div>
</ContextMenuTrigger>
<ContextMenuContent className="min-w-[180px]">
{renderProjectMenuItems(ContextMenuItem)}
</ContextMenuContent>
</ContextMenu>
</>
) : null}
{children}
</div>
);
};
const SortableGroupItemBase: React.FC<{
id: string;
disabled?: boolean;
children: (dragHandleProps: SortableDragHandleProps) => React.ReactNode;
}> = ({ id, disabled = false, children }) => {
const {
listeners,
setNodeRef,
setActivatorNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id, disabled });
const dragHandleProps = React.useMemo<SortableDragHandleProps>(() => ({
listeners,
setActivatorNodeRef,
}), [listeners, setActivatorNodeRef]);
return (
<div
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition,
}}
className={cn(
'space-y-0.5 rounded-md',
isDragging && 'opacity-50',
)}
>
{children(dragHandleProps)}
</div>
);
};
export const SortableGroupItem = React.memo(SortableGroupItemBase);
@@ -0,0 +1,37 @@
import React from 'react';
import type { SessionGroup } from '../types';
export const useGroupOrdering = (groupOrderByProject: Map<string, string[]>) => {
const getOrderedGroups = React.useCallback(
(projectId: string, groups: SessionGroup[]) => {
const preferredOrder = groupOrderByProject.get(projectId);
if (!preferredOrder || preferredOrder.length === 0) {
return groups;
}
const groupById = new Map(groups.map((group) => [group.id, group]));
const ordered: SessionGroup[] = [];
preferredOrder.forEach((id) => {
const group = groupById.get(id);
if (group) {
ordered.push(group);
groupById.delete(id);
}
});
// Groups unknown to the saved order are NEW worktrees — surface them at
// the top of the worktree list (the root/main group is positioned by
// the renderer regardless of this ordering). Archived buckets keep
// appending at the end.
const newGroups: SessionGroup[] = [];
const trailingGroups: SessionGroup[] = [];
groups.forEach((group) => {
if (!groupById.has(group.id)) return;
if (group.isArchivedBucket) trailingGroups.push(group);
else newGroups.push(group);
});
return [...newGroups, ...ordered, ...trailingGroups];
},
[groupOrderByProject],
);
return { getOrderedGroups };
};
@@ -0,0 +1,175 @@
import React from 'react';
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
import { mapWithConcurrency } from '@/lib/concurrency';
import { useGitStore } from '@/stores/useGitStore';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { runBackgroundNetworkTask } from '@/lib/background-network';
type Project = { id: string; path: string; normalizedPath: string };
const ROOT_BRANCH_TTL_MS = 5 * 60_000;
type Args = {
enabled?: boolean;
normalizedProjects: Project[];
gitRepoStatus: Map<string, { isGitRepo: boolean | null; branch: string | null }>;
setProjectRepoStatus: React.Dispatch<React.SetStateAction<Map<string, boolean | null>>>;
setProjectRootBranches: React.Dispatch<React.SetStateAction<Map<string, string>>>;
};
export const useProjectRepoStatus = (args: Args): void => {
const {
normalizedProjects,
enabled = true,
gitRepoStatus,
setProjectRepoStatus,
setProjectRootBranches,
} = args;
const { git } = useRuntimeAPIs();
const ensureStatus = useGitStore((state) => state.ensureStatus);
// Derive repo status from centralized Git store
React.useEffect(() => {
if (!enabled || !git || normalizedProjects.length === 0) {
setProjectRepoStatus(new Map());
return;
}
// Trigger ensureStatus for each project to populate store
normalizedProjects.forEach((project) => {
void runBackgroundNetworkTask(() => ensureStatus(project.normalizedPath, git));
});
}, [enabled, normalizedProjects, git, ensureStatus, setProjectRepoStatus]);
// Read isGitRepo from the store-populated state
React.useEffect(() => {
if (!enabled) return;
const next = new Map<string, boolean | null>();
normalizedProjects.forEach((project) => {
next.set(project.id, gitRepoStatus.get(project.normalizedPath)?.isGitRepo ?? null);
});
setProjectRepoStatus(next);
}, [enabled, normalizedProjects, gitRepoStatus, setProjectRepoStatus]);
const projectGitBranchesKey = React.useMemo(() => {
return normalizedProjects
.map((project) => {
const branch = gitRepoStatus.get(project.normalizedPath)?.branch ?? '';
return `${project.id}:${branch}`;
})
.join('|');
}, [normalizedProjects, gitRepoStatus]);
// Tracks the project path + input branch we last resolved against, per project.
// Used to resolve `getRootBranch` only for projects that are new or whose
// input actually changed — rather than re-resolving every project whenever
// any single project's branch settles (the old N² cascade).
const resolvedInputKeyByProjectId = React.useRef<Map<string, string>>(new Map());
// TTL cache: when a project's `gitRepoStatus` refreshes (it can fire on
// every background poll even if the branch is unchanged), skip the
// `getRootBranch` re-resolution if we resolved the same input within
// the TTL window. 5 minutes matches the polling interval that typically
// drives these refreshes, so we always serve cached results on
// background updates and only re-resolve on cold start or actual
// branch changes (those still invalidate via the input-key check).
const rootBranchCacheRef = React.useRef<Map<string, { branch: string; at: number }>>(new Map());
React.useEffect(() => {
if (!enabled) return;
let cancelled = false;
// Debounce so the initial burst of per-project `ensureStatus` updates
// settles into a single resolution pass instead of one pass per project.
const timer = setTimeout(() => {
const run = async () => {
const validIds = new Set(normalizedProjects.map((project) => project.id));
// Drop bookkeeping for projects that are no longer present.
for (const id of resolvedInputKeyByProjectId.current.keys()) {
if (!validIds.has(id)) {
resolvedInputKeyByProjectId.current.delete(id);
rootBranchCacheRef.current.delete(id);
}
}
const now = Date.now();
const pending = normalizedProjects.filter((project) => {
const status = gitRepoStatus.get(project.normalizedPath);
if (status?.isGitRepo === false) {
resolvedInputKeyByProjectId.current.delete(project.id);
rootBranchCacheRef.current.delete(project.id);
return false;
}
if (status?.isGitRepo !== true || status.branch === null) {
return false;
}
const currentBranch = status.branch.trim();
const currentInputKey = `${project.normalizedPath}\0${currentBranch}`;
const lastInputKey = resolvedInputKeyByProjectId.current.get(project.id);
if (lastInputKey === currentInputKey) {
// We've already resolved this exact (path, branch) pair.
// The TTL cache is just an extra protection for the case
// where the input key was reset by a transient blip —
// keep the existing map entry fresh so future re-renders
// hit the cache instead of refetching.
const cached = rootBranchCacheRef.current.get(project.id);
if (cached) cached.at = now;
return false;
}
// Same input? Serve from TTL cache if it's still warm.
if (lastInputKey !== undefined && now - (rootBranchCacheRef.current.get(project.id)?.at ?? 0) < ROOT_BRANCH_TTL_MS) {
return false;
}
return true;
});
if (pending.length === 0) {
return;
}
const entries = await mapWithConcurrency(pending, 2, async (project) => {
const inputBranch = gitRepoStatus.get(project.normalizedPath)?.branch?.trim() ?? '';
const inputKey = `${project.normalizedPath}\0${inputBranch}`;
const branch = await runBackgroundNetworkTask(() =>
getRootBranch(
project.normalizedPath,
inputBranch ? { knownBranch: inputBranch } : undefined,
)
).catch(() => null);
return { id: project.id, inputKey, branch };
});
if (cancelled) {
return;
}
const resolved = entries.filter((entry) => entry.branch);
if (resolved.length === 0) {
return;
}
const nowAfter = Date.now();
setProjectRootBranches((prev) => {
const next = new Map(prev);
resolved.forEach(({ id, branch }) => {
if (branch) {
next.set(id, branch);
}
});
return next;
});
resolved.forEach(({ id, inputKey, branch }) => {
resolvedInputKeyByProjectId.current.set(id, inputKey);
if (branch) {
rootBranchCacheRef.current.set(id, { branch, at: nowAfter });
}
});
};
void run();
}, 150);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [enabled, normalizedProjects, projectGitBranchesKey, gitRepoStatus, setProjectRootBranches]);
};
@@ -0,0 +1,31 @@
import React from 'react';
import type { SessionOwnershipIndex } from '../sessions/sessionOwnership';
type Args = {
ownership: SessionOwnershipIndex;
};
export const useProjectSessionLists = (args: Args) => {
const {
ownership,
} = args;
const getSessionsForProject = React.useCallback(
(projectId: string) => {
return ownership.sessionsByProject.get(projectId) ?? [];
},
[ownership],
);
const getArchivedSessionsForProject = React.useCallback(
(projectId: string) => {
return ownership.archivedSessionsByProject.get(projectId) ?? [];
},
[ownership],
);
return {
getSessionsForProject,
getArchivedSessionsForProject,
};
};
@@ -0,0 +1,289 @@
import React from 'react';
import type { Session } from '@opencode-ai/sdk/v2';
import type { SessionGroup, SessionNode } from '../types';
import { normalizePath } from '../utils';
import { useUIStore } from '@/stores/useUIStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
type ProjectSection = {
project: { id: string; normalizedPath: string };
groups: SessionGroup[];
};
type Args = {
projectSections: ProjectSection[];
activeProjectId: string | null;
activeSessionByProject: Map<string, string>;
setActiveSessionByProject: React.Dispatch<React.SetStateAction<Map<string, string>>>;
currentSessionId: string | null;
currentSessionOwnerProjectId?: string | null;
handleSessionSelect: (sessionId: string, sessionDirectory: string | null) => void;
newSessionDraftOpen: boolean;
mobileVariant: boolean;
openNewSessionDraft: (options?: { selectedProjectId?: string | null; directoryOverride?: string | null }) => void;
setSessionSwitcherOpen: (open: boolean) => void;
};
export type MissingProjectSessionSelection =
| { kind: 'preserve-current' }
| { kind: 'open-draft' }
| { kind: 'select-session'; sessionId: string }
| { kind: 'none' };
/**
* Resolves the active-project action after its rendered session map does not
* contain the current session.
*
* Authoritative ownership wins. If ownership is still unknown, a session that
* already appears under another project's rendered map is treated as foreign,
* while a session missing from every rendered map is preserved so stale
* worktree metadata can catch up.
*/
export function resolveMissingProjectSessionSelection<T>({
activeProjectId,
currentSessionId,
currentSessionOwnerProjectId,
projectMap,
metaByProject,
rememberedSessionId,
fallbackSessionId,
}: {
activeProjectId: string;
currentSessionId: string | null;
currentSessionOwnerProjectId?: string | null;
projectMap: ReadonlyMap<string, T> | undefined;
metaByProject: ReadonlyMap<string, ReadonlyMap<string, T>>;
rememberedSessionId: string | undefined;
fallbackSessionId: string | null;
}): MissingProjectSessionSelection {
if (currentSessionId && currentSessionOwnerProjectId === activeProjectId) {
return { kind: 'preserve-current' };
}
if (currentSessionOwnerProjectId == null) {
const currentSessionBelongsToAnotherProject = Boolean(
currentSessionId
&& Array.from(metaByProject.entries()).some(
([projectId, sessions]) => projectId !== activeProjectId && sessions.has(currentSessionId),
),
);
if (currentSessionId && projectMap && !currentSessionBelongsToAnotherProject) {
return { kind: 'preserve-current' };
}
}
if (!projectMap || projectMap.size === 0) {
return { kind: 'open-draft' };
}
const remembered = rememberedSessionId && projectMap.has(rememberedSessionId)
? rememberedSessionId
: null;
const targetSessionId = remembered ?? fallbackSessionId;
if (!targetSessionId || targetSessionId === currentSessionId) {
return { kind: 'none' };
}
return { kind: 'select-session', sessionId: targetSessionId };
}
export const useProjectSessionSelection = (args: Args): void => {
const {
projectSections,
activeProjectId,
activeSessionByProject,
setActiveSessionByProject,
currentSessionId,
currentSessionOwnerProjectId,
handleSessionSelect,
newSessionDraftOpen,
mobileVariant,
openNewSessionDraft,
setSessionSwitcherOpen,
} = args;
const projectSessionMeta = React.useMemo(() => {
const metaByProject = new Map<string, Map<string, { directory: string | null }>>();
const firstSessionByProject = new Map<string, { id: string; directory: string | null }>();
const visitNodes = (
projectId: string,
projectRoot: string,
fallbackDirectory: string | null,
nodes: SessionNode[],
) => {
if (!metaByProject.has(projectId)) {
metaByProject.set(projectId, new Map());
}
const projectMap = metaByProject.get(projectId)!;
nodes.forEach((node) => {
const sessionDirectory = normalizePath(
node.worktree?.path
?? (node.session as Session & { directory?: string | null }).directory
?? fallbackDirectory
?? projectRoot,
);
projectMap.set(node.session.id, { directory: sessionDirectory });
if (!firstSessionByProject.has(projectId)) {
firstSessionByProject.set(projectId, { id: node.session.id, directory: sessionDirectory });
}
if (node.children.length > 0) {
visitNodes(projectId, projectRoot, sessionDirectory, node.children);
}
});
};
projectSections.forEach((section) => {
section.groups.forEach((group) => {
visitNodes(section.project.id, section.project.normalizedPath, group.directory, group.sessions);
});
});
return { metaByProject, firstSessionByProject };
}, [projectSections]);
const previousActiveProjectRef = React.useRef<string | null>(null);
React.useLayoutEffect(() => {
if (!activeProjectId) {
return;
}
if (newSessionDraftOpen) {
return;
}
if (useUIStore.getState().isNewWorktreeDialogOpen) {
return;
}
if (previousActiveProjectRef.current === activeProjectId) {
return;
}
const section = projectSections.find((item) => item.project.id === activeProjectId);
if (!section) {
return;
}
const projectMap = projectSessionMeta.metaByProject.get(activeProjectId);
if (currentSessionId && projectMap && projectMap.has(currentSessionId)) {
previousActiveProjectRef.current = activeProjectId;
setActiveSessionByProject((prev) => {
if (prev.get(activeProjectId) === currentSessionId) {
return prev;
}
const next = new Map(prev);
next.set(activeProjectId, currentSessionId);
return next;
});
return;
}
const selection = resolveMissingProjectSessionSelection({
activeProjectId,
currentSessionId,
currentSessionOwnerProjectId,
projectMap,
metaByProject: projectSessionMeta.metaByProject,
rememberedSessionId: activeSessionByProject.get(activeProjectId),
fallbackSessionId: projectSessionMeta.firstSessionByProject.get(activeProjectId)?.id ?? null,
});
// Keep the project unprocessed while ownership/maps may still catch up,
// so a later owner of another project can still select B.
if (selection.kind === 'preserve-current') {
if (currentSessionOwnerProjectId === activeProjectId) {
previousActiveProjectRef.current = activeProjectId;
}
return;
}
previousActiveProjectRef.current = activeProjectId;
if (selection.kind === 'open-draft') {
if (mobileVariant) {
setSessionSwitcherOpen(false);
}
openNewSessionDraft({
selectedProjectId: section.project.id,
directoryOverride: section.project.normalizedPath,
});
return;
}
if (selection.kind !== 'select-session') {
return;
}
const targetDirectory = projectMap?.get(selection.sessionId)?.directory ?? null;
handleSessionSelect(selection.sessionId, targetDirectory);
}, [
activeProjectId,
activeSessionByProject,
currentSessionId,
currentSessionOwnerProjectId,
handleSessionSelect,
newSessionDraftOpen,
mobileVariant,
openNewSessionDraft,
projectSections,
projectSessionMeta,
setSessionSwitcherOpen,
setActiveSessionByProject,
]);
React.useEffect(() => {
if (!activeProjectId || !currentSessionId) {
return;
}
const projectMap = projectSessionMeta.metaByProject.get(activeProjectId);
if (!projectMap || !projectMap.has(currentSessionId)) {
return;
}
setActiveSessionByProject((prev) => {
if (prev.get(activeProjectId) === currentSessionId) {
return prev;
}
const next = new Map(prev);
next.set(activeProjectId, currentSessionId);
return next;
});
}, [activeProjectId, currentSessionId, projectSessionMeta, setActiveSessionByProject]);
};
type ProjectSessionSelectionEffectProps = Omit<
Args,
'activeSessionByProject' | 'setActiveSessionByProject' | 'currentSessionId' | 'newSessionDraftOpen' | 'currentSessionOwnerProjectId'
> & {
initialActiveSessionByProject: Map<string, string>;
persistActiveSessionByProject: (value: Map<string, string>) => void;
sessionOwnerBySessionId?: ReadonlyMap<string, { projectId: string }>;
};
export const ProjectSessionSelectionEffect: React.FC<ProjectSessionSelectionEffectProps> = ({
initialActiveSessionByProject,
persistActiveSessionByProject,
sessionOwnerBySessionId,
...args
}) => {
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const newSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open));
const currentSessionOwnerProjectId = currentSessionId
? sessionOwnerBySessionId?.get(currentSessionId)?.projectId ?? null
: null;
const [activeSessionByProject, setActiveSessionByProject] = React.useState(
() => new Map(initialActiveSessionByProject),
);
useProjectSessionSelection({
...args,
activeSessionByProject,
setActiveSessionByProject,
currentSessionId,
currentSessionOwnerProjectId,
newSessionDraftOpen,
});
React.useEffect(() => {
persistActiveSessionByProject(activeSessionByProject);
}, [activeSessionByProject, persistActiveSessionByProject]);
return null;
};
@@ -0,0 +1,103 @@
import { describe, expect, test } from 'bun:test';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import type { Session } from '@opencode-ai/sdk/v2';
import { I18nProvider } from '@/lib/i18n';
import { useSessionActions } from '../sessions/useSessionActions';
import { useSessionGrouping } from './useSessionGrouping';
import type { SessionNode } from '../types';
type FixtureSession = Session & { parentID?: string };
const session = (id: string, parentID?: string): Session => {
const value: FixtureSession = {
id,
slug: id,
projectID: 'project',
title: id,
version: '1',
directory: '/workspace',
time: { created: 1, updated: 1 },
};
if (parentID) value.parentID = parentID;
return value;
};
const collectIds = (nodes: SessionNode[]): string[] => {
const ids: string[] = [];
const visit = (items: SessionNode[]): void => {
for (const node of items) {
ids.push(node.session.id);
visit(node.children);
}
};
visit(nodes);
return ids;
};
describe('useSessionGrouping malformed hierarchy fallbacks', () => {
test('renders a deterministic cycle/orphan fallback tree without duplicate sessions', async () => {
type GroupingCapture = { buildGroupedSessions?: ReturnType<typeof useSessionGrouping>['buildGroupedSessions'] };
const state: GroupingCapture = {};
const Harness = () => {
state.buildGroupedSessions = useSessionGrouping({
homeDirectory: null,
worktreeMetadata: new Map(),
pinnedSessionIds: new Set(),
sessionOrderRanks: new Map(),
gitBranches: new Map(),
isVSCode: false,
}).buildGroupedSessions;
return null;
};
renderToStaticMarkup(React.createElement(I18nProvider, null, React.createElement(Harness)));
const buildGroupedSessions = state.buildGroupedSessions;
if (!buildGroupedSessions) throw new Error('grouping callback was not mounted');
const groups = buildGroupedSessions(
[session('a', 'b'), session('b', 'a'), session('orphan', 'missing')],
'/workspace',
[],
null,
false,
);
const rootGroup = groups.find((group) => group.isMain);
const ids = collectIds(rootGroup?.sessions ?? []);
expect(ids).toEqual(['orphan', 'a', 'b']);
expect(new Set(ids).size).toBe(ids.length);
});
test('uses the row-local descendant snapshot for archive and hard-delete actions', async () => {
type ActionsCapture = { handleDeleteSession?: ReturnType<typeof useSessionActions>['handleDeleteSession'] };
const state: ActionsCapture = {};
const Harness = () => {
state.handleDeleteSession = useSessionActions({
mobileVariant: false,
allowReselect: false,
isSessionSearchOpen: false,
sessionSearchQuery: '',
setSessionSearchQuery: () => undefined,
setIsSessionSearchOpen: () => undefined,
descendantIds: ['active-child', 'archived-child'],
showDeletionDialog: false,
setDeleteSessionConfirm: () => undefined,
deleteSessionConfirm: null,
setEditingId: () => undefined,
setEditTitle: () => undefined,
editingId: null,
editTitle: '',
copiedSessionId: null,
setCopiedSessionId: () => undefined,
}).handleDeleteSession;
return null;
};
renderToStaticMarkup(React.createElement(I18nProvider, null, React.createElement(Harness)));
const handleDeleteSession = state.handleDeleteSession;
if (!handleDeleteSession) throw new Error('session actions callback was not mounted');
handleDeleteSession(session('root'));
handleDeleteSession(session('root'), { hardDelete: true });
});
});
@@ -0,0 +1,286 @@
import { matchesRankQuery } from '@/lib/search/fuzzySearch';
import React from 'react';
import type { Session } from '@opencode-ai/sdk/v2';
import type { WorktreeMetadata } from '@/types/worktree';
import type { SessionGroup, SessionNode } from '../types';
import {
dedupeSessionsById,
getArchivedScopeKey,
normalizeForBranchComparison,
normalizePath,
} from '../utils';
import { getSessionLifecycleOrderValue } from '@/sync/session-ordering';
import { formatDirectoryName, formatPathForDisplay } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
import { getWorktreeFirstSeenAt } from './worktreeFirstSeen';
type Args = {
homeDirectory: string | null;
worktreeMetadata: Map<string, WorktreeMetadata>;
pinnedSessionIds: Set<string>;
sessionOrderRanks: ReadonlyMap<string, number>;
gitBranches: Map<string, string | null>;
isVSCode: boolean;
};
const isArchivedSession = (session: Session): boolean => Boolean(session.time?.archived);
export const useSessionGrouping = (args: Args) => {
const { t } = useI18n();
const buildGroupSearchText = React.useCallback((group: SessionGroup): string => {
return [group.label, group.branch ?? '', group.description ?? '', group.directory ?? ''].join(' ').toLowerCase();
}, []);
const buildSessionSearchText = React.useCallback((session: Session): string => {
const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null) ?? '';
const sessionTitle = (session.title || t('sessions.sidebar.session.untitled')).trim();
return `${sessionTitle} ${sessionDirectory}`.toLowerCase();
}, [t]);
const filterSessionNodesForSearch = React.useCallback(
(nodes: SessionNode[], query: string): SessionNode[] => {
if (!query) {
return nodes;
}
return nodes.flatMap((node) => {
const nodeMatches = matchesRankQuery([buildSessionSearchText(node.session)], query);
if (nodeMatches) {
return [node];
}
const filteredChildren = filterSessionNodesForSearch(node.children, query);
if (filteredChildren.length === 0) {
return [];
}
return [{ ...node, children: filteredChildren }];
});
},
[buildSessionSearchText],
);
const buildGroupedSessions = React.useCallback(
(
projectSessions: Session[],
projectRoot: string | null,
availableWorktrees: WorktreeMetadata[],
projectRootBranch: string | null,
projectIsRepo: boolean,
) => {
const normalizedProjectRoot = normalizePath(projectRoot ?? null);
// `orderSessionsByLifecycleScopes` owns lifecycle ordering before project
// ownership buckets are built. Dedupe retains that root/sibling order.
const sortedProjectSessions = dedupeSessionsById(projectSessions);
const sessionMap = new Map(sortedProjectSessions.map((session) => [session.id, session]));
const childrenMap = new Map<string, Session[]>();
sortedProjectSessions.forEach((session) => {
const parentID = (session as Session & { parentID?: string | null }).parentID;
if (!parentID) return;
const parentSession = sessionMap.get(parentID);
if (!parentSession || isArchivedSession(parentSession) !== isArchivedSession(session)) {
return;
}
const collection = childrenMap.get(parentID) ?? [];
collection.push(session);
childrenMap.set(parentID, collection);
});
const worktreeByPath = new Map<string, WorktreeMetadata>();
availableWorktrees.forEach((meta) => {
if (meta.path) {
const normalized = normalizePath(meta.path) ?? meta.path;
worktreeByPath.set(normalized, meta);
}
});
const getSessionWorktree = (session: Session): WorktreeMetadata | null => {
const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null);
const sessionWorktreeMeta = args.worktreeMetadata.get(session.id) ?? null;
if (sessionWorktreeMeta) return sessionWorktreeMeta;
if (sessionDirectory) {
const worktree = worktreeByPath.get(sessionDirectory) ?? null;
if (worktree && sessionDirectory !== normalizedProjectRoot) {
return worktree;
}
}
return null;
};
const claimedSessionIds = new Set<string>();
const buildProjectNode = (session: Session): SessionNode => {
claimedSessionIds.add(session.id);
const children = childrenMap.get(session.id) ?? [];
const childNodes: SessionNode[] = [];
for (const child of children) {
if (claimedSessionIds.has(child.id)) continue;
childNodes.push(buildProjectNode(child));
}
return { session, children: childNodes, worktree: getSessionWorktree(session) };
};
const rootCandidates = sortedProjectSessions.filter((session) => {
const parentID = (session as Session & { parentID?: string | null }).parentID;
if (!parentID) return true;
const parentSession = sessionMap.get(parentID);
if (!parentSession) return true;
return isArchivedSession(parentSession) !== isArchivedSession(session);
});
// A malformed cycle has no structural root. Start with normal roots,
// then expose each still-unclaimed component from its first input row.
const roots: SessionNode[] = [];
const addRoot = (session: Session): void => {
if (claimedSessionIds.has(session.id)) return;
roots.push(buildProjectNode(session));
};
rootCandidates.forEach(addRoot);
sortedProjectSessions.forEach(addRoot);
const groupedNodes = new Map<string, SessionNode[]>();
const archivedKey = '__archived__';
const getGroupKey = (session: Session) => {
if (session.time?.archived) return archivedKey;
// VS Code groups by open workspace, not by worktree: every non-archived
// session in a project belongs to that project's single (root) group.
// Worktrees aren't registered in VS Code, so the desktop directory-match
// below would otherwise dump these sessions into the archived bucket.
if (args.isVSCode) return normalizedProjectRoot ?? '__project_root__';
const metadataPath = normalizePath(args.worktreeMetadata.get(session.id)?.path ?? null);
const normalizedDir = metadataPath ?? resolveGlobalSessionDirectory(session);
if (!normalizedDir) return archivedKey;
if (normalizedDir !== normalizedProjectRoot && worktreeByPath.has(normalizedDir)) return normalizedDir;
if (normalizedDir === normalizedProjectRoot) return normalizedProjectRoot ?? '__project_root__';
return archivedKey;
};
roots.forEach((node) => {
const groupKey = getGroupKey(node.session);
if (!groupedNodes.has(groupKey)) groupedNodes.set(groupKey, []);
groupedNodes.get(groupKey)?.push(node);
});
const rootKey = normalizedProjectRoot ?? '__project_root__';
const groups: SessionGroup[] = [{
id: 'root',
label: (projectIsRepo && projectRootBranch && projectRootBranch !== 'HEAD')
? t('sessions.sidebar.grouping.projectRootWithBranch', { branch: projectRootBranch })
: t('sessions.sidebar.grouping.projectRoot'),
branch: projectRootBranch ?? null,
description: normalizedProjectRoot ? formatPathForDisplay(normalizedProjectRoot, args.homeDirectory) : null,
isMain: true,
isArchivedBucket: false,
worktree: null,
directory: normalizedProjectRoot,
folderScopeKey: normalizedProjectRoot,
sessions: groupedNodes.get(rootKey) ?? [],
}];
// Calculate display-order activity for each worktree.
const worktreeActivityInfo = new Map<string, { hasActiveSession: boolean; lastUpdatedAt: number }>();
availableWorktrees.forEach((meta) => {
const directory = normalizePath(meta.path) ?? meta.path;
const sessionsInWorktree = groupedNodes.get(directory) ?? [];
const hasActiveSession = sessionsInWorktree.length > 0;
// Lifecycle rank wins when present; timestamps seed bootstrap ordering.
const lastUpdatedAt = sessionsInWorktree.reduce((max, node) => {
const updatedAt = getSessionLifecycleOrderValue(node.session, args.sessionOrderRanks);
if (!Number.isFinite(updatedAt)) {
return max;
}
return Math.max(max, updatedAt);
}, 0);
worktreeActivityInfo.set(directory, { hasActiveSession, lastUpdatedAt });
});
// Sort populated worktrees by shared session activity, then empty ones by label.
const sortedWorktrees = [...availableWorktrees].sort((a, b) => {
const aDir = normalizePath(a.path) ?? a.path;
const bDir = normalizePath(b.path) ?? b.path;
const aInfo = worktreeActivityInfo.get(aDir) ?? { hasActiveSession: false, lastUpdatedAt: 0 };
const bInfo = worktreeActivityInfo.get(bDir) ?? { hasActiveSession: false, lastUpdatedAt: 0 };
// First priority: active status (active first)
if (aInfo.hasActiveSession !== bInfo.hasActiveSession) {
return aInfo.hasActiveSession ? -1 : 1;
}
// Second priority: for populated worktrees, sort by latest display activity.
if (aInfo.hasActiveSession && bInfo.hasActiveSession) {
return bInfo.lastUpdatedAt - aInfo.lastUpdatedAt;
}
// Third priority: for inactive worktrees, most recently discovered
// first (a worktree created mid-session surfaces at the top of the
// list; startup discovery ties and falls through to labels).
const aSeen = getWorktreeFirstSeenAt(a.path);
const bSeen = getWorktreeFirstSeenAt(b.path);
if (aSeen !== bSeen) {
return bSeen - aSeen;
}
// Fourth priority: sort by label (asc)
const aLabel = (a.label || a.branch || a.name || a.path || '').toLowerCase();
const bLabel = (b.label || b.branch || b.name || b.path || '').toLowerCase();
return aLabel.localeCompare(bLabel);
});
// VS Code groups strictly by open workspace — no per-worktree subgroups.
const worktreeGroups = args.isVSCode ? [] : sortedWorktrees;
worktreeGroups.forEach((meta) => {
const directory = normalizePath(meta.path) ?? meta.path;
const currentBranch = args.gitBranches.get(directory)?.trim() || null;
const metadataBranch = meta.branch?.trim() || null;
const shouldSyncLabelWithBranch = Boolean(
currentBranch && metadataBranch && meta.label && normalizeForBranchComparison(meta.label) === normalizeForBranchComparison(metadataBranch),
);
const label = shouldSyncLabelWithBranch
? currentBranch!
: (meta.label || meta.name || formatDirectoryName(directory, args.homeDirectory) || directory);
groups.push({
id: `worktree:${directory}`,
label,
branch: currentBranch || metadataBranch,
description: formatPathForDisplay(directory, args.homeDirectory),
isMain: false,
isArchivedBucket: false,
worktree: meta,
directory,
folderScopeKey: directory,
sessions: groupedNodes.get(directory) ?? [],
});
});
const archivedSessions = groupedNodes.get(archivedKey) ?? [];
if (archivedSessions.length > 0) {
groups.push({
id: 'archived',
label: t('sessions.sidebar.grouping.archived'),
branch: null,
description: t('sessions.sidebar.grouping.archivedDescription'),
isMain: false,
isArchivedBucket: true,
worktree: null,
directory: null,
folderScopeKey: !args.isVSCode && normalizedProjectRoot ? getArchivedScopeKey(normalizedProjectRoot) : null,
sessions: archivedSessions,
});
}
return groups;
},
[args.homeDirectory, args.worktreeMetadata, args.pinnedSessionIds, args.sessionOrderRanks, args.gitBranches, args.isVSCode, t],
);
return {
buildGroupSearchText,
buildSessionSearchText,
filterSessionNodesForSearch,
buildGroupedSessions,
};
};
@@ -0,0 +1,220 @@
import { beforeEach, describe, expect, test } from 'bun:test';
import React, { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { getDeferredSafeStorage } from '@/stores/utils/safeStorage';
import type { SessionGroup } from '../types';
import { useSessionProjectViewState } from './useSessionProjectViewState';
class ElementStub implements Partial<Element> {
nodeType = 1;
}
type DocumentStub = {
nodeType: number;
defaultView: typeof globalThis;
activeElement: null;
addEventListener: () => void;
removeEventListener: () => void;
documentElement?: Element;
body?: Element;
};
type GlobalValue = typeof globalThis | typeof ElementStub | DocumentStub | boolean;
type HookCapture = {
state?: ReturnType<typeof useSessionProjectViewState>['state'];
actions?: ReturnType<typeof useSessionProjectViewState>['actions'];
renderCount: number;
};
const installMinimalDom = () => {
const descriptors = new Map<string, PropertyDescriptor | undefined>();
const setGlobal = (name: string, value: GlobalValue) => {
descriptors.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
Object.defineProperty(globalThis, name, { configurable: true, writable: true, value });
};
const documentStub: DocumentStub = {
nodeType: 9,
defaultView: globalThis,
activeElement: null,
addEventListener: () => undefined,
removeEventListener: () => undefined,
};
// SAFETY: React's test renderer only inspects this fixture's DOM identity fields and listeners.
const container = Object.create(ElementStub.prototype) as Element;
Object.assign(container, {
nodeType: 1,
tagName: 'DIV',
nodeName: 'DIV',
namespaceURI: 'http://www.w3.org/1999/xhtml',
ownerDocument: documentStub,
addEventListener: () => undefined,
removeEventListener: () => undefined,
});
documentStub.documentElement = container;
documentStub.body = container;
setGlobal('document', documentStub);
setGlobal('window', globalThis);
setGlobal('Element', ElementStub);
setGlobal('HTMLElement', ElementStub);
setGlobal('HTMLIFrameElement', ElementStub);
setGlobal('IS_REACT_ACT_ENVIRONMENT', true);
return {
container,
restore: () => {
for (const [name, descriptor] of descriptors) {
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
else Reflect.deleteProperty(globalThis, name);
}
},
};
};
describe('useSessionProjectViewState', () => {
beforeEach(() => {
const storage = getDeferredSafeStorage();
storage.removeItem('oc.sessions.projectCollapse');
storage.removeItem('oc.sessions.groupCollapse');
storage.removeItem('oc.sessions.groupOrder');
});
test('keeps stable state/actions and ignores selection-store updates', async () => {
const dom = installMinimalDom();
const root: Root = createRoot(dom.container);
const capture: HookCapture = { renderCount: 0 };
const projects = [{ id: 'project-a' }, { id: 'project-b' }];
const Harness = () => {
capture.renderCount += 1;
const viewState = useSessionProjectViewState({ isVSCode: true, projects });
capture.state = viewState.state;
capture.actions = viewState.actions;
return null;
};
try {
await act(async () => root.render(React.createElement(Harness)));
const initialState = capture.state;
const initialActions = capture.actions;
const initialRenderCount = capture.renderCount;
if (!initialState || !initialActions) throw new Error('hook did not mount');
await act(async () => {
useSessionUIStore.setState({ currentSessionId: 'selection-only' });
});
expect(capture.renderCount).toBe(initialRenderCount);
expect(capture.state).toBe(initialState);
expect(capture.actions).toBe(initialActions);
await act(async () => initialActions.toggleProject('project-a'));
expect(capture.state?.collapsedProjects).toEqual(new Set(['project-a']));
await act(async () => initialActions.collapseAllProjects());
expect(capture.state?.collapsedProjects).toEqual(new Set(['project-a', 'project-b']));
await act(async () => initialActions.expandAllProjects());
expect(capture.state?.collapsedProjects).toEqual(new Set());
await act(async () => initialActions.toggleGroup('project-a:group-a'));
expect(capture.state?.collapsedGroups).toEqual(new Set(['project-a:group-a']));
await act(async () => {
initialActions.setGroupOrderByProject((previous) => {
const next = new Map(previous);
next.set('project-a', ['group-b', 'group-a']);
return next;
});
});
const group = (id: string): SessionGroup => ({
id,
label: id,
branch: null,
description: null,
isMain: false,
worktree: null,
directory: null,
sessions: [],
});
expect(capture.actions?.getOrderedGroups('project-a', [group('group-a'), group('group-b')])
.map((item) => item.id)).toEqual(['group-b', 'group-a']);
await new Promise((resolve) => setTimeout(resolve, 0));
const storage = getDeferredSafeStorage();
expect(JSON.parse(storage.getItem('oc.sessions.projectCollapse') ?? 'null')).toEqual([]);
expect(JSON.parse(storage.getItem('oc.sessions.groupCollapse') ?? 'null')).toEqual(['project-a:group-a']);
expect(JSON.parse(storage.getItem('oc.sessions.groupOrder') ?? 'null')).toEqual({
'project-a': ['group-b', 'group-a'],
});
} finally {
await act(async () => root.unmount());
dom.restore();
}
});
test('preserves malformed group storage until explicit user mutation', async () => {
const storage = getDeferredSafeStorage();
const malformedCollapse = '{malformed-collapse';
const malformedOrder = JSON.stringify({ 'project-a': ['group-a', 2] });
storage.setItem('oc.sessions.groupCollapse', malformedCollapse);
storage.setItem('oc.sessions.groupOrder', malformedOrder);
const dom = installMinimalDom();
const root = createRoot(dom.container);
const capture: HookCapture = { renderCount: 0 };
const Harness = () => {
capture.renderCount += 1;
const value = useSessionProjectViewState({ isVSCode: true, projects: [{ id: 'project-a' }] });
capture.state = value.state;
capture.actions = value.actions;
return null;
};
try {
await act(async () => root.render(React.createElement(Harness)));
await new Promise((resolve) => setTimeout(resolve, 0));
expect(capture.state?.collapsedGroups).toEqual(new Set());
expect(capture.state?.groupOrderByProject).toEqual(new Map());
expect(storage.getItem('oc.sessions.groupCollapse')).toBe(malformedCollapse);
expect(storage.getItem('oc.sessions.groupOrder')).toBe(malformedOrder);
await act(async () => capture.actions!.toggleGroup('project-a:group-a'));
await act(async () => capture.actions!.setGroupOrderByProject(new Map([['project-a', ['group-a']]])));
await new Promise((resolve) => setTimeout(resolve, 0));
expect(JSON.parse(storage.getItem('oc.sessions.groupCollapse') ?? 'null')).toEqual(['project-a:group-a']);
expect(JSON.parse(storage.getItem('oc.sessions.groupOrder') ?? 'null')).toEqual({ 'project-a': ['group-a'] });
} finally {
await act(async () => root.unmount());
dom.restore();
}
});
test('retains persisted project/group state while hidden and across a full remount', async () => {
const storage = getDeferredSafeStorage();
storage.setItem('oc.sessions.projectCollapse', JSON.stringify(['project-a']));
storage.setItem('oc.sessions.groupCollapse', JSON.stringify(['project-a:group-a']));
storage.setItem('oc.sessions.groupOrder', JSON.stringify({ 'project-a': ['group-b', 'group-a'] }));
const dom = installMinimalDom();
const root = createRoot(dom.container);
const capture: HookCapture = { renderCount: 0 };
const Harness = ({ hidden }: { hidden: boolean }) => {
void hidden;
capture.renderCount += 1;
const value = useSessionProjectViewState({ isVSCode: true, projects: [{ id: 'project-a' }] });
capture.state = value.state;
capture.actions = value.actions;
return null;
};
try {
await act(async () => root.render(React.createElement(Harness, { hidden: false })));
await act(async () => root.render(React.createElement(Harness, { hidden: true })));
await act(async () => root.render(React.createElement(Harness, { hidden: false })));
expect(capture.state?.collapsedProjects).toEqual(new Set(['project-a']));
expect(capture.state?.collapsedGroups).toEqual(new Set(['project-a:group-a']));
expect(capture.state?.groupOrderByProject).toEqual(new Map([['project-a', ['group-b', 'group-a']]]));
await act(async () => root.render(null));
await act(async () => root.render(React.createElement(Harness, { hidden: false })));
expect(capture.state?.collapsedProjects).toEqual(new Set(['project-a']));
expect(capture.state?.collapsedGroups).toEqual(new Set(['project-a:group-a']));
expect(capture.state?.groupOrderByProject).toEqual(new Map([['project-a', ['group-b', 'group-a']]]));
} finally {
await act(async () => root.unmount());
dom.restore();
}
});
});
@@ -0,0 +1,199 @@
import React from 'react';
import { updateDesktopSettings } from '@/lib/persistence';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { getDeferredSafeStorage } from '@/stores/utils/safeStorage';
import { z } from 'zod';
import { useGroupOrdering } from './useGroupOrdering';
const PROJECT_COLLAPSE_STORAGE_KEY = 'oc.sessions.projectCollapse';
const GROUP_ORDER_STORAGE_KEY = 'oc.sessions.groupOrder';
const GROUP_COLLAPSE_STORAGE_KEY = 'oc.sessions.groupCollapse';
type Project = { id: string };
type SessionProjectViewStateArgs = {
isVSCode: boolean;
projects: readonly Project[];
};
const parseStringSet = (raw: string | null): Set<string> => {
if (!raw) return new Set();
try {
const parsed = z.array(z.string()).safeParse(JSON.parse(raw));
return new Set(parsed.success ? parsed.data : []);
} catch {
return new Set();
}
};
const parseGroupOrder = (raw: string | null): Map<string, string[]> => {
if (!raw) return new Map();
try {
const parsed = z.record(z.string(), z.array(z.string())).safeParse(JSON.parse(raw));
if (!parsed.success) return new Map();
const next = new Map<string, string[]>();
for (const [projectId, order] of Object.entries(parsed.data)) {
next.set(projectId, order);
}
return next;
} catch {
return new Map();
}
};
export const useSessionProjectViewState = ({
isVSCode,
projects,
}: SessionProjectViewStateArgs) => {
const safeStorage = React.useMemo(() => getDeferredSafeStorage(), []);
const [collapsedProjects, setCollapsedProjects] = React.useState<Set<string>>(() => (
parseStringSet(safeStorage.getItem(PROJECT_COLLAPSE_STORAGE_KEY))
));
const [collapsedGroups, setCollapsedGroups] = React.useState<Set<string>>(() => (
parseStringSet(safeStorage.getItem(GROUP_COLLAPSE_STORAGE_KEY))
));
const [groupOrderByProject, setGroupOrderByProject] = React.useState<Map<string, string[]>>(() => (
parseGroupOrder(safeStorage.getItem(GROUP_ORDER_STORAGE_KEY))
));
const ignoreIntersectionUntil = React.useRef<number>(0);
const groupCollapseDirty = React.useRef(false);
const groupOrderDirty = React.useRef(false);
const persistCollapsedProjectsTimer = React.useRef<number | null>(null);
const pendingCollapsedProjects = React.useRef<Set<string> | null>(null);
const flushCollapsedProjectsPersist = React.useCallback(() => {
if (isVSCode) return;
const collapsed = pendingCollapsedProjects.current;
pendingCollapsedProjects.current = null;
persistCollapsedProjectsTimer.current = null;
if (!collapsed) return;
const { projects: storedProjects } = useProjectsStore.getState();
const updatedProjects = storedProjects.map((project) => ({
...project,
sidebarCollapsed: collapsed.has(project.id),
}));
void updateDesktopSettings({ projects: updatedProjects }).catch(() => {});
}, [isVSCode]);
const scheduleCollapsedProjectsPersist = React.useCallback((collapsed: Set<string>) => {
if (!globalThis.window || isVSCode) return;
pendingCollapsedProjects.current = collapsed;
if (persistCollapsedProjectsTimer.current !== null) {
window.clearTimeout(persistCollapsedProjectsTimer.current);
}
persistCollapsedProjectsTimer.current = window.setTimeout(() => {
flushCollapsedProjectsPersist();
}, 700);
}, [flushCollapsedProjectsPersist, isVSCode]);
React.useEffect(() => {
return () => {
if (globalThis.window && persistCollapsedProjectsTimer.current !== null) {
window.clearTimeout(persistCollapsedProjectsTimer.current);
}
persistCollapsedProjectsTimer.current = null;
pendingCollapsedProjects.current = null;
};
}, []);
React.useEffect(() => {
if (!groupOrderDirty.current) return;
try {
safeStorage.setItem(GROUP_ORDER_STORAGE_KEY, JSON.stringify(Object.fromEntries(groupOrderByProject.entries())));
} catch {
// ignored
}
}, [groupOrderByProject, safeStorage]);
React.useEffect(() => {
if (!groupCollapseDirty.current) return;
try {
safeStorage.setItem(GROUP_COLLAPSE_STORAGE_KEY, JSON.stringify(Array.from(collapsedGroups)));
} catch {
// ignored
}
}, [collapsedGroups, safeStorage]);
const collapseAllProjects = React.useCallback(() => {
ignoreIntersectionUntil.current = Date.now() + 150;
groupCollapseDirty.current = true;
setCollapsedGroups(new Set());
setCollapsedProjects(() => {
const allIds = new Set(projects.map((project) => project.id));
try {
safeStorage.setItem(PROJECT_COLLAPSE_STORAGE_KEY, JSON.stringify(Array.from(allIds)));
} catch {
// ignored
}
scheduleCollapsedProjectsPersist(allIds);
return allIds;
});
}, [projects, safeStorage, scheduleCollapsedProjectsPersist]);
const expandAllProjects = React.useCallback(() => {
ignoreIntersectionUntil.current = Date.now() + 150;
groupCollapseDirty.current = true;
setCollapsedGroups(new Set());
setCollapsedProjects(() => {
const empty = new Set<string>();
try {
safeStorage.setItem(PROJECT_COLLAPSE_STORAGE_KEY, JSON.stringify([]));
} catch {
// ignored
}
scheduleCollapsedProjectsPersist(empty);
return empty;
});
}, [safeStorage, scheduleCollapsedProjectsPersist]);
const toggleProject = React.useCallback((projectId: string) => {
ignoreIntersectionUntil.current = Date.now() + 150;
setCollapsedProjects((previous) => {
const next = new Set(previous);
if (next.has(projectId)) next.delete(projectId);
else next.add(projectId);
try {
safeStorage.setItem(PROJECT_COLLAPSE_STORAGE_KEY, JSON.stringify(Array.from(next)));
} catch {
// ignored
}
scheduleCollapsedProjectsPersist(next);
return next;
});
}, [safeStorage, scheduleCollapsedProjectsPersist]);
const toggleGroup = React.useCallback((key: string) => {
groupCollapseDirty.current = true;
setCollapsedGroups((previous) => {
const next = new Set(previous);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
}, []);
const updateGroupOrderByProject = React.useCallback<React.Dispatch<React.SetStateAction<Map<string, string[]>>>>((update) => {
groupOrderDirty.current = true;
setGroupOrderByProject(update);
}, []);
const { getOrderedGroups } = useGroupOrdering(groupOrderByProject);
const state = React.useMemo(() => ({
collapsedProjects,
collapsedGroups,
groupOrderByProject,
}), [collapsedGroups, collapsedProjects, groupOrderByProject]);
const actions = React.useMemo(() => ({
setCollapsedProjects,
toggleProject,
collapseAllProjects,
expandAllProjects,
scheduleCollapsedProjectsPersist,
setCollapsedGroups,
toggleGroup,
setGroupOrderByProject: updateGroupOrderByProject,
getOrderedGroups,
}), [collapseAllProjects, expandAllProjects, getOrderedGroups, scheduleCollapsedProjectsPersist, toggleGroup, toggleProject, updateGroupOrderByProject]);
return { state, actions };
};
@@ -0,0 +1,295 @@
import { matchesRankQuery } from '@/lib/search/fuzzySearch';
import React from 'react';
import type { Session } from '@opencode-ai/sdk/v2';
import type { SessionGroup, SessionNode, GroupSearchData } from '../types';
import { dedupeSessionsById, normalizePath } from '../utils';
import type { WorktreeMetadata } from '@/types/worktree';
import type { SessionFoldersMap } from '@/stores/useSessionFoldersStore';
import { streamPerfCount } from '@/stores/utils/streamDebug';
type ProjectItem = {
id: string;
path: string;
label?: string;
normalizedPath: string;
icon?: string;
color?: string;
iconImage?: { mime: string; updatedAt: number; source: 'custom' | 'auto' };
iconBackground?: string;
};
type ProjectSection = {
project: ProjectItem;
groups: SessionGroup[];
};
type ProjectSectionCacheEntry = {
project: ProjectItem;
activeSessions: Session[];
archivedSessions: Session[];
availableWorktrees: WorktreeMetadata[];
rootBranch: string | null;
isRepo: boolean;
buildGroupedSessions: Args['buildGroupedSessions'];
section: ProjectSection;
};
const EMPTY_WORKTREES: WorktreeMetadata[] = [];
type Args = {
normalizedProjects: ProjectItem[];
getSessionsForProject: (projectId: string) => Session[];
getArchivedSessionsForProject: (projectId: string) => Session[];
availableWorktreesByProject: Map<string, WorktreeMetadata[]>;
projectRepoStatus: Map<string, boolean | null>;
projectRootBranches: Map<string, string | null>;
lastRepoStatus: boolean;
buildGroupedSessions: (
sessions: Session[],
projectRoot: string,
availableWorktrees: WorktreeMetadata[],
rootBranch: string | null,
isRepo: boolean,
) => SessionGroup[];
hasSessionSearchQuery: boolean;
normalizedSessionSearchQuery: string;
filterSessionNodesForSearch: (nodes: SessionNode[], query: string) => SessionNode[];
buildGroupSearchText: (group: SessionGroup) => string;
foldersMap: SessionFoldersMap;
};
export const useSessionSidebarSections = (args: Args) => {
const {
normalizedProjects,
getSessionsForProject,
getArchivedSessionsForProject,
availableWorktreesByProject,
projectRepoStatus,
projectRootBranches,
lastRepoStatus,
buildGroupedSessions,
hasSessionSearchQuery,
normalizedSessionSearchQuery,
filterSessionNodesForSearch,
buildGroupSearchText,
foldersMap,
} = args;
const projectSectionCacheRef = React.useRef<Map<string, ProjectSectionCacheEntry>>(new Map());
const projectSections = React.useMemo<ProjectSection[]>(() => {
const previousCache = projectSectionCacheRef.current;
const nextCache = new Map<string, ProjectSectionCacheEntry>();
let reusedSections = 0;
let rebuiltSections = 0;
const sameSessions = (left: Session[], right: Session[]): boolean => (
left.length === right.length && left.every((session, index) => session === right[index])
);
const sections = normalizedProjects.map((project) => {
const activeSessions = getSessionsForProject(project.id);
const archivedSessions = getArchivedSessionsForProject(project.id);
const worktreesForProject = availableWorktreesByProject.get(project.normalizedPath) ?? EMPTY_WORKTREES;
const isRepo = projectRepoStatus.has(project.id)
? Boolean(projectRepoStatus.get(project.id))
: lastRepoStatus;
const rootBranch = projectRootBranches.get(project.id) ?? null;
const cached = previousCache.get(project.id);
if (
cached
&& cached.project === project
&& sameSessions(cached.activeSessions, activeSessions)
&& sameSessions(cached.archivedSessions, archivedSessions)
&& cached.availableWorktrees === worktreesForProject
&& cached.rootBranch === rootBranch
&& cached.isRepo === isRepo
&& cached.buildGroupedSessions === buildGroupedSessions
) {
reusedSections += 1;
nextCache.set(project.id, cached);
return cached.section;
}
rebuiltSections += 1;
const projectSessions = dedupeSessionsById([...activeSessions, ...archivedSessions]);
const groups = buildGroupedSessions(
projectSessions,
project.normalizedPath,
worktreesForProject,
rootBranch,
isRepo,
);
const section = { project, groups };
nextCache.set(project.id, {
project,
activeSessions,
archivedSessions,
availableWorktrees: worktreesForProject,
rootBranch,
isRepo,
buildGroupedSessions,
section,
});
return section;
});
projectSectionCacheRef.current = nextCache;
if (reusedSections > 0) streamPerfCount('ui.sidebar.project_section.reused', reusedSections);
if (rebuiltSections > 0) streamPerfCount('ui.sidebar.project_section.rebuilt', rebuiltSections);
return sections;
}, [
normalizedProjects,
getSessionsForProject,
getArchivedSessionsForProject,
availableWorktreesByProject,
projectRepoStatus,
lastRepoStatus,
buildGroupedSessions,
projectRootBranches,
]);
const visibleProjectSections = React.useMemo(() => {
return projectSections;
}, [projectSections]);
const groupSearchDataByGroup = React.useMemo(() => {
const result = new WeakMap<SessionGroup, GroupSearchData>();
if (!hasSessionSearchQuery) {
return result;
}
const countNodes = (nodes: SessionNode[]): number => nodes.reduce((total, node) => total + 1 + countNodes(node.children), 0);
visibleProjectSections.forEach((section) => {
section.groups.forEach((group) => {
const filteredNodes = filterSessionNodesForSearch(group.sessions, normalizedSessionSearchQuery);
const matchedSessionCount = countNodes(filteredNodes);
const groupMatches = matchesRankQuery([buildGroupSearchText(group)], normalizedSessionSearchQuery);
const scopeKey = normalizePath(group.directory ?? null);
const scopeFolders = scopeKey ? (foldersMap[scopeKey] ?? []) : [];
const folderNameMatchCount = scopeFolders.filter((folder) => matchesRankQuery([folder.name], normalizedSessionSearchQuery)).length;
result.set(group, {
filteredNodes,
matchedSessionCount,
folderNameMatchCount,
groupMatches,
hasMatch: groupMatches || matchedSessionCount > 0 || folderNameMatchCount > 0,
});
});
});
return result;
}, [
hasSessionSearchQuery,
visibleProjectSections,
filterSessionNodesForSearch,
normalizedSessionSearchQuery,
buildGroupSearchText,
foldersMap,
]);
const searchableProjectSections = React.useMemo(() => {
if (!hasSessionSearchQuery) {
return visibleProjectSections;
}
return visibleProjectSections
.map((section) => ({
...section,
groups: section.groups.filter((group) => groupSearchDataByGroup.get(group)?.hasMatch === true),
}))
.filter((section) => section.groups.length > 0);
}, [hasSessionSearchQuery, visibleProjectSections, groupSearchDataByGroup]);
const sectionsForRender = hasSessionSearchQuery ? searchableProjectSections : visibleProjectSections;
// Flat display sections: one merged group per project containing every
// non-archived session from the project root and all of its worktrees.
// Worktree grouping stays available in `projectSections` for data consumers
// (bootstrap demand planning, ownership); rendering is flat.
// The per-section cache keeps merged group references stable so the
// memoized SessionGroupSection subtree skips unrelated update waves.
const flatSectionCacheRef = React.useRef<WeakMap<ProjectSection, { query: string; section: ProjectSection }>>(new WeakMap());
const flatSectionsForRender = React.useMemo<ProjectSection[]>(() => {
const cache = flatSectionCacheRef.current;
return sectionsForRender.map((section) => {
const cached = cache.get(section);
if (cached && cached.query === normalizedSessionSearchQuery) {
return cached.section;
}
const nonArchivedGroups = section.groups.filter((group) => !group.isArchivedBucket);
const archivedGroups = section.groups.filter((group) => group.isArchivedBucket);
const sessions = nonArchivedGroups.flatMap((group) => hasSessionSearchQuery
? (groupSearchDataByGroup.get(group)?.filteredNodes ?? [])
: group.sessions);
const folderScopes = nonArchivedGroups
.map((group) => ({
scopeKey: group.folderScopeKey ?? normalizePath(group.directory ?? null),
directory: group.directory ?? null,
}))
.filter((scope): scope is { scopeKey: string; directory: string | null } => Boolean(scope.scopeKey));
const rootGroup = nonArchivedGroups.find((group) => group.isMain) ?? null;
const flatGroup: SessionGroup = {
id: 'flat',
label: rootGroup?.label ?? '',
branch: rootGroup?.branch ?? null,
description: rootGroup?.description ?? null,
isMain: true,
isArchivedBucket: false,
worktree: null,
directory: rootGroup?.directory ?? section.project.normalizedPath,
folderScopeKey: rootGroup?.folderScopeKey ?? section.project.normalizedPath,
folderScopes,
sessions,
};
if (hasSessionSearchQuery) {
const merged = nonArchivedGroups
.map((group) => groupSearchDataByGroup.get(group))
.filter((data): data is GroupSearchData => Boolean(data));
groupSearchDataByGroup.set(flatGroup, {
filteredNodes: sessions,
matchedSessionCount: merged.reduce((total, data) => total + data.matchedSessionCount, 0),
folderNameMatchCount: merged.reduce((total, data) => total + data.folderNameMatchCount, 0),
groupMatches: merged.some((data) => data.groupMatches),
hasMatch: merged.some((data) => data.hasMatch),
});
}
const flatSection: ProjectSection = {
project: section.project,
groups: [flatGroup, ...archivedGroups],
};
cache.set(section, { query: normalizedSessionSearchQuery, section: flatSection });
return flatSection;
});
}, [groupSearchDataByGroup, hasSessionSearchQuery, normalizedSessionSearchQuery, sectionsForRender]);
const searchMatchCount = React.useMemo(() => {
if (!hasSessionSearchQuery) {
return 0;
}
return sectionsForRender.reduce((total, section) => {
return total + section.groups.reduce((groupTotal, group) => {
const data = groupSearchDataByGroup.get(group);
if (!data) {
return groupTotal;
}
const metadataMatches = data.folderNameMatchCount + (data.groupMatches ? 1 : 0);
return groupTotal + data.matchedSessionCount + metadataMatches;
}, 0);
}, 0);
}, [hasSessionSearchQuery, sectionsForRender, groupSearchDataByGroup]);
return {
projectSections,
visibleProjectSections,
groupSearchDataByGroup,
searchableProjectSections,
sectionsForRender,
flatSectionsForRender,
searchMatchCount,
};
};
@@ -0,0 +1,59 @@
import React from 'react';
type Args = {
enabled?: boolean;
isDesktopShellRuntime: boolean;
projectSections: unknown[];
projectHeaderSentinelRefs: React.MutableRefObject<Map<string, HTMLDivElement | null>>;
};
export const useStickyProjectHeaders = (args: Args): Set<string> => {
const { enabled = true, isDesktopShellRuntime, projectSections, projectHeaderSentinelRefs } = args;
const [stuckProjectHeaders, setStuckProjectHeaders] = React.useState<Set<string>>(new Set());
React.useEffect(() => {
if (!enabled || !isDesktopShellRuntime) {
setStuckProjectHeaders((prev) => (prev.size === 0 ? prev : new Set()));
return;
}
const firstSentinel = Array.from(projectHeaderSentinelRefs.current.values()).find((el) => el !== null);
const root = firstSentinel?.closest<HTMLElement>('.oc-sidebar-scroller') ?? null;
if (!root) {
return;
}
const observer = new IntersectionObserver(
(entries) => {
setStuckProjectHeaders((prev) => {
const next = new Set(prev);
let changed = false;
for (const entry of entries) {
const projectId = (entry.target as HTMLElement).dataset.projectId;
if (!projectId) continue;
const rootTop = entry.rootBounds?.top ?? root.getBoundingClientRect().top;
const isAboveScroller = !entry.isIntersecting && entry.boundingClientRect.top < rootTop;
if (next.has(projectId) === isAboveScroller) continue;
changed = true;
if (isAboveScroller) next.add(projectId);
else next.delete(projectId);
}
return changed ? next : prev;
});
},
{ root, threshold: 0 },
);
projectHeaderSentinelRefs.current.forEach((el) => {
if (el) {
observer.observe(el);
}
});
return () => observer.disconnect();
}, [enabled, isDesktopShellRuntime, projectHeaderSentinelRefs, projectSections]);
return stuckProjectHeaders;
};
@@ -0,0 +1,22 @@
import { normalizePath } from '../utils';
// In-memory first-seen tracker for worktree directories. Worktree metadata
// carries no creation time, so we record when a path first appears during
// this app run: a worktree created mid-session sorts to the top of its
// project's empty-worktree tail, while everything discovered at startup ties
// (same tick) and falls back to alphabetical order.
const firstSeenAtByPath = new Map<string, number>();
export const recordWorktreesSeen = (paths: Iterable<string | null | undefined>, seenAt: number): void => {
for (const path of paths) {
const normalized = normalizePath(path ?? null);
if (normalized && !firstSeenAtByPath.has(normalized)) {
firstSeenAtByPath.set(normalized, seenAt);
}
}
};
export const getWorktreeFirstSeenAt = (path: string | null | undefined): number => {
const normalized = normalizePath(path ?? null);
return normalized ? (firstSeenAtByPath.get(normalized) ?? 0) : 0;
};