mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-04 09:13:08 +00:00
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:
@@ -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 type { NavigateOptions } from '@tanstack/react-router';
|
||||||
import { ChevronDown, Wrench, Github } from 'lucide-react';
|
import { ChevronDown, Wrench, Github } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
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 { NavSection } from '../types';
|
||||||
import type { Project } from '@/lib/electron';
|
import type { Project } from '@/lib/electron';
|
||||||
|
import type { SidebarStyle } from '@automaker/types';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -23,6 +24,7 @@ const sectionIcons: Record<string, React.ComponentType<{ className?: string }>>
|
|||||||
interface SidebarNavigationProps {
|
interface SidebarNavigationProps {
|
||||||
currentProject: Project | null;
|
currentProject: Project | null;
|
||||||
sidebarOpen: boolean;
|
sidebarOpen: boolean;
|
||||||
|
sidebarStyle: SidebarStyle;
|
||||||
navSections: NavSection[];
|
navSections: NavSection[];
|
||||||
isActiveRoute: (id: string) => boolean;
|
isActiveRoute: (id: string) => boolean;
|
||||||
navigate: (opts: NavigateOptions) => void;
|
navigate: (opts: NavigateOptions) => void;
|
||||||
@@ -32,6 +34,7 @@ interface SidebarNavigationProps {
|
|||||||
export function SidebarNavigation({
|
export function SidebarNavigation({
|
||||||
currentProject,
|
currentProject,
|
||||||
sidebarOpen,
|
sidebarOpen,
|
||||||
|
sidebarStyle,
|
||||||
navSections,
|
navSections,
|
||||||
isActiveRoute,
|
isActiveRoute,
|
||||||
navigate,
|
navigate,
|
||||||
@@ -39,21 +42,26 @@ export function SidebarNavigation({
|
|||||||
}: SidebarNavigationProps) {
|
}: SidebarNavigationProps) {
|
||||||
const navRef = useRef<HTMLElement>(null);
|
const navRef = useRef<HTMLElement>(null);
|
||||||
|
|
||||||
// Track collapsed state for each collapsible section
|
// Get collapsed state from store (persisted across restarts)
|
||||||
const [collapsedSections, setCollapsedSections] = useState<Record<string, boolean>>({});
|
const { collapsedNavSections, setCollapsedNavSections, toggleNavSection } = useAppStore();
|
||||||
|
|
||||||
// Initialize collapsed state when sections change (e.g., GitHub section appears)
|
// Initialize collapsed state when sections change (e.g., GitHub section appears)
|
||||||
|
// Only set defaults for sections that don't have a persisted state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCollapsedSections((prev) => {
|
let hasNewSections = false;
|
||||||
const updated = { ...prev };
|
const updated = { ...collapsedNavSections };
|
||||||
navSections.forEach((section) => {
|
|
||||||
if (section.collapsible && section.label && !(section.label in updated)) {
|
navSections.forEach((section) => {
|
||||||
updated[section.label] = section.defaultCollapsed ?? false;
|
if (section.collapsible && section.label && !(section.label in updated)) {
|
||||||
}
|
updated[section.label] = section.defaultCollapsed ?? false;
|
||||||
});
|
hasNewSections = true;
|
||||||
return updated;
|
}
|
||||||
});
|
});
|
||||||
}, [navSections]);
|
|
||||||
|
if (hasNewSections) {
|
||||||
|
setCollapsedNavSections(updated);
|
||||||
|
}
|
||||||
|
}, [navSections, collapsedNavSections, setCollapsedNavSections]);
|
||||||
|
|
||||||
// Check scroll state
|
// Check scroll state
|
||||||
const checkScrollState = useCallback(() => {
|
const checkScrollState = useCallback(() => {
|
||||||
@@ -77,14 +85,7 @@ export function SidebarNavigation({
|
|||||||
nav.removeEventListener('scroll', checkScrollState);
|
nav.removeEventListener('scroll', checkScrollState);
|
||||||
resizeObserver.disconnect();
|
resizeObserver.disconnect();
|
||||||
};
|
};
|
||||||
}, [checkScrollState, collapsedSections]);
|
}, [checkScrollState, collapsedNavSections]);
|
||||||
|
|
||||||
const toggleSection = useCallback((label: string) => {
|
|
||||||
setCollapsedSections((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[label]: !prev[label],
|
|
||||||
}));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Filter sections: always show non-project sections, only show project sections when project exists
|
// Filter sections: always show non-project sections, only show project sections when project exists
|
||||||
const visibleSections = navSections.filter((section) => {
|
const visibleSections = navSections.filter((section) => {
|
||||||
@@ -97,10 +98,17 @@ export function SidebarNavigation({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Navigation sections */}
|
||||||
{visibleSections.map((section, sectionIdx) => {
|
{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 isCollapsible = section.collapsible && section.label && sidebarOpen;
|
||||||
|
|
||||||
const SectionIcon = section.label ? sectionIcons[section.label] : null;
|
const SectionIcon = section.label ? sectionIcons[section.label] : null;
|
||||||
@@ -110,21 +118,37 @@ export function SidebarNavigation({
|
|||||||
{/* Section Label - clickable if collapsible (expanded sidebar) */}
|
{/* Section Label - clickable if collapsible (expanded sidebar) */}
|
||||||
{section.label && sidebarOpen && (
|
{section.label && sidebarOpen && (
|
||||||
<button
|
<button
|
||||||
onClick={() => isCollapsible && toggleSection(section.label!)}
|
onClick={() => isCollapsible && toggleNavSection(section.label!)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center w-full px-3 mb-1.5',
|
'group flex items-center w-full px-3 py-1.5 mb-1 rounded-md',
|
||||||
isCollapsible && 'cursor-pointer hover:text-foreground'
|
'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}
|
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}
|
{section.label}
|
||||||
</span>
|
</span>
|
||||||
{isCollapsible && (
|
{isCollapsible && (
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-3 h-3 ml-auto text-muted-foreground/50 transition-transform duration-200',
|
'w-3 h-3 ml-auto transition-all duration-200',
|
||||||
isCollapsed && '-rotate-90'
|
isCollapsed
|
||||||
|
? '-rotate-90 text-muted-foreground/50 group-hover:text-muted-foreground'
|
||||||
|
: 'text-muted-foreground/50 group-hover:text-muted-foreground'
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export function Sidebar() {
|
|||||||
trashedProjects,
|
trashedProjects,
|
||||||
currentProject,
|
currentProject,
|
||||||
sidebarOpen,
|
sidebarOpen,
|
||||||
|
sidebarStyle,
|
||||||
mobileSidebarHidden,
|
mobileSidebarHidden,
|
||||||
projectHistory,
|
projectHistory,
|
||||||
upsertAndSetCurrentProject,
|
upsertAndSetCurrentProject,
|
||||||
@@ -381,17 +382,21 @@ export function Sidebar() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
<SidebarHeader
|
{/* Only show header in unified mode - in discord mode, ProjectSwitcher has the logo */}
|
||||||
sidebarOpen={sidebarOpen}
|
{sidebarStyle === 'unified' && (
|
||||||
currentProject={currentProject}
|
<SidebarHeader
|
||||||
onNewProject={handleNewProject}
|
sidebarOpen={sidebarOpen}
|
||||||
onOpenFolder={handleOpenFolder}
|
currentProject={currentProject}
|
||||||
onProjectContextMenu={handleContextMenu}
|
onNewProject={handleNewProject}
|
||||||
/>
|
onOpenFolder={handleOpenFolder}
|
||||||
|
onProjectContextMenu={handleContextMenu}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<SidebarNavigation
|
<SidebarNavigation
|
||||||
currentProject={currentProject}
|
currentProject={currentProject}
|
||||||
sidebarOpen={sidebarOpen}
|
sidebarOpen={sidebarOpen}
|
||||||
|
sidebarStyle={sidebarStyle}
|
||||||
navSections={navSections}
|
navSections={navSections}
|
||||||
isActiveRoute={isActiveRoute}
|
isActiveRoute={isActiveRoute}
|
||||||
navigate={navigate}
|
navigate={navigate}
|
||||||
|
|||||||
@@ -699,6 +699,7 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
|
|||||||
fontFamilyMono: settings.fontFamilyMono ?? null,
|
fontFamilyMono: settings.fontFamilyMono ?? null,
|
||||||
sidebarOpen: settings.sidebarOpen ?? true,
|
sidebarOpen: settings.sidebarOpen ?? true,
|
||||||
sidebarStyle: settings.sidebarStyle ?? 'unified',
|
sidebarStyle: settings.sidebarStyle ?? 'unified',
|
||||||
|
collapsedNavSections: settings.collapsedNavSections ?? {},
|
||||||
chatHistoryOpen: settings.chatHistoryOpen ?? false,
|
chatHistoryOpen: settings.chatHistoryOpen ?? false,
|
||||||
maxConcurrency: settings.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY,
|
maxConcurrency: settings.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY,
|
||||||
autoModeByWorktree: restoredAutoModeByWorktree,
|
autoModeByWorktree: restoredAutoModeByWorktree,
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ const SETTINGS_FIELDS_TO_SYNC = [
|
|||||||
'openTerminalMode', // Maps to terminalState.openTerminalMode
|
'openTerminalMode', // Maps to terminalState.openTerminalMode
|
||||||
'sidebarOpen',
|
'sidebarOpen',
|
||||||
'sidebarStyle',
|
'sidebarStyle',
|
||||||
|
'collapsedNavSections',
|
||||||
'chatHistoryOpen',
|
'chatHistoryOpen',
|
||||||
'maxConcurrency',
|
'maxConcurrency',
|
||||||
'autoModeByWorktree', // Per-worktree auto mode settings (only maxConcurrency is persisted)
|
'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,
|
theme: serverSettings.theme as unknown as ThemeMode,
|
||||||
sidebarOpen: serverSettings.sidebarOpen,
|
sidebarOpen: serverSettings.sidebarOpen,
|
||||||
sidebarStyle: serverSettings.sidebarStyle ?? 'unified',
|
sidebarStyle: serverSettings.sidebarStyle ?? 'unified',
|
||||||
|
collapsedNavSections: serverSettings.collapsedNavSections ?? {},
|
||||||
chatHistoryOpen: serverSettings.chatHistoryOpen,
|
chatHistoryOpen: serverSettings.chatHistoryOpen,
|
||||||
maxConcurrency: serverSettings.maxConcurrency,
|
maxConcurrency: serverSettings.maxConcurrency,
|
||||||
autoModeByWorktree: restoredAutoModeByWorktree,
|
autoModeByWorktree: restoredAutoModeByWorktree,
|
||||||
|
|||||||
@@ -612,6 +612,7 @@ export interface AppState {
|
|||||||
currentView: ViewMode;
|
currentView: ViewMode;
|
||||||
sidebarOpen: boolean;
|
sidebarOpen: boolean;
|
||||||
sidebarStyle: SidebarStyle; // 'unified' (modern) or 'discord' (classic two-sidebar layout)
|
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
|
mobileSidebarHidden: boolean; // Completely hides sidebar on mobile
|
||||||
|
|
||||||
// Agent Session state (per-project, keyed by project path)
|
// Agent Session state (per-project, keyed by project path)
|
||||||
@@ -1049,6 +1050,8 @@ export interface AppActions {
|
|||||||
toggleSidebar: () => void;
|
toggleSidebar: () => void;
|
||||||
setSidebarOpen: (open: boolean) => void;
|
setSidebarOpen: (open: boolean) => void;
|
||||||
setSidebarStyle: (style: SidebarStyle) => void;
|
setSidebarStyle: (style: SidebarStyle) => void;
|
||||||
|
setCollapsedNavSections: (sections: Record<string, boolean>) => void;
|
||||||
|
toggleNavSection: (sectionLabel: string) => void;
|
||||||
toggleMobileSidebarHidden: () => void;
|
toggleMobileSidebarHidden: () => void;
|
||||||
setMobileSidebarHidden: (hidden: boolean) => void;
|
setMobileSidebarHidden: (hidden: boolean) => void;
|
||||||
|
|
||||||
@@ -1475,6 +1478,7 @@ const initialState: AppState = {
|
|||||||
currentView: 'welcome',
|
currentView: 'welcome',
|
||||||
sidebarOpen: true,
|
sidebarOpen: true,
|
||||||
sidebarStyle: 'unified', // Default to modern unified sidebar
|
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
|
mobileSidebarHidden: false, // Sidebar visible by default on mobile
|
||||||
lastSelectedSessionByProject: {},
|
lastSelectedSessionByProject: {},
|
||||||
theme: getStoredTheme() || 'dark', // Use localStorage theme as initial value, fallback to 'dark'
|
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 }),
|
toggleSidebar: () => set({ sidebarOpen: !get().sidebarOpen }),
|
||||||
setSidebarOpen: (open) => set({ sidebarOpen: open }),
|
setSidebarOpen: (open) => set({ sidebarOpen: open }),
|
||||||
setSidebarStyle: (style) => set({ sidebarStyle: style }),
|
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 }),
|
toggleMobileSidebarHidden: () => set({ mobileSidebarHidden: !get().mobileSidebarHidden }),
|
||||||
setMobileSidebarHidden: (hidden) => set({ mobileSidebarHidden: hidden }),
|
setMobileSidebarHidden: (hidden) => set({ mobileSidebarHidden: hidden }),
|
||||||
|
|
||||||
|
|||||||
@@ -846,6 +846,8 @@ export interface GlobalSettings {
|
|||||||
sidebarOpen: boolean;
|
sidebarOpen: boolean;
|
||||||
/** Sidebar layout style ('unified' = modern single sidebar, 'discord' = classic two-sidebar layout) */
|
/** Sidebar layout style ('unified' = modern single sidebar, 'discord' = classic two-sidebar layout) */
|
||||||
sidebarStyle: SidebarStyle;
|
sidebarStyle: SidebarStyle;
|
||||||
|
/** Collapsed state of sidebar navigation sections (key: section label, value: is collapsed) */
|
||||||
|
collapsedNavSections?: Record<string, boolean>;
|
||||||
/** Whether chat history panel is open */
|
/** Whether chat history panel is open */
|
||||||
chatHistoryOpen: boolean;
|
chatHistoryOpen: boolean;
|
||||||
|
|
||||||
@@ -1321,6 +1323,7 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
|
|||||||
theme: 'dark',
|
theme: 'dark',
|
||||||
sidebarOpen: true,
|
sidebarOpen: true,
|
||||||
sidebarStyle: 'unified',
|
sidebarStyle: 'unified',
|
||||||
|
collapsedNavSections: {},
|
||||||
chatHistoryOpen: false,
|
chatHistoryOpen: false,
|
||||||
maxConcurrency: DEFAULT_MAX_CONCURRENCY,
|
maxConcurrency: DEFAULT_MAX_CONCURRENCY,
|
||||||
defaultSkipTests: true,
|
defaultSkipTests: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user