/** * SidebarHeader - Linear-style header with project name and worktree selector. * * Layout (2 stacked horizontal bars): * - Row 1: Project name (left-aligned after macOS traffic lights) * - Row 2: Worktree selector (full-width button) * * Visual requirements: * - Row 1 is the drag region for window movement * - Row 1 reserves left space for macOS traffic lights via shared layout CSS variable * - Row 2 is a full-width button with no side margins */ import { useEffect, useMemo, useRef, useState } from 'react'; import { isElectronMode } from '@renderer/api'; import { HEADER_ROW1_HEIGHT, HEADER_ROW2_HEIGHT } from '@renderer/constants/layout'; import { cn } from '@renderer/lib/utils'; import { useStore } from '@renderer/store'; import { formatShortcut, truncateMiddle } from '@renderer/utils/stringUtils'; import { Check, ChevronDown, GitBranch, PanelLeft } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; import { AppLogo } from '../common/AppLogo'; import { WorktreeBadge } from '../common/WorktreeBadge'; import { Combobox, type ComboboxOption } from '../ui/combobox'; import type { Worktree, WorktreeSource } from '@renderer/types/data'; /** * Group worktrees by source for organized dropdown display. * Returns: main worktree first, then groups sorted by most recent activity. */ interface WorktreeGroup { source: WorktreeSource; label: string; worktrees: Worktree[]; mostRecent: number; } const SOURCE_LABELS: Record = { 'vibe-kanban': 'Vibe Kanban', conductor: 'Conductor', 'auto-claude': 'Auto Claude', '21st': '21st', 'claude-desktop': 'Claude Desktop', ccswitch: 'ccswitch', git: 'Git', unknown: 'Other', }; function groupWorktreesBySource(worktrees: Worktree[]): { mainWorktree: Worktree | null; groups: WorktreeGroup[]; } { // Find main worktree const mainWorktree = worktrees.find((w) => w.isMainWorktree) ?? null; // Group remaining worktrees by source const groupMap = new Map(); for (const wt of worktrees) { if (wt.isMainWorktree) continue; // Skip main, handled separately const existing = groupMap.get(wt.source) ?? []; existing.push(wt); groupMap.set(wt.source, existing); } // Convert to array and sort each group internally by most recent const groups: WorktreeGroup[] = []; for (const [source, wts] of groupMap) { // Sort worktrees within group by most recent const sorted = [...wts].sort((a, b) => (b.mostRecentSession ?? 0) - (a.mostRecentSession ?? 0)); const mostRecent = Math.max(...sorted.map((w) => w.mostRecentSession ?? 0)); groups.push({ source, label: SOURCE_LABELS[source] ?? source, worktrees: sorted, mostRecent, }); } // Sort groups by most recent activity groups.sort((a, b) => b.mostRecent - a.mostRecent); return { mainWorktree, groups }; } /** * Individual worktree item in the dropdown. */ interface WorktreeItemProps { worktree: Worktree; isSelected: boolean; onSelect: () => void; } const WorktreeItem = ({ worktree, isSelected, onSelect, }: Readonly): React.JSX.Element => { const [isHovered, setIsHovered] = useState(false); const buttonStyle: React.CSSProperties = isSelected ? { backgroundColor: 'var(--color-surface-raised)', color: 'var(--color-text)' } : { backgroundColor: isHovered ? 'var(--color-surface-raised)' : 'transparent', opacity: isHovered ? 0.5 : 1, }; return ( ); }; export const SidebarHeader = (): React.JSX.Element => { const isMacElectron = isElectronMode() && window.navigator.userAgent.toLowerCase().includes('mac'); const { repositoryGroups, selectedRepositoryId, selectedWorktreeId, selectWorktree, selectRepository, viewMode, projects, activeProjectId, setActiveProject, clearActiveProject, fetchRepositoryGroups, fetchProjects, toggleSidebar, } = useStore( useShallow((s) => ({ repositoryGroups: s.repositoryGroups, selectedRepositoryId: s.selectedRepositoryId, selectedWorktreeId: s.selectedWorktreeId, selectWorktree: s.selectWorktree, selectRepository: s.selectRepository, viewMode: s.viewMode, projects: s.projects, activeProjectId: s.activeProjectId, setActiveProject: s.setActiveProject, clearActiveProject: s.clearActiveProject, fetchRepositoryGroups: s.fetchRepositoryGroups, fetchProjects: s.fetchProjects, toggleSidebar: s.toggleSidebar, })) ); // Fetch data on mount based on view mode useEffect(() => { if (viewMode === 'grouped' && repositoryGroups.length === 0) { void fetchRepositoryGroups(); } else if (viewMode === 'flat' && projects.length === 0) { void fetchProjects(); } }, [viewMode, repositoryGroups.length, projects.length, fetchRepositoryGroups, fetchProjects]); const [isWorktreeDropdownOpen, setIsWorktreeDropdownOpen] = useState(false); const worktreeDropdownRef = useRef(null); // Find the active repository and worktree const activeRepo = repositoryGroups.find((r) => r.id === selectedRepositoryId); const activeWorktree = activeRepo?.worktrees.find((w) => w.id === selectedWorktreeId); // Filter worktrees to only show those with sessions const worktrees = (activeRepo?.worktrees ?? []).filter((w) => w.sessions.length > 0); const hasMultipleWorktrees = worktrees.length > 1; // Group worktrees by source for organized dropdown const worktreeGroupingResult = groupWorktreesBySource(worktrees); const mainWorktree = worktreeGroupingResult.mainWorktree; const worktreeGroups = worktreeGroupingResult.groups; const worktreeName = activeWorktree?.name ?? 'main'; const handleSelectWorktree = (worktree: Worktree): void => { selectWorktree(worktree.id); setIsWorktreeDropdownOpen(false); }; const handleProjectValueChange = (id: string): void => { if (viewMode === 'grouped') selectRepository(id); else setActiveProject(id); }; // Items for project combobox - filter out repositories/projects with 0 sessions const projectItems = viewMode === 'grouped' ? repositoryGroups.filter((r) => r.totalSessions > 0) : projects.filter((p) => p.sessions.length > 0); const projectComboboxOptions = useMemo((): ComboboxOption[] => { const items = viewMode === 'grouped' ? repositoryGroups.filter((r) => r.totalSessions > 0) : projects.filter((p) => p.sessions.length > 0); return items.map((item) => { const sessionCount = viewMode === 'grouped' ? (item as (typeof repositoryGroups)[0]).totalSessions : (item as (typeof projects)[0]).sessions.length; const path = viewMode === 'grouped' ? (item as (typeof repositoryGroups)[0]).worktrees[0]?.path : (item as (typeof projects)[0]).path; return { value: item.id, label: item.name, description: path, meta: { sessionCount, path }, }; }); }, [viewMode, repositoryGroups, projects]); const activeProjectValue = viewMode === 'grouped' ? selectedRepositoryId : activeProjectId; const [isCollapseHovered, setIsCollapseHovered] = useState(false); return (
{/* ROW 1: Logo in corner, project selector fills width, collapse button */}
{ const sessionCount = (option.meta?.sessionCount as number) ?? 0; const path = option.meta?.path as string | undefined; return ( <>

{option.label}

{path ? (

{path}

) : null}
{sessionCount} ); }} />
{/* ROW 2: Worktree Selector (Full Width) */} {viewMode === 'grouped' && activeRepo && (
{/* Worktree Dropdown */} {isWorktreeDropdownOpen && hasMultipleWorktrees && ( <>
setIsWorktreeDropdownOpen(false)} />
Switch Worktree
{/* Main worktree first */} {mainWorktree && ( handleSelectWorktree(mainWorktree)} /> )} {/* Grouped worktrees by source */} {worktreeGroups.map((group) => (
{/* Group header */}
{group.label}
{/* Worktrees in group */} {group.worktrees.map((worktree) => ( handleSelectWorktree(worktree)} /> ))}
))}
)}
)}
); };