diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index ca38d65908b..89e719f7760 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -1,19 +1,5 @@ -import { - closestCenter, - DndContext, - type DragEndEvent, - KeyboardSensor, - PointerSensor, - useSensor, - useSensors -} from '@dnd-kit/core' -import { - arrayMove, - SortableContext, - sortableKeyboardCoordinates, - useSortable, - verticalListSortingStrategy -} from '@dnd-kit/sortable' +import { KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core' +import { sortableKeyboardCoordinates } from '@dnd-kit/sortable' import { useStore } from '@nanostores/react' import type * as React from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -21,7 +7,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { PlatformAvatar } from '@/app/messaging/platform-icon' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' -import { DisclosureCaret } from '@/components/ui/disclosure-caret' import { GlyphSpinner } from '@/components/ui/glyph-spinner' import { KbdGroup } from '@/components/ui/kbd' import { SearchField } from '@/components/ui/search-field' @@ -34,13 +19,10 @@ import { SidebarMenuButton, SidebarMenuItem } from '@/components/ui/sidebar' -import { Skeleton } from '@/components/ui/skeleton' -import type { HermesGitWorktree } from '@/global' import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes' import { useI18n } from '@/i18n' import { comboTokens } from '@/lib/keybinds/combo' import { profileColor } from '@/lib/profile-color' -import { flattenSessionsWithBranches } from '@/lib/session-branch-tree' import { sessionMatchesSearch } from '@/lib/session-search' import { normalizeSessionSource, sessionSourceLabel } from '@/lib/session-source' import { cn } from '@/lib/utils' @@ -114,37 +96,31 @@ import { } from '@/store/session' import { type AppView, ARTIFACTS_ROUTE, MESSAGING_ROUTE, SKILLS_ROUTE } from '../../routes' -import { SidebarPanelLabel } from '../../shell/sidebar-label' import type { SidebarNavItem } from '../../types' -import { countLabel, SidebarCount } from './chrome' +import { countLabel } from './chrome' import { SidebarCronJobsSection } from './cron-jobs-section' import { SidebarLoadMoreRow } from './load-more-row' -import { reconcileFreshFirst, resolveManualSessionOrderIds } from './order' +import { orderByIds, reconcileOrderIds, resolveManualSessionOrderIds, sameIds } from './order' import { ProfileRail } from './profile-switcher' import { ProjectDialog } from './project-dialog' import { - EnteredProjectContent, overlayLiveLanes, overlayLivePreviews, PROJECT_PREVIEW_COUNT, ProjectBackRow, ProjectMenu, - ProjectOverviewRow, projectTreeCwd, sessionRecency as sessionTime, type SidebarProjectTree, type SidebarSessionGroup, - SidebarWorkspaceGroup, type SidebarWorkspaceTree, sortProjectsForOverview, StartWorkButton, useRepoWorktreeMap } from './projects' -import { SidebarSessionRow } from './session-row' -import { VirtualSessionList } from './virtual-session-list' - -const VIRTUALIZE_THRESHOLD = 25 +import { SidebarBlankState, SidebarPinnedEmptyState, SidebarSessionSkeletons } from './section-states' +import { SidebarSessionsSection, VIRTUALIZE_THRESHOLD } from './sessions-section' // Non-session groups (messaging platforms) stay compact: show a few rows up // front, reveal more in larger steps on demand. Keeps a busy platform from @@ -196,108 +172,6 @@ const HEADER_ACTION_BTN = const HEADER_NAV_BTN = 'text-(--ui-text-tertiary) opacity-70 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100 focus-visible:opacity-100' -// Sidebar reordering is a strictly vertical list. The dragged item's transform -// is rendered Y-only in useSortableBindings (no x, no scale); this just stops -// dnd-kit's auto-scroll from dragging the rail — or the window — sideways when -// the pointer nears an edge, killing the horizontal "drag to valhalla". -const reorderAutoScroll = { threshold: { x: 0, y: 0.2 } } - -// One self-contained, nesting-safe reorderable list. It owns its DndContext, so a -// drag only ever collides with THIS list's own items — drop it at any depth (repos, -// worktrees, sessions) and reordering "just works" without leaking into the lists -// around or inside it. Pair each item with useSortableBindings(id); the list reports -// the new id order and the caller persists it. This is the single generic primitive -// behind every reorderable surface in the sidebar. -function ReorderableList({ - children, - ids, - onReorder, - sensors -}: { - children: React.ReactNode - ids: string[] - onReorder: (ids: string[]) => void - sensors?: ReturnType -}) { - const handleDragEnd = ({ activatorEvent, active, over }: DragEndEvent) => { - // dnd-kit only restores focus for keyboard drags; after a pointer drop the - // browser leaves :focus on the grab handle, which keeps a focus-within - // grabber/affordance reveal stuck "on". Drop that focus so the row returns - // to its resting state once the pointer moves away. - if (!(activatorEvent instanceof KeyboardEvent)) { - ;(document.activeElement as HTMLElement | null)?.blur() - } - - if (!over || active.id === over.id) { - return - } - - const from = ids.indexOf(String(active.id)) - const to = ids.indexOf(String(over.id)) - - if (from >= 0 && to >= 0) { - onReorder(arrayMove(ids, from, to)) - } - } - - return ( - - - {children} - - - ) -} - -function orderByIds(items: T[], getId: (item: T) => string, orderIds: string[]): T[] { - if (!orderIds.length) { - return items - } - - const byId = new Map(items.map(item => [getId(item), item])) - const seen = new Set() - const ordered: T[] = [] - - for (const id of orderIds) { - const item = byId.get(id) - - if (item) { - ordered.push(item) - seen.add(id) - } - } - - // Items missing from the persisted order are new since it was last - // reconciled. Callers pass recency-sorted lists (newest first), so surface - // these at the TOP instead of burying them beneath the saved order — - // otherwise a brand-new session sinks to the bottom of the sidebar and reads - // as "my latest session never showed up". - const fresh = items.filter(item => !seen.has(getId(item))) - - return fresh.length ? [...fresh, ...ordered] : ordered -} - -function reconcileOrderIds(currentIds: string[], orderIds: string[]): string[] { - if (!currentIds.length) { - return [] - } - - if (!orderIds.length) { - return currentIds - } - - return reconcileFreshFirst(currentIds, orderIds) -} - -function sameIds(left: string[], right: string[]) { - return left.length === right.length && left.every((item, index) => item === right[index]) -} - // FTS results cover sessions that aren't in the loaded page; synthesize a // minimal SessionInfo so they render in the same row component (resume works // by id; the snippet stands in for the preview). @@ -324,25 +198,6 @@ function searchResultToSession(result: SessionSearchResult): SessionInfo { } } -function useSortableBindings(id: string) { - const { attributes, isDragging, listeners, setNodeRef, transform, transition } = useSortable({ id }) - - return { - dragging: isDragging, - dragHandleProps: { ...attributes, ...listeners }, - ref: setNodeRef, - reorderable: true as const, - style: { - // Uniform vertical list: only ever translate on Y. Ignoring x and the - // scaleX/scaleY that CSS.Transform.toString would emit keeps a dragged - // group/row from drifting sideways or morphing its size mid-drag. - transform: transform ? `translate3d(0px, ${transform.y}px, 0)` : undefined, - transition: isDragging ? undefined : transition, - willChange: isDragging ? 'transform' : undefined - } - } -} - interface ChatSidebarProps extends React.ComponentProps { currentView: AppView onNavigate: (item: SidebarNavItem) => void @@ -1149,8 +1004,7 @@ export function ChatSidebar({ const showSessionSkeletons = sessionsLoading && sortedSessions.length === 0 - const showSessionSections = - showSessionSkeletons || sortedSessions.length > 0 || projectModel.length > 0 + const showSessionSections = showSessionSkeletons || sortedSessions.length > 0 || projectModel.length > 0 // Each reorderable list reports its OWN new id order; persisting is a direct, // typed write — no id-prefix sniffing to figure out which level moved. @@ -1551,110 +1405,6 @@ export function ChatSidebar({ ) } -interface SidebarSectionHeaderProps { - label: string - open: boolean - onToggle: () => void - action?: React.ReactNode - meta?: React.ReactNode - icon?: React.ReactNode - // When false the section can't be collapsed: the label renders static (no - // toggle, no caret) and the section is always open. Used for the single- - // project view, where collapsing one project makes no sense. - collapsible?: boolean -} - -function SidebarSectionHeader({ - label, - open, - onToggle, - action, - meta, - icon, - collapsible = true -}: SidebarSectionHeaderProps) { - const labelBody = ( - <> - {icon} - {label} - {meta && {meta}} - - ) - - return ( -
- {collapsible ? ( - - ) : ( -
{labelBody}
- )} - {action} -
- ) -} - -function SidebarSessionSkeletons() { - return ( - - ) -} - -function SidebarBlankState({ onNewProject }: { onNewProject: () => void }) { - const { t } = useI18n() - const s = t.sidebar - - return ( -
-
- -

{s.noSessions}

- -
-
- ) -} - -function SidebarPinnedEmptyState() { - const { t } = useI18n() - - return ( -
- - - - {t.sidebar.shiftClickHint} -
- ) -} - interface MessagingSection { sourceId: string label: string @@ -1662,302 +1412,3 @@ interface MessagingSection { total: number hasMore: boolean } - -interface SidebarSessionsSectionProps { - label: string - open: boolean - onToggle: () => void - sessions: SessionInfo[] - activeSessionId: null | string - workingSessionIdSet: Set - onResumeSession: (sessionId: string) => void - onDeleteSession: (sessionId: string) => void - onArchiveSession: (sessionId: string) => void - onBranchSession?: (sessionId: string, profile?: string) => void - onTogglePin: (sessionId: string) => void - onNewSessionInWorkspace?: (path: null | string) => void - pinned: boolean - rootClassName?: string - contentClassName?: string - emptyState: React.ReactNode - forceEmptyState?: boolean - headerAction?: React.ReactNode - footer?: React.ReactNode - groups?: SidebarSessionGroup[] - tree?: SidebarWorkspaceTree[] - // Project overview: when present, render a drill-in list of project rows - // instead of sessions. Clicking a row enters that project (onEnterProject), - // which then passes `projectContent` on the next render. Takes precedence - // over `tree` / `groups`. - projectOverview?: SidebarProjectTree[] - // Per-project preview rows (from the backend tree), keyed by project path. - projectOverviewPreviews?: Record - // True while the backend project tree is loading (overview skeleton). - projectsLoading?: boolean - onEnterProject?: (id: string) => void - // The entered project's flattened content: main-checkout sessions render - // directly (no redundant repo/branch header); only linked worktrees nest. - projectContent?: SidebarProjectTree - // Live git lanes (`git worktree list`) for repos in the entered project — - // a VISUAL enhancer only (empty lanes), never session membership. - projectRepoWorktrees?: Record - // Live session cache used for optimistic placement inside entered-project lanes. - liveSessions?: SessionInfo[] - // Client-side optimistic eviction layer (deleted/archived ids). - removedSessionIds?: ReadonlySet - activeProjectId?: null | string - labelMeta?: React.ReactNode - labelIcon?: React.ReactNode - // When false the section header is static (no caret/toggle) and always open. - collapsible?: boolean - sortable?: boolean - // The flat session list is the only hand-reorderable surface (grouped/project - // views sort deterministically), so it owns the one ReorderableList. - onReorderSessions?: (ids: string[]) => void - // Drag-to-reorder for the project overview list (top-level projects). - onReorderProjects?: (ids: string[]) => void - // Rendered atop the entered-project body (a "back to overview" row). - projectBackRow?: React.ReactNode - dndSensors?: ReturnType -} - -function SidebarSessionsSection({ - label, - open, - onToggle, - sessions, - activeSessionId, - workingSessionIdSet, - onResumeSession, - onDeleteSession, - onArchiveSession, - onBranchSession, - onTogglePin, - onNewSessionInWorkspace, - pinned, - rootClassName, - contentClassName, - emptyState, - forceEmptyState = false, - headerAction, - footer, - groups, - projectOverview, - projectOverviewPreviews, - projectsLoading = false, - onEnterProject, - projectContent, - projectRepoWorktrees, - liveSessions, - removedSessionIds, - activeProjectId, - labelMeta, - labelIcon, - collapsible = true, - sortable = false, - onReorderSessions, - onReorderProjects, - projectBackRow, - dndSensors -}: SidebarSessionsSectionProps) { - const sectionOpen = collapsible ? open : true - const hasGroupedSessions = Boolean(groups?.some(group => group.sessions.length > 0)) - // A defined project list is itself content (even an empty project should - // render as a drill-in row so the user can see it exists). - const hasProjectOverview = Boolean(projectOverview?.length) - const hasProjectContent = Boolean(projectContent && projectContent.sessionCount > 0) - - const showEmptyState = - forceEmptyState || (!hasGroupedSessions && !hasProjectOverview && !hasProjectContent && sessions.length === 0) - - // The flat recents/pinned list is the only place sessions reorder by hand; - // grouped/tree views always sort by creation date and never drag. - const sessionsDraggable = sortable && !!onReorderSessions - const displayEntries = useMemo(() => flattenSessionsWithBranches(sessions), [sessions]) - - const renderRow = (session: SessionInfo, draggable: boolean, branchStem?: string) => { - const rowProps = { - branchStem, - isPinned: pinned, - isSelected: session.id === activeSessionId, - isWorking: workingSessionIdSet.has(session.id), - onArchive: () => onArchiveSession(session.id), - onBranch: onBranchSession ? () => onBranchSession(session.id, session.profile) : undefined, - onDelete: () => onDeleteSession(session.id), - onPin: () => onTogglePin(sessionPinId(session)), - onResume: () => onResumeSession(session.id), - reorderable: draggable && !branchStem, - session - } - - return draggable && !branchStem ? ( - - ) : ( - - ) - } - - // Sessions inside repos/worktrees are date-ordered and static. - const renderRows = (items: SessionInfo[]) => - flattenSessionsWithBranches(items).map(({ branchStem, session }) => renderRow(session, false, branchStem)) - - const flatVirtualized = - !showEmptyState && - !groups?.length && - !projectOverview?.length && - !projectContent && - sessions.length >= VIRTUALIZE_THRESHOLD - - // First paint into the grouped view (e.g. the app restoring the Projects tab) - // has flat recents in `sessions` but no tree yet. Show skeletons rather than - // flashing the flat session list until the overview/content/groups resolve. A - // background refresh keeps the prior tree, so this only fires when empty. - const showProjectsSkeleton = - projectsLoading && !hasProjectOverview && !hasProjectContent && !projectContent && !groups?.length - - let inner: React.ReactNode - - if (showProjectsSkeleton) { - inner = - } else if (projectContent) { - // Entered a project: the back row is always present, then either the - // (overlay-aware) content or a clean empty state — never a bare spinner or a - // blank pane while lanes hydrate. - inner = ( - <> - {projectBackRow} - {hasProjectContent ? ( - - ) : ( - emptyState - )} - - ) - } else if (showEmptyState) { - inner = emptyState - } else if (projectOverview?.length) { - // The model is already ordered (default sort groups explicit-before-auto; - // a manual drag-order, when present, wins). Render in that order and make - // rows drag-to-reorder when a handler is wired. - const projectsDraggable = projectOverview.length > 1 && !!onReorderProjects - const Row = projectsDraggable ? SortableProjectOverviewRow : ProjectOverviewRow - - const rows = projectOverview.map(project => ( - - )) - - inner = - projectsDraggable && onReorderProjects ? ( - project.id)} - onReorder={onReorderProjects} - sensors={dndSensors} - > - {rows} - - ) : ( - rows - ) - } else if (groups?.length) { - // Profile/source groups never reorder; render them flat with static rows. - inner = groups.map(group => ( - - )) - } else if (flatVirtualized) { - const virtual = ( - - ) - - inner = - sessionsDraggable && onReorderSessions ? ( - s.id)} onReorder={onReorderSessions} sensors={dndSensors}> - {virtual} - - ) : ( - virtual - ) - } else if (sessionsDraggable && onReorderSessions) { - inner = ( - s.id)} onReorder={onReorderSessions} sensors={dndSensors}> - {displayEntries.map(({ branchStem, session }) => renderRow(session, true, branchStem))} - - ) - } else { - inner = displayEntries.map(({ branchStem, session }) => renderRow(session, false, branchStem)) - } - - // The virtualizer owns its own scroller, so suppress the wrapper's overflow - // to avoid a double scroll container. - const resolvedContentClassName = cn(contentClassName, flatVirtualized && 'overflow-y-visible') - - return ( - - - {sectionOpen && ( - - {inner} - {footer} - - )} - - ) -} - -interface SortableSessionRowProps { - session: SessionInfo - isPinned: boolean - isSelected: boolean - isWorking: boolean - onArchive: () => void - onDelete: () => void - onPin: () => void - onResume: () => void -} - -function SortableSidebarSessionRow(props: SortableSessionRowProps) { - return -} - -function SortableProjectOverviewRow(props: React.ComponentProps) { - return -} diff --git a/apps/desktop/src/app/chat/sidebar/order.test.ts b/apps/desktop/src/app/chat/sidebar/order.test.ts index f65b08e260c..e1a48bc5fbf 100644 --- a/apps/desktop/src/app/chat/sidebar/order.test.ts +++ b/apps/desktop/src/app/chat/sidebar/order.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { resolveManualSessionOrderIds } from './order' +import { orderByIds, reconcileOrderIds, resolveManualSessionOrderIds, sameIds } from './order' describe('resolveManualSessionOrderIds', () => { it('clears legacy auto-seeded order until the user manually reorders sessions', () => { @@ -19,3 +19,44 @@ describe('resolveManualSessionOrderIds', () => { expect(resolveManualSessionOrderIds(['newest'], ['gone'], true)).toEqual([]) }) }) + +describe('orderByIds', () => { + const id = (item: { id: string }) => item.id + + it('returns items untouched when no order is given', () => { + const items = [{ id: 'a' }, { id: 'b' }] + expect(orderByIds(items, id, [])).toBe(items) + }) + + it('reorders by the given ids and drops missing ones', () => { + const items = [{ id: 'a' }, { id: 'b' }, { id: 'c' }] + expect(orderByIds(items, id, ['c', 'gone', 'a'])).toEqual([{ id: 'b' }, { id: 'c' }, { id: 'a' }]) + }) + + it('surfaces items absent from the order first', () => { + const items = [{ id: 'fresh' }, { id: 'a' }, { id: 'b' }] + expect(orderByIds(items, id, ['b', 'a'])).toEqual([{ id: 'fresh' }, { id: 'b' }, { id: 'a' }]) + }) +}) + +describe('reconcileOrderIds', () => { + it('returns empty for no current ids', () => { + expect(reconcileOrderIds([], ['a'])).toEqual([]) + }) + + it('returns current ids when there is no saved order', () => { + expect(reconcileOrderIds(['a', 'b'], [])).toEqual(['a', 'b']) + }) + + it('puts newly-seen ids ahead of the retained saved order', () => { + expect(reconcileOrderIds(['fresh', 'a', 'b'], ['b', 'a', 'gone'])).toEqual(['fresh', 'b', 'a']) + }) +}) + +describe('sameIds', () => { + it('is true only for identical ordered lists', () => { + expect(sameIds(['a', 'b'], ['a', 'b'])).toBe(true) + expect(sameIds(['a', 'b'], ['b', 'a'])).toBe(false) + expect(sameIds(['a'], ['a', 'b'])).toBe(false) + }) +}) diff --git a/apps/desktop/src/app/chat/sidebar/order.ts b/apps/desktop/src/app/chat/sidebar/order.ts index 97225ac5a4c..9cefea57d01 100644 --- a/apps/desktop/src/app/chat/sidebar/order.ts +++ b/apps/desktop/src/app/chat/sidebar/order.ts @@ -21,3 +21,50 @@ export function resolveManualSessionOrderIds(currentIds: string[], orderIds: str return reconcileFreshFirst(currentIds, orderIds) } + +/** Reorder `items` by `orderIds`; items missing from the order surface first. */ +export function orderByIds(items: T[], getId: (item: T) => string, orderIds: string[]): T[] { + if (!orderIds.length) { + return items + } + + const byId = new Map(items.map(item => [getId(item), item])) + const seen = new Set() + const ordered: T[] = [] + + for (const id of orderIds) { + const item = byId.get(id) + + if (item) { + ordered.push(item) + seen.add(id) + } + } + + // Items missing from the persisted order are new since it was last + // reconciled. Callers pass recency-sorted lists (newest first), so surface + // these at the TOP instead of burying them beneath the saved order — + // otherwise a brand-new session sinks to the bottom of the sidebar and reads + // as "my latest session never showed up". + const fresh = items.filter(item => !seen.has(getId(item))) + + return fresh.length ? [...fresh, ...ordered] : ordered +} + +/** Reconcile a persisted order against the live id set (fresh-first). */ +export function reconcileOrderIds(currentIds: string[], orderIds: string[]): string[] { + if (!currentIds.length) { + return [] + } + + if (!orderIds.length) { + return currentIds + } + + return reconcileFreshFirst(currentIds, orderIds) +} + +/** True when two id lists are element-for-element identical. */ +export function sameIds(left: string[], right: string[]): boolean { + return left.length === right.length && left.every((item, index) => item === right[index]) +} diff --git a/apps/desktop/src/app/chat/sidebar/reorderable-list.tsx b/apps/desktop/src/app/chat/sidebar/reorderable-list.tsx new file mode 100644 index 00000000000..8be14fcb8ea --- /dev/null +++ b/apps/desktop/src/app/chat/sidebar/reorderable-list.tsx @@ -0,0 +1,81 @@ +import type { useSensors } from '@dnd-kit/core'; +import { closestCenter, DndContext, type DragEndEvent } from '@dnd-kit/core' +import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable' +import type * as React from 'react' + +// Sidebar reordering is a strictly vertical list. The dragged item's transform +// is rendered Y-only in useSortableBindings (no x, no scale); this just stops +// dnd-kit's auto-scroll from dragging the rail — or the window — sideways when +// the pointer nears an edge, killing the horizontal "drag to valhalla". +const reorderAutoScroll = { threshold: { x: 0, y: 0.2 } } + +// One self-contained, nesting-safe reorderable list. It owns its DndContext, so a +// drag only ever collides with THIS list's own items — drop it at any depth (repos, +// worktrees, sessions) and reordering "just works" without leaking into the lists +// around or inside it. Pair each item with useSortableBindings(id); the list reports +// the new id order and the caller persists it. This is the single generic primitive +// behind every reorderable surface in the sidebar. +export function ReorderableList({ + children, + ids, + onReorder, + sensors +}: { + children: React.ReactNode + ids: string[] + onReorder: (ids: string[]) => void + sensors?: ReturnType +}) { + const handleDragEnd = ({ activatorEvent, active, over }: DragEndEvent) => { + // dnd-kit only restores focus for keyboard drags; after a pointer drop the + // browser leaves :focus on the grab handle, which keeps a focus-within + // grabber/affordance reveal stuck "on". Drop that focus so the row returns + // to its resting state once the pointer moves away. + if (!(activatorEvent instanceof KeyboardEvent)) { + ;(document.activeElement as HTMLElement | null)?.blur() + } + + if (!over || active.id === over.id) { + return + } + + const from = ids.indexOf(String(active.id)) + const to = ids.indexOf(String(over.id)) + + if (from >= 0 && to >= 0) { + onReorder(arrayMove(ids, from, to)) + } + } + + return ( + + + {children} + + + ) +} + +export function useSortableBindings(id: string) { + const { attributes, isDragging, listeners, setNodeRef, transform, transition } = useSortable({ id }) + + return { + dragging: isDragging, + dragHandleProps: { ...attributes, ...listeners }, + ref: setNodeRef, + reorderable: true as const, + style: { + // Uniform vertical list: only ever translate on Y. Ignoring x and the + // scaleX/scaleY that CSS.Transform.toString would emit keeps a dragged + // group/row from drifting sideways or morphing its size mid-drag. + transform: transform ? `translate3d(0px, ${transform.y}px, 0)` : undefined, + transition: isDragging ? undefined : transition, + willChange: isDragging ? 'transform' : undefined + } + } +} diff --git a/apps/desktop/src/app/chat/sidebar/section-states.tsx b/apps/desktop/src/app/chat/sidebar/section-states.tsx new file mode 100644 index 00000000000..d65eda98132 --- /dev/null +++ b/apps/desktop/src/app/chat/sidebar/section-states.tsx @@ -0,0 +1,52 @@ +import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' +import { Skeleton } from '@/components/ui/skeleton' +import { useI18n } from '@/i18n' +import { cn } from '@/lib/utils' + +export function SidebarSessionSkeletons() { + return ( + + ) +} + +export function SidebarBlankState({ onNewProject }: { onNewProject: () => void }) { + const { t } = useI18n() + const s = t.sidebar + + return ( +
+
+ +

{s.noSessions}

+ +
+
+ ) +} + +export function SidebarPinnedEmptyState() { + const { t } = useI18n() + + return ( +
+ + + + {t.sidebar.shiftClickHint} +
+ ) +} diff --git a/apps/desktop/src/app/chat/sidebar/sessions-section.tsx b/apps/desktop/src/app/chat/sidebar/sessions-section.tsx new file mode 100644 index 00000000000..ffe729eb51e --- /dev/null +++ b/apps/desktop/src/app/chat/sidebar/sessions-section.tsx @@ -0,0 +1,379 @@ +import type { useSensors } from '@dnd-kit/core' +import type * as React from 'react' +import { useMemo } from 'react' + +import { SidebarPanelLabel } from '@/app/shell/sidebar-label' +import { DisclosureCaret } from '@/components/ui/disclosure-caret' +import { SidebarGroup, SidebarGroupContent } from '@/components/ui/sidebar' +import type { HermesGitWorktree } from '@/global' +import type { SessionInfo } from '@/hermes' +import { flattenSessionsWithBranches } from '@/lib/session-branch-tree' +import { cn } from '@/lib/utils' +import { sessionPinId } from '@/store/session' + +import { SidebarCount } from './chrome' +import { + EnteredProjectContent, + ProjectOverviewRow, + type SidebarProjectTree, + type SidebarSessionGroup, + SidebarWorkspaceGroup, + type SidebarWorkspaceTree +} from './projects' +import { ReorderableList, useSortableBindings } from './reorderable-list' +import { SidebarSessionSkeletons } from './section-states' +import { SidebarSessionRow } from './session-row' +import { VirtualSessionList } from './virtual-session-list' + +export const VIRTUALIZE_THRESHOLD = 25 + +interface SidebarSectionHeaderProps { + label: string + open: boolean + onToggle: () => void + action?: React.ReactNode + meta?: React.ReactNode + icon?: React.ReactNode + // When false the section can't be collapsed: the label renders static (no + // toggle, no caret) and the section is always open. Used for the single- + // project view, where collapsing one project makes no sense. + collapsible?: boolean +} + +function SidebarSectionHeader({ + label, + open, + onToggle, + action, + meta, + icon, + collapsible = true +}: SidebarSectionHeaderProps) { + const labelBody = ( + <> + {icon} + {label} + {meta && {meta}} + + ) + + return ( +
+ {collapsible ? ( + + ) : ( +
{labelBody}
+ )} + {action} +
+ ) +} + +interface SidebarSessionsSectionProps { + label: string + open: boolean + onToggle: () => void + sessions: SessionInfo[] + activeSessionId: null | string + workingSessionIdSet: Set + onResumeSession: (sessionId: string) => void + onDeleteSession: (sessionId: string) => void + onArchiveSession: (sessionId: string) => void + onBranchSession?: (sessionId: string, profile?: string) => void + onTogglePin: (sessionId: string) => void + onNewSessionInWorkspace?: (path: null | string) => void + pinned: boolean + rootClassName?: string + contentClassName?: string + emptyState: React.ReactNode + forceEmptyState?: boolean + headerAction?: React.ReactNode + footer?: React.ReactNode + groups?: SidebarSessionGroup[] + tree?: SidebarWorkspaceTree[] + // Project overview: when present, render a drill-in list of project rows + // instead of sessions. Clicking a row enters that project (onEnterProject), + // which then passes `projectContent` on the next render. Takes precedence + // over `tree` / `groups`. + projectOverview?: SidebarProjectTree[] + // Per-project preview rows (from the backend tree), keyed by project path. + projectOverviewPreviews?: Record + // True while the backend project tree is loading (overview skeleton). + projectsLoading?: boolean + onEnterProject?: (id: string) => void + // The entered project's flattened content: main-checkout sessions render + // directly (no redundant repo/branch header); only linked worktrees nest. + projectContent?: SidebarProjectTree + // Live git lanes (`git worktree list`) for repos in the entered project — + // a VISUAL enhancer only (empty lanes), never session membership. + projectRepoWorktrees?: Record + // Live session cache used for optimistic placement inside entered-project lanes. + liveSessions?: SessionInfo[] + // Client-side optimistic eviction layer (deleted/archived ids). + removedSessionIds?: ReadonlySet + activeProjectId?: null | string + labelMeta?: React.ReactNode + labelIcon?: React.ReactNode + // When false the section header is static (no caret/toggle) and always open. + collapsible?: boolean + sortable?: boolean + // The flat session list is the only hand-reorderable surface (grouped/project + // views sort deterministically), so it owns the one ReorderableList. + onReorderSessions?: (ids: string[]) => void + // Drag-to-reorder for the project overview list (top-level projects). + onReorderProjects?: (ids: string[]) => void + // Rendered atop the entered-project body (a "back to overview" row). + projectBackRow?: React.ReactNode + dndSensors?: ReturnType +} + +export function SidebarSessionsSection({ + label, + open, + onToggle, + sessions, + activeSessionId, + workingSessionIdSet, + onResumeSession, + onDeleteSession, + onArchiveSession, + onBranchSession, + onTogglePin, + onNewSessionInWorkspace, + pinned, + rootClassName, + contentClassName, + emptyState, + forceEmptyState = false, + headerAction, + footer, + groups, + projectOverview, + projectOverviewPreviews, + projectsLoading = false, + onEnterProject, + projectContent, + projectRepoWorktrees, + liveSessions, + removedSessionIds, + activeProjectId, + labelMeta, + labelIcon, + collapsible = true, + sortable = false, + onReorderSessions, + onReorderProjects, + projectBackRow, + dndSensors +}: SidebarSessionsSectionProps) { + const sectionOpen = collapsible ? open : true + const hasGroupedSessions = Boolean(groups?.some(group => group.sessions.length > 0)) + // A defined project list is itself content (even an empty project should + // render as a drill-in row so the user can see it exists). + const hasProjectOverview = Boolean(projectOverview?.length) + const hasProjectContent = Boolean(projectContent && projectContent.sessionCount > 0) + + const showEmptyState = + forceEmptyState || (!hasGroupedSessions && !hasProjectOverview && !hasProjectContent && sessions.length === 0) + + // The flat recents/pinned list is the only place sessions reorder by hand; + // grouped/tree views always sort by creation date and never drag. + const sessionsDraggable = sortable && !!onReorderSessions + const displayEntries = useMemo(() => flattenSessionsWithBranches(sessions), [sessions]) + + const renderRow = (session: SessionInfo, draggable: boolean, branchStem?: string) => { + const rowProps = { + branchStem, + isPinned: pinned, + isSelected: session.id === activeSessionId, + isWorking: workingSessionIdSet.has(session.id), + onArchive: () => onArchiveSession(session.id), + onBranch: onBranchSession ? () => onBranchSession(session.id, session.profile) : undefined, + onDelete: () => onDeleteSession(session.id), + onPin: () => onTogglePin(sessionPinId(session)), + onResume: () => onResumeSession(session.id), + reorderable: draggable && !branchStem, + session + } + + return draggable && !branchStem ? ( + + ) : ( + + ) + } + + // Sessions inside repos/worktrees are date-ordered and static. + const renderRows = (items: SessionInfo[]) => + flattenSessionsWithBranches(items).map(({ branchStem, session }) => renderRow(session, false, branchStem)) + + const flatVirtualized = + !showEmptyState && + !groups?.length && + !projectOverview?.length && + !projectContent && + sessions.length >= VIRTUALIZE_THRESHOLD + + // First paint into the grouped view (e.g. the app restoring the Projects tab) + // has flat recents in `sessions` but no tree yet. Show skeletons rather than + // flashing the flat session list until the overview/content/groups resolve. A + // background refresh keeps the prior tree, so this only fires when empty. + const showProjectsSkeleton = + projectsLoading && !hasProjectOverview && !hasProjectContent && !projectContent && !groups?.length + + let inner: React.ReactNode + + if (showProjectsSkeleton) { + inner = + } else if (projectContent) { + // Entered a project: the back row is always present, then either the + // (overlay-aware) content or a clean empty state — never a bare spinner or a + // blank pane while lanes hydrate. + inner = ( + <> + {projectBackRow} + {hasProjectContent ? ( + + ) : ( + emptyState + )} + + ) + } else if (showEmptyState) { + inner = emptyState + } else if (projectOverview?.length) { + // The model is already ordered (default sort groups explicit-before-auto; + // a manual drag-order, when present, wins). Render in that order and make + // rows drag-to-reorder when a handler is wired. + const projectsDraggable = projectOverview.length > 1 && !!onReorderProjects + const Row = projectsDraggable ? SortableProjectOverviewRow : ProjectOverviewRow + + const rows = projectOverview.map(project => ( + + )) + + inner = + projectsDraggable && onReorderProjects ? ( + project.id)} + onReorder={onReorderProjects} + sensors={dndSensors} + > + {rows} + + ) : ( + rows + ) + } else if (groups?.length) { + // Profile/source groups never reorder; render them flat with static rows. + inner = groups.map(group => ( + + )) + } else if (flatVirtualized) { + const virtual = ( + + ) + + inner = + sessionsDraggable && onReorderSessions ? ( + s.id)} onReorder={onReorderSessions} sensors={dndSensors}> + {virtual} + + ) : ( + virtual + ) + } else if (sessionsDraggable && onReorderSessions) { + inner = ( + s.id)} onReorder={onReorderSessions} sensors={dndSensors}> + {displayEntries.map(({ branchStem, session }) => renderRow(session, true, branchStem))} + + ) + } else { + inner = displayEntries.map(({ branchStem, session }) => renderRow(session, false, branchStem)) + } + + // The virtualizer owns its own scroller, so suppress the wrapper's overflow + // to avoid a double scroll container. + const resolvedContentClassName = cn(contentClassName, flatVirtualized && 'overflow-y-visible') + + return ( + + + {sectionOpen && ( + + {inner} + {footer} + + )} + + ) +} + +interface SortableSessionRowProps { + session: SessionInfo + isPinned: boolean + isSelected: boolean + isWorking: boolean + onArchive: () => void + onDelete: () => void + onPin: () => void + onResume: () => void +} + +function SortableSidebarSessionRow(props: SortableSessionRowProps) { + return +} + +function SortableProjectOverviewRow(props: React.ComponentProps) { + return +}