mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-01 12:02:05 +00:00
refactor(desktop): split sidebar/index.tsx god file into focused modules
Behavior-preserving extraction of the 1,963-line ChatSidebar file into the existing sidebar/ sibling-module convention: - order.ts: add pure orderByIds / reconcileOrderIds / sameIds helpers (+ tests) - reorderable-list.tsx: the generic ReorderableList + useSortableBindings DnD primitive - section-states.tsx: SidebarSessionSkeletons / SidebarBlankState / SidebarPinnedEmptyState - sessions-section.tsx: SidebarSectionHeader + the large SidebarSessionsSection renderer + its sortable row wrappers index.tsx now holds only the ChatSidebar component (1,963 -> 1,416 lines).
This commit is contained in:
parent
a81c5922a2
commit
88e29e35bd
6 changed files with 608 additions and 557 deletions
|
|
@ -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<typeof useSensors>
|
||||
}) {
|
||||
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 (
|
||||
<DndContext
|
||||
autoScroll={reorderAutoScroll}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
sensors={sensors}
|
||||
>
|
||||
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
|
||||
{children}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)
|
||||
}
|
||||
|
||||
function orderByIds<T>(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<string>()
|
||||
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<typeof Sidebar> {
|
||||
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}
|
||||
<SidebarPanelLabel>{label}</SidebarPanelLabel>
|
||||
{meta && <SidebarCount>{meta}</SidebarCount>}
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="group/section flex shrink-0 items-center justify-between gap-1 pb-1 pt-1.5">
|
||||
{collapsible ? (
|
||||
<button
|
||||
className="group/section-label flex w-fit items-center gap-1 bg-transparent text-left leading-none"
|
||||
onClick={onToggle}
|
||||
type="button"
|
||||
>
|
||||
{labelBody}
|
||||
<DisclosureCaret
|
||||
className="text-(--ui-text-tertiary) opacity-0 transition group-hover/section-label:opacity-100"
|
||||
open={open}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex w-fit items-center gap-1 leading-none">{labelBody}</div>
|
||||
)}
|
||||
{action}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarSessionSkeletons() {
|
||||
return (
|
||||
<div aria-hidden="true" className="grid gap-px">
|
||||
{['w-32', 'w-40', 'w-28', 'w-36', 'w-24'].map((width, i) => (
|
||||
<div
|
||||
className="grid min-h-[1.625rem] grid-cols-[minmax(0,1fr)_1.375rem] items-center rounded-md pl-2"
|
||||
key={`${width}-${i}`}
|
||||
>
|
||||
<Skeleton className={cn('h-3 rounded-sm', width)} />
|
||||
<Skeleton className="mx-auto size-3.5 rounded-sm opacity-60" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarBlankState({ onNewProject }: { onNewProject: () => void }) {
|
||||
const { t } = useI18n()
|
||||
const s = t.sidebar
|
||||
|
||||
return (
|
||||
<div className="grid min-h-0 flex-1 place-items-center px-4 text-center">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Codicon className="text-(--ui-text-quaternary)" name="root-folder" size="1.25rem" />
|
||||
<p className="text-xs text-(--ui-text-tertiary)">{s.noSessions}</p>
|
||||
<Button
|
||||
className="mt-0.5 text-(--ui-text-secondary)"
|
||||
onClick={onNewProject}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="add" size="0.75rem" />
|
||||
{s.projects.newButton}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarPinnedEmptyState() {
|
||||
const { t } = useI18n()
|
||||
|
||||
return (
|
||||
<div className="flex min-h-7 items-center gap-1.5 rounded-lg pl-2 text-[0.75rem] text-(--ui-text-tertiary)">
|
||||
<span className="grid w-3.5 shrink-0 place-items-center text-(--ui-text-quaternary)">
|
||||
<Codicon name="pin" size="0.75rem" />
|
||||
</span>
|
||||
<span>{t.sidebar.shiftClickHint}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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<string>
|
||||
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<string, SessionInfo[]>
|
||||
// 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<string, HermesGitWorktree[]>
|
||||
// Live session cache used for optimistic placement inside entered-project lanes.
|
||||
liveSessions?: SessionInfo[]
|
||||
// Client-side optimistic eviction layer (deleted/archived ids).
|
||||
removedSessionIds?: ReadonlySet<string>
|
||||
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<typeof useSensors>
|
||||
}
|
||||
|
||||
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 ? (
|
||||
<SortableSidebarSessionRow key={session.id} {...rowProps} />
|
||||
) : (
|
||||
<SidebarSessionRow key={session.id} {...rowProps} />
|
||||
)
|
||||
}
|
||||
|
||||
// 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 = <SidebarSessionSkeletons />
|
||||
} 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 ? (
|
||||
<EnteredProjectContent
|
||||
liveSessions={liveSessions}
|
||||
onNewSession={onNewSessionInWorkspace}
|
||||
project={projectContent}
|
||||
removedSessionIds={removedSessionIds}
|
||||
renderRows={renderRows}
|
||||
repoWorktrees={projectRepoWorktrees}
|
||||
/>
|
||||
) : (
|
||||
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 => (
|
||||
<Row
|
||||
activeProjectId={activeProjectId}
|
||||
key={project.id}
|
||||
onEnter={onEnterProject}
|
||||
onNewSession={onNewSessionInWorkspace}
|
||||
previewSessions={project.path ? projectOverviewPreviews?.[project.path] : undefined}
|
||||
project={project}
|
||||
renderRows={renderRows}
|
||||
/>
|
||||
))
|
||||
|
||||
inner =
|
||||
projectsDraggable && onReorderProjects ? (
|
||||
<ReorderableList
|
||||
ids={projectOverview.map(project => project.id)}
|
||||
onReorder={onReorderProjects}
|
||||
sensors={dndSensors}
|
||||
>
|
||||
{rows}
|
||||
</ReorderableList>
|
||||
) : (
|
||||
rows
|
||||
)
|
||||
} else if (groups?.length) {
|
||||
// Profile/source groups never reorder; render them flat with static rows.
|
||||
inner = groups.map(group => (
|
||||
<SidebarWorkspaceGroup
|
||||
group={group}
|
||||
key={group.id}
|
||||
onNewSession={onNewSessionInWorkspace}
|
||||
renderRows={renderRows}
|
||||
/>
|
||||
))
|
||||
} else if (flatVirtualized) {
|
||||
const virtual = (
|
||||
<VirtualSessionList
|
||||
activeSessionId={activeSessionId}
|
||||
className={contentClassName}
|
||||
entries={displayEntries}
|
||||
onArchiveSession={onArchiveSession}
|
||||
onBranchSession={onBranchSession}
|
||||
onDeleteSession={onDeleteSession}
|
||||
onResumeSession={onResumeSession}
|
||||
onTogglePin={onTogglePin}
|
||||
pinned={pinned}
|
||||
sortable={sessionsDraggable}
|
||||
workingSessionIdSet={workingSessionIdSet}
|
||||
/>
|
||||
)
|
||||
|
||||
inner =
|
||||
sessionsDraggable && onReorderSessions ? (
|
||||
<ReorderableList ids={sessions.map(s => s.id)} onReorder={onReorderSessions} sensors={dndSensors}>
|
||||
{virtual}
|
||||
</ReorderableList>
|
||||
) : (
|
||||
virtual
|
||||
)
|
||||
} else if (sessionsDraggable && onReorderSessions) {
|
||||
inner = (
|
||||
<ReorderableList ids={sessions.map(s => s.id)} onReorder={onReorderSessions} sensors={dndSensors}>
|
||||
{displayEntries.map(({ branchStem, session }) => renderRow(session, true, branchStem))}
|
||||
</ReorderableList>
|
||||
)
|
||||
} 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 (
|
||||
<SidebarGroup className={rootClassName}>
|
||||
<SidebarSectionHeader
|
||||
action={headerAction}
|
||||
collapsible={collapsible}
|
||||
icon={labelIcon}
|
||||
label={label}
|
||||
meta={labelMeta}
|
||||
onToggle={onToggle}
|
||||
open={sectionOpen}
|
||||
/>
|
||||
{sectionOpen && (
|
||||
<SidebarGroupContent className={resolvedContentClassName}>
|
||||
{inner}
|
||||
{footer}
|
||||
</SidebarGroupContent>
|
||||
)}
|
||||
</SidebarGroup>
|
||||
)
|
||||
}
|
||||
|
||||
interface SortableSessionRowProps {
|
||||
session: SessionInfo
|
||||
isPinned: boolean
|
||||
isSelected: boolean
|
||||
isWorking: boolean
|
||||
onArchive: () => void
|
||||
onDelete: () => void
|
||||
onPin: () => void
|
||||
onResume: () => void
|
||||
}
|
||||
|
||||
function SortableSidebarSessionRow(props: SortableSessionRowProps) {
|
||||
return <SidebarSessionRow {...props} {...useSortableBindings(props.session.id)} />
|
||||
}
|
||||
|
||||
function SortableProjectOverviewRow(props: React.ComponentProps<typeof ProjectOverviewRow>) {
|
||||
return <ProjectOverviewRow {...props} {...useSortableBindings(props.project.id)} />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<T>(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<string>()
|
||||
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])
|
||||
}
|
||||
|
|
|
|||
81
apps/desktop/src/app/chat/sidebar/reorderable-list.tsx
Normal file
81
apps/desktop/src/app/chat/sidebar/reorderable-list.tsx
Normal file
|
|
@ -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<typeof useSensors>
|
||||
}) {
|
||||
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 (
|
||||
<DndContext
|
||||
autoScroll={reorderAutoScroll}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
sensors={sensors}
|
||||
>
|
||||
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
|
||||
{children}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
52
apps/desktop/src/app/chat/sidebar/section-states.tsx
Normal file
52
apps/desktop/src/app/chat/sidebar/section-states.tsx
Normal file
|
|
@ -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 (
|
||||
<div aria-hidden="true" className="grid gap-px">
|
||||
{['w-32', 'w-40', 'w-28', 'w-36', 'w-24'].map((width, i) => (
|
||||
<div
|
||||
className="grid min-h-[1.625rem] grid-cols-[minmax(0,1fr)_1.375rem] items-center rounded-md pl-2"
|
||||
key={`${width}-${i}`}
|
||||
>
|
||||
<Skeleton className={cn('h-3 rounded-sm', width)} />
|
||||
<Skeleton className="mx-auto size-3.5 rounded-sm opacity-60" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SidebarBlankState({ onNewProject }: { onNewProject: () => void }) {
|
||||
const { t } = useI18n()
|
||||
const s = t.sidebar
|
||||
|
||||
return (
|
||||
<div className="grid min-h-0 flex-1 place-items-center px-4 text-center">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Codicon className="text-(--ui-text-quaternary)" name="root-folder" size="1.25rem" />
|
||||
<p className="text-xs text-(--ui-text-tertiary)">{s.noSessions}</p>
|
||||
<Button className="mt-0.5 text-(--ui-text-secondary)" onClick={onNewProject} size="sm" variant="ghost">
|
||||
<Codicon name="add" size="0.75rem" />
|
||||
{s.projects.newButton}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SidebarPinnedEmptyState() {
|
||||
const { t } = useI18n()
|
||||
|
||||
return (
|
||||
<div className="flex min-h-7 items-center gap-1.5 rounded-lg pl-2 text-[0.75rem] text-(--ui-text-tertiary)">
|
||||
<span className="grid w-3.5 shrink-0 place-items-center text-(--ui-text-quaternary)">
|
||||
<Codicon name="pin" size="0.75rem" />
|
||||
</span>
|
||||
<span>{t.sidebar.shiftClickHint}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
379
apps/desktop/src/app/chat/sidebar/sessions-section.tsx
Normal file
379
apps/desktop/src/app/chat/sidebar/sessions-section.tsx
Normal file
|
|
@ -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}
|
||||
<SidebarPanelLabel>{label}</SidebarPanelLabel>
|
||||
{meta && <SidebarCount>{meta}</SidebarCount>}
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="group/section flex shrink-0 items-center justify-between gap-1 pb-1 pt-1.5">
|
||||
{collapsible ? (
|
||||
<button
|
||||
className="group/section-label flex w-fit items-center gap-1 bg-transparent text-left leading-none"
|
||||
onClick={onToggle}
|
||||
type="button"
|
||||
>
|
||||
{labelBody}
|
||||
<DisclosureCaret
|
||||
className="text-(--ui-text-tertiary) opacity-0 transition group-hover/section-label:opacity-100"
|
||||
open={open}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex w-fit items-center gap-1 leading-none">{labelBody}</div>
|
||||
)}
|
||||
{action}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SidebarSessionsSectionProps {
|
||||
label: string
|
||||
open: boolean
|
||||
onToggle: () => void
|
||||
sessions: SessionInfo[]
|
||||
activeSessionId: null | string
|
||||
workingSessionIdSet: Set<string>
|
||||
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<string, SessionInfo[]>
|
||||
// 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<string, HermesGitWorktree[]>
|
||||
// Live session cache used for optimistic placement inside entered-project lanes.
|
||||
liveSessions?: SessionInfo[]
|
||||
// Client-side optimistic eviction layer (deleted/archived ids).
|
||||
removedSessionIds?: ReadonlySet<string>
|
||||
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<typeof useSensors>
|
||||
}
|
||||
|
||||
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 ? (
|
||||
<SortableSidebarSessionRow key={session.id} {...rowProps} />
|
||||
) : (
|
||||
<SidebarSessionRow key={session.id} {...rowProps} />
|
||||
)
|
||||
}
|
||||
|
||||
// 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 = <SidebarSessionSkeletons />
|
||||
} 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 ? (
|
||||
<EnteredProjectContent
|
||||
liveSessions={liveSessions}
|
||||
onNewSession={onNewSessionInWorkspace}
|
||||
project={projectContent}
|
||||
removedSessionIds={removedSessionIds}
|
||||
renderRows={renderRows}
|
||||
repoWorktrees={projectRepoWorktrees}
|
||||
/>
|
||||
) : (
|
||||
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 => (
|
||||
<Row
|
||||
activeProjectId={activeProjectId}
|
||||
key={project.id}
|
||||
onEnter={onEnterProject}
|
||||
onNewSession={onNewSessionInWorkspace}
|
||||
previewSessions={project.path ? projectOverviewPreviews?.[project.path] : undefined}
|
||||
project={project}
|
||||
renderRows={renderRows}
|
||||
/>
|
||||
))
|
||||
|
||||
inner =
|
||||
projectsDraggable && onReorderProjects ? (
|
||||
<ReorderableList
|
||||
ids={projectOverview.map(project => project.id)}
|
||||
onReorder={onReorderProjects}
|
||||
sensors={dndSensors}
|
||||
>
|
||||
{rows}
|
||||
</ReorderableList>
|
||||
) : (
|
||||
rows
|
||||
)
|
||||
} else if (groups?.length) {
|
||||
// Profile/source groups never reorder; render them flat with static rows.
|
||||
inner = groups.map(group => (
|
||||
<SidebarWorkspaceGroup
|
||||
group={group}
|
||||
key={group.id}
|
||||
onNewSession={onNewSessionInWorkspace}
|
||||
renderRows={renderRows}
|
||||
/>
|
||||
))
|
||||
} else if (flatVirtualized) {
|
||||
const virtual = (
|
||||
<VirtualSessionList
|
||||
activeSessionId={activeSessionId}
|
||||
className={contentClassName}
|
||||
entries={displayEntries}
|
||||
onArchiveSession={onArchiveSession}
|
||||
onBranchSession={onBranchSession}
|
||||
onDeleteSession={onDeleteSession}
|
||||
onResumeSession={onResumeSession}
|
||||
onTogglePin={onTogglePin}
|
||||
pinned={pinned}
|
||||
sortable={sessionsDraggable}
|
||||
workingSessionIdSet={workingSessionIdSet}
|
||||
/>
|
||||
)
|
||||
|
||||
inner =
|
||||
sessionsDraggable && onReorderSessions ? (
|
||||
<ReorderableList ids={sessions.map(s => s.id)} onReorder={onReorderSessions} sensors={dndSensors}>
|
||||
{virtual}
|
||||
</ReorderableList>
|
||||
) : (
|
||||
virtual
|
||||
)
|
||||
} else if (sessionsDraggable && onReorderSessions) {
|
||||
inner = (
|
||||
<ReorderableList ids={sessions.map(s => s.id)} onReorder={onReorderSessions} sensors={dndSensors}>
|
||||
{displayEntries.map(({ branchStem, session }) => renderRow(session, true, branchStem))}
|
||||
</ReorderableList>
|
||||
)
|
||||
} 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 (
|
||||
<SidebarGroup className={rootClassName}>
|
||||
<SidebarSectionHeader
|
||||
action={headerAction}
|
||||
collapsible={collapsible}
|
||||
icon={labelIcon}
|
||||
label={label}
|
||||
meta={labelMeta}
|
||||
onToggle={onToggle}
|
||||
open={sectionOpen}
|
||||
/>
|
||||
{sectionOpen && (
|
||||
<SidebarGroupContent className={resolvedContentClassName}>
|
||||
{inner}
|
||||
{footer}
|
||||
</SidebarGroupContent>
|
||||
)}
|
||||
</SidebarGroup>
|
||||
)
|
||||
}
|
||||
|
||||
interface SortableSessionRowProps {
|
||||
session: SessionInfo
|
||||
isPinned: boolean
|
||||
isSelected: boolean
|
||||
isWorking: boolean
|
||||
onArchive: () => void
|
||||
onDelete: () => void
|
||||
onPin: () => void
|
||||
onResume: () => void
|
||||
}
|
||||
|
||||
function SortableSidebarSessionRow(props: SortableSessionRowProps) {
|
||||
return <SidebarSessionRow {...props} {...useSortableBindings(props.session.id)} />
|
||||
}
|
||||
|
||||
function SortableProjectOverviewRow(props: React.ComponentProps<typeof ProjectOverviewRow>) {
|
||||
return <ProjectOverviewRow {...props} {...useSortableBindings(props.project.id)} />
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue