feat: enhance sidebar functionality for mobile and compact views

- Introduced a floating toggle button for mobile to show/hide the sidebar when collapsed.
- Updated sidebar behavior to completely hide on mobile when the new mobileSidebarHidden state is true.
- Added logic to conditionally render sidebar components based on screen size using the new useIsCompact hook.
- Enhanced SidebarHeader to include close and expand buttons for mobile views.
- Refactored CollapseToggleButton to hide in compact mode.
- Implemented HeaderActionsPanel for mobile actions in various views, improving accessibility and usability on smaller screens.

These changes improve the user experience on mobile devices by providing better navigation options and visibility controls.
This commit is contained in:
webdevcody
2026-01-16 22:27:19 -05:00
parent 26aaef002d
commit d98cae124f
23 changed files with 982 additions and 362 deletions

View File

@@ -20,7 +20,10 @@ import {
SidebarHeader,
SidebarNavigation,
SidebarFooter,
MobileSidebarToggle,
} from './sidebar/components';
import { useIsCompact } from '@/hooks/use-media-query';
import { PanelLeftClose } from 'lucide-react';
import { TrashDialog, OnboardingDialog } from './sidebar/dialogs';
import { SIDEBAR_FEATURE_FLAGS } from './sidebar/constants';
import {
@@ -44,9 +47,11 @@ export function Sidebar() {
trashedProjects,
currentProject,
sidebarOpen,
mobileSidebarHidden,
projectHistory,
upsertAndSetCurrentProject,
toggleSidebar,
toggleMobileSidebarHidden,
restoreTrashedProject,
deleteTrashedProject,
emptyTrash,
@@ -57,6 +62,8 @@ export function Sidebar() {
setSpecCreatingForProject,
} = useAppStore();
const isCompact = useIsCompact();
// Environment variable flags for hiding sidebar items
const { hideTerminal, hideRunningAgents, hideContext, hideSpecEditor } = SIDEBAR_FEATURE_FLAGS;
@@ -255,10 +262,16 @@ export function Sidebar() {
return location.pathname === routePath;
};
// Check if sidebar should be completely hidden on mobile
const shouldHideSidebar = isCompact && mobileSidebarHidden;
return (
<>
{/* Floating toggle to show sidebar on mobile when hidden */}
<MobileSidebarToggle />
{/* Mobile backdrop overlay */}
{sidebarOpen && (
{sidebarOpen && !shouldHideSidebar && (
<div
className="fixed inset-0 bg-black/50 z-20 lg:hidden"
onClick={toggleSidebar}
@@ -274,8 +287,11 @@ export function Sidebar() {
'border-r border-border/60 shadow-[1px_0_20px_-5px_rgba(0,0,0,0.1)]',
// Smooth width transition
'transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]',
// Mobile: completely hidden when mobileSidebarHidden is true
shouldHideSidebar && 'hidden',
// Mobile: overlay when open, collapsed when closed
sidebarOpen ? 'fixed inset-y-0 left-0 w-72 lg:relative lg:w-72' : 'relative w-16'
!shouldHideSidebar &&
(sidebarOpen ? 'fixed inset-y-0 left-0 w-72 lg:relative lg:w-72' : 'relative w-16')
)}
data-testid="sidebar"
>
@@ -285,8 +301,33 @@ export function Sidebar() {
shortcut={shortcuts.toggleSidebar}
/>
{/* Floating hide button on right edge - only visible on compact screens when sidebar is collapsed */}
{!sidebarOpen && isCompact && (
<button
onClick={toggleMobileSidebarHidden}
className={cn(
'absolute -right-6 top-1/2 -translate-y-1/2 z-40',
'flex items-center justify-center w-6 h-10 rounded-r-lg',
'bg-card/95 backdrop-blur-sm border border-l-0 border-border/80',
'text-muted-foreground hover:text-brand-500 hover:bg-accent/80',
'shadow-lg hover:shadow-xl hover:shadow-brand-500/10',
'transition-all duration-200',
'hover:w-8 active:scale-95'
)}
aria-label="Hide sidebar"
data-testid="sidebar-mobile-hide"
>
<PanelLeftClose className="w-3.5 h-3.5" />
</button>
)}
<div className="flex-1 flex flex-col overflow-hidden">
<SidebarHeader sidebarOpen={sidebarOpen} currentProject={currentProject} />
<SidebarHeader
sidebarOpen={sidebarOpen}
currentProject={currentProject}
onClose={toggleSidebar}
onExpand={toggleSidebar}
/>
<SidebarNavigation
currentProject={currentProject}

View File

@@ -1,6 +1,7 @@
import { PanelLeft, PanelLeftClose } from 'lucide-react';
import { cn } from '@/lib/utils';
import { formatShortcut } from '@/store/app-store';
import { useIsCompact } from '@/hooks/use-media-query';
interface CollapseToggleButtonProps {
sidebarOpen: boolean;
@@ -13,6 +14,13 @@ export function CollapseToggleButton({
toggleSidebar,
shortcut,
}: CollapseToggleButtonProps) {
const isCompact = useIsCompact();
// Hide when in compact mode (mobile menu is shown in board header)
if (isCompact) {
return null;
}
return (
<button
onClick={toggleSidebar}

View File

@@ -8,3 +8,4 @@ export { ProjectActions } from './project-actions';
export { SidebarNavigation } from './sidebar-navigation';
export { ProjectSelectorWithOptions } from './project-selector-with-options';
export { SidebarFooter } from './sidebar-footer';
export { MobileSidebarToggle } from './mobile-sidebar-toggle';

View File

@@ -0,0 +1,42 @@
import { PanelLeft } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAppStore } from '@/store/app-store';
import { useIsCompact } from '@/hooks/use-media-query';
/**
* Floating toggle button for mobile that completely hides/shows the sidebar.
* Positioned at the left-center of the screen.
* Only visible on compact/mobile screens when the sidebar is hidden.
*/
export function MobileSidebarToggle() {
const isCompact = useIsCompact();
const { mobileSidebarHidden, toggleMobileSidebarHidden } = useAppStore();
// Only show on compact screens when sidebar is hidden
if (!isCompact || !mobileSidebarHidden) {
return null;
}
return (
<button
onClick={toggleMobileSidebarHidden}
className={cn(
'fixed left-0 top-1/2 -translate-y-1/2 z-50',
'flex items-center justify-center',
'w-8 h-12 rounded-r-lg',
// Glass morphism background
'bg-card/95 backdrop-blur-sm border border-l-0 border-border/80',
// Shadow and hover effects
'shadow-lg shadow-black/10 hover:shadow-xl hover:shadow-brand-500/10',
'text-muted-foreground hover:text-brand-500 hover:bg-accent/80',
'hover:border-brand-500/30',
'transition-all duration-200 ease-out',
'hover:w-10 active:scale-95'
)}
aria-label="Show sidebar"
data-testid="mobile-sidebar-toggle"
>
<PanelLeft className="w-4 h-4 pointer-events-none" />
</button>
);
}

View File

@@ -1,15 +1,29 @@
import { Folder, LucideIcon } from 'lucide-react';
import { useState } from 'react';
import { Folder, LucideIcon, X, Menu, Check } from 'lucide-react';
import * as LucideIcons from 'lucide-react';
import { cn, isMac } from '@/lib/utils';
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
import { isElectron, type Project } from '@/lib/electron';
import { useIsCompact } from '@/hooks/use-media-query';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { useAppStore } from '@/store/app-store';
interface SidebarHeaderProps {
sidebarOpen: boolean;
currentProject: Project | null;
onClose?: () => void;
onExpand?: () => void;
}
export function SidebarHeader({ sidebarOpen, currentProject }: SidebarHeaderProps) {
export function SidebarHeader({
sidebarOpen,
currentProject,
onClose,
onExpand,
}: SidebarHeaderProps) {
const isCompact = useIsCompact();
const [projectListOpen, setProjectListOpen] = useState(false);
const { projects, setCurrentProject } = useAppStore();
// Get the icon component from lucide-react
const getIconComponent = (): LucideIcon => {
if (currentProject?.icon && currentProject.icon in LucideIcons) {
@@ -24,43 +38,141 @@ export function SidebarHeader({ sidebarOpen, currentProject }: SidebarHeaderProp
return (
<div
className={cn(
'shrink-0 flex flex-col',
'shrink-0 flex flex-col relative',
// Add padding on macOS Electron for traffic light buttons
isMac && isElectron() && 'pt-[10px]'
)}
>
{/* Project name and icon display */}
{currentProject && (
<div
{/* Mobile close button - only visible on mobile when sidebar is open */}
{sidebarOpen && onClose && (
<button
onClick={onClose}
className={cn(
'flex items-center gap-3 px-4 pt-3 pb-1',
!sidebarOpen && 'justify-center px-2'
'lg:hidden absolute top-3 right-3 z-10',
'flex items-center justify-center w-8 h-8 rounded-lg',
'bg-muted/50 hover:bg-muted',
'text-muted-foreground hover:text-foreground',
'transition-colors duration-200'
)}
aria-label="Close navigation"
data-testid="sidebar-mobile-close"
>
{/* Project Icon */}
<div className="shrink-0">
{hasCustomIcon ? (
<img
src={getAuthenticatedImageUrl(currentProject.customIconPath!, currentProject.path)}
alt={currentProject.name}
className="w-8 h-8 rounded-lg object-cover ring-1 ring-border/50"
/>
) : (
<div className="w-8 h-8 rounded-lg bg-brand-500/10 border border-brand-500/20 flex items-center justify-center">
<IconComponent className="w-5 h-5 text-brand-500" />
</div>
)}
</div>
{/* Project Name - only show when sidebar is open */}
{sidebarOpen && (
<div className="flex-1 min-w-0">
<h2 className="text-sm font-semibold text-foreground truncate">
{currentProject.name}
</h2>
</div>
<X className="w-5 h-5" />
</button>
)}
{/* Mobile expand button - hamburger menu to expand sidebar when collapsed on mobile */}
{!sidebarOpen && isCompact && onExpand && (
<button
onClick={onExpand}
className={cn(
'flex items-center justify-center w-10 h-10 mx-auto mt-2 rounded-lg',
'bg-muted/50 hover:bg-muted',
'text-muted-foreground hover:text-foreground',
'transition-colors duration-200'
)}
</div>
aria-label="Expand navigation"
data-testid="sidebar-mobile-expand"
>
<Menu className="w-5 h-5" />
</button>
)}
{/* Project name and icon display - entire element clickable on mobile */}
{currentProject && (
<Popover open={projectListOpen} onOpenChange={setProjectListOpen}>
<PopoverTrigger asChild>
<button
className={cn(
'flex items-center gap-3 px-4 pt-3 pb-1 w-full text-left',
'rounded-lg transition-colors duration-150',
!sidebarOpen && 'justify-center px-2',
// Only enable click behavior on compact screens
isCompact && 'hover:bg-accent/50 cursor-pointer',
!isCompact && 'pointer-events-none'
)}
title={isCompact ? 'Switch project' : undefined}
>
{/* Project Icon */}
<div className="shrink-0">
{hasCustomIcon ? (
<img
src={getAuthenticatedImageUrl(
currentProject.customIconPath!,
currentProject.path
)}
alt={currentProject.name}
className="w-8 h-8 rounded-lg object-cover ring-1 ring-border/50"
/>
) : (
<div className="w-8 h-8 rounded-lg bg-brand-500/10 border border-brand-500/20 flex items-center justify-center">
<IconComponent className="w-5 h-5 text-brand-500" />
</div>
)}
</div>
{/* Project Name - only show when sidebar is open */}
{sidebarOpen && (
<div className="flex-1 min-w-0">
<h2 className="text-sm font-semibold text-foreground truncate">
{currentProject.name}
</h2>
</div>
)}
</button>
</PopoverTrigger>
<PopoverContent className="w-64 p-2" align="start" side="bottom" sideOffset={8}>
<div className="space-y-1">
<p className="text-xs font-medium text-muted-foreground px-2 py-1">Switch Project</p>
{projects.map((project) => {
const ProjectIcon =
project.icon && project.icon in LucideIcons
? (LucideIcons as Record<string, LucideIcon>)[project.icon]
: Folder;
const isActive = currentProject?.id === project.id;
return (
<button
key={project.id}
onClick={() => {
setCurrentProject(project);
setProjectListOpen(false);
}}
className={cn(
'w-full flex items-center gap-3 px-2 py-2 rounded-lg text-left',
'transition-colors duration-150',
isActive
? 'bg-brand-500/10 text-brand-500'
: 'hover:bg-accent text-foreground'
)}
>
{project.customIconPath ? (
<img
src={getAuthenticatedImageUrl(project.customIconPath, project.path)}
alt={project.name}
className="w-6 h-6 rounded object-cover ring-1 ring-border/50"
/>
) : (
<div
className={cn(
'w-6 h-6 rounded flex items-center justify-center',
isActive ? 'bg-brand-500/20' : 'bg-muted'
)}
>
<ProjectIcon
className={cn(
'w-4 h-4',
isActive ? 'text-brand-500' : 'text-muted-foreground'
)}
/>
</div>
)}
<span className="flex-1 text-sm truncate">{project.name}</span>
{isActive && <Check className="w-4 h-4 text-brand-500" />}
</button>
);
})}
</div>
</PopoverContent>
</Popover>
)}
</div>
);

View File

@@ -21,7 +21,12 @@ export function SidebarNavigation({
navigate,
}: SidebarNavigationProps) {
return (
<nav className={cn('flex-1 overflow-y-auto px-3 pb-2', sidebarOpen ? 'mt-1' : 'mt-1')}>
<nav
className={cn(
'flex-1 overflow-y-auto scrollbar-hide px-3 pb-2',
sidebarOpen ? 'mt-1' : 'mt-1'
)}
>
{!currentProject && sidebarOpen ? (
// Placeholder when no project is selected (only in expanded state)
<div className="flex items-center justify-center h-full px-4">