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.
442 lines
16 KiB
TypeScript
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>
|
|
);
|
|
};
|