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, SidebarHeader,
SidebarNavigation, SidebarNavigation,
SidebarFooter, SidebarFooter,
MobileSidebarToggle,
} from './sidebar/components'; } from './sidebar/components';
import { useIsCompact } from '@/hooks/use-media-query';
import { PanelLeftClose } from 'lucide-react';
import { TrashDialog, OnboardingDialog } from './sidebar/dialogs'; import { TrashDialog, OnboardingDialog } from './sidebar/dialogs';
import { SIDEBAR_FEATURE_FLAGS } from './sidebar/constants'; import { SIDEBAR_FEATURE_FLAGS } from './sidebar/constants';
import { import {
@@ -44,9 +47,11 @@ export function Sidebar() {
trashedProjects, trashedProjects,
currentProject, currentProject,
sidebarOpen, sidebarOpen,
mobileSidebarHidden,
projectHistory, projectHistory,
upsertAndSetCurrentProject, upsertAndSetCurrentProject,
toggleSidebar, toggleSidebar,
toggleMobileSidebarHidden,
restoreTrashedProject, restoreTrashedProject,
deleteTrashedProject, deleteTrashedProject,
emptyTrash, emptyTrash,
@@ -57,6 +62,8 @@ export function Sidebar() {
setSpecCreatingForProject, setSpecCreatingForProject,
} = useAppStore(); } = useAppStore();
const isCompact = useIsCompact();
// Environment variable flags for hiding sidebar items // Environment variable flags for hiding sidebar items
const { hideTerminal, hideRunningAgents, hideContext, hideSpecEditor } = SIDEBAR_FEATURE_FLAGS; const { hideTerminal, hideRunningAgents, hideContext, hideSpecEditor } = SIDEBAR_FEATURE_FLAGS;
@@ -255,10 +262,16 @@ export function Sidebar() {
return location.pathname === routePath; return location.pathname === routePath;
}; };
// Check if sidebar should be completely hidden on mobile
const shouldHideSidebar = isCompact && mobileSidebarHidden;
return ( return (
<> <>
{/* Floating toggle to show sidebar on mobile when hidden */}
<MobileSidebarToggle />
{/* Mobile backdrop overlay */} {/* Mobile backdrop overlay */}
{sidebarOpen && ( {sidebarOpen && !shouldHideSidebar && (
<div <div
className="fixed inset-0 bg-black/50 z-20 lg:hidden" className="fixed inset-0 bg-black/50 z-20 lg:hidden"
onClick={toggleSidebar} 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)]', 'border-r border-border/60 shadow-[1px_0_20px_-5px_rgba(0,0,0,0.1)]',
// Smooth width transition // Smooth width transition
'transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]', '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 // 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" data-testid="sidebar"
> >
@@ -285,8 +301,33 @@ export function Sidebar() {
shortcut={shortcuts.toggleSidebar} 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"> <div className="flex-1 flex flex-col overflow-hidden">
<SidebarHeader sidebarOpen={sidebarOpen} currentProject={currentProject} /> <SidebarHeader
sidebarOpen={sidebarOpen}
currentProject={currentProject}
onClose={toggleSidebar}
onExpand={toggleSidebar}
/>
<SidebarNavigation <SidebarNavigation
currentProject={currentProject} currentProject={currentProject}

View File

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

View File

@@ -8,3 +8,4 @@ export { ProjectActions } from './project-actions';
export { SidebarNavigation } from './sidebar-navigation'; export { SidebarNavigation } from './sidebar-navigation';
export { ProjectSelectorWithOptions } from './project-selector-with-options'; export { ProjectSelectorWithOptions } from './project-selector-with-options';
export { SidebarFooter } from './sidebar-footer'; 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 * as LucideIcons from 'lucide-react';
import { cn, isMac } from '@/lib/utils'; import { cn, isMac } from '@/lib/utils';
import { getAuthenticatedImageUrl } from '@/lib/api-fetch'; import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
import { isElectron, type Project } from '@/lib/electron'; 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 { interface SidebarHeaderProps {
sidebarOpen: boolean; sidebarOpen: boolean;
currentProject: Project | null; 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 // Get the icon component from lucide-react
const getIconComponent = (): LucideIcon => { const getIconComponent = (): LucideIcon => {
if (currentProject?.icon && currentProject.icon in LucideIcons) { if (currentProject?.icon && currentProject.icon in LucideIcons) {
@@ -24,24 +38,67 @@ export function SidebarHeader({ sidebarOpen, currentProject }: SidebarHeaderProp
return ( return (
<div <div
className={cn( className={cn(
'shrink-0 flex flex-col', 'shrink-0 flex flex-col relative',
// Add padding on macOS Electron for traffic light buttons // Add padding on macOS Electron for traffic light buttons
isMac && isElectron() && 'pt-[10px]' isMac && isElectron() && 'pt-[10px]'
)} )}
> >
{/* Project name and icon display */} {/* Mobile close button - only visible on mobile when sidebar is open */}
{currentProject && ( {sidebarOpen && onClose && (
<div <button
onClick={onClose}
className={cn( className={cn(
'flex items-center gap-3 px-4 pt-3 pb-1', 'lg:hidden absolute top-3 right-3 z-10',
!sidebarOpen && 'justify-center px-2' '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"
>
<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'
)}
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 */} {/* Project Icon */}
<div className="shrink-0"> <div className="shrink-0">
{hasCustomIcon ? ( {hasCustomIcon ? (
<img <img
src={getAuthenticatedImageUrl(currentProject.customIconPath!, currentProject.path)} src={getAuthenticatedImageUrl(
currentProject.customIconPath!,
currentProject.path
)}
alt={currentProject.name} alt={currentProject.name}
className="w-8 h-8 rounded-lg object-cover ring-1 ring-border/50" className="w-8 h-8 rounded-lg object-cover ring-1 ring-border/50"
/> />
@@ -60,8 +117,63 @@ export function SidebarHeader({ sidebarOpen, currentProject }: SidebarHeaderProp
</h2> </h2>
</div> </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> </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> </div>
); );
} }

View File

@@ -21,7 +21,12 @@ export function SidebarNavigation({
navigate, navigate,
}: SidebarNavigationProps) { }: SidebarNavigationProps) {
return ( 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 ? ( {!currentProject && sidebarOpen ? (
// Placeholder when no project is selected (only in expanded state) // Placeholder when no project is selected (only in expanded state)
<div className="flex items-center justify-center h-full px-4"> <div className="flex items-center justify-center h-full px-4">

View File

@@ -0,0 +1,105 @@
import { createPortal } from 'react-dom';
import { X, Menu } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
interface HeaderActionsPanelProps {
isOpen: boolean;
onClose: () => void;
title?: string;
children: React.ReactNode;
}
/**
* A slide-out panel for header actions on tablet and below.
* Shows as a right-side panel that slides in from the right edge.
* On desktop (lg+), this component is hidden and children should be rendered inline.
*/
export function HeaderActionsPanel({
isOpen,
onClose,
title = 'Actions',
children,
}: HeaderActionsPanelProps) {
// Use portal to render outside parent stacking contexts (backdrop-blur creates stacking context)
const panelContent = (
<>
{/* Mobile backdrop overlay - only shown when isOpen is true on tablet/mobile */}
{isOpen && (
<div
className="fixed inset-0 bg-black/50 z-[60] lg:hidden"
onClick={onClose}
data-testid="header-actions-backdrop"
/>
)}
{/* Actions panel */}
<div
className={cn(
// Mobile: fixed position overlay with slide transition from right
'fixed inset-y-0 right-0 w-72 z-[70]',
'transition-transform duration-200 ease-out',
// Hide on mobile when closed, show when open
isOpen ? 'translate-x-0' : 'translate-x-full',
// Desktop: hidden entirely (actions shown inline in header)
'lg:hidden',
'flex flex-col',
'border-l border-border/50',
'bg-gradient-to-b from-card/95 via-card/90 to-card/85 backdrop-blur-xl'
)}
>
{/* Panel header with close button */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border/50">
<span className="text-sm font-semibold text-foreground">{title}</span>
<Button
variant="ghost"
size="sm"
onClick={onClose}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
aria-label="Close actions panel"
>
<X className="w-4 h-4" />
</Button>
</div>
{/* Panel content */}
<div className="flex-1 overflow-y-auto p-4 space-y-3">{children}</div>
</div>
</>
);
// Render to document.body to escape stacking context
if (typeof document !== 'undefined') {
return createPortal(panelContent, document.body);
}
return panelContent;
}
interface HeaderActionsPanelTriggerProps {
isOpen: boolean;
onToggle: () => void;
className?: string;
}
/**
* Toggle button for the HeaderActionsPanel.
* Only visible on tablet and below (lg:hidden).
*/
export function HeaderActionsPanelTrigger({
isOpen,
onToggle,
className,
}: HeaderActionsPanelTriggerProps) {
return (
<Button
variant="ghost"
size="sm"
onClick={onToggle}
className={cn('h-8 w-8 p-0 text-muted-foreground hover:text-foreground lg:hidden', className)}
aria-label={isOpen ? 'Close actions menu' : 'Open actions menu'}
>
{isOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
</Button>
);
}

View File

@@ -27,18 +27,6 @@ export function AgentHeader({
return ( return (
<div className="flex items-center justify-between px-6 py-4 border-b border-border bg-card/50 backdrop-blur-sm"> <div className="flex items-center justify-between px-6 py-4 border-b border-border bg-card/50 backdrop-blur-sm">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Button
variant="ghost"
size="sm"
onClick={onToggleSessionManager}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
{showSessionManager ? (
<PanelLeftClose className="w-4 h-4" />
) : (
<PanelLeft className="w-4 h-4" />
)}
</Button>
<div className="w-9 h-9 rounded-xl bg-primary/10 flex items-center justify-center"> <div className="w-9 h-9 rounded-xl bg-primary/10 flex items-center justify-center">
<Bot className="w-5 h-5 text-primary" /> <Bot className="w-5 h-5 text-primary" />
</div> </div>
@@ -71,6 +59,19 @@ export function AgentHeader({
Clear Clear
</Button> </Button>
)} )}
<Button
variant="ghost"
size="sm"
onClick={onToggleSessionManager}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
aria-label={showSessionManager ? 'Hide sessions panel' : 'Show sessions panel'}
>
{showSessionManager ? (
<PanelLeftClose className="w-4 h-4" />
) : (
<PanelLeft className="w-4 h-4" />
)}
</Button>
</div> </div>
</div> </div>
); );

View File

@@ -1,11 +1,11 @@
import { useCallback } from 'react'; import { useCallback, useState } from 'react';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Wand2, GitBranch, ClipboardCheck } from 'lucide-react'; import { Wand2, GitBranch, ClipboardCheck } from 'lucide-react';
import { UsagePopover } from '@/components/usage-popover'; import { UsagePopover } from '@/components/usage-popover';
import { useAppStore } from '@/store/app-store'; import { useAppStore } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store'; import { useSetupStore } from '@/store/setup-store';
import { useIsMobile } from '@/hooks/use-media-query'; import { useIsTablet } from '@/hooks/use-media-query';
import { AutoModeSettingsPopover } from './dialogs/auto-mode-settings-popover'; import { AutoModeSettingsPopover } from './dialogs/auto-mode-settings-popover';
import { WorktreeSettingsPopover } from './dialogs/worktree-settings-popover'; import { WorktreeSettingsPopover } from './dialogs/worktree-settings-popover';
import { PlanSettingsPopover } from './dialogs/plan-settings-popover'; import { PlanSettingsPopover } from './dialogs/plan-settings-popover';
@@ -108,7 +108,10 @@ export function BoardHeader({
// Show if Codex is authenticated (CLI or API key) // Show if Codex is authenticated (CLI or API key)
const showCodexUsage = !!codexAuthStatus?.authenticated; const showCodexUsage = !!codexAuthStatus?.authenticated;
const isMobile = useIsMobile(); // State for mobile actions panel
const [showActionsPanel, setShowActionsPanel] = useState(false);
const isTablet = useIsTablet();
return ( return (
<div className="flex items-center justify-between gap-5 p-4 border-b border-border bg-glass backdrop-blur-md"> <div className="flex items-center justify-between gap-5 p-4 border-b border-border bg-glass backdrop-blur-md">
@@ -125,11 +128,13 @@ export function BoardHeader({
</div> </div>
<div className="flex gap-4 items-center"> <div className="flex gap-4 items-center">
{/* Usage Popover - show if either provider is authenticated, only on desktop */} {/* Usage Popover - show if either provider is authenticated, only on desktop */}
{isMounted && !isMobile && (showClaudeUsage || showCodexUsage) && <UsagePopover />} {isMounted && !isTablet && (showClaudeUsage || showCodexUsage) && <UsagePopover />}
{/* Mobile view: show hamburger menu with all controls */} {/* Tablet/Mobile view: show hamburger menu with all controls */}
{isMounted && isMobile && ( {isMounted && isTablet && (
<HeaderMobileMenu <HeaderMobileMenu
isOpen={showActionsPanel}
onToggle={() => setShowActionsPanel(!showActionsPanel)}
isWorktreePanelVisible={isWorktreePanelVisible} isWorktreePanelVisible={isWorktreePanelVisible}
onWorktreePanelToggle={handleWorktreePanelToggle} onWorktreePanelToggle={handleWorktreePanelToggle}
maxConcurrency={maxConcurrency} maxConcurrency={maxConcurrency}
@@ -146,7 +151,7 @@ export function BoardHeader({
{/* Desktop view: show full controls */} {/* Desktop view: show full controls */}
{/* Worktrees Toggle - only show after mount to prevent hydration issues */} {/* Worktrees Toggle - only show after mount to prevent hydration issues */}
{isMounted && !isMobile && ( {isMounted && !isTablet && (
<div className={controlContainerClass} data-testid="worktrees-toggle-container"> <div className={controlContainerClass} data-testid="worktrees-toggle-container">
<GitBranch className="w-4 h-4 text-muted-foreground" /> <GitBranch className="w-4 h-4 text-muted-foreground" />
<Label <Label
@@ -169,7 +174,7 @@ export function BoardHeader({
)} )}
{/* Auto Mode Toggle - only show after mount to prevent hydration issues */} {/* Auto Mode Toggle - only show after mount to prevent hydration issues */}
{isMounted && !isMobile && ( {isMounted && !isTablet && (
<div className={controlContainerClass} data-testid="auto-mode-toggle-container"> <div className={controlContainerClass} data-testid="auto-mode-toggle-container">
<Label <Label
htmlFor="auto-mode-toggle" htmlFor="auto-mode-toggle"
@@ -193,8 +198,8 @@ export function BoardHeader({
</div> </div>
)} )}
{/* Plan Button with Settings - only show on desktop, mobile has it in the menu */} {/* Plan Button with Settings - only show on desktop, tablet/mobile has it in the panel */}
{isMounted && !isMobile && ( {isMounted && !isTablet && (
<div className={controlContainerClass} data-testid="plan-button-container"> <div className={controlContainerClass} data-testid="plan-button-container">
{hasPendingPlan && ( {hasPendingPlan && (
<button <button

View File

@@ -2,18 +2,17 @@ import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { Slider } from '@/components/ui/slider'; import { Slider } from '@/components/ui/slider';
import { import {
DropdownMenu, HeaderActionsPanel,
DropdownMenuContent, HeaderActionsPanelTrigger,
DropdownMenuItem, } from '@/components/ui/header-actions-panel';
DropdownMenuLabel, import { Bot, Wand2, Settings2, GitBranch, Zap } from 'lucide-react';
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Menu, Bot, Wand2, Settings2, GitBranch, Zap } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { MobileUsageBar } from './mobile-usage-bar'; import { MobileUsageBar } from './mobile-usage-bar';
interface HeaderMobileMenuProps { interface HeaderMobileMenuProps {
// Panel visibility
isOpen: boolean;
onToggle: () => void;
// Worktree panel visibility // Worktree panel visibility
isWorktreePanelVisible: boolean; isWorktreePanelVisible: boolean;
onWorktreePanelToggle: (visible: boolean) => void; onWorktreePanelToggle: (visible: boolean) => void;
@@ -33,6 +32,8 @@ interface HeaderMobileMenuProps {
} }
export function HeaderMobileMenu({ export function HeaderMobileMenu({
isOpen,
onToggle,
isWorktreePanelVisible, isWorktreePanelVisible,
onWorktreePanelToggle, onWorktreePanelToggle,
maxConcurrency, maxConcurrency,
@@ -46,37 +47,28 @@ export function HeaderMobileMenu({
showCodexUsage, showCodexUsage,
}: HeaderMobileMenuProps) { }: HeaderMobileMenuProps) {
return ( return (
<DropdownMenu> <>
<DropdownMenuTrigger asChild> <HeaderActionsPanelTrigger isOpen={isOpen} onToggle={onToggle} />
<Button <HeaderActionsPanel isOpen={isOpen} onClose={onToggle} title="Board Controls">
variant="outline"
size="sm"
className="h-8 w-8 p-0"
data-testid="header-mobile-menu-trigger"
>
<Menu className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
{/* Usage Bar - show if either provider is authenticated */} {/* Usage Bar - show if either provider is authenticated */}
{(showClaudeUsage || showCodexUsage) && ( {(showClaudeUsage || showCodexUsage) && (
<> <div className="space-y-2">
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground"> <span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Usage Usage
</DropdownMenuLabel> </span>
<MobileUsageBar showClaudeUsage={showClaudeUsage} showCodexUsage={showCodexUsage} /> <MobileUsageBar showClaudeUsage={showClaudeUsage} showCodexUsage={showCodexUsage} />
<DropdownMenuSeparator /> </div>
</>
)} )}
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground"> {/* Controls Section */}
<div className="space-y-1">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Controls Controls
</DropdownMenuLabel> </span>
<DropdownMenuSeparator />
{/* Auto Mode Toggle */} {/* Auto Mode Toggle */}
<div <div
className="flex items-center justify-between px-2 py-2 cursor-pointer hover:bg-accent rounded-sm" className="flex items-center justify-between p-3 cursor-pointer hover:bg-accent/50 rounded-lg border border-border/50 transition-colors"
onClick={() => onAutoModeToggle(!isAutoModeRunning)} onClick={() => onAutoModeToggle(!isAutoModeRunning)}
data-testid="mobile-auto-mode-toggle-container" data-testid="mobile-auto-mode-toggle-container"
> >
@@ -111,17 +103,15 @@ export function HeaderMobileMenu({
</div> </div>
</div> </div>
<DropdownMenuSeparator />
{/* Worktrees Toggle */} {/* Worktrees Toggle */}
<div <div
className="flex items-center justify-between px-2 py-2 cursor-pointer hover:bg-accent rounded-sm" className="flex items-center justify-between p-3 cursor-pointer hover:bg-accent/50 rounded-lg border border-border/50 transition-colors"
onClick={() => onWorktreePanelToggle(!isWorktreePanelVisible)} onClick={() => onWorktreePanelToggle(!isWorktreePanelVisible)}
data-testid="mobile-worktrees-toggle-container" data-testid="mobile-worktrees-toggle-container"
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<GitBranch className="w-4 h-4 text-muted-foreground" /> <GitBranch className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium">Worktrees</span> <span className="text-sm font-medium">Worktree Bar</span>
</div> </div>
<Switch <Switch
id="mobile-worktrees-toggle" id="mobile-worktrees-toggle"
@@ -132,11 +122,12 @@ export function HeaderMobileMenu({
/> />
</div> </div>
<DropdownMenuSeparator />
{/* Concurrency Control */} {/* Concurrency Control */}
<div className="px-2 py-2" data-testid="mobile-concurrency-control"> <div
<div className="flex items-center gap-2 mb-2"> className="p-3 rounded-lg border border-border/50"
data-testid="mobile-concurrency-control"
>
<div className="flex items-center gap-2 mb-3">
<Bot className="w-4 h-4 text-muted-foreground" /> <Bot className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium">Max Agents</span> <span className="text-sm font-medium">Max Agents</span>
<span <span
@@ -157,18 +148,21 @@ export function HeaderMobileMenu({
/> />
</div> </div>
<DropdownMenuSeparator />
{/* Plan Button */} {/* Plan Button */}
<DropdownMenuItem <Button
onClick={onOpenPlanDialog} variant="outline"
className="flex items-center gap-2" className="w-full justify-start"
onClick={() => {
onOpenPlanDialog();
onToggle();
}}
data-testid="mobile-plan-button" data-testid="mobile-plan-button"
> >
<Wand2 className="w-4 h-4" /> <Wand2 className="w-4 h-4 mr-2" />
<span>Plan</span> Plan
</DropdownMenuItem> </Button>
</DropdownMenuContent> </div>
</DropdownMenu> </HeaderActionsPanel>
</>
); );
} }

View File

@@ -7,6 +7,10 @@ import { toast } from 'sonner';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { HotkeyButton } from '@/components/ui/hotkey-button'; import { HotkeyButton } from '@/components/ui/hotkey-button';
import { Card } from '@/components/ui/card'; import { Card } from '@/components/ui/card';
import {
HeaderActionsPanel,
HeaderActionsPanelTrigger,
} from '@/components/ui/header-actions-panel';
import { import {
RefreshCw, RefreshCw,
FileText, FileText,
@@ -94,6 +98,9 @@ export function ContextView() {
const [editDescriptionValue, setEditDescriptionValue] = useState(''); const [editDescriptionValue, setEditDescriptionValue] = useState('');
const [editDescriptionFileName, setEditDescriptionFileName] = useState(''); const [editDescriptionFileName, setEditDescriptionFileName] = useState('');
// Actions panel state (for tablet/mobile)
const [showActionsPanel, setShowActionsPanel] = useState(false);
// File input ref for import // File input ref for import
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@@ -691,7 +698,9 @@ export function ContextView() {
</p> </p>
</div> </div>
</div> </div>
<div className="flex gap-2"> <div className="flex items-center gap-2">
{/* Desktop: show actions inline */}
<div className="hidden lg:flex gap-2">
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -713,7 +722,45 @@ export function ContextView() {
Create Markdown Create Markdown
</HotkeyButton> </HotkeyButton>
</div> </div>
{/* Tablet/Mobile: show trigger for actions panel */}
<HeaderActionsPanelTrigger
isOpen={showActionsPanel}
onToggle={() => setShowActionsPanel(!showActionsPanel)}
/>
</div> </div>
</div>
{/* Actions Panel (tablet/mobile) */}
<HeaderActionsPanel
isOpen={showActionsPanel}
onClose={() => setShowActionsPanel(false)}
title="Context Actions"
>
<Button
variant="outline"
className="w-full justify-start"
onClick={() => {
handleImportClick();
setShowActionsPanel(false);
}}
disabled={isUploading}
data-testid="import-file-button-mobile"
>
<FileUp className="w-4 h-4 mr-2" />
Import File
</Button>
<Button
className="w-full justify-start"
onClick={() => {
setIsCreateMarkdownOpen(true);
setShowActionsPanel(false);
}}
data-testid="create-markdown-button-mobile"
>
<FilePlus className="w-4 h-4 mr-2" />
Create Markdown
</Button>
</HeaderActionsPanel>
{/* Main content area with file list and editor */} {/* Main content area with file list and editor */}
<div <div

View File

@@ -21,10 +21,15 @@ import {
Loader2, Loader2,
ChevronDown, ChevronDown,
MessageSquare, MessageSquare,
Settings,
MoreVertical, MoreVertical,
Trash2, Trash2,
Search,
X,
type LucideIcon,
} from 'lucide-react'; } from 'lucide-react';
import * as LucideIcons from 'lucide-react';
import { Input } from '@/components/ui/input';
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -55,6 +60,13 @@ function getOSAbbreviation(os: string): string {
} }
} }
function getIconComponent(iconName?: string): LucideIcon {
if (iconName && iconName in LucideIcons) {
return (LucideIcons as unknown as Record<string, LucideIcon>)[iconName];
}
return Folder;
}
export function DashboardView() { export function DashboardView() {
const navigate = useNavigate(); const navigate = useNavigate();
const { os } = useOSDetection(); const { os } = useOSDetection();
@@ -79,6 +91,7 @@ export function DashboardView() {
const [isCreating, setIsCreating] = useState(false); const [isCreating, setIsCreating] = useState(false);
const [isOpening, setIsOpening] = useState(false); const [isOpening, setIsOpening] = useState(false);
const [projectToRemove, setProjectToRemove] = useState<{ id: string; name: string } | null>(null); const [projectToRemove, setProjectToRemove] = useState<{ id: string; name: string } | null>(null);
const [searchQuery, setSearchQuery] = useState('');
// Sort projects: favorites first, then by last opened // Sort projects: favorites first, then by last opened
const sortedProjects = [...projects].sort((a, b) => { const sortedProjects = [...projects].sort((a, b) => {
@@ -91,8 +104,15 @@ export function DashboardView() {
return dateB - dateA; return dateB - dateA;
}); });
const favoriteProjects = sortedProjects.filter((p) => p.isFavorite); // Filter projects based on search query
const recentProjects = sortedProjects.filter((p) => !p.isFavorite); const filteredProjects = sortedProjects.filter((project) => {
if (!searchQuery.trim()) return true;
const query = searchQuery.toLowerCase();
return project.name.toLowerCase().includes(query) || project.path.toLowerCase().includes(query);
});
const favoriteProjects = filteredProjects.filter((p) => p.isFavorite);
const recentProjects = filteredProjects.filter((p) => !p.isFavorite);
/** /**
* Initialize project and navigate to board * Initialize project and navigate to board
@@ -529,14 +549,35 @@ export function DashboardView() {
</span> </span>
</div> </div>
</div> </div>
<Button
variant="ghost" {/* Mobile action buttons in header */}
size="icon" {hasProjects && (
onClick={() => navigate({ to: '/settings' })} <div className="flex sm:hidden gap-2 titlebar-no-drag">
className="titlebar-no-drag" <Button variant="outline" size="icon" onClick={handleOpenProject}>
> <FolderOpen className="w-4 h-4" />
<Settings className="w-5 h-5" />
</Button> </Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
className="bg-linear-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-700 text-white"
>
<Plus className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem onClick={handleNewProject}>
<Plus className="w-4 h-4 mr-2" />
Quick Setup
</DropdownMenuItem>
<DropdownMenuItem onClick={handleInteractiveMode}>
<MessageSquare className="w-4 h-4 mr-2" />
Interactive Mode
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</div> </div>
</header> </header>
@@ -646,25 +687,42 @@ export function DashboardView() {
{/* Has projects - show project list */} {/* Has projects - show project list */}
{hasProjects && ( {hasProjects && (
<div className="space-y-6 sm:space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500"> <div className="space-y-6 sm:space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
{/* Quick actions header */} {/* Search and actions header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<h2 className="text-2xl font-bold text-foreground">Your Projects</h2> <h2 className="text-xl sm:text-2xl font-bold text-foreground">Your Projects</h2>
<div className="flex gap-2"> <div className="flex items-center gap-2">
<Button {/* Search input */}
variant="outline" <div className="relative flex-1 sm:flex-none">
onClick={handleOpenProject} <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
className="flex-1 sm:flex-none" <Input
type="text"
placeholder="Search projects..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 pr-8 w-full sm:w-64"
data-testid="project-search-input"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-2 top-1/2 -translate-y-1/2 p-0.5 rounded hover:bg-muted transition-colors"
title="Clear search"
> >
<FolderOpen className="w-4 h-4 sm:mr-2" /> <X className="w-4 h-4 text-muted-foreground" />
<span className="hidden sm:inline">Open Folder</span> </button>
)}
</div>
{/* Desktop only buttons */}
<Button variant="outline" onClick={handleOpenProject} className="hidden sm:flex">
<FolderOpen className="w-4 h-4 mr-2" />
Open Folder
</Button> </Button>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button className="flex-1 sm:flex-none bg-linear-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-700 text-white"> <Button className="hidden sm:flex bg-linear-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-700 text-white">
<Plus className="w-4 h-4 sm:mr-2" /> <Plus className="w-4 h-4 mr-2" />
<span className="hidden sm:inline">New Project</span> New Project
<span className="sm:hidden">New</span> <ChevronDown className="w-4 h-4 ml-2" />
<ChevronDown className="w-4 h-4 ml-1 sm:ml-2" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56"> <DropdownMenuContent align="end" className="w-56">
@@ -703,8 +761,24 @@ export function DashboardView() {
<div className="absolute inset-0 rounded-xl bg-linear-to-br from-yellow-500/5 to-amber-600/5 opacity-0 group-hover:opacity-100 transition-all duration-300" /> <div className="absolute inset-0 rounded-xl bg-linear-to-br from-yellow-500/5 to-amber-600/5 opacity-0 group-hover:opacity-100 transition-all duration-300" />
<div className="relative p-3 sm:p-4"> <div className="relative p-3 sm:p-4">
<div className="flex items-start gap-2.5 sm:gap-3"> <div className="flex items-start gap-2.5 sm:gap-3">
<div className="w-9 h-9 sm:w-10 sm:h-10 rounded-lg bg-yellow-500/10 border border-yellow-500/30 flex items-center justify-center group-hover:bg-yellow-500/20 transition-all duration-300 shrink-0"> <div className="w-9 h-9 sm:w-10 sm:h-10 rounded-lg bg-yellow-500/10 border border-yellow-500/30 flex items-center justify-center group-hover:bg-yellow-500/20 transition-all duration-300 shrink-0 overflow-hidden">
<Folder className="w-4 h-4 sm:w-5 sm:h-5 text-yellow-500" /> {project.customIconPath ? (
<img
src={getAuthenticatedImageUrl(
project.customIconPath,
project.path
)}
alt={project.name}
className="w-full h-full object-cover"
/>
) : (
(() => {
const IconComponent = getIconComponent(project.icon);
return (
<IconComponent className="w-4 h-4 sm:w-5 sm:h-5 text-yellow-500" />
);
})()
)}
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm sm:text-base font-medium text-foreground truncate group-hover:text-yellow-500 transition-colors duration-300"> <p className="text-sm sm:text-base font-medium text-foreground truncate group-hover:text-yellow-500 transition-colors duration-300">
@@ -778,8 +852,24 @@ export function DashboardView() {
<div className="absolute inset-0 rounded-xl bg-linear-to-br from-brand-500/0 to-purple-600/0 group-hover:from-brand-500/5 group-hover:to-purple-600/5 transition-all duration-300" /> <div className="absolute inset-0 rounded-xl bg-linear-to-br from-brand-500/0 to-purple-600/0 group-hover:from-brand-500/5 group-hover:to-purple-600/5 transition-all duration-300" />
<div className="relative p-3 sm:p-4"> <div className="relative p-3 sm:p-4">
<div className="flex items-start gap-2.5 sm:gap-3"> <div className="flex items-start gap-2.5 sm:gap-3">
<div className="w-9 h-9 sm:w-10 sm:h-10 rounded-lg bg-muted/80 border border-border flex items-center justify-center group-hover:bg-brand-500/10 group-hover:border-brand-500/30 transition-all duration-300 shrink-0"> <div className="w-9 h-9 sm:w-10 sm:h-10 rounded-lg bg-muted/80 border border-border flex items-center justify-center group-hover:bg-brand-500/10 group-hover:border-brand-500/30 transition-all duration-300 shrink-0 overflow-hidden">
<Folder className="w-4 h-4 sm:w-5 sm:h-5 text-muted-foreground group-hover:text-brand-500 transition-colors duration-300" /> {project.customIconPath ? (
<img
src={getAuthenticatedImageUrl(
project.customIconPath,
project.path
)}
alt={project.name}
className="w-full h-full object-cover"
/>
) : (
(() => {
const IconComponent = getIconComponent(project.icon);
return (
<IconComponent className="w-4 h-4 sm:w-5 sm:h-5 text-muted-foreground group-hover:text-brand-500 transition-colors duration-300" />
);
})()
)}
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm sm:text-base font-medium text-foreground truncate group-hover:text-brand-500 transition-colors duration-300"> <p className="text-sm sm:text-base font-medium text-foreground truncate group-hover:text-brand-500 transition-colors duration-300">
@@ -797,10 +887,10 @@ export function DashboardView() {
<div className="flex items-center gap-0.5 sm:gap-1"> <div className="flex items-center gap-0.5 sm:gap-1">
<button <button
onClick={(e) => handleToggleFavorite(e, project.id)} onClick={(e) => handleToggleFavorite(e, project.id)}
className="p-1 sm:p-1.5 rounded-lg hover:bg-muted transition-colors opacity-0 group-hover:opacity-100" className="p-1 sm:p-1.5 rounded-lg hover:bg-muted transition-colors"
title="Add to favorites" title="Add to favorites"
> >
<Star className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-muted-foreground hover:text-yellow-500" /> <Star className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-muted-foreground/50 hover:text-yellow-500 transition-colors" />
</button> </button>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@@ -830,6 +920,22 @@ export function DashboardView() {
</div> </div>
</div> </div>
)} )}
{/* No search results */}
{searchQuery && favoriteProjects.length === 0 && recentProjects.length === 0 && (
<div className="text-center py-12">
<div className="w-16 h-16 rounded-full bg-muted/50 flex items-center justify-center mx-auto mb-4">
<Search className="w-8 h-8 text-muted-foreground" />
</div>
<h3 className="text-lg font-medium text-foreground mb-2">No projects found</h3>
<p className="text-sm text-muted-foreground mb-4">
No projects match "{searchQuery}"
</p>
<Button variant="outline" size="sm" onClick={() => setSearchQuery('')}>
Clear search
</Button>
</div>
)}
</div> </div>
)} )}
</div> </div>

View File

@@ -4,6 +4,10 @@ import { useAppStore } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron'; import { getElectronAPI } from '@/lib/electron';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card'; import { Card } from '@/components/ui/card';
import {
HeaderActionsPanel,
HeaderActionsPanelTrigger,
} from '@/components/ui/header-actions-panel';
import { import {
RefreshCw, RefreshCw,
FileText, FileText,
@@ -60,6 +64,9 @@ export function MemoryView() {
const [newMemoryName, setNewMemoryName] = useState(''); const [newMemoryName, setNewMemoryName] = useState('');
const [newMemoryContent, setNewMemoryContent] = useState(''); const [newMemoryContent, setNewMemoryContent] = useState('');
// Actions panel state (for tablet/mobile)
const [showActionsPanel, setShowActionsPanel] = useState(false);
// Get memory directory path // Get memory directory path
const getMemoryPath = useCallback(() => { const getMemoryPath = useCallback(() => {
if (!currentProject) return null; if (!currentProject) return null;
@@ -310,7 +317,9 @@ export function MemoryView() {
</p> </p>
</div> </div>
</div> </div>
<div className="flex gap-2"> <div className="flex items-center gap-2">
{/* Desktop: show actions inline */}
<div className="hidden lg:flex gap-2">
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -329,7 +338,44 @@ export function MemoryView() {
Create Memory File Create Memory File
</Button> </Button>
</div> </div>
{/* Tablet/Mobile: show trigger for actions panel */}
<HeaderActionsPanelTrigger
isOpen={showActionsPanel}
onToggle={() => setShowActionsPanel(!showActionsPanel)}
/>
</div> </div>
</div>
{/* Actions Panel (tablet/mobile) */}
<HeaderActionsPanel
isOpen={showActionsPanel}
onClose={() => setShowActionsPanel(false)}
title="Memory Actions"
>
<Button
variant="outline"
className="w-full justify-start"
onClick={() => {
loadMemoryFiles();
setShowActionsPanel(false);
}}
data-testid="refresh-memory-button-mobile"
>
<RefreshCw className="w-4 h-4 mr-2" />
Refresh
</Button>
<Button
className="w-full justify-start"
onClick={() => {
setIsCreateMemoryOpen(true);
setShowActionsPanel(false);
}}
data-testid="create-memory-button-mobile"
>
<FilePlus className="w-4 h-4 mr-2" />
Create Memory File
</Button>
</HeaderActionsPanel>
{/* Main content area with file list and editor */} {/* Main content area with file list and editor */}
<div className="flex-1 flex overflow-hidden"> <div className="flex-1 flex overflow-hidden">

View File

@@ -31,15 +31,15 @@ export function ProjectSettingsNavigation({
{/* Navigation sidebar */} {/* Navigation sidebar */}
<nav <nav
className={cn( className={cn(
// Mobile: fixed position overlay with slide transition // Mobile: fixed position overlay with slide transition from right
'fixed inset-y-0 left-0 w-72 z-30', 'fixed inset-y-0 right-0 w-72 z-30',
'transition-transform duration-200 ease-out', 'transition-transform duration-200 ease-out',
// Hide on mobile when closed, show when open // Hide on mobile when closed, show when open
isOpen ? 'translate-x-0' : '-translate-x-full', isOpen ? 'translate-x-0' : 'translate-x-full',
// Desktop: relative position in layout, always visible // Desktop: relative position in layout, always visible
'lg:relative lg:w-64 lg:z-auto lg:translate-x-0', 'lg:relative lg:w-64 lg:z-auto lg:translate-x-0',
'shrink-0 overflow-y-auto', 'shrink-0 overflow-y-auto',
'border-r border-border/50', 'border-l border-border/50 lg:border-l-0 lg:border-r',
'bg-gradient-to-b from-card/95 via-card/90 to-card/85 backdrop-blur-xl', 'bg-gradient-to-b from-card/95 via-card/90 to-card/85 backdrop-blur-xl',
// Desktop background // Desktop background
'lg:from-card/80 lg:via-card/60 lg:to-card/40' 'lg:from-card/80 lg:via-card/60 lg:to-card/40'

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useAppStore } from '@/store/app-store'; import { useAppStore } from '@/store/app-store';
import { Settings, FolderOpen, Menu } from 'lucide-react'; import { Settings, FolderOpen, Menu, X } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { ProjectIdentitySection } from './project-identity-section'; import { ProjectIdentitySection } from './project-identity-section';
import { ProjectThemeSection } from './project-theme-section'; import { ProjectThemeSection } from './project-theme-section';
@@ -126,16 +126,6 @@ export function ProjectSettingsView() {
{/* Header */} {/* Header */}
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md"> <div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{/* Mobile menu button */}
<Button
variant="ghost"
size="sm"
onClick={() => setShowNavigation(!showNavigation)}
className="lg:hidden h-8 w-8 p-0"
aria-label="Toggle navigation menu"
>
<Menu className="w-4 h-4" />
</Button>
<Settings className="w-5 h-5 text-muted-foreground" /> <Settings className="w-5 h-5 text-muted-foreground" />
<div> <div>
<h1 className="text-xl font-bold">Project Settings</h1> <h1 className="text-xl font-bold">Project Settings</h1>
@@ -144,6 +134,16 @@ export function ProjectSettingsView() {
</p> </p>
</div> </div>
</div> </div>
{/* Mobile menu button - far right */}
<Button
variant="ghost"
size="sm"
onClick={() => setShowNavigation(!showNavigation)}
className="lg:hidden h-8 w-8 p-0"
aria-label={showNavigation ? 'Close navigation menu' : 'Open navigation menu'}
>
{showNavigation ? <X className="w-4 h-4" /> : <Menu className="w-4 h-4" />}
</Button>
</div> </div>
{/* Content Area with Sidebar */} {/* Content Area with Sidebar */}

View File

@@ -1,4 +1,4 @@
import { Settings, PanelLeft, PanelLeftClose, FileJson } from 'lucide-react'; import { Cog, Menu, X, FileJson } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -11,7 +11,7 @@ interface SettingsHeaderProps {
} }
export function SettingsHeader({ export function SettingsHeader({
title = 'Settings', title = 'Global Settings',
description = 'Configure your API keys and preferences', description = 'Configure your API keys and preferences',
showNavigation, showNavigation,
onToggleNavigation, onToggleNavigation,
@@ -28,6 +28,31 @@ export function SettingsHeader({
<div className="px-4 py-4 lg:px-8 lg:py-6"> <div className="px-4 py-4 lg:px-8 lg:py-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3 lg:gap-4"> <div className="flex items-center gap-3 lg:gap-4">
<div
className={cn(
'w-10 h-10 lg:w-12 lg:h-12 rounded-xl lg:rounded-2xl flex items-center justify-center',
'bg-gradient-to-br from-brand-500 to-brand-600',
'shadow-lg shadow-brand-500/25',
'ring-1 ring-white/10'
)}
>
<Cog className="w-5 h-5 lg:w-6 lg:h-6 text-white" />
</div>
<div>
<h1 className="text-xl lg:text-2xl font-bold text-foreground tracking-tight">
{title}
</h1>
<p className="text-xs lg:text-sm text-muted-foreground/80 mt-0.5">{description}</p>
</div>
</div>
<div className="flex items-center gap-2">
{/* Import/Export button */}
{onImportExportClick && (
<Button variant="outline" size="sm" onClick={onImportExportClick} className="gap-2">
<FileJson className="w-4 h-4" />
<span className="hidden sm:inline">Import / Export</span>
</Button>
)}
{/* Mobile menu toggle button - only visible on mobile */} {/* Mobile menu toggle button - only visible on mobile */}
{onToggleNavigation && ( {onToggleNavigation && (
<Button <Button
@@ -37,37 +62,10 @@ export function SettingsHeader({
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground lg:hidden" className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground lg:hidden"
aria-label={showNavigation ? 'Close navigation menu' : 'Open navigation menu'} aria-label={showNavigation ? 'Close navigation menu' : 'Open navigation menu'}
> >
{showNavigation ? ( {showNavigation ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
<PanelLeftClose className="w-5 h-5" />
) : (
<PanelLeft className="w-5 h-5" />
)}
</Button> </Button>
)} )}
<div
className={cn(
'w-10 h-10 lg:w-12 lg:h-12 rounded-xl lg:rounded-2xl flex items-center justify-center',
'bg-gradient-to-br from-brand-500 to-brand-600',
'shadow-lg shadow-brand-500/25',
'ring-1 ring-white/10'
)}
>
<Settings className="w-5 h-5 lg:w-6 lg:h-6 text-white" />
</div> </div>
<div>
<h1 className="text-xl lg:text-2xl font-bold text-foreground tracking-tight">
{title}
</h1>
<p className="text-xs lg:text-sm text-muted-foreground/80 mt-0.5">{description}</p>
</div>
</div>
{/* Import/Export button */}
{onImportExportClick && (
<Button variant="outline" size="sm" onClick={onImportExportClick} className="gap-2">
<FileJson className="w-4 h-4" />
<span className="hidden sm:inline">Import / Export</span>
</Button>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -210,15 +210,15 @@ export function SettingsNavigation({
{/* Navigation sidebar */} {/* Navigation sidebar */}
<nav <nav
className={cn( className={cn(
// Mobile: fixed position overlay with slide transition // Mobile: fixed position overlay with slide transition from right
'fixed inset-y-0 left-0 w-72 z-30', 'fixed inset-y-0 right-0 w-72 z-30',
'transition-transform duration-200 ease-out', 'transition-transform duration-200 ease-out',
// Hide on mobile when closed, show when open // Hide on mobile when closed, show when open
isOpen ? 'translate-x-0' : '-translate-x-full', isOpen ? 'translate-x-0' : 'translate-x-full',
// Desktop: relative position in layout, always visible // Desktop: relative position in layout, always visible
'lg:relative lg:w-64 lg:z-auto lg:translate-x-0', 'lg:relative lg:w-64 lg:z-auto lg:translate-x-0',
'shrink-0 overflow-y-auto', 'shrink-0 overflow-y-auto',
'border-r border-border/50', 'border-l border-border/50 lg:border-l-0 lg:border-r',
'bg-gradient-to-b from-card/95 via-card/90 to-card/85 backdrop-blur-xl', 'bg-gradient-to-b from-card/95 via-card/90 to-card/85 backdrop-blur-xl',
// Desktop background // Desktop background
'lg:from-card/80 lg:via-card/60 lg:to-card/40' 'lg:from-card/80 lg:via-card/60 lg:to-card/40'

View File

@@ -1,3 +1,4 @@
import { useState } from 'react';
import { RefreshCw } from 'lucide-react'; import { RefreshCw } from 'lucide-react';
import { useAppStore } from '@/store/app-store'; import { useAppStore } from '@/store/app-store';
@@ -13,6 +14,9 @@ import { CreateSpecDialog, RegenerateSpecDialog } from './spec-view/dialogs';
export function SpecView() { export function SpecView() {
const { currentProject, appSpec } = useAppStore(); const { currentProject, appSpec } = useAppStore();
// Actions panel state (for tablet/mobile)
const [showActionsPanel, setShowActionsPanel] = useState(false);
// Loading state // Loading state
const { isLoading, specExists, isGenerationRunning, loadSpec } = useSpecLoading(); const { isLoading, specExists, isGenerationRunning, loadSpec } = useSpecLoading();
@@ -132,6 +136,8 @@ export function SpecView() {
errorMessage={errorMessage} errorMessage={errorMessage}
onRegenerateClick={() => setShowRegenerateDialog(true)} onRegenerateClick={() => setShowRegenerateDialog(true)}
onSaveClick={saveSpec} onSaveClick={saveSpec}
showActionsPanel={showActionsPanel}
onToggleActionsPanel={() => setShowActionsPanel(!showActionsPanel)}
/> />
<SpecEditor value={appSpec} onChange={handleChange} /> <SpecEditor value={appSpec} onChange={handleChange} />

View File

@@ -1,4 +1,8 @@
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import {
HeaderActionsPanel,
HeaderActionsPanelTrigger,
} from '@/components/ui/header-actions-panel';
import { Save, Sparkles, Loader2, FileText, AlertCircle } from 'lucide-react'; import { Save, Sparkles, Loader2, FileText, AlertCircle } from 'lucide-react';
import { PHASE_LABELS } from '../constants'; import { PHASE_LABELS } from '../constants';
@@ -13,6 +17,8 @@ interface SpecHeaderProps {
errorMessage: string; errorMessage: string;
onRegenerateClick: () => void; onRegenerateClick: () => void;
onSaveClick: () => void; onSaveClick: () => void;
showActionsPanel: boolean;
onToggleActionsPanel: () => void;
} }
export function SpecHeader({ export function SpecHeader({
@@ -26,11 +32,14 @@ export function SpecHeader({
errorMessage, errorMessage,
onRegenerateClick, onRegenerateClick,
onSaveClick, onSaveClick,
showActionsPanel,
onToggleActionsPanel,
}: SpecHeaderProps) { }: SpecHeaderProps) {
const isProcessing = isRegenerating || isCreating || isGeneratingFeatures; const isProcessing = isRegenerating || isCreating || isGeneratingFeatures;
const phaseLabel = PHASE_LABELS[currentPhase] || currentPhase; const phaseLabel = PHASE_LABELS[currentPhase] || currentPhase;
return ( return (
<>
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md"> <div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<FileText className="w-5 h-5 text-muted-foreground" /> <FileText className="w-5 h-5 text-muted-foreground" />
@@ -40,8 +49,9 @@ export function SpecHeader({
</div> </div>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{/* Status indicators - always visible */}
{isProcessing && ( {isProcessing && (
<div className="flex items-center gap-3 px-6 py-3.5 rounded-xl bg-linear-to-r from-primary/15 to-primary/5 border border-primary/30 shadow-lg backdrop-blur-md"> <div className="hidden lg:flex items-center gap-3 px-6 py-3.5 rounded-xl bg-linear-to-r from-primary/15 to-primary/5 border border-primary/30 shadow-lg backdrop-blur-md">
<div className="relative"> <div className="relative">
<Loader2 className="w-5 h-5 animate-spin text-primary shrink-0" /> <Loader2 className="w-5 h-5 animate-spin text-primary shrink-0" />
<div className="absolute inset-0 w-5 h-5 animate-ping text-primary/20" /> <div className="absolute inset-0 w-5 h-5 animate-ping text-primary/20" />
@@ -62,8 +72,15 @@ export function SpecHeader({
</div> </div>
</div> </div>
)} )}
{/* Mobile processing indicator */}
{isProcessing && (
<div className="lg:hidden flex items-center gap-2 px-3 py-2 rounded-lg bg-primary/10 border border-primary/20">
<Loader2 className="w-4 h-4 animate-spin text-primary" />
<span className="text-xs font-medium text-primary">Processing...</span>
</div>
)}
{errorMessage && ( {errorMessage && (
<div className="flex items-center gap-3 px-6 py-3.5 rounded-xl bg-linear-to-r from-destructive/15 to-destructive/5 border border-destructive/30 shadow-lg backdrop-blur-md"> <div className="hidden lg:flex items-center gap-3 px-6 py-3.5 rounded-xl bg-linear-to-r from-destructive/15 to-destructive/5 border border-destructive/30 shadow-lg backdrop-blur-md">
<AlertCircle className="w-5 h-5 text-destructive shrink-0" /> <AlertCircle className="w-5 h-5 text-destructive shrink-0" />
<div className="flex flex-col gap-1 min-w-0"> <div className="flex flex-col gap-1 min-w-0">
<span className="text-sm font-semibold text-destructive leading-tight tracking-tight"> <span className="text-sm font-semibold text-destructive leading-tight tracking-tight">
@@ -75,7 +92,15 @@ export function SpecHeader({
</div> </div>
</div> </div>
)} )}
<div className="flex gap-2"> {/* Mobile error indicator */}
{errorMessage && (
<div className="lg:hidden flex items-center gap-2 px-3 py-2 rounded-lg bg-destructive/10 border border-destructive/20">
<AlertCircle className="w-4 h-4 text-destructive" />
<span className="text-xs font-medium text-destructive">Error</span>
</div>
)}
{/* Desktop: show actions inline */}
<div className="hidden lg:flex gap-2">
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
@@ -100,7 +125,66 @@ export function SpecHeader({
{isSaving ? 'Saving...' : hasChanges ? 'Save Changes' : 'Saved'} {isSaving ? 'Saving...' : hasChanges ? 'Save Changes' : 'Saved'}
</Button> </Button>
</div> </div>
{/* Tablet/Mobile: show trigger for actions panel */}
<HeaderActionsPanelTrigger isOpen={showActionsPanel} onToggle={onToggleActionsPanel} />
</div> </div>
</div> </div>
{/* Actions Panel (tablet/mobile) */}
<HeaderActionsPanel
isOpen={showActionsPanel}
onClose={onToggleActionsPanel}
title="Specification Actions"
>
{/* Status messages in panel */}
{isProcessing && (
<div className="flex items-center gap-3 p-3 rounded-lg bg-primary/10 border border-primary/20">
<Loader2 className="w-4 h-4 animate-spin text-primary shrink-0" />
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-sm font-medium text-primary">
{isGeneratingFeatures
? 'Generating Features'
: isCreating
? 'Generating Specification'
: 'Regenerating Specification'}
</span>
{currentPhase && <span className="text-xs text-muted-foreground">{phaseLabel}</span>}
</div>
</div>
)}
{errorMessage && (
<div className="flex items-center gap-3 p-3 rounded-lg bg-destructive/10 border border-destructive/20">
<AlertCircle className="w-4 h-4 text-destructive shrink-0" />
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-sm font-medium text-destructive">Error</span>
<span className="text-xs text-destructive/80">{errorMessage}</span>
</div>
</div>
)}
<Button
variant="outline"
className="w-full justify-start"
onClick={onRegenerateClick}
disabled={isProcessing}
data-testid="regenerate-spec-mobile"
>
{isRegenerating ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Sparkles className="w-4 h-4 mr-2" />
)}
{isRegenerating ? 'Regenerating...' : 'Regenerate'}
</Button>
<Button
className="w-full justify-start"
onClick={onSaveClick}
disabled={!hasChanges || isSaving || isProcessing}
data-testid="save-spec-mobile"
>
<Save className="w-4 h-4 mr-2" />
{isSaving ? 'Saving...' : hasChanges ? 'Save Changes' : 'Saved'}
</Button>
</HeaderActionsPanel>
</>
); );
} }

View File

@@ -56,3 +56,12 @@ export function useIsMobile(): boolean {
export function useIsTablet(): boolean { export function useIsTablet(): boolean {
return useMediaQuery('(max-width: 1024px)'); return useMediaQuery('(max-width: 1024px)');
} }
/**
* Hook to detect compact layout (screen width <= 1240px)
* Used for collapsing top bar controls into mobile menu
* @returns boolean indicating if compact layout should be used
*/
export function useIsCompact(): boolean {
return useMediaQuery('(max-width: 1240px)');
}

View File

@@ -28,12 +28,12 @@ import {
performSettingsMigration, performSettingsMigration,
} from '@/hooks/use-settings-migration'; } from '@/hooks/use-settings-migration';
import { Toaster } from 'sonner'; import { Toaster } from 'sonner';
import { Menu } from 'lucide-react';
import { ThemeOption, themeOptions } from '@/config/theme-options'; import { ThemeOption, themeOptions } from '@/config/theme-options';
import { SandboxRiskDialog } from '@/components/dialogs/sandbox-risk-dialog'; import { SandboxRiskDialog } from '@/components/dialogs/sandbox-risk-dialog';
import { SandboxRejectionScreen } from '@/components/dialogs/sandbox-rejection-screen'; import { SandboxRejectionScreen } from '@/components/dialogs/sandbox-rejection-screen';
import { LoadingState } from '@/components/ui/loading-state'; import { LoadingState } from '@/components/ui/loading-state';
import { useProjectSettingsLoader } from '@/hooks/use-project-settings-loader'; import { useProjectSettingsLoader } from '@/hooks/use-project-settings-loader';
import { useIsCompact } from '@/hooks/use-media-query';
import type { Project } from '@/lib/electron'; import type { Project } from '@/lib/electron';
const logger = createLogger('RootLayout'); const logger = createLogger('RootLayout');
@@ -176,6 +176,9 @@ function RootLayoutContent() {
// Load project settings when switching projects // Load project settings when switching projects
useProjectSettingsLoader(); useProjectSettingsLoader();
// Check if we're in compact mode (< 1240px) to hide project switcher
const isCompact = useIsCompact();
const isSetupRoute = location.pathname === '/setup'; const isSetupRoute = location.pathname === '/setup';
const isLoginRoute = location.pathname === '/login'; const isLoginRoute = location.pathname === '/login';
const isLoggedOutRoute = location.pathname === '/logged-out'; const isLoggedOutRoute = location.pathname === '/logged-out';
@@ -805,8 +808,9 @@ function RootLayoutContent() {
} }
// Show project switcher on all app pages (not on dashboard, setup, or login) // Show project switcher on all app pages (not on dashboard, setup, or login)
// Also hide on compact screens (< 1240px) - the sidebar will show a logo instead
const showProjectSwitcher = const showProjectSwitcher =
!isDashboardRoute && !isSetupRoute && !isLoginRoute && !isLoggedOutRoute; !isDashboardRoute && !isSetupRoute && !isLoginRoute && !isLoggedOutRoute && !isCompact;
return ( return (
<> <>
@@ -820,16 +824,6 @@ function RootLayoutContent() {
)} )}
{showProjectSwitcher && <ProjectSwitcher />} {showProjectSwitcher && <ProjectSwitcher />}
<Sidebar /> <Sidebar />
{/* Mobile menu toggle button - only shows when sidebar is closed on mobile */}
{!sidebarOpen && (
<button
onClick={toggleSidebar}
className="fixed top-3 left-3 z-50 p-2 rounded-lg bg-card/90 backdrop-blur-sm border border-border shadow-lg lg:hidden"
aria-label="Open menu"
>
<Menu className="w-5 h-5 text-foreground" />
</button>
)}
<div <div
className="flex-1 flex flex-col overflow-hidden transition-all duration-300" className="flex-1 flex flex-col overflow-hidden transition-all duration-300"
style={{ marginRight: streamerPanelOpen ? '250px' : '0' }} style={{ marginRight: streamerPanelOpen ? '250px' : '0' }}

View File

@@ -502,6 +502,7 @@ export interface AppState {
// View state // View state
currentView: ViewMode; currentView: ViewMode;
sidebarOpen: boolean; sidebarOpen: boolean;
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)
lastSelectedSessionByProject: Record<string, string>; // projectPath -> sessionId lastSelectedSessionByProject: Record<string, string>; // projectPath -> sessionId
@@ -910,6 +911,8 @@ export interface AppActions {
setCurrentView: (view: ViewMode) => void; setCurrentView: (view: ViewMode) => void;
toggleSidebar: () => void; toggleSidebar: () => void;
setSidebarOpen: (open: boolean) => void; setSidebarOpen: (open: boolean) => void;
toggleMobileSidebarHidden: () => void;
setMobileSidebarHidden: (hidden: boolean) => void;
// Theme actions // Theme actions
setTheme: (theme: ThemeMode) => void; setTheme: (theme: ThemeMode) => void;
@@ -1252,6 +1255,7 @@ const initialState: AppState = {
projectHistoryIndex: -1, projectHistoryIndex: -1,
currentView: 'welcome', currentView: 'welcome',
sidebarOpen: true, sidebarOpen: true,
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'
features: [], features: [],
@@ -1681,6 +1685,8 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
setCurrentView: (view) => set({ currentView: view }), setCurrentView: (view) => set({ currentView: view }),
toggleSidebar: () => set({ sidebarOpen: !get().sidebarOpen }), toggleSidebar: () => set({ sidebarOpen: !get().sidebarOpen }),
setSidebarOpen: (open) => set({ sidebarOpen: open }), setSidebarOpen: (open) => set({ sidebarOpen: open }),
toggleMobileSidebarHidden: () => set({ mobileSidebarHidden: !get().mobileSidebarHidden }),
setMobileSidebarHidden: (hidden) => set({ mobileSidebarHidden: hidden }),
// Theme actions // Theme actions
setTheme: (theme) => { setTheme: (theme) => {

View File

@@ -483,6 +483,16 @@
background: oklch(0.45 0 0); background: oklch(0.45 0 0);
} }
/* Hidden scrollbar - still scrollable but no visible scrollbar */
.scrollbar-hide {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
.scrollbar-hide::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera */
}
/* Glass morphism utilities */ /* Glass morphism utilities */
@layer utilities { @layer utilities {
.glass { .glass {