mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 08:13:37 +00:00
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:
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
105
apps/ui/src/components/ui/header-actions-panel.tsx
Normal file
105
apps/ui/src/components/ui/header-actions-panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -27,18 +27,6 @@ export function AgentHeader({
|
||||
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 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">
|
||||
<Bot className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
@@ -71,6 +59,19 @@ export function AgentHeader({
|
||||
Clear
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Wand2, GitBranch, ClipboardCheck } from 'lucide-react';
|
||||
import { UsagePopover } from '@/components/usage-popover';
|
||||
import { useAppStore } from '@/store/app-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 { WorktreeSettingsPopover } from './dialogs/worktree-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)
|
||||
const showCodexUsage = !!codexAuthStatus?.authenticated;
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
// State for mobile actions panel
|
||||
const [showActionsPanel, setShowActionsPanel] = useState(false);
|
||||
|
||||
const isTablet = useIsTablet();
|
||||
|
||||
return (
|
||||
<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 className="flex gap-4 items-center">
|
||||
{/* 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 */}
|
||||
{isMounted && isMobile && (
|
||||
{/* Tablet/Mobile view: show hamburger menu with all controls */}
|
||||
{isMounted && isTablet && (
|
||||
<HeaderMobileMenu
|
||||
isOpen={showActionsPanel}
|
||||
onToggle={() => setShowActionsPanel(!showActionsPanel)}
|
||||
isWorktreePanelVisible={isWorktreePanelVisible}
|
||||
onWorktreePanelToggle={handleWorktreePanelToggle}
|
||||
maxConcurrency={maxConcurrency}
|
||||
@@ -146,7 +151,7 @@ export function BoardHeader({
|
||||
|
||||
{/* Desktop view: show full controls */}
|
||||
{/* Worktrees Toggle - only show after mount to prevent hydration issues */}
|
||||
{isMounted && !isMobile && (
|
||||
{isMounted && !isTablet && (
|
||||
<div className={controlContainerClass} data-testid="worktrees-toggle-container">
|
||||
<GitBranch className="w-4 h-4 text-muted-foreground" />
|
||||
<Label
|
||||
@@ -169,7 +174,7 @@ export function BoardHeader({
|
||||
)}
|
||||
|
||||
{/* Auto Mode Toggle - only show after mount to prevent hydration issues */}
|
||||
{isMounted && !isMobile && (
|
||||
{isMounted && !isTablet && (
|
||||
<div className={controlContainerClass} data-testid="auto-mode-toggle-container">
|
||||
<Label
|
||||
htmlFor="auto-mode-toggle"
|
||||
@@ -193,8 +198,8 @@ export function BoardHeader({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Plan Button with Settings - only show on desktop, mobile has it in the menu */}
|
||||
{isMounted && !isMobile && (
|
||||
{/* Plan Button with Settings - only show on desktop, tablet/mobile has it in the panel */}
|
||||
{isMounted && !isTablet && (
|
||||
<div className={controlContainerClass} data-testid="plan-button-container">
|
||||
{hasPendingPlan && (
|
||||
<button
|
||||
|
||||
@@ -2,18 +2,17 @@ import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Menu, Bot, Wand2, Settings2, GitBranch, Zap } from 'lucide-react';
|
||||
HeaderActionsPanel,
|
||||
HeaderActionsPanelTrigger,
|
||||
} from '@/components/ui/header-actions-panel';
|
||||
import { Bot, Wand2, Settings2, GitBranch, Zap } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { MobileUsageBar } from './mobile-usage-bar';
|
||||
|
||||
interface HeaderMobileMenuProps {
|
||||
// Panel visibility
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
// Worktree panel visibility
|
||||
isWorktreePanelVisible: boolean;
|
||||
onWorktreePanelToggle: (visible: boolean) => void;
|
||||
@@ -33,6 +32,8 @@ interface HeaderMobileMenuProps {
|
||||
}
|
||||
|
||||
export function HeaderMobileMenu({
|
||||
isOpen,
|
||||
onToggle,
|
||||
isWorktreePanelVisible,
|
||||
onWorktreePanelToggle,
|
||||
maxConcurrency,
|
||||
@@ -46,129 +47,122 @@ export function HeaderMobileMenu({
|
||||
showCodexUsage,
|
||||
}: HeaderMobileMenuProps) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
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">
|
||||
<>
|
||||
<HeaderActionsPanelTrigger isOpen={isOpen} onToggle={onToggle} />
|
||||
<HeaderActionsPanel isOpen={isOpen} onClose={onToggle} title="Board Controls">
|
||||
{/* Usage Bar - show if either provider is authenticated */}
|
||||
{(showClaudeUsage || showCodexUsage) && (
|
||||
<>
|
||||
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Usage
|
||||
</DropdownMenuLabel>
|
||||
</span>
|
||||
<MobileUsageBar showClaudeUsage={showClaudeUsage} showCodexUsage={showCodexUsage} />
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
|
||||
Controls
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{/* Controls Section */}
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Controls
|
||||
</span>
|
||||
|
||||
{/* Auto Mode Toggle */}
|
||||
<div
|
||||
className="flex items-center justify-between px-2 py-2 cursor-pointer hover:bg-accent rounded-sm"
|
||||
onClick={() => onAutoModeToggle(!isAutoModeRunning)}
|
||||
data-testid="mobile-auto-mode-toggle-container"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap
|
||||
className={cn(
|
||||
'w-4 h-4',
|
||||
isAutoModeRunning ? 'text-yellow-500' : 'text-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
<span className="text-sm font-medium">Auto Mode</span>
|
||||
{/* Auto Mode Toggle */}
|
||||
<div
|
||||
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)}
|
||||
data-testid="mobile-auto-mode-toggle-container"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap
|
||||
className={cn(
|
||||
'w-4 h-4',
|
||||
isAutoModeRunning ? 'text-yellow-500' : 'text-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
<span className="text-sm font-medium">Auto Mode</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="mobile-auto-mode-toggle"
|
||||
checked={isAutoModeRunning}
|
||||
onCheckedChange={onAutoModeToggle}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
data-testid="mobile-auto-mode-toggle"
|
||||
/>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenAutoModeSettings();
|
||||
}}
|
||||
className="p-1 rounded hover:bg-accent/50 transition-colors"
|
||||
title="Auto Mode Settings"
|
||||
data-testid="mobile-auto-mode-settings-button"
|
||||
>
|
||||
<Settings2 className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
{/* Worktrees Toggle */}
|
||||
<div
|
||||
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)}
|
||||
data-testid="mobile-worktrees-toggle-container"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<GitBranch className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Worktree Bar</span>
|
||||
</div>
|
||||
<Switch
|
||||
id="mobile-auto-mode-toggle"
|
||||
checked={isAutoModeRunning}
|
||||
onCheckedChange={onAutoModeToggle}
|
||||
id="mobile-worktrees-toggle"
|
||||
checked={isWorktreePanelVisible}
|
||||
onCheckedChange={onWorktreePanelToggle}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
data-testid="mobile-auto-mode-toggle"
|
||||
data-testid="mobile-worktrees-toggle"
|
||||
/>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenAutoModeSettings();
|
||||
}}
|
||||
className="p-1 rounded hover:bg-accent/50 transition-colors"
|
||||
title="Auto Mode Settings"
|
||||
data-testid="mobile-auto-mode-settings-button"
|
||||
>
|
||||
<Settings2 className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* Worktrees Toggle */}
|
||||
<div
|
||||
className="flex items-center justify-between px-2 py-2 cursor-pointer hover:bg-accent rounded-sm"
|
||||
onClick={() => onWorktreePanelToggle(!isWorktreePanelVisible)}
|
||||
data-testid="mobile-worktrees-toggle-container"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<GitBranch className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Worktrees</span>
|
||||
{/* Concurrency Control */}
|
||||
<div
|
||||
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" />
|
||||
<span className="text-sm font-medium">Max Agents</span>
|
||||
<span
|
||||
className="text-sm text-muted-foreground ml-auto"
|
||||
data-testid="mobile-concurrency-value"
|
||||
>
|
||||
{runningAgentsCount}/{maxConcurrency}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[maxConcurrency]}
|
||||
onValueChange={(value) => onConcurrencyChange(value[0])}
|
||||
min={1}
|
||||
max={10}
|
||||
step={1}
|
||||
className="w-full"
|
||||
data-testid="mobile-concurrency-slider"
|
||||
/>
|
||||
</div>
|
||||
<Switch
|
||||
id="mobile-worktrees-toggle"
|
||||
checked={isWorktreePanelVisible}
|
||||
onCheckedChange={onWorktreePanelToggle}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
data-testid="mobile-worktrees-toggle"
|
||||
/>
|
||||
|
||||
{/* Plan Button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start"
|
||||
onClick={() => {
|
||||
onOpenPlanDialog();
|
||||
onToggle();
|
||||
}}
|
||||
data-testid="mobile-plan-button"
|
||||
>
|
||||
<Wand2 className="w-4 h-4 mr-2" />
|
||||
Plan
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* Concurrency Control */}
|
||||
<div className="px-2 py-2" data-testid="mobile-concurrency-control">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Bot className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Max Agents</span>
|
||||
<span
|
||||
className="text-sm text-muted-foreground ml-auto"
|
||||
data-testid="mobile-concurrency-value"
|
||||
>
|
||||
{runningAgentsCount}/{maxConcurrency}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[maxConcurrency]}
|
||||
onValueChange={(value) => onConcurrencyChange(value[0])}
|
||||
min={1}
|
||||
max={10}
|
||||
step={1}
|
||||
className="w-full"
|
||||
data-testid="mobile-concurrency-slider"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* Plan Button */}
|
||||
<DropdownMenuItem
|
||||
onClick={onOpenPlanDialog}
|
||||
className="flex items-center gap-2"
|
||||
data-testid="mobile-plan-button"
|
||||
>
|
||||
<Wand2 className="w-4 h-4" />
|
||||
<span>Plan</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</HeaderActionsPanel>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,10 @@ import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { HotkeyButton } from '@/components/ui/hotkey-button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import {
|
||||
HeaderActionsPanel,
|
||||
HeaderActionsPanelTrigger,
|
||||
} from '@/components/ui/header-actions-panel';
|
||||
import {
|
||||
RefreshCw,
|
||||
FileText,
|
||||
@@ -94,6 +98,9 @@ export function ContextView() {
|
||||
const [editDescriptionValue, setEditDescriptionValue] = useState('');
|
||||
const [editDescriptionFileName, setEditDescriptionFileName] = useState('');
|
||||
|
||||
// Actions panel state (for tablet/mobile)
|
||||
const [showActionsPanel, setShowActionsPanel] = useState(false);
|
||||
|
||||
// File input ref for import
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -691,30 +698,70 @@ export function ContextView() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleImportClick}
|
||||
disabled={isUploading}
|
||||
data-testid="import-file-button"
|
||||
>
|
||||
<FileUp className="w-4 h-4 mr-2" />
|
||||
Import File
|
||||
</Button>
|
||||
<HotkeyButton
|
||||
size="sm"
|
||||
onClick={() => setIsCreateMarkdownOpen(true)}
|
||||
hotkey={shortcuts.addContextFile}
|
||||
hotkeyActive={false}
|
||||
data-testid="create-markdown-button"
|
||||
>
|
||||
<FilePlus className="w-4 h-4 mr-2" />
|
||||
Create Markdown
|
||||
</HotkeyButton>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Desktop: show actions inline */}
|
||||
<div className="hidden lg:flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleImportClick}
|
||||
disabled={isUploading}
|
||||
data-testid="import-file-button"
|
||||
>
|
||||
<FileUp className="w-4 h-4 mr-2" />
|
||||
Import File
|
||||
</Button>
|
||||
<HotkeyButton
|
||||
size="sm"
|
||||
onClick={() => setIsCreateMarkdownOpen(true)}
|
||||
hotkey={shortcuts.addContextFile}
|
||||
hotkeyActive={false}
|
||||
data-testid="create-markdown-button"
|
||||
>
|
||||
<FilePlus className="w-4 h-4 mr-2" />
|
||||
Create Markdown
|
||||
</HotkeyButton>
|
||||
</div>
|
||||
{/* Tablet/Mobile: show trigger for actions panel */}
|
||||
<HeaderActionsPanelTrigger
|
||||
isOpen={showActionsPanel}
|
||||
onToggle={() => setShowActionsPanel(!showActionsPanel)}
|
||||
/>
|
||||
</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 */}
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -21,10 +21,15 @@ import {
|
||||
Loader2,
|
||||
ChevronDown,
|
||||
MessageSquare,
|
||||
Settings,
|
||||
MoreVertical,
|
||||
Trash2,
|
||||
Search,
|
||||
X,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
|
||||
import {
|
||||
DropdownMenu,
|
||||
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() {
|
||||
const navigate = useNavigate();
|
||||
const { os } = useOSDetection();
|
||||
@@ -79,6 +91,7 @@ export function DashboardView() {
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [isOpening, setIsOpening] = useState(false);
|
||||
const [projectToRemove, setProjectToRemove] = useState<{ id: string; name: string } | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// Sort projects: favorites first, then by last opened
|
||||
const sortedProjects = [...projects].sort((a, b) => {
|
||||
@@ -91,8 +104,15 @@ export function DashboardView() {
|
||||
return dateB - dateA;
|
||||
});
|
||||
|
||||
const favoriteProjects = sortedProjects.filter((p) => p.isFavorite);
|
||||
const recentProjects = sortedProjects.filter((p) => !p.isFavorite);
|
||||
// Filter projects based on search query
|
||||
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
|
||||
@@ -529,14 +549,35 @@ export function DashboardView() {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate({ to: '/settings' })}
|
||||
className="titlebar-no-drag"
|
||||
>
|
||||
<Settings className="w-5 h-5" />
|
||||
</Button>
|
||||
|
||||
{/* Mobile action buttons in header */}
|
||||
{hasProjects && (
|
||||
<div className="flex sm:hidden gap-2 titlebar-no-drag">
|
||||
<Button variant="outline" size="icon" onClick={handleOpenProject}>
|
||||
<FolderOpen className="w-4 h-4" />
|
||||
</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>
|
||||
</header>
|
||||
|
||||
@@ -646,25 +687,42 @@ export function DashboardView() {
|
||||
{/* Has projects - show project list */}
|
||||
{hasProjects && (
|
||||
<div className="space-y-6 sm:space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
{/* Quick actions header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<h2 className="text-2xl font-bold text-foreground">Your Projects</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleOpenProject}
|
||||
className="flex-1 sm:flex-none"
|
||||
>
|
||||
<FolderOpen className="w-4 h-4 sm:mr-2" />
|
||||
<span className="hidden sm:inline">Open Folder</span>
|
||||
{/* Search and actions header */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-foreground">Your Projects</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Search input */}
|
||||
<div className="relative flex-1 sm:flex-none">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-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"
|
||||
>
|
||||
<X className="w-4 h-4 text-muted-foreground" />
|
||||
</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>
|
||||
<DropdownMenu>
|
||||
<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">
|
||||
<Plus className="w-4 h-4 sm:mr-2" />
|
||||
<span className="hidden sm:inline">New Project</span>
|
||||
<span className="sm:hidden">New</span>
|
||||
<ChevronDown className="w-4 h-4 ml-1 sm:ml-2" />
|
||||
<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 mr-2" />
|
||||
New Project
|
||||
<ChevronDown className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<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="relative p-3 sm:p-4">
|
||||
<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">
|
||||
<Folder className="w-4 h-4 sm:w-5 sm:h-5 text-yellow-500" />
|
||||
<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">
|
||||
{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 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">
|
||||
@@ -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="relative p-3 sm:p-4">
|
||||
<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">
|
||||
<Folder className="w-4 h-4 sm:w-5 sm:h-5 text-muted-foreground group-hover:text-brand-500 transition-colors duration-300" />
|
||||
<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">
|
||||
{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 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">
|
||||
@@ -797,10 +887,10 @@ export function DashboardView() {
|
||||
<div className="flex items-center gap-0.5 sm:gap-1">
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -830,6 +920,22 @@ export function DashboardView() {
|
||||
</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>
|
||||
|
||||
@@ -4,6 +4,10 @@ import { useAppStore } from '@/store/app-store';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import {
|
||||
HeaderActionsPanel,
|
||||
HeaderActionsPanelTrigger,
|
||||
} from '@/components/ui/header-actions-panel';
|
||||
import {
|
||||
RefreshCw,
|
||||
FileText,
|
||||
@@ -60,6 +64,9 @@ export function MemoryView() {
|
||||
const [newMemoryName, setNewMemoryName] = useState('');
|
||||
const [newMemoryContent, setNewMemoryContent] = useState('');
|
||||
|
||||
// Actions panel state (for tablet/mobile)
|
||||
const [showActionsPanel, setShowActionsPanel] = useState(false);
|
||||
|
||||
// Get memory directory path
|
||||
const getMemoryPath = useCallback(() => {
|
||||
if (!currentProject) return null;
|
||||
@@ -310,27 +317,66 @@ export function MemoryView() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadMemoryFiles}
|
||||
data-testid="refresh-memory-button"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setIsCreateMemoryOpen(true)}
|
||||
data-testid="create-memory-button"
|
||||
>
|
||||
<FilePlus className="w-4 h-4 mr-2" />
|
||||
Create Memory File
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Desktop: show actions inline */}
|
||||
<div className="hidden lg:flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadMemoryFiles}
|
||||
data-testid="refresh-memory-button"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setIsCreateMemoryOpen(true)}
|
||||
data-testid="create-memory-button"
|
||||
>
|
||||
<FilePlus className="w-4 h-4 mr-2" />
|
||||
Create Memory File
|
||||
</Button>
|
||||
</div>
|
||||
{/* Tablet/Mobile: show trigger for actions panel */}
|
||||
<HeaderActionsPanelTrigger
|
||||
isOpen={showActionsPanel}
|
||||
onToggle={() => setShowActionsPanel(!showActionsPanel)}
|
||||
/>
|
||||
</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 */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Left Panel - File List */}
|
||||
|
||||
@@ -31,15 +31,15 @@ export function ProjectSettingsNavigation({
|
||||
{/* Navigation sidebar */}
|
||||
<nav
|
||||
className={cn(
|
||||
// Mobile: fixed position overlay with slide transition
|
||||
'fixed inset-y-0 left-0 w-72 z-30',
|
||||
// Mobile: fixed position overlay with slide transition from right
|
||||
'fixed inset-y-0 right-0 w-72 z-30',
|
||||
'transition-transform duration-200 ease-out',
|
||||
// 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
|
||||
'lg:relative lg:w-64 lg:z-auto lg:translate-x-0',
|
||||
'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',
|
||||
// Desktop background
|
||||
'lg:from-card/80 lg:via-card/60 lg:to-card/40'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
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 { ProjectIdentitySection } from './project-identity-section';
|
||||
import { ProjectThemeSection } from './project-theme-section';
|
||||
@@ -126,16 +126,6 @@ export function ProjectSettingsView() {
|
||||
{/* Header */}
|
||||
<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">
|
||||
{/* 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" />
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Project Settings</h1>
|
||||
@@ -144,6 +134,16 @@ export function ProjectSettingsView() {
|
||||
</p>
|
||||
</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>
|
||||
|
||||
{/* Content Area with Sidebar */}
|
||||
|
||||
@@ -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 { Button } from '@/components/ui/button';
|
||||
|
||||
@@ -11,7 +11,7 @@ interface SettingsHeaderProps {
|
||||
}
|
||||
|
||||
export function SettingsHeader({
|
||||
title = 'Settings',
|
||||
title = 'Global Settings',
|
||||
description = 'Configure your API keys and preferences',
|
||||
showNavigation,
|
||||
onToggleNavigation,
|
||||
@@ -28,6 +28,31 @@ export function SettingsHeader({
|
||||
<div className="px-4 py-4 lg:px-8 lg:py-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<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 */}
|
||||
{onToggleNavigation && (
|
||||
<Button
|
||||
@@ -37,37 +62,10 @@ export function SettingsHeader({
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground lg:hidden"
|
||||
aria-label={showNavigation ? 'Close navigation menu' : 'Open navigation menu'}
|
||||
>
|
||||
{showNavigation ? (
|
||||
<PanelLeftClose className="w-5 h-5" />
|
||||
) : (
|
||||
<PanelLeft className="w-5 h-5" />
|
||||
)}
|
||||
{showNavigation ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
|
||||
</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>
|
||||
<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>
|
||||
|
||||
@@ -210,15 +210,15 @@ export function SettingsNavigation({
|
||||
{/* Navigation sidebar */}
|
||||
<nav
|
||||
className={cn(
|
||||
// Mobile: fixed position overlay with slide transition
|
||||
'fixed inset-y-0 left-0 w-72 z-30',
|
||||
// Mobile: fixed position overlay with slide transition from right
|
||||
'fixed inset-y-0 right-0 w-72 z-30',
|
||||
'transition-transform duration-200 ease-out',
|
||||
// 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
|
||||
'lg:relative lg:w-64 lg:z-auto lg:translate-x-0',
|
||||
'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',
|
||||
// Desktop background
|
||||
'lg:from-card/80 lg:via-card/60 lg:to-card/40'
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
|
||||
@@ -13,6 +14,9 @@ import { CreateSpecDialog, RegenerateSpecDialog } from './spec-view/dialogs';
|
||||
export function SpecView() {
|
||||
const { currentProject, appSpec } = useAppStore();
|
||||
|
||||
// Actions panel state (for tablet/mobile)
|
||||
const [showActionsPanel, setShowActionsPanel] = useState(false);
|
||||
|
||||
// Loading state
|
||||
const { isLoading, specExists, isGenerationRunning, loadSpec } = useSpecLoading();
|
||||
|
||||
@@ -132,6 +136,8 @@ export function SpecView() {
|
||||
errorMessage={errorMessage}
|
||||
onRegenerateClick={() => setShowRegenerateDialog(true)}
|
||||
onSaveClick={saveSpec}
|
||||
showActionsPanel={showActionsPanel}
|
||||
onToggleActionsPanel={() => setShowActionsPanel(!showActionsPanel)}
|
||||
/>
|
||||
|
||||
<SpecEditor value={appSpec} onChange={handleChange} />
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
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 { PHASE_LABELS } from '../constants';
|
||||
|
||||
@@ -13,6 +17,8 @@ interface SpecHeaderProps {
|
||||
errorMessage: string;
|
||||
onRegenerateClick: () => void;
|
||||
onSaveClick: () => void;
|
||||
showActionsPanel: boolean;
|
||||
onToggleActionsPanel: () => void;
|
||||
}
|
||||
|
||||
export function SpecHeader({
|
||||
@@ -26,81 +32,159 @@ export function SpecHeader({
|
||||
errorMessage,
|
||||
onRegenerateClick,
|
||||
onSaveClick,
|
||||
showActionsPanel,
|
||||
onToggleActionsPanel,
|
||||
}: SpecHeaderProps) {
|
||||
const isProcessing = isRegenerating || isCreating || isGeneratingFeatures;
|
||||
const phaseLabel = PHASE_LABELS[currentPhase] || currentPhase;
|
||||
|
||||
return (
|
||||
<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">
|
||||
<FileText className="w-5 h-5 text-muted-foreground" />
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">App Specification</h1>
|
||||
<p className="text-sm text-muted-foreground">{projectPath}/.automaker/app_spec.txt</p>
|
||||
<>
|
||||
<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">
|
||||
<FileText className="w-5 h-5 text-muted-foreground" />
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">App Specification</h1>
|
||||
<p className="text-sm text-muted-foreground">{projectPath}/.automaker/app_spec.txt</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Status indicators - always visible */}
|
||||
{isProcessing && (
|
||||
<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">
|
||||
<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>
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<span className="text-sm font-semibold text-primary leading-tight tracking-tight">
|
||||
{isGeneratingFeatures
|
||||
? 'Generating Features'
|
||||
: isCreating
|
||||
? 'Generating Specification'
|
||||
: 'Regenerating Specification'}
|
||||
</span>
|
||||
{currentPhase && (
|
||||
<span className="text-xs text-muted-foreground/90 leading-tight font-medium">
|
||||
{phaseLabel}
|
||||
</span>
|
||||
)}
|
||||
</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 && (
|
||||
<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" />
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<span className="text-sm font-semibold text-destructive leading-tight tracking-tight">
|
||||
Error
|
||||
</span>
|
||||
<span className="text-xs text-destructive/90 leading-tight font-medium">
|
||||
{errorMessage}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* 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
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onRegenerateClick}
|
||||
disabled={isProcessing}
|
||||
data-testid="regenerate-spec"
|
||||
>
|
||||
{isRegenerating ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{isRegenerating ? 'Regenerating...' : 'Regenerate'}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onSaveClick}
|
||||
disabled={!hasChanges || isSaving || isProcessing}
|
||||
data-testid="save-spec"
|
||||
>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{isSaving ? 'Saving...' : hasChanges ? 'Save Changes' : 'Saved'}
|
||||
</Button>
|
||||
</div>
|
||||
{/* Tablet/Mobile: show trigger for actions panel */}
|
||||
<HeaderActionsPanelTrigger isOpen={showActionsPanel} onToggle={onToggleActionsPanel} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
{/* Actions Panel (tablet/mobile) */}
|
||||
<HeaderActionsPanel
|
||||
isOpen={showActionsPanel}
|
||||
onClose={onToggleActionsPanel}
|
||||
title="Specification Actions"
|
||||
>
|
||||
{/* Status messages in panel */}
|
||||
{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="relative">
|
||||
<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>
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<span className="text-sm font-semibold text-primary leading-tight tracking-tight">
|
||||
<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/90 leading-tight font-medium">
|
||||
{phaseLabel}
|
||||
</span>
|
||||
)}
|
||||
{currentPhase && <span className="text-xs text-muted-foreground">{phaseLabel}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{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">
|
||||
<AlertCircle className="w-5 h-5 text-destructive shrink-0" />
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<span className="text-sm font-semibold text-destructive leading-tight tracking-tight">
|
||||
Error
|
||||
</span>
|
||||
<span className="text-xs text-destructive/90 leading-tight font-medium">
|
||||
{errorMessage}
|
||||
</span>
|
||||
<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>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onRegenerateClick}
|
||||
disabled={isProcessing}
|
||||
data-testid="regenerate-spec"
|
||||
>
|
||||
{isRegenerating ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{isRegenerating ? 'Regenerating...' : 'Regenerate'}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onSaveClick}
|
||||
disabled={!hasChanges || isSaving || isProcessing}
|
||||
data-testid="save-spec"
|
||||
>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{isSaving ? 'Saving...' : hasChanges ? 'Save Changes' : 'Saved'}
|
||||
</Button>
|
||||
</div>
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user