/** * 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, useRef, useState } from 'react'; import { HEADER_ROW1_HEIGHT, HEADER_ROW2_HEIGHT } from '@renderer/constants/layout'; import { useStore } from '@renderer/store'; import { truncateMiddle } from '@renderer/utils/stringUtils'; import { Check, ChevronDown, GitBranch, PanelLeft } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; import { ContextSwitcher } from '../common/ContextSwitcher'; import { WorktreeBadge } from '../common/WorktreeBadge'; 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 ( ); }; /** * Individual project/repository item in the dropdown. */ interface ProjectDropdownItemProps { name: string; path?: string; sessionCount: number; isSelected: boolean; onSelect: () => void; } const ProjectDropdownItem = ({ name, path, sessionCount, 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 { repositoryGroups, selectedRepositoryId, selectedWorktreeId, selectWorktree, selectRepository, viewMode, projects, activeProjectId, setActiveProject, 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, 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 [isProjectDropdownOpen, setIsProjectDropdownOpen] = useState(false); const worktreeDropdownRef = useRef(null); const projectDropdownRef = 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; // For flat mode const activeProject = projects.find((p) => p.id === activeProjectId); // Get display name const projectName = viewMode === 'grouped' ? (activeRepo?.name ?? 'Select Project') : (activeProject?.name ?? 'Select Project'); const worktreeName = activeWorktree?.name ?? 'main'; const hasSelection = viewMode === 'grouped' ? !!activeRepo : !!activeProject; // Close dropdowns on outside click useEffect(() => { function handleClickOutside(event: MouseEvent): void { if ( worktreeDropdownRef.current && !worktreeDropdownRef.current.contains(event.target as Node) ) { setIsWorktreeDropdownOpen(false); } if ( projectDropdownRef.current && !projectDropdownRef.current.contains(event.target as Node) ) { setIsProjectDropdownOpen(false); } } document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, []); // Close on escape useEffect(() => { function handleEscape(event: KeyboardEvent): void { if (event.key === 'Escape') { setIsWorktreeDropdownOpen(false); setIsProjectDropdownOpen(false); } } document.addEventListener('keydown', handleEscape); return () => document.removeEventListener('keydown', handleEscape); }, []); const handleSelectWorktree = (worktree: Worktree): void => { selectWorktree(worktree.id); setIsWorktreeDropdownOpen(false); }; const handleSelectRepo = (repoId: string): void => { selectRepository(repoId); setIsProjectDropdownOpen(false); }; const handleSelectProject = (projectId: string): void => { setActiveProject(projectId); setIsProjectDropdownOpen(false); }; // Items for project dropdown - 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 [isCollapseHovered, setIsCollapseHovered] = useState(false); return (
{/* ROW 1: Project Identity (Title Bar / Drag Region) */}
{/* Context Switcher - workspace indicator */} {/* Visual separator */}
{/* Project name dropdown button */} {/* Collapse sidebar button */} {/* Project Dropdown */} {isProjectDropdownOpen && ( <>
setIsProjectDropdownOpen(false)} />
Switch {viewMode === 'grouped' ? 'Repository' : 'Project'}
{projectItems.length === 0 ? (
No {viewMode === 'grouped' ? 'repositories' : 'projects'} found
) : ( projectItems.map((item) => { const isSelected = viewMode === 'grouped' ? item.id === selectedRepositoryId : item.id === activeProjectId; const itemSessions = viewMode === 'grouped' ? (item as (typeof repositoryGroups)[0]).totalSessions : (item as (typeof projects)[0]).sessions.length; // Get path for display const itemPath = viewMode === 'grouped' ? (item as (typeof repositoryGroups)[0]).worktrees[0]?.path : (item as (typeof projects)[0]).path; return ( viewMode === 'grouped' ? handleSelectRepo(item.id) : handleSelectProject(item.id) } /> ); }) )}
)}
{/* 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)} /> ))}
))}
)}
)}
); };