agent-ecosystem/src/renderer/components/layout/SidebarHeader.tsx
iliya 06bf5d4381 merge: sync with upstream/main — session reports, cost calculation, Linux title bar, auto-expand AI groups
Brings in upstream changes:
- Session analysis reports (overview, cost, tokens, tools, git, quality)
- Unified cost calculation with LiteLLM pricing data
- Custom title bar for Linux with native toggle
- Auto-expand AI response groups setting
- MoreMenu toolbar component
- Various fixes (window drag, Ctrl+R, notification guard)

All merge conflicts resolved preserving both fork features (team management,
agent language, fullscreen, diff view) and upstream additions.
2026-02-24 21:04:23 +02:00

442 lines
16 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, 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<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>
);
};
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<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;
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 (
<div
className="flex w-full flex-col"
style={{ backgroundColor: 'var(--color-surface-sidebar)' }}
>
{/* ROW 1: Logo in corner, project selector fills width, collapse button */}
<div
className="flex select-none items-center gap-1.5 pr-1"
style={
{
height: `${HEADER_ROW1_HEIGHT}px`,
paddingLeft: isMacElectron ? 'var(--macos-traffic-light-padding-left, 72px)' : 0,
WebkitAppRegion: isMacElectron ? 'drag' : undefined,
} as React.CSSProperties
}
>
<div style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
<AppLogo size={22} className="shrink-0" />
</div>
<div
className="min-w-0 flex-1"
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
>
<Combobox
options={projectComboboxOptions}
value={activeProjectValue ?? ''}
onValueChange={handleProjectValueChange}
placeholder="Select Project"
searchPlaceholder="Search..."
emptyMessage={
projectItems.length === 0
? `No ${viewMode === 'grouped' ? 'repositories' : 'projects'} found`
: 'Nothing found'
}
className="text-sm font-medium"
resetLabel="Reset selection"
onReset={clearActiveProject}
renderOption={(option, isSelected) => {
const sessionCount = (option.meta?.sessionCount as number) ?? 0;
const path = option.meta?.path as string | undefined;
return (
<>
<Check
className={cn(
'mr-2 size-3.5 shrink-0',
isSelected ? 'text-indigo-400 opacity-100' : 'opacity-0'
)}
/>
<div className="min-w-0 flex-1">
<p
className={cn(
'truncate',
isSelected
? 'font-medium text-[var(--color-text)]'
: 'text-[var(--color-text-muted)]'
)}
>
{option.label}
</p>
{path ? (
<p className="truncate text-[10px] text-[var(--color-text-muted)]">{path}</p>
) : null}
</div>
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">
{sessionCount}
</span>
</>
);
}}
/>
</div>
<button
onClick={toggleSidebar}
onMouseEnter={() => setIsCollapseHovered(true)}
onMouseLeave={() => setIsCollapseHovered(false)}
className="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 (${formatShortcut('B')})`}
>
<PanelLeft className="size-4" />
</button>
</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>
);
};