diff --git a/apps/desktop/src/app/chat/sidebar/chrome.tsx b/apps/desktop/src/app/chat/sidebar/chrome.tsx new file mode 100644 index 00000000000..45b20ce13dd --- /dev/null +++ b/apps/desktop/src/app/chat/sidebar/chrome.tsx @@ -0,0 +1,158 @@ +import type * as React from 'react' + +import { Codicon } from '@/components/ui/codicon' +import { cn } from '@/lib/utils' + +// Shared, content-agnostic sidebar chrome — used by both the flat session +// sections and the project/workspace tree, so it lives outside either to keep +// imports one-directional (no index <-> projects cycle). + +/** `loaded/total` when there's more on the server, else just the loaded count. */ +export const countLabel = (loaded: number, total: number): string => + total > loaded ? `${loaded}/${total}` : String(loaded) + +/** The muted count chip next to a section/workspace label. */ +export function SidebarCount({ children }: { children: React.ReactNode }) { + return {children} +} + +// ── Row geometry (session row is canonical — everything composes these) ───── +// +// Height lives ONLY on SidebarRowShell (min-h-[1.625rem]). Inset children +// stretch to fill the cell and center content internally — never items-center +// on the shell grid, or short clusters (projects) float 1–2px off sessions. + +const rowMinH = 'min-h-[1.625rem]' +const rowPadX = 'pl-2 pr-1' +const rowGap = 'gap-1.5' +const rowLead = 'grid size-3.5 shrink-0 place-items-center' +const rowInset = cn(rowPadX, rowGap, 'flex h-full min-w-0 items-center self-stretch py-0.5') +const rowLabel = 'min-w-0 truncate text-[0.8125rem] leading-none text-(--ui-text-secondary)' + +/** Codicon size in sidebar row leads — matches the file tree (`tree.tsx`). */ +export const SIDEBAR_LEAD_ICON_SIZE = '0.875rem' as const + +/** Vertical stack of rows (gap-px, single column). */ +export function SidebarRowStack({ className, ...props }: React.ComponentProps<'div'>) { + return
+} + +/** Nested rows (session previews, worktree bodies). */ +export function SidebarRowNest({ className, ...props }: React.ComponentProps<'div'>) { + return +} + +/** Outer grid — sole owner of row height. */ +export function SidebarRowShell({ + actions, + children, + className, + ...props +}: React.ComponentProps<'div'> & { actions?: React.ReactNode }) { + return ( +
+ {children} + {actions ?
{actions}
: null} +
+ ) +} + +/** Multi-control left cluster (project rows). */ +export function SidebarRowCluster({ className, ...props }: React.ComponentProps<'div'>) { + return
+} + +/** Session row main tap target. */ +export function SidebarRowBody({ className, ...props }: React.ComponentProps<'button'>) { + return + ) +} + +/** Fixed leading column (dot, icon, drag handle). */ +export function SidebarRowLead({ className, ...props }: React.ComponentProps<'span'>) { + return +} + +/** Standard row label typography. */ +export function SidebarRowLabel({ className, ...props }: React.ComponentProps<'span'>) { + return +} + +/** Dot ↔ grabber swap for dnd-kit reorder rows. */ +export function SidebarRowGrab({ + ariaLabel, + children, + className, + dragging = false, + dragHandleProps, + leadClassName +}: { + ariaLabel: string + children: React.ReactNode + className?: string + dragging?: boolean + dragHandleProps?: React.HTMLAttributes + leadClassName?: string +}) { + return ( + event.stopPropagation()} + > + + {children} + + + + ) +} + +/** Icon/dot slot inside SidebarRowLead — caps visual size so rows align. */ +export function SidebarRowLeadGlyph({ + children, + className, + style +}: { + children: React.ReactNode + className?: string + style?: React.CSSProperties +}) { + return ( + + {children} + + ) +} diff --git a/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx b/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx index 006bba439dd..707e7c5e6c0 100644 --- a/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx +++ b/apps/desktop/src/app/chat/sidebar/cron-jobs-section.tsx @@ -3,6 +3,7 @@ import { useEffect, useMemo, useState } from 'react' import { Codicon } from '@/components/ui/codicon' import { DisclosureCaret } from '@/components/ui/disclosure-caret' +import { GlyphSpinner } from '@/components/ui/glyph-spinner' import { SidebarGroup, SidebarGroupContent } from '@/components/ui/sidebar' import { Tip } from '@/components/ui/tooltip' import { getCronJobRuns, type SessionInfo } from '@/hermes' @@ -328,7 +329,7 @@ function CronJobSidebarRuns({ jobId, onOpenRun }: { jobId: string; onOpenRun: (s
{runs === null ? (
- +
) : runs.length === 0 ? (
{c.noRuns}
diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index 7f46367344d..06ca1fc96cf 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -22,6 +22,7 @@ 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' import { @@ -34,17 +35,18 @@ import { SidebarMenuItem } from '@/components/ui/sidebar' import { Skeleton } from '@/components/ui/skeleton' -import { Tip } from '@/components/ui/tooltip' +import type { HermesGitWorktree } from '@/global' import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes' -import { useWorktreeInfo } from '@/hooks/use-worktree-info' 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' import { $cronJobs } from '@/store/cron' import { + $dismissedAutoProjectIds, $panesFlipped, $pinnedSessionIds, $sidebarAgentsGrouped, @@ -53,6 +55,7 @@ import { $sidebarOpen, $sidebarOverlayMounted, $sidebarPinsOpen, + $sidebarProjectOrderIds, $sidebarRecentsOpen, $sidebarSessionOrderIds, $sidebarSessionOrderManual, @@ -64,6 +67,7 @@ import { setSidebarAgentsGrouped, setSidebarCronOpen, setSidebarPinsOpen, + setSidebarProjectOrderIds, setSidebarRecentsOpen, setSidebarSessionOrderIds, setSidebarSessionOrderManual, @@ -78,11 +82,30 @@ import { $profiles, $profileScope, ALL_PROFILES, - newSessionInProfile, normalizeProfileKey } from '@/store/profile' +import { + $activeProjectId, + $projects, + $projectScope, + $projectTree, + $projectTreeLoading, + $removedSessionIds, + $reposScanning, + ALL_PROJECTS, + enterProject, + exitProjectScope, + fetchProjectSessions, + openProjectCreate, + refreshProjects, + refreshProjectTree, + refreshWorktrees, + scanAndRecordRepos +} from '@/store/projects' import { $cronSessions, + $currentCwd, + $gatewayState, $messagingPlatformTotals, $messagingSessions, $messagingTruncated, @@ -92,20 +115,40 @@ import { $sessionsLoading, $sessionsTotal, $workingSessionIds, - sessionPinId + sessionPinId, + setCurrentCwd } 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 { SidebarCronJobsSection } from './cron-jobs-section' import { SidebarLoadMoreRow } from './load-more-row' -import { resolveManualSessionOrderIds } from './order' +import { reconcileFreshFirst, resolveManualSessionOrderIds } 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' -import { type SidebarSessionGroup, type SidebarWorkspaceTree, workspaceTreeFor } from './workspace-groups' const VIRTUALIZE_THRESHOLD = 25 @@ -134,10 +177,6 @@ const SIDEBAR_NAV: SidebarNavItem[] = [ { id: 'artifacts', label: '', icon: props => , route: ARTIFACTS_ROUTE } ] -const WORKSPACE_PAGE = 5 -// ALL-profiles view: show only the latest N per profile up front to keep the -// unified list scannable, then reveal/fetch more in N-sized steps on demand. -const PROFILE_INITIAL_PAGE = 5 // Two modes via the `compact` height variant (styles.css): // tall → each section is shrink-0, capped, its own scroller; Sessions is flex-1. // compact → COMPACT_FLAT drops the caps so the whole stack scrolls as one. @@ -151,6 +190,18 @@ const SCROLL_Y = 'overflow-y-auto overflow-x-hidden overscroll-contain' // A non-session group's scroll body: own scroller when tall, flattened when compact. const GROUP_BODY = cn(SCROLL_Y, COMPACT_FLAT) +// Section-header action icons stay hidden until the whole header row is hovered +// (group/section lives on SidebarSectionHeader), mirroring the artifacts/file +// browser header affordances. focus-visible keeps them keyboard-reachable. +const HEADER_ACTION_BTN = + 'text-(--ui-text-tertiary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground group-hover/section:opacity-100 focus-visible:opacity-100' + +// The view toggle (overview group toggle / in-project back) is the one control +// that stays visible at all times — it's the stable navigation affordance, not +// a hover-revealed action. +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 @@ -174,7 +225,15 @@ function ReorderableList({ onReorder: (ids: string[]) => void sensors?: ReturnType }) { - const handleDragEnd = ({ active, over }: DragEndEvent) => { + 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 } @@ -196,9 +255,6 @@ function ReorderableList({ ) } -const countLabel = (loaded: number, total: number) => (total > loaded ? `${loaded}/${total}` : String(loaded)) -const sessionTime = (s: SessionInfo) => s.last_active || s.started_at || 0 - function orderByIds(items: T[], getId: (item: T) => string, orderIds: string[]): T[] { if (!orderIds.length) { return items @@ -236,16 +292,7 @@ function reconcileOrderIds(currentIds: string[], orderIds: string[]): string[] { return currentIds } - const current = new Set(currentIds) - const retained = orderIds.filter(id => current.has(id)) - const retainedSet = new Set(retained) - - // New ids (absent from the saved order) are the newest sessions/groups; keep - // them ahead of the persisted order so fresh activity surfaces at the top of - // the sidebar rather than being appended to the bottom. - const fresh = currentIds.filter(id => !retainedSet.has(id)) - - return [...fresh, ...retained] + return reconcileFreshFirst(currentIds, orderIds) } function sameIds(left: string[], right: string[]) { @@ -300,12 +347,13 @@ function useSortableBindings(id: string) { interface ChatSidebarProps extends React.ComponentProps { currentView: AppView onNavigate: (item: SidebarNavItem) => void - onLoadMoreSessions: () => void + onLoadMoreSessions: () => Promise | void onLoadMoreProfileSessions?: (profile: string) => Promise | void onLoadMoreMessaging?: (platform: string) => Promise | void onResumeSession: (sessionId: string) => void onDeleteSession: (sessionId: string) => void onArchiveSession: (sessionId: string) => void + onBranchSession: (sessionId: string) => void onNewSessionInWorkspace: (path: null | string) => void onManageCronJob: (jobId: string) => void onTriggerCronJob: (jobId: string) => void @@ -320,6 +368,7 @@ export function ChatSidebar({ onResumeSession, onDeleteSession, onArchiveSession, + onBranchSession, onNewSessionInWorkspace, onManageCronJob, onTriggerCronJob @@ -360,11 +409,24 @@ export function ChatSidebar({ const agentOrderManual = useStore($sidebarSessionOrderManual) const workspaceOrderIds = useStore($sidebarWorkspaceOrderIds) const workspaceParentOrderIds = useStore($sidebarWorkspaceParentOrderIds) + const projectOrderIds = useStore($sidebarProjectOrderIds) + const projects = useStore($projects) + const projectTree = useStore($projectTree) + const projectTreeLoading = useStore($projectTreeLoading) + const removedSessionIds = useStore($removedSessionIds) + const reposScanning = useStore($reposScanning) + const activeProjectId = useStore($activeProjectId) + const projectScope = useStore($projectScope) + const currentCwd = useStore($currentCwd) + const gatewayState = useStore($gatewayState) + const dismissedAutoProjects = useStore($dismissedAutoProjectIds) const [searchQuery, setSearchQuery] = useState('') const [serverMatches, setServerMatches] = useState([]) + const [searchPending, setSearchPending] = useState(false) const [newSessionKbdFlash, setNewSessionKbdFlash] = useState(false) const [profileLoadMorePending, setProfileLoadMorePending] = useState>({}) const [messagingLoadMorePending, setMessagingLoadMorePending] = useState>({}) + const [recentsLoadMorePending, setRecentsLoadMorePending] = useState(false) const messagingOpenIds = useStore($sidebarMessagingOpenIds) // Per-platform count of rows currently revealed (starts at NON_SESSION_INITIAL_ROWS). const [messagingVisible, setMessagingVisible] = useState>({}) @@ -468,12 +530,15 @@ export function ChatSidebar({ useEffect(() => { if (!trimmedQuery) { setServerMatches([]) + setSearchPending(false) return } let cancelled = false + setSearchPending(true) + const id = window.setTimeout(() => { void searchSessions(trimmedQuery) .then(res => { @@ -482,6 +547,11 @@ export function ChatSidebar({ } }) .catch(() => undefined) + .finally(() => { + if (!cancelled) { + setSearchPending(false) + } + }) }, 200) return () => { @@ -533,6 +603,7 @@ export function ChatSidebar({ if (!next.length && agentOrderIds.length) { setSidebarSessionOrderIds([]) + return } @@ -550,55 +621,321 @@ export function ChatSidebar({ // own slice ($messagingSessions) and rendered in self-managed per-platform // sections below, so there is no source-grouping magic to untangle here. // - // Workspace grouping is a `parent (repo) → worktree → sessions` tree. Git - // metadata (probed locally) is authoritative; unresolved cwds fall back to a - // path-name heuristic inside workspaceTreeFor. Parents reorder via + // Workspace grouping is a `project -> repo -> lane -> sessions` tree computed + // authoritatively on the backend (projects.tree). Parents reorder via // workspaceParentOrderIds; worktrees within a parent via workspaceOrderIds. const worktreeGroupingActive = agentsGrouped && !showAllProfiles - const worktreeResolver = useWorktreeInfo(agentSessions, worktreeGroupingActive) + const gatewayReady = gatewayState === 'open' - const agentTree = useMemo(() => { - if (!worktreeGroupingActive) { + // The backend project tree is a structural snapshot, NOT a per-message feed. + // Refresh it on structural edges only — entering the grouped view, a profile + // switch, gateway (re)connect — plus the once-per-run disk scan. Live session + // changes between refreshes are reflected by the in-memory overlay + // (overlayLiveLanes / overlayLivePreviews) off `$sessions`, so a turn + // completing does NOT re-run the heavy list_sessions_rich scan. Project + // mutations refresh the tree from their own store actions. + useEffect(() => { + if (worktreeGroupingActive && gatewayReady) { + void refreshProjects() + // Paint the list from the fast tree fetch (explicit projects + repos from + // existing sessions / the backend cache) FIRST, then kick off the heavy + // home-dir git crawl so newly-discovered repos fold in afterward — instead + // of the crawl blocking the first render. + void refreshProjectTree().finally(() => void scanAndRecordRepos()) + } + }, [worktreeGroupingActive, profileScope, gatewayReady]) + + // Out-of-band repo changes (a `git init` / `rm -rf` in another terminal) emit + // no git events, so — like every git GUI — re-pull on window focus / tab + // visibility instead of stranding the tree until a hard reload. The tree + // fetch is cheap and runs every focus (picks up explicit create/delete + + // session regrouping); the heavy disk crawl that surfaces brand-new repos is + // throttled. Agent-driven changes already refresh via $workspaceChangeTick. + useEffect(() => { + if (!worktreeGroupingActive || !gatewayReady) { + return + } + + let lastScanAt = 0 + const SCAN_THROTTLE_MS = 30_000 + + const onActive = () => { + if (document.visibilityState === 'hidden') { + return + } + + void refreshProjects() + void refreshProjectTree() + + const now = Date.now() + + if (now - lastScanAt >= SCAN_THROTTLE_MS) { + lastScanAt = now + void scanAndRecordRepos(true) + } + } + + window.addEventListener('focus', onActive) + document.addEventListener('visibilitychange', onActive) + + return () => { + window.removeEventListener('focus', onActive) + document.removeEventListener('visibilitychange', onActive) + } + }, [worktreeGroupingActive, gatewayReady]) + + // Apply the persisted repo + worktree orders to a project's repo subtrees. + const orderRepos = useCallback( + (repos: SidebarWorkspaceTree[]): SidebarWorkspaceTree[] => + orderByIds(repos, parent => parent.id, workspaceParentOrderIds).map(parent => ({ + ...parent, + groups: orderByIds(parent.groups, group => group.id, workspaceOrderIds) + })), + [workspaceParentOrderIds, workspaceOrderIds] + ) + + // ── Projects: the single top-level model (authoritative, from the backend) ── + // `projects.tree` already unifies explicit projects + auto repos and folds + // linked worktrees under their main repo. The desktop only layers local view + // state on top: dismissed auto-projects, persisted repo/lane order, and the + // overview sort. Membership is the backend tree's — never re-derived here. + const projectModel = useMemo(() => { + if (showAllProfiles) { + return [] + } + + const dismissed = new Set(dismissedAutoProjects) + + const sorted = sortProjectsForOverview( + projectTree + .filter(node => !(node.isAuto && dismissed.has(node.id))) + .map(project => ({ ...project, repos: orderRepos(project.repos) })), + activeProjectId + ) + + // Layer the user's manual drag-order on top of the deterministic sort. Empty + // (default) returns `sorted` untouched; new projects surface on top. + return orderByIds(sorted, project => project.id, projectOrderIds) + }, [showAllProfiles, projectTree, dismissedAutoProjects, orderRepos, activeProjectId, projectOrderIds]) + + // The overview only renders in grouped mode; the model stays live regardless + // so scoping is consistent across views. + const agentProjectTree = worktreeGroupingActive ? projectModel : undefined + + // ── Project switcher (drill-in) ──────────────────────────────────────────── + // Grouped, single-profile view is a project switcher: ALL_PROJECTS shows the + // overview (a list you click into); a concrete scope means you've "entered" a + // project, so the Sessions list shows ONLY that project's worktrees/sessions. + const projectsActive = Boolean(agentProjectTree?.length) + + // The overview node for the entered project (structure + counts, empty lanes). + const overviewEnteredProject = + projectsActive && projectScope !== ALL_PROJECTS + ? agentProjectTree?.find(node => node.id === projectScope) + : undefined + + const inProject = Boolean(overviewEnteredProject) + const enteredProjectId = overviewEnteredProject?.id + + // Entering a project lazily hydrates its full lanes (repo -> lane -> sessions) + // from the backend — same grouping/ids as the overview, just with rows. + const [enteredProjectTree, setEnteredProjectTree] = useState(null) + + useEffect(() => { + if (!enteredProjectId || !gatewayReady) { + setEnteredProjectTree(null) + + return + } + + let cancelled = false + + void fetchProjectSessions(enteredProjectId).then(project => { + if (!cancelled) { + setEnteredProjectTree(project) + } + }) + + return () => { + cancelled = true + } + // `projectTree` in deps: re-hydrate after a tree refresh so the entered view + // stays current with new/ended sessions. + }, [enteredProjectId, gatewayReady, projectTree]) + + // Prefer the hydrated tree; fall back to the overview node (empty lanes) while + // the drill-in fetch is in flight, so the header/structure render immediately. + const enteredProject = useMemo(() => { + if (!overviewEnteredProject) { return undefined } - const tree = workspaceTreeFor(agentSessions, s.noWorkspace, worktreeResolver) - const orderedParents = orderByIds(tree, parent => parent.id, workspaceParentOrderIds) + const hydrated = + enteredProjectTree && enteredProjectTree.id === overviewEnteredProject.id + ? enteredProjectTree + : overviewEnteredProject - return orderedParents.map(parent => ({ - ...parent, - groups: orderByIds(parent.groups, group => group.id, workspaceOrderIds) - })) - }, [worktreeGroupingActive, agentSessions, s.noWorkspace, worktreeResolver, workspaceParentOrderIds, workspaceOrderIds]) + // The live-session overlay (creates/evictions) is applied per-repo in + // RepoFlatSection, AFTER the visual git-worktree lanes are merged in (so + // out-of-tree worktrees can be placed). Here we just order the snapshot. + return { ...hydrated, repos: orderRepos(hydrated.repos) } + }, [overviewEnteredProject, enteredProjectTree, orderRepos]) - const loadMoreForProfileGroup = useCallback( - (profile: string) => { - if (!onLoadMoreProfileSessions) { + // Overlay live `$sessions` onto the entered project so a just-created session + // (which the backend snapshot hasn't folded in yet) counts as content and + // renders immediately — same optimistic layer as the overview previews. The + // backend now seeds each project folder as an (empty) repo, so the overlay + // always has a lane to place a new in-project session into. + const enteredProjectContent = useMemo( + () => (enteredProject ? overlayLiveLanes(enteredProject, agentSessions, removedSessionIds) : undefined), + [enteredProject, agentSessions, removedSessionIds] + ) + + const scopedRepoPaths = useMemo( + () => + enteredProject ? enteredProject.repos.map(repo => repo.path).filter((path): path is string => Boolean(path)) : [], + [enteredProject] + ) + + // git worktree list is a VISUAL-only enhancer (empty lanes); never membership. + const inEnteredProject = Boolean(enteredProject && !showAllProfiles) + const [scopedRepoWorktrees] = useRepoWorktreeMap(scopedRepoPaths, inEnteredProject) + + // Re-probe worktree lanes on out-of-band git changes the renderer can't see. + // A turn can `git worktree add/remove` in the terminal (e.g. you ask Hermes to + // "remove that worktree"), and the window never blurs during an in-app chat, + // so nothing would otherwise re-run the visual probe. Re-sync when a working + // session settles (its turn finished) or the window refocuses (an external + // terminal may have changed things) — only while a project is entered, and + // only the cheap per-repo `git worktree list`, never the heavy tree scan. + const prevWorkingIdsRef = useRef(workingSessionIds) + + useEffect(() => { + const prev = prevWorkingIdsRef.current + prevWorkingIdsRef.current = workingSessionIds + + // A session leaving the working set means its turn just completed. + const aTurnSettled = prev.some(id => !workingSessionIds.includes(id)) + + if (inEnteredProject && aTurnSettled) { + refreshWorktrees() + } + }, [workingSessionIds, inEnteredProject]) + + useEffect(() => { + if (!inEnteredProject) { + return + } + + const onFocus = () => refreshWorktrees() + window.addEventListener('focus', onFocus) + + return () => window.removeEventListener('focus', onFocus) + }, [inEnteredProject]) + + const lastProjectCwdSyncRef = useRef(null) + + const syncProjectCwd = useCallback( + (project: SidebarProjectTree) => { + const target = projectTreeCwd(project) + + if (target && target !== currentCwd) { + setCurrentCwd(target) + } + }, + [currentCwd] + ) + + useEffect(() => { + if (!inProject || !enteredProject) { + lastProjectCwdSyncRef.current = null + + return + } + + if (lastProjectCwdSyncRef.current === enteredProject.id) { + return + } + + syncProjectCwd(enteredProject) + lastProjectCwdSyncRef.current = enteredProject.id + }, [inProject, enteredProject, syncProjectCwd]) + + // A persisted scope can go stale (project archived/removed, or a profile + // switch swapped the whole catalog). Once projects have loaded, drop back to + // the overview if the scoped id is gone. + useEffect(() => { + if (projectScope !== ALL_PROJECTS && projectsActive && !enteredProject) { + exitProjectScope() + } + }, [projectScope, projectsActive, enteredProject]) + + // The project overview (drill-in list) vs. the entered project's content. + const projectOverview = projectsActive && !inProject ? agentProjectTree : undefined + + // Preview rows come from the backend tree (each project carries its + // most-recent sessions), overlaid with live $sessions so a just-created + // session shows under its project instantly (and with its working arc), + // matching the flat Recents list. Keyed by project path for the rows. + const overviewPreviews = useMemo>( + () => overlayLivePreviews(projectOverview ?? [], agentSessions, projects, PROJECT_PREVIEW_COUNT, removedSessionIds), + [projectOverview, agentSessions, projects, removedSessionIds] + ) + + const onEnterProject = useCallback( + (id: string) => { + const project = projectModel.find(node => node.id === id) + + if (project) { + syncProjectCwd(project) + } + + enterProject(id) + }, + [projectModel, syncProjectCwd] + ) + + // The Sessions section is a project switcher in grouped mode: its label reads + // "Sessions" when flat, "Projects" at the overview, and the project's name + // once you've entered one. + const sessionsLabel = + inProject && enteredProject ? enteredProject.label : worktreeGroupingActive ? s.projects.sectionLabel : s.sessions + + // Mirror the section's skeleton gate (projectsLoading + nothing to show yet): + // while the skeleton is up there's no point also spinning the header count. + const projectsSkeletonVisible = + worktreeGroupingActive && + projectTreeLoading && + !projectOverview?.length && + !(inProject && (enteredProject?.sessionCount ?? 0) > 0) + + const runKeyedLoad = useCallback( + ( + key: string, + load: ((key: string) => Promise | void) | undefined, + setPending: React.Dispatch>> + ) => { + if (!load) { return } - setProfileLoadMorePending(prev => ({ ...prev, [profile]: true })) + setPending(prev => ({ ...prev, [key]: true })) - void Promise.resolve(onLoadMoreProfileSessions(profile)) + void Promise.resolve(load(key)) .catch(() => undefined) - .finally(() => setProfileLoadMorePending(({ [profile]: _done, ...rest }) => rest)) + .finally(() => setPending(({ [key]: _done, ...rest }) => rest)) }, - [onLoadMoreProfileSessions] + [] + ) + + const loadMoreForProfileGroup = useCallback( + (profile: string) => runKeyedLoad(profile, onLoadMoreProfileSessions, setProfileLoadMorePending), + [onLoadMoreProfileSessions, runKeyedLoad] ) const loadMoreForMessaging = useCallback( - (platform: string) => { - if (!onLoadMoreMessaging) { - return - } - - setMessagingLoadMorePending(prev => ({ ...prev, [platform]: true })) - - void Promise.resolve(onLoadMoreMessaging(platform)) - .catch(() => undefined) - .finally(() => setMessagingLoadMorePending(({ [platform]: _done, ...rest }) => rest)) - }, - [onLoadMoreMessaging] + (platform: string) => runKeyedLoad(platform, onLoadMoreMessaging, setMessagingLoadMorePending), + [onLoadMoreMessaging, runKeyedLoad] ) // Reveal another batch of a platform's rows; fetch from the backend too if we @@ -702,6 +1039,8 @@ export function ChatSidebar({ sessionProfileTotals ]) + // The flat Sessions list always shows ALL recent sessions; Projects is a + // parallel grouped view, not a filter on this one — nothing is hidden here. const displayAgentSessions = agentSessions // Pagination is scope-aware. In "All profiles" mode it tracks the global @@ -719,9 +1058,50 @@ export function ChatSidebar({ ) const hasMoreSessions = knownSessionTotal > loadedSessionCount - const remainingSessionCount = Math.max(0, knownSessionTotal - loadedSessionCount) - const recentsMeta = countLabel(agentSessions.length, knownSessionTotal) + const recentsMeta = countLabel(displayAgentSessions.length, knownSessionTotal) + const displayRecentsCountRef = useRef(0) + const loadedRecentsCountRef = useRef(0) + displayRecentsCountRef.current = displayAgentSessions.length + loadedRecentsCountRef.current = loadedSessionCount + + const onLoadMoreRecents = useCallback(async () => { + if (recentsLoadMorePending) { + return + } + + setRecentsLoadMorePending(true) + + try { + const startVisible = displayRecentsCountRef.current + const targetVisible = startVisible + SIDEBAR_SESSIONS_PAGE_SIZE + let lastLoaded = loadedRecentsCountRef.current + + // Project-less recents can be sparse in the global recent stream (because + // project-scoped sessions are filtered out in the UI). Keep paging until + // we actually reveal a full page of visible rows, or the backend window + // stops growing. + for (let attempt = 0; attempt < 6; attempt += 1) { + await Promise.resolve(onLoadMoreSessions()) + await new Promise(resolve => window.requestAnimationFrame(() => resolve())) + + const visibleNow = displayRecentsCountRef.current + const loadedNow = loadedRecentsCountRef.current + + if (visibleNow >= targetVisible) { + break + } + + if (loadedNow <= lastLoaded) { + break + } + + lastLoaded = loadedNow + } + } finally { + setRecentsLoadMorePending(false) + } + }, [onLoadMoreSessions, recentsLoadMorePending]) const displayAgentGroups = showAllProfiles ? profileGroups : undefined @@ -730,19 +1110,29 @@ export function ChatSidebar({ // we don't flatten it (flattening would defeat virtualization). Short flat lists // and grouped views (profile groups or the worktree tree) flatten into the // single outer scroll instead. + // Whichever grouping is active, the flat set of repo subtrees on screen — the + // single source for reconciling repo/worktree order, whether repos hang off + // the bare tree or are nested under projects. + const activeRepoTrees = useMemo( + () => (agentProjectTree ? agentProjectTree.flatMap(project => project.repos) : []), + [agentProjectTree] + ) + const recentsVirtualizes = - !displayAgentGroups?.length && !agentTree?.length && displayAgentSessions.length >= VIRTUALIZE_THRESHOLD + !displayAgentGroups?.length && + !agentProjectTree?.length && + displayAgentSessions.length >= VIRTUALIZE_THRESHOLD // Keep the persisted parent + worktree orders reconciled with what's on screen: // freshly-seen repos/worktrees surface at the top, vanished ones drop out of // the saved order. useEffect(() => { - if (!agentTree?.length) { + if (!activeRepoTrees.length) { return } const nextParents = reconcileOrderIds( - agentTree.map(parent => parent.id), + activeRepoTrees.map(parent => parent.id), workspaceParentOrderIds ) @@ -751,14 +1141,14 @@ export function ChatSidebar({ } const nextWorktrees = reconcileOrderIds( - agentTree.flatMap(parent => parent.groups.map(group => group.id)), + activeRepoTrees.flatMap(parent => parent.groups.map(group => group.id)), workspaceOrderIds ) if (!sameIds(nextWorktrees, workspaceOrderIds)) { setSidebarWorkspaceOrderIds(nextWorktrees) } - }, [agentTree, workspaceParentOrderIds, workspaceOrderIds]) + }, [activeRepoTrees, workspaceParentOrderIds, workspaceOrderIds]) const showSessionSkeletons = sessionsLoading && sortedSessions.length === 0 @@ -771,14 +1161,9 @@ export function ChatSidebar({ setSidebarSessionOrderIds(ids) } - const reorderParents = (ids: string[]) => setSidebarWorkspaceParentOrderIds(ids) - - // Worktrees persist as one flat list (orderByIds applies it per parent), so a - // single parent's new worktree order is spliced back over its slice. - const reorderWorktree = (parentId: string, ids: string[]) => - setSidebarWorkspaceOrderIds( - (agentTree ?? []).flatMap(parent => (parent.id === parentId ? ids : parent.groups.map(group => group.id))) - ) + // Persist the new project overview order (drag-to-reorder); orderByIds applies + // it over the default sort, so stale/new ids reconcile on the next render. + const reorderProjects = (ids: string[]) => setSidebarProjectOrderIds(ids) // Sortable rows carry live session ids; the pinned store is keyed by durable // (lineage-root) ids, so translate before persisting the new order. @@ -892,13 +1277,18 @@ export function ChatSidebar({ activeSessionId={activeSidebarSessionId} contentClassName={cn('flex min-h-0 flex-1 flex-col gap-px pb-1.75', SCROLL_Y)} emptyState={ -
- {s.noMatch(trimmedQuery)} -
+ searchPending ? ( + + ) : ( +
+ {s.noMatch(trimmedQuery)} +
+ ) } label={s.results} labelMeta={String(searchResults.length)} onArchiveSession={onArchiveSession} + onBranchSession={onBranchSession} onDeleteSession={onDeleteSession} onResumeSession={onResumeSession} onToggle={() => undefined} @@ -919,6 +1309,7 @@ export function ChatSidebar({ emptyState={} label={s.pinned} onArchiveSession={onArchiveSession} + onBranchSession={onBranchSession} onDeleteSession={onDeleteSession} onReorderSessions={reorderPinned} onResumeSession={onResumeSession} @@ -935,7 +1326,9 @@ export function ChatSidebar({ {!trimmedQuery && ( : } + emptyState={ + showSessionSkeletons ? ( + + ) : ( +
+ {inProject ? s.projectEmpty : pinnedSessions.length > 0 ? s.allPinned : s.noSessions} +
+ ) + } footer={ // Hide "load more" only when workspace-grouped (those groups page // themselves). ALL-profiles now pages per-profile from each profile // header; the global footer only applies to non-ALL views. !showAllProfiles && !agentsGrouped && !showSessionSkeletons && hasMoreSessions ? ( void onLoadMoreRecents()} + // Recents are post-filtered to non-project sessions, so a + // backend page size (50) is not a truthful "rows you'll + // see" count. Use the generic label instead of a fake N. + step={0} /> ) : null } forceEmptyState={showSessionSkeletons} groups={displayAgentGroups} headerAction={ - // Always reserve the icon-xs (size-6) slot so the header keeps the - // same height whether or not the toggle renders — otherwise the - // "Sessions" label jumps when switching to the ALL-profiles view. - // Grouping operates on unpinned recents; if everything is pinned - // the toggle does nothing, and it's irrelevant in the ALL-profiles - // view (always grouped by profile), so hide the button (not the slot). -
- {!showAllProfiles && agentSessions.length > 0 ? ( - + inProject && enteredProject ? ( +
+ {enteredProject.path && ( + + )} + +
- - ) : null} -
+
+
+ ) : ( +
+ {!showAllProfiles ? ( + + ) : null} +
+ {!showAllProfiles && agentSessions.length > 0 ? ( + + ) : null} +
+
+ ) } - label={s.sessions} - labelMeta={recentsMeta} + label={sessionsLabel} + labelMeta={ + worktreeGroupingActive + ? reposScanning && !projectsSkeletonVisible + ? + : undefined + : recentsMeta + } + liveSessions={inProject ? agentSessions : undefined} onArchiveSession={onArchiveSession} + onBranchSession={onBranchSession} onDeleteSession={onDeleteSession} + onEnterProject={onEnterProject} onNewSessionInWorkspace={showAllProfiles ? undefined : onNewSessionInWorkspace} - onReorderParents={showAllProfiles ? undefined : reorderParents} + onReorderProjects={showAllProfiles ? undefined : reorderProjects} onReorderSessions={showAllProfiles ? undefined : reorderSessions} - onReorderWorktree={showAllProfiles ? undefined : reorderWorktree} onResumeSession={onResumeSession} onToggle={() => setSidebarRecentsOpen(!agentsOpen)} onTogglePin={pinSession} open={agentsOpen} pinned={false} + projectBackRow={inProject ? : undefined} + projectContent={inProject ? enteredProjectContent : undefined} + projectOverview={projectOverview} + projectOverviewPreviews={overviewPreviews} + projectRepoWorktrees={inProject ? scopedRepoWorktrees : undefined} + projectsLoading={worktreeGroupingActive ? projectTreeLoading : false} + removedSessionIds={inProject ? removedSessionIds : undefined} rootClassName={cn( 'min-h-32 flex-1 overflow-hidden p-0', !recentsVirtualizes && 'compact:min-h-0 compact:flex-none compact:overflow-visible' )} sessions={displayAgentSessions} sortable={!showAllProfiles && agentSessions.length > 1} - tree={agentTree} workingSessionIdSet={workingSessionIdSet} /> )} {!trimmedQuery && + !worktreeGroupingActive && messagingGroups.map(group => { const visible = messagingVisible[group.sourceId] ?? NON_SESSION_INITIAL_ROWS const shownSessions = group.sessions.slice(0, visible) @@ -1062,7 +1522,7 @@ export function ChatSidebar({ ) })} - {!trimmedQuery && cronJobs.length > 0 && ( + {!trimmedQuery && !worktreeGroupingActive && cronJobs.length > 0 && ( )} + ) } @@ -1095,24 +1556,38 @@ interface SidebarSectionHeaderProps { 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 }: SidebarSectionHeaderProps) { +function SidebarSectionHeader({ label, open, onToggle, action, meta, icon, collapsible = true }: SidebarSectionHeaderProps) { + const labelBody = ( + <> + {icon} + {label} + {meta && {meta}} + + ) + return ( -
- +
+ {collapsible ? ( + + ) : ( +
{labelBody}
+ )} {action}
) @@ -1122,25 +1597,15 @@ function SidebarSessionSkeletons() { return (