feat: Enhance sidebar navigation with collapsible sections and state management

- Added support for collapsible navigation sections in the sidebar, allowing users to expand or collapse sections based on their preferences.
- Integrated the collapsed state management into the app store for persistence across sessions.
- Updated the sidebar component to conditionally render the header based on the selected sidebar style.
- Ensured synchronization of collapsed section states with user settings for a consistent experience.
This commit is contained in:
Shirone
2026-01-23 16:47:32 +01:00
parent 4012a2964a
commit f005c30017
6 changed files with 83 additions and 36 deletions

View File

@@ -1,10 +1,11 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import { useCallback, useEffect, useRef } from 'react';
import type { NavigateOptions } from '@tanstack/react-router';
import { ChevronDown, Wrench, Github } from 'lucide-react';
import { cn } from '@/lib/utils';
import { formatShortcut } from '@/store/app-store';
import { formatShortcut, useAppStore } from '@/store/app-store';
import type { NavSection } from '../types';
import type { Project } from '@/lib/electron';
import type { SidebarStyle } from '@automaker/types';
import { Spinner } from '@/components/ui/spinner';
import {
DropdownMenu,
@@ -23,6 +24,7 @@ const sectionIcons: Record<string, React.ComponentType<{ className?: string }>>
interface SidebarNavigationProps {
currentProject: Project | null;
sidebarOpen: boolean;
sidebarStyle: SidebarStyle;
navSections: NavSection[];
isActiveRoute: (id: string) => boolean;
navigate: (opts: NavigateOptions) => void;
@@ -32,6 +34,7 @@ interface SidebarNavigationProps {
export function SidebarNavigation({
currentProject,
sidebarOpen,
sidebarStyle,
navSections,
isActiveRoute,
navigate,
@@ -39,21 +42,26 @@ export function SidebarNavigation({
}: SidebarNavigationProps) {
const navRef = useRef<HTMLElement>(null);
// Track collapsed state for each collapsible section
const [collapsedSections, setCollapsedSections] = useState<Record<string, boolean>>({});
// Get collapsed state from store (persisted across restarts)
const { collapsedNavSections, setCollapsedNavSections, toggleNavSection } = useAppStore();
// Initialize collapsed state when sections change (e.g., GitHub section appears)
// Only set defaults for sections that don't have a persisted state
useEffect(() => {
setCollapsedSections((prev) => {
const updated = { ...prev };
navSections.forEach((section) => {
if (section.collapsible && section.label && !(section.label in updated)) {
updated[section.label] = section.defaultCollapsed ?? false;
}
});
return updated;
let hasNewSections = false;
const updated = { ...collapsedNavSections };
navSections.forEach((section) => {
if (section.collapsible && section.label && !(section.label in updated)) {
updated[section.label] = section.defaultCollapsed ?? false;
hasNewSections = true;
}
});
}, [navSections]);
if (hasNewSections) {
setCollapsedNavSections(updated);
}
}, [navSections, collapsedNavSections, setCollapsedNavSections]);
// Check scroll state
const checkScrollState = useCallback(() => {
@@ -77,14 +85,7 @@ export function SidebarNavigation({
nav.removeEventListener('scroll', checkScrollState);
resizeObserver.disconnect();
};
}, [checkScrollState, collapsedSections]);
const toggleSection = useCallback((label: string) => {
setCollapsedSections((prev) => ({
...prev,
[label]: !prev[label],
}));
}, []);
}, [checkScrollState, collapsedNavSections]);
// Filter sections: always show non-project sections, only show project sections when project exists
const visibleSections = navSections.filter((section) => {
@@ -97,10 +98,17 @@ export function SidebarNavigation({
});
return (
<nav ref={navRef} className={cn('flex-1 overflow-y-auto scrollbar-hide px-3 pb-2 mt-1')}>
<nav
ref={navRef}
className={cn(
'flex-1 overflow-y-auto scrollbar-hide px-3 pb-2',
// Add top padding in discord mode since there's no header
sidebarStyle === 'discord' ? 'pt-3' : 'mt-1'
)}
>
{/* Navigation sections */}
{visibleSections.map((section, sectionIdx) => {
const isCollapsed = section.label ? collapsedSections[section.label] : false;
const isCollapsed = section.label ? collapsedNavSections[section.label] : false;
const isCollapsible = section.collapsible && section.label && sidebarOpen;
const SectionIcon = section.label ? sectionIcons[section.label] : null;
@@ -110,21 +118,37 @@ export function SidebarNavigation({
{/* Section Label - clickable if collapsible (expanded sidebar) */}
{section.label && sidebarOpen && (
<button
onClick={() => isCollapsible && toggleSection(section.label!)}
onClick={() => isCollapsible && toggleNavSection(section.label!)}
className={cn(
'flex items-center w-full px-3 mb-1.5',
isCollapsible && 'cursor-pointer hover:text-foreground'
'group flex items-center w-full px-3 py-1.5 mb-1 rounded-md',
'transition-all duration-200 ease-out',
isCollapsible
? [
'cursor-pointer',
'hover:bg-accent/50 hover:text-foreground',
'border border-transparent hover:border-border/40',
]
: 'cursor-default'
)}
disabled={!isCollapsible}
>
<span className="text-[10px] font-semibold text-muted-foreground/70 uppercase tracking-widest">
<span
className={cn(
'text-[10px] font-semibold uppercase tracking-widest transition-colors duration-200',
isCollapsible
? 'text-muted-foreground/70 group-hover:text-foreground'
: 'text-muted-foreground/70'
)}
>
{section.label}
</span>
{isCollapsible && (
<ChevronDown
className={cn(
'w-3 h-3 ml-auto text-muted-foreground/50 transition-transform duration-200',
isCollapsed && '-rotate-90'
'w-3 h-3 ml-auto transition-all duration-200',
isCollapsed
? '-rotate-90 text-muted-foreground/50 group-hover:text-muted-foreground'
: 'text-muted-foreground/50 group-hover:text-muted-foreground'
)}
/>
)}

View File

@@ -53,6 +53,7 @@ export function Sidebar() {
trashedProjects,
currentProject,
sidebarOpen,
sidebarStyle,
mobileSidebarHidden,
projectHistory,
upsertAndSetCurrentProject,
@@ -381,17 +382,21 @@ export function Sidebar() {
)}
<div className="flex-1 flex flex-col overflow-hidden">
<SidebarHeader
sidebarOpen={sidebarOpen}
currentProject={currentProject}
onNewProject={handleNewProject}
onOpenFolder={handleOpenFolder}
onProjectContextMenu={handleContextMenu}
/>
{/* Only show header in unified mode - in discord mode, ProjectSwitcher has the logo */}
{sidebarStyle === 'unified' && (
<SidebarHeader
sidebarOpen={sidebarOpen}
currentProject={currentProject}
onNewProject={handleNewProject}
onOpenFolder={handleOpenFolder}
onProjectContextMenu={handleContextMenu}
/>
)}
<SidebarNavigation
currentProject={currentProject}
sidebarOpen={sidebarOpen}
sidebarStyle={sidebarStyle}
navSections={navSections}
isActiveRoute={isActiveRoute}
navigate={navigate}

View File

@@ -699,6 +699,7 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
fontFamilyMono: settings.fontFamilyMono ?? null,
sidebarOpen: settings.sidebarOpen ?? true,
sidebarStyle: settings.sidebarStyle ?? 'unified',
collapsedNavSections: settings.collapsedNavSections ?? {},
chatHistoryOpen: settings.chatHistoryOpen ?? false,
maxConcurrency: settings.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY,
autoModeByWorktree: restoredAutoModeByWorktree,

View File

@@ -54,6 +54,7 @@ const SETTINGS_FIELDS_TO_SYNC = [
'openTerminalMode', // Maps to terminalState.openTerminalMode
'sidebarOpen',
'sidebarStyle',
'collapsedNavSections',
'chatHistoryOpen',
'maxConcurrency',
'autoModeByWorktree', // Per-worktree auto mode settings (only maxConcurrency is persisted)
@@ -699,6 +700,7 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
theme: serverSettings.theme as unknown as ThemeMode,
sidebarOpen: serverSettings.sidebarOpen,
sidebarStyle: serverSettings.sidebarStyle ?? 'unified',
collapsedNavSections: serverSettings.collapsedNavSections ?? {},
chatHistoryOpen: serverSettings.chatHistoryOpen,
maxConcurrency: serverSettings.maxConcurrency,
autoModeByWorktree: restoredAutoModeByWorktree,

View File

@@ -612,6 +612,7 @@ export interface AppState {
currentView: ViewMode;
sidebarOpen: boolean;
sidebarStyle: SidebarStyle; // 'unified' (modern) or 'discord' (classic two-sidebar layout)
collapsedNavSections: Record<string, boolean>; // Collapsed state of nav sections (key: section label)
mobileSidebarHidden: boolean; // Completely hides sidebar on mobile
// Agent Session state (per-project, keyed by project path)
@@ -1049,6 +1050,8 @@ export interface AppActions {
toggleSidebar: () => void;
setSidebarOpen: (open: boolean) => void;
setSidebarStyle: (style: SidebarStyle) => void;
setCollapsedNavSections: (sections: Record<string, boolean>) => void;
toggleNavSection: (sectionLabel: string) => void;
toggleMobileSidebarHidden: () => void;
setMobileSidebarHidden: (hidden: boolean) => void;
@@ -1475,6 +1478,7 @@ const initialState: AppState = {
currentView: 'welcome',
sidebarOpen: true,
sidebarStyle: 'unified', // Default to modern unified sidebar
collapsedNavSections: {}, // Nav sections expanded by default (sections set their own defaults)
mobileSidebarHidden: false, // Sidebar visible by default on mobile
lastSelectedSessionByProject: {},
theme: getStoredTheme() || 'dark', // Use localStorage theme as initial value, fallback to 'dark'
@@ -1934,6 +1938,14 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
toggleSidebar: () => set({ sidebarOpen: !get().sidebarOpen }),
setSidebarOpen: (open) => set({ sidebarOpen: open }),
setSidebarStyle: (style) => set({ sidebarStyle: style }),
setCollapsedNavSections: (sections) => set({ collapsedNavSections: sections }),
toggleNavSection: (sectionLabel) =>
set((state) => ({
collapsedNavSections: {
...state.collapsedNavSections,
[sectionLabel]: !state.collapsedNavSections[sectionLabel],
},
})),
toggleMobileSidebarHidden: () => set({ mobileSidebarHidden: !get().mobileSidebarHidden }),
setMobileSidebarHidden: (hidden) => set({ mobileSidebarHidden: hidden }),

View File

@@ -846,6 +846,8 @@ export interface GlobalSettings {
sidebarOpen: boolean;
/** Sidebar layout style ('unified' = modern single sidebar, 'discord' = classic two-sidebar layout) */
sidebarStyle: SidebarStyle;
/** Collapsed state of sidebar navigation sections (key: section label, value: is collapsed) */
collapsedNavSections?: Record<string, boolean>;
/** Whether chat history panel is open */
chatHistoryOpen: boolean;
@@ -1321,6 +1323,7 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
theme: 'dark',
sidebarOpen: true,
sidebarStyle: 'unified',
collapsedNavSections: {},
chatHistoryOpen: false,
maxConcurrency: DEFAULT_MAX_CONCURRENCY,
defaultSkipTests: true,