agent-ecosystem/src/renderer/components/layout/SidebarHeader.tsx
matt 58f4be0a61 feat(04-01): wire ContextSwitcher into SidebarHeader and add keyboard shortcut
- Add ContextSwitcher to SidebarHeader Row 1 with visual separator
- Add Cmd+Shift+K shortcut to cycle through available contexts
- Add SSH status listener in App.tsx to refresh context list on changes
- Shortcut guard: disabled during active context switch

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 04:37:49 +00:00

552 lines
19 KiB
TypeScript

/**
* 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<WorktreeSource, string> = {
'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<WorktreeSource, Worktree[]>();
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<WorktreeItemProps>): 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 (
<button
onClick={onSelect}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
className="flex w-full items-center gap-1.5 px-4 py-1.5 text-left transition-colors"
style={buttonStyle}
>
<GitBranch
className="size-3.5 shrink-0"
style={{ color: isSelected ? '#34d399' : 'var(--color-text-muted)' }}
/>
{/* Only show badge for main worktree - others are grouped by header */}
{worktree.isMainWorktree && <WorktreeBadge source={worktree.source} isMain />}
<span
className="flex-1 truncate font-mono text-xs"
style={{ color: isSelected ? 'var(--color-text)' : 'var(--color-text-muted)' }}
>
{truncateMiddle(worktree.name, 28)}
</span>
<span className="shrink-0 text-[10px]" style={{ color: 'var(--color-text-muted)' }}>
{worktree.sessions.length}
</span>
{isSelected && <Check className="size-3.5 shrink-0 text-indigo-400" />}
</button>
);
};
/**
* 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<ProjectDropdownItemProps>): 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 (
<button
onClick={onSelect}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
className="flex w-full items-center gap-2 px-3 py-2 text-left transition-colors"
style={buttonStyle}
>
<div className="min-w-0 flex-1">
<span
className={`block truncate text-sm ${isSelected ? 'font-medium' : ''}`}
style={{ color: isSelected ? 'var(--color-text)' : 'var(--color-text-muted)' }}
>
{name}
</span>
{path && (
<span className="block truncate text-[10px]" style={{ color: 'var(--color-text-muted)' }}>
{path}
</span>
)}
</div>
<span className="shrink-0 text-[10px]" style={{ color: 'var(--color-text-muted)' }}>
{sessionCount}
</span>
{isSelected && <Check className="size-3.5 shrink-0 text-indigo-400" />}
</button>
);
};
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<HTMLDivElement>(null);
const projectDropdownRef = useRef<HTMLDivElement>(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 (
<div
className="flex w-full flex-col"
style={{ backgroundColor: 'var(--color-surface-sidebar)' }}
>
{/* ROW 1: Project Identity (Title Bar / Drag Region) */}
<div
ref={projectDropdownRef}
className="relative flex select-none items-center gap-2 pr-2"
style={
{
height: `${HEADER_ROW1_HEIGHT}px`,
paddingLeft: 'var(--macos-traffic-light-padding-left, 72px)',
WebkitAppRegion: 'drag',
} as React.CSSProperties
}
>
{/* Context Switcher - workspace indicator */}
<ContextSwitcher />
{/* Visual separator */}
<div
className="h-4 w-px"
style={{ backgroundColor: 'var(--color-border)' }}
/>
{/* Project name dropdown button */}
<button
onClick={() => setIsProjectDropdownOpen(!isProjectDropdownOpen)}
className="flex min-w-0 items-center gap-2 transition-opacity hover:opacity-80"
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
>
<span
className="min-w-0 truncate text-sm font-bold tracking-tight"
style={{ color: hasSelection ? 'var(--color-text)' : 'var(--color-text-muted)' }}
>
{projectName}
</span>
<ChevronDown
className={`size-3.5 shrink-0 transition-transform ${isProjectDropdownOpen ? 'rotate-180' : ''}`}
style={{ color: 'var(--color-text-muted)' }}
/>
</button>
{/* Collapse sidebar button */}
<button
onClick={toggleSidebar}
onMouseEnter={() => setIsCollapseHovered(true)}
onMouseLeave={() => setIsCollapseHovered(false)}
className="ml-auto shrink-0 rounded-md p-1.5 transition-colors"
style={
{
WebkitAppRegion: 'no-drag',
color: isCollapseHovered ? 'var(--color-text-secondary)' : 'var(--color-text-muted)',
backgroundColor: isCollapseHovered ? 'var(--color-surface-raised)' : 'transparent',
} as React.CSSProperties
}
title="Collapse sidebar (⌘B)"
>
<PanelLeft className="size-4" />
</button>
{/* Project Dropdown */}
{isProjectDropdownOpen && (
<>
<div
role="presentation"
className="fixed inset-0 z-10"
onClick={() => setIsProjectDropdownOpen(false)}
/>
<div
className="absolute inset-x-4 top-full z-20 mt-1 max-h-[350px] overflow-y-auto rounded-lg py-1 shadow-xl"
style={{
backgroundColor: 'var(--color-surface-sidebar)',
borderWidth: '1px',
borderStyle: 'solid',
borderColor: 'var(--color-border)',
}}
>
<div
className="px-3 py-2 text-[10px] font-semibold uppercase tracking-wider"
style={{ color: 'var(--color-text-muted)' }}
>
Switch {viewMode === 'grouped' ? 'Repository' : 'Project'}
</div>
{projectItems.length === 0 ? (
<div className="p-3 text-sm" style={{ color: 'var(--color-text-muted)' }}>
No {viewMode === 'grouped' ? 'repositories' : 'projects'} found
</div>
) : (
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 (
<ProjectDropdownItem
key={item.id}
name={item.name}
path={itemPath}
sessionCount={itemSessions}
isSelected={isSelected}
onSelect={() =>
viewMode === 'grouped'
? handleSelectRepo(item.id)
: handleSelectProject(item.id)
}
/>
);
})
)}
</div>
</>
)}
</div>
{/* ROW 2: Worktree Selector (Full Width) */}
{viewMode === 'grouped' && activeRepo && (
<div ref={worktreeDropdownRef} className="relative w-full">
<button
onClick={() =>
hasMultipleWorktrees && setIsWorktreeDropdownOpen(!isWorktreeDropdownOpen)
}
disabled={!hasMultipleWorktrees}
className={`flex w-full items-center justify-between px-4 text-left transition-colors ${hasMultipleWorktrees ? 'cursor-pointer' : 'cursor-default'}`}
style={{
height: `${HEADER_ROW2_HEIGHT}px`,
backgroundColor: isWorktreeDropdownOpen
? 'var(--color-surface-raised)'
: 'var(--color-surface-sidebar)',
color: isWorktreeDropdownOpen ? 'var(--color-text)' : 'var(--color-text-muted)',
}}
>
<div className="flex flex-1 items-center gap-1.5 overflow-hidden">
<GitBranch
className="size-4 shrink-0"
style={{ color: isWorktreeDropdownOpen ? '#34d399' : 'rgba(52, 211, 153, 0.7)' }}
/>
{activeWorktree?.isMainWorktree ? (
<WorktreeBadge source={activeWorktree.source} isMain />
) : (
activeWorktree?.source && <WorktreeBadge source={activeWorktree.source} />
)}
<span className="truncate font-mono text-xs">{truncateMiddle(worktreeName, 28)}</span>
</div>
{hasMultipleWorktrees && (
<ChevronDown
className={`size-4 shrink-0 transition-transform ${isWorktreeDropdownOpen ? 'rotate-180' : ''}`}
style={{ color: 'var(--color-text-muted)' }}
/>
)}
</button>
{/* Worktree Dropdown */}
{isWorktreeDropdownOpen && hasMultipleWorktrees && (
<>
<div
role="presentation"
className="fixed inset-0 z-10"
onClick={() => setIsWorktreeDropdownOpen(false)}
/>
<div
className="absolute inset-x-0 top-full z-20 mt-0 max-h-[400px] overflow-y-auto py-1 shadow-xl"
style={{
backgroundColor: 'var(--color-surface-sidebar)',
borderWidth: '1px',
borderTopWidth: '0',
borderStyle: 'solid',
borderColor: 'var(--color-border)',
}}
>
<div
className="px-4 py-2 text-[10px] font-semibold uppercase tracking-wider"
style={{ color: 'var(--color-text-muted)' }}
>
Switch Worktree
</div>
{/* Main worktree first */}
{mainWorktree && (
<WorktreeItem
worktree={mainWorktree}
isSelected={mainWorktree.id === selectedWorktreeId}
onSelect={() => handleSelectWorktree(mainWorktree)}
/>
)}
{/* Grouped worktrees by source */}
{worktreeGroups.map((group) => (
<div key={group.source}>
{/* Group header */}
<div
className="mt-1 px-4 py-1.5 text-[9px] font-medium uppercase tracking-wider"
style={{
borderTopWidth: '1px',
borderTopStyle: 'solid',
borderTopColor: 'var(--color-border)',
color: 'var(--color-text-muted)',
}}
>
{group.label}
</div>
{/* Worktrees in group */}
{group.worktrees.map((worktree) => (
<WorktreeItem
key={worktree.id}
worktree={worktree}
isSelected={worktree.id === selectedWorktreeId}
onSelect={() => handleSelectWorktree(worktree)}
/>
))}
</div>
))}
</div>
</>
)}
</div>
)}
</div>
);
};