Merge branch 'v0.11.0rc' into feat/dev-server-log-panel

Resolved conflict in worktree-panel.tsx by combining imports:
- DevServerLogsPanel from this branch
- WorktreeMobileDropdown, WorktreeActionsDropdown, BranchSwitchDropdown from v0.11.0rc

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Shirone
2026-01-13 22:01:44 +01:00
111 changed files with 7248 additions and 990 deletions

View File

@@ -0,0 +1,187 @@
import { useState, useRef } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Upload, X, ImageIcon } from 'lucide-react';
import { useAppStore } from '@/store/app-store';
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
import { getHttpApiClient } from '@/lib/http-api-client';
import type { Project } from '@/lib/electron';
import { IconPicker } from './icon-picker';
interface EditProjectDialogProps {
project: Project;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function EditProjectDialog({ project, open, onOpenChange }: EditProjectDialogProps) {
const { setProjectName, setProjectIcon, setProjectCustomIcon } = useAppStore();
const [name, setName] = useState(project.name);
const [icon, setIcon] = useState<string | null>((project as any).icon || null);
const [customIconPath, setCustomIconPath] = useState<string | null>(
(project as any).customIconPath || null
);
const [isUploadingIcon, setIsUploadingIcon] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleSave = () => {
if (name.trim() !== project.name) {
setProjectName(project.id, name.trim());
}
if (icon !== (project as any).icon) {
setProjectIcon(project.id, icon);
}
if (customIconPath !== (project as any).customIconPath) {
setProjectCustomIcon(project.id, customIconPath);
}
onOpenChange(false);
};
const handleCustomIconUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// Validate file type
const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if (!validTypes.includes(file.type)) {
return;
}
// Validate file size (max 2MB for icons)
if (file.size > 2 * 1024 * 1024) {
return;
}
setIsUploadingIcon(true);
try {
// Convert to base64
const reader = new FileReader();
reader.onload = async () => {
const base64Data = reader.result as string;
const result = await getHttpApiClient().saveImageToTemp(
base64Data,
`project-icon-${file.name}`,
file.type,
project.path
);
if (result.success && result.path) {
setCustomIconPath(result.path);
// Clear the Lucide icon when custom icon is set
setIcon(null);
}
setIsUploadingIcon(false);
};
reader.readAsDataURL(file);
} catch {
setIsUploadingIcon(false);
}
};
const handleRemoveCustomIcon = () => {
setCustomIconPath(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Edit Project</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4 overflow-y-auto flex-1 min-h-0">
{/* Project Name */}
<div className="space-y-2">
<Label htmlFor="project-name">Project Name</Label>
<Input
id="project-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter project name"
/>
</div>
{/* Icon Picker */}
<div className="space-y-2">
<Label>Project Icon</Label>
<p className="text-xs text-muted-foreground mb-2">
Choose a preset icon or upload a custom image
</p>
{/* Custom Icon Upload */}
<div className="mb-4">
<div className="flex items-center gap-3">
{customIconPath ? (
<div className="relative">
<img
src={getAuthenticatedImageUrl(customIconPath, project.path)}
alt="Custom project icon"
className="w-12 h-12 rounded-lg object-cover border border-border"
/>
<button
type="button"
onClick={handleRemoveCustomIcon}
className="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full bg-destructive text-destructive-foreground flex items-center justify-center hover:bg-destructive/90"
>
<X className="w-3 h-3" />
</button>
</div>
) : (
<div className="w-12 h-12 rounded-lg border border-dashed border-border flex items-center justify-center bg-accent/30">
<ImageIcon className="w-5 h-5 text-muted-foreground" />
</div>
)}
<div className="flex-1">
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
onChange={handleCustomIconUpload}
className="hidden"
id="custom-icon-upload-dialog"
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={isUploadingIcon}
className="gap-1.5"
>
<Upload className="w-3.5 h-3.5" />
{isUploadingIcon ? 'Uploading...' : 'Upload Custom Icon'}
</Button>
<p className="text-xs text-muted-foreground mt-1">
PNG, JPG, GIF or WebP. Max 2MB.
</p>
</div>
</div>
</div>
{/* Preset Icon Picker - only show if no custom icon */}
{!customIconPath && <IconPicker selectedIcon={icon} onSelectIcon={setIcon} />}
</div>
</div>
<DialogFooter className="flex-shrink-0">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSave} disabled={!name.trim()}>
Save Changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,129 @@
import { useState } from 'react';
import { X, Search } from 'lucide-react';
import * as LucideIcons from 'lucide-react';
import { cn } from '@/lib/utils';
import { Input } from '@/components/ui/input';
import { ScrollArea } from '@/components/ui/scroll-area';
interface IconPickerProps {
selectedIcon: string | null;
onSelectIcon: (icon: string | null) => void;
}
// Popular project-related icons
const POPULAR_ICONS = [
'Folder',
'FolderOpen',
'FolderCode',
'FolderGit',
'FolderKanban',
'Package',
'Box',
'Boxes',
'Code',
'Code2',
'Braces',
'FileCode',
'Terminal',
'Globe',
'Server',
'Database',
'Layout',
'Layers',
'Blocks',
'Component',
'Puzzle',
'Cog',
'Wrench',
'Hammer',
'Zap',
'Rocket',
'Sparkles',
'Star',
'Heart',
'Shield',
'Lock',
'Key',
'Cpu',
'CircuitBoard',
'Workflow',
];
export function IconPicker({ selectedIcon, onSelectIcon }: IconPickerProps) {
const [search, setSearch] = useState('');
const filteredIcons = POPULAR_ICONS.filter((icon) =>
icon.toLowerCase().includes(search.toLowerCase())
);
const getIconComponent = (iconName: string) => {
return (LucideIcons as Record<string, React.ComponentType<{ className?: string }>>)[iconName];
};
return (
<div className="space-y-3">
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search icons..."
className="pl-9"
/>
</div>
{/* Selected Icon Display */}
{selectedIcon && (
<div className="flex items-center gap-2 p-2 rounded-md bg-accent/50 border border-border">
<div className="flex items-center gap-2 flex-1">
{(() => {
const IconComponent = getIconComponent(selectedIcon);
return IconComponent ? <IconComponent className="w-5 h-5 text-brand-500" /> : null;
})()}
<span className="text-sm font-medium">{selectedIcon}</span>
</div>
<button
onClick={() => onSelectIcon(null)}
className="p-1 hover:bg-background rounded transition-colors"
title="Clear icon"
>
<X className="w-4 h-4" />
</button>
</div>
)}
{/* Icons Grid */}
<ScrollArea className="h-64 rounded-md border">
<div className="grid grid-cols-6 gap-1 p-2">
{filteredIcons.map((iconName) => {
const IconComponent = getIconComponent(iconName);
if (!IconComponent) return null;
const isSelected = selectedIcon === iconName;
return (
<button
key={iconName}
onClick={() => onSelectIcon(iconName)}
className={cn(
'aspect-square rounded-md flex items-center justify-center',
'transition-all duration-150',
'hover:bg-accent hover:scale-110',
isSelected
? 'bg-brand-500/20 border-2 border-brand-500'
: 'border border-transparent'
)}
title={iconName}
>
<IconComponent
className={cn('w-5 h-5', isSelected ? 'text-brand-500' : 'text-foreground')}
/>
</button>
);
})}
</div>
</ScrollArea>
</div>
);
}

View File

@@ -0,0 +1,4 @@
export { ProjectSwitcherItem } from './project-switcher-item';
export { ProjectContextMenu } from './project-context-menu';
export { EditProjectDialog } from './edit-project-dialog';
export { IconPicker } from './icon-picker';

View File

@@ -0,0 +1,103 @@
import { useEffect, useRef } from 'react';
import { Edit2, Trash2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAppStore } from '@/store/app-store';
import type { Project } from '@/lib/electron';
interface ProjectContextMenuProps {
project: Project;
position: { x: number; y: number };
onClose: () => void;
onEdit: (project: Project) => void;
}
export function ProjectContextMenu({
project,
position,
onClose,
onEdit,
}: ProjectContextMenuProps) {
const menuRef = useRef<HTMLDivElement>(null);
const { moveProjectToTrash } = useAppStore();
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
onClose();
}
};
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
}
};
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleEscape);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleEscape);
};
}, [onClose]);
const handleEdit = () => {
onEdit(project);
};
const handleRemove = () => {
if (confirm(`Remove "${project.name}" from the project list?`)) {
moveProjectToTrash(project.id);
}
onClose();
};
return (
<div
ref={menuRef}
className={cn(
'fixed z-[100] min-w-48 rounded-lg',
'bg-popover text-popover-foreground',
'border border-border shadow-lg',
'animate-in fade-in zoom-in-95 duration-100'
)}
style={{
top: position.y,
left: position.x,
}}
data-testid="project-context-menu"
>
<div className="p-1">
<button
onClick={handleEdit}
className={cn(
'w-full flex items-center gap-2 px-3 py-2 rounded-md',
'text-sm font-medium text-left',
'hover:bg-accent transition-colors',
'focus:outline-none focus:bg-accent'
)}
data-testid="edit-project-button"
>
<Edit2 className="w-4 h-4" />
<span>Edit Name & Icon</span>
</button>
<button
onClick={handleRemove}
className={cn(
'w-full flex items-center gap-2 px-3 py-2 rounded-md',
'text-sm font-medium text-left',
'text-destructive hover:bg-destructive/10',
'transition-colors',
'focus:outline-none focus:bg-destructive/10'
)}
data-testid="remove-project-button"
>
<Trash2 className="w-4 h-4" />
<span>Remove Project</span>
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,116 @@
import { Folder, LucideIcon } from 'lucide-react';
import * as LucideIcons from 'lucide-react';
import { cn } from '@/lib/utils';
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
import type { Project } from '@/lib/electron';
interface ProjectSwitcherItemProps {
project: Project;
isActive: boolean;
hotkeyIndex?: number; // 0-9 for hotkeys 1-9, 0
onClick: () => void;
onContextMenu: (event: React.MouseEvent) => void;
}
export function ProjectSwitcherItem({
project,
isActive,
hotkeyIndex,
onClick,
onContextMenu,
}: ProjectSwitcherItemProps) {
// Convert index to hotkey label: 0 -> "1", 1 -> "2", ..., 8 -> "9", 9 -> "0"
const hotkeyLabel =
hotkeyIndex !== undefined && hotkeyIndex >= 0 && hotkeyIndex <= 9
? hotkeyIndex === 9
? '0'
: String(hotkeyIndex + 1)
: undefined;
// Get the icon component from lucide-react
const getIconComponent = (): LucideIcon => {
if (project.icon && project.icon in LucideIcons) {
return (LucideIcons as Record<string, LucideIcon>)[project.icon];
}
return Folder;
};
const IconComponent = getIconComponent();
const hasCustomIcon = !!project.customIconPath;
return (
<button
onClick={onClick}
onContextMenu={onContextMenu}
className={cn(
'group w-full aspect-square rounded-xl flex items-center justify-center relative overflow-hidden',
'transition-all duration-200 ease-out',
isActive
? [
// Active: Premium gradient with glow
'bg-gradient-to-r from-brand-500/20 via-brand-500/15 to-brand-600/10',
'border border-brand-500/30',
'shadow-md shadow-brand-500/10',
]
: [
// Inactive: Subtle hover state
'hover:bg-accent/50',
'border border-transparent hover:border-border/40',
'hover:shadow-sm',
],
'hover:scale-105 active:scale-95'
)}
title={project.name}
data-testid={`project-switcher-${project.id}`}
>
{hasCustomIcon ? (
<img
src={getAuthenticatedImageUrl(project.customIconPath!, project.path)}
alt={project.name}
className={cn(
'w-8 h-8 rounded-lg object-cover transition-all duration-200',
isActive ? 'ring-1 ring-brand-500/50' : 'group-hover:scale-110'
)}
/>
) : (
<IconComponent
className={cn(
'w-6 h-6 transition-all duration-200',
isActive
? 'text-brand-500 drop-shadow-sm'
: 'text-muted-foreground group-hover:text-brand-400 group-hover:scale-110'
)}
/>
)}
{/* Tooltip on hover */}
<span
className={cn(
'absolute left-full ml-3 px-2.5 py-1.5 rounded-lg',
'bg-popover text-popover-foreground text-xs font-medium',
'border border-border shadow-lg',
'opacity-0 group-hover:opacity-100',
'transition-all duration-200 whitespace-nowrap z-50',
'translate-x-1 group-hover:translate-x-0 pointer-events-none'
)}
>
{project.name}
</span>
{/* Hotkey badge */}
{hotkeyLabel && (
<span
className={cn(
'absolute bottom-0.5 right-0.5 min-w-[16px] h-4 px-1',
'flex items-center justify-center',
'text-[10px] font-medium rounded',
'bg-muted/80 text-muted-foreground',
'border border-border/50',
'pointer-events-none'
)}
>
{hotkeyLabel}
</span>
)}
</button>
);
}

View File

@@ -0,0 +1 @@
export { ProjectSwitcher } from './project-switcher';

View File

@@ -0,0 +1,317 @@
import { useState, useCallback, useEffect } from 'react';
import { Plus, Bug } from 'lucide-react';
import { useNavigate } from '@tanstack/react-router';
import { cn } from '@/lib/utils';
import { useAppStore } from '@/store/app-store';
import { useOSDetection } from '@/hooks/use-os-detection';
import { ProjectSwitcherItem } from './components/project-switcher-item';
import { ProjectContextMenu } from './components/project-context-menu';
import { EditProjectDialog } from './components/edit-project-dialog';
import { NewProjectModal } from '@/components/dialogs/new-project-modal';
import { OnboardingDialog } from '@/components/layout/sidebar/dialogs';
import { useProjectCreation, useProjectTheme } from '@/components/layout/sidebar/hooks';
import type { Project } from '@/lib/electron';
import { getElectronAPI } from '@/lib/electron';
function getOSAbbreviation(os: string): string {
switch (os) {
case 'mac':
return 'M';
case 'windows':
return 'W';
case 'linux':
return 'L';
default:
return '?';
}
}
export function ProjectSwitcher() {
const navigate = useNavigate();
const {
projects,
currentProject,
setCurrentProject,
trashedProjects,
upsertAndSetCurrentProject,
} = useAppStore();
const [contextMenuProject, setContextMenuProject] = useState<Project | null>(null);
const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(
null
);
const [editDialogProject, setEditDialogProject] = useState<Project | null>(null);
// Version info
const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0';
const { os } = useOSDetection();
const appMode = import.meta.env.VITE_APP_MODE || '?';
const versionSuffix = `${getOSAbbreviation(os)}${appMode}`;
// Get global theme for project creation
const { globalTheme } = useProjectTheme();
// Project creation state and handlers
const {
showNewProjectModal,
setShowNewProjectModal,
isCreatingProject,
showOnboardingDialog,
setShowOnboardingDialog,
newProjectName,
handleCreateBlankProject,
handleCreateFromTemplate,
handleCreateFromCustomUrl,
} = useProjectCreation({
trashedProjects,
currentProject,
globalTheme,
upsertAndSetCurrentProject,
});
const handleContextMenu = (project: Project, event: React.MouseEvent) => {
event.preventDefault();
setContextMenuProject(project);
setContextMenuPosition({ x: event.clientX, y: event.clientY });
};
const handleCloseContextMenu = () => {
setContextMenuProject(null);
setContextMenuPosition(null);
};
const handleEditProject = (project: Project) => {
setEditDialogProject(project);
handleCloseContextMenu();
};
const handleProjectClick = useCallback(
(project: Project) => {
setCurrentProject(project);
// Navigate to board view when switching projects
navigate({ to: '/board' });
},
[setCurrentProject, navigate]
);
const handleNewProject = () => {
// Open the new project modal
setShowNewProjectModal(true);
};
const handleOnboardingSkip = () => {
setShowOnboardingDialog(false);
navigate({ to: '/board' });
};
const handleBugReportClick = useCallback(() => {
const api = getElectronAPI();
api.openExternalLink('https://github.com/AutoMaker-Org/automaker/issues');
}, []);
// Keyboard shortcuts for project switching (1-9, 0)
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
// Ignore if user is typing in an input, textarea, or contenteditable
const target = event.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
return;
}
// Ignore if modifier keys are pressed (except for standalone number keys)
if (event.ctrlKey || event.metaKey || event.altKey) {
return;
}
// Map key to project index: "1" -> 0, "2" -> 1, ..., "9" -> 8, "0" -> 9
const key = event.key;
let projectIndex: number | null = null;
if (key >= '1' && key <= '9') {
projectIndex = parseInt(key, 10) - 1; // "1" -> 0, "9" -> 8
} else if (key === '0') {
projectIndex = 9; // "0" -> 9
}
if (projectIndex !== null && projectIndex < projects.length) {
const targetProject = projects[projectIndex];
if (targetProject && targetProject.id !== currentProject?.id) {
handleProjectClick(targetProject);
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [projects, currentProject, handleProjectClick]);
return (
<>
<aside
className={cn(
'flex-shrink-0 flex flex-col w-16 z-50 relative',
// Glass morphism background with gradient
'bg-gradient-to-b from-sidebar/95 via-sidebar/85 to-sidebar/90 backdrop-blur-2xl',
// Premium border with subtle glow
'border-r border-border/60 shadow-[1px_0_20px_-5px_rgba(0,0,0,0.1)]'
)}
data-testid="project-switcher"
>
{/* Automaker Logo and Version */}
<div className="flex flex-col items-center pt-3 pb-2 px-2">
<button
onClick={() => navigate({ to: '/dashboard' })}
className="group flex flex-col items-center gap-0.5"
title="Go to Dashboard"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
role="img"
aria-label="Automaker Logo"
className="size-10 group-hover:rotate-12 transition-transform duration-300 ease-out"
>
<defs>
<linearGradient
id="bg-switcher"
x1="0"
y1="0"
x2="256"
y2="256"
gradientUnits="userSpaceOnUse"
>
<stop offset="0%" style={{ stopColor: 'var(--brand-400)' }} />
<stop offset="100%" style={{ stopColor: 'var(--brand-600)' }} />
</linearGradient>
</defs>
<rect x="16" y="16" width="224" height="224" rx="56" fill="url(#bg-switcher)" />
<g
fill="none"
stroke="#FFFFFF"
strokeWidth="20"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M92 92 L52 128 L92 164" />
<path d="M144 72 L116 184" />
<path d="M164 92 L204 128 L164 164" />
</g>
</svg>
<span className="text-[0.625rem] text-muted-foreground leading-none font-medium">
v{appVersion} {versionSuffix}
</span>
</button>
<div className="w-full h-px bg-border mt-3" />
</div>
{/* Projects List */}
<div className="flex-1 overflow-y-auto py-3 px-2 space-y-2">
{projects.map((project, index) => (
<ProjectSwitcherItem
key={project.id}
project={project}
isActive={currentProject?.id === project.id}
hotkeyIndex={index < 10 ? index : undefined}
onClick={() => handleProjectClick(project)}
onContextMenu={(e) => handleContextMenu(project, e)}
/>
))}
{/* Horizontal rule and Add Project Button - only show if there are projects */}
{projects.length > 0 && (
<>
<div className="w-full h-px bg-border/40 my-2" />
<button
onClick={handleNewProject}
className={cn(
'w-full aspect-square rounded-xl flex items-center justify-center',
'transition-all duration-200 ease-out',
'text-muted-foreground hover:text-foreground',
'hover:bg-accent/50 border border-transparent hover:border-border/40',
'hover:shadow-sm hover:scale-105 active:scale-95'
)}
title="New Project"
data-testid="new-project-button"
>
<Plus className="w-5 h-5" />
</button>
</>
)}
{/* Add Project Button - when no projects, show without rule */}
{projects.length === 0 && (
<button
onClick={handleNewProject}
className={cn(
'w-full aspect-square rounded-xl flex items-center justify-center',
'transition-all duration-200 ease-out',
'text-muted-foreground hover:text-foreground',
'hover:bg-accent/50 border border-transparent hover:border-border/40',
'hover:shadow-sm hover:scale-105 active:scale-95'
)}
title="New Project"
data-testid="new-project-button"
>
<Plus className="w-5 h-5" />
</button>
)}
</div>
{/* Bug Report Button at the very bottom */}
<div className="p-2 border-t border-border/40">
<button
onClick={handleBugReportClick}
className={cn(
'w-full aspect-square rounded-xl flex items-center justify-center',
'transition-all duration-200 ease-out',
'text-muted-foreground hover:text-foreground',
'hover:bg-accent/50 border border-transparent hover:border-border/40',
'hover:shadow-sm hover:scale-105 active:scale-95'
)}
title="Report Bug / Feature Request"
data-testid="bug-report-button"
>
<Bug className="w-5 h-5" />
</button>
</div>
</aside>
{/* Context Menu */}
{contextMenuProject && contextMenuPosition && (
<ProjectContextMenu
project={contextMenuProject}
position={contextMenuPosition}
onClose={handleCloseContextMenu}
onEdit={handleEditProject}
/>
)}
{/* Edit Project Dialog */}
{editDialogProject && (
<EditProjectDialog
project={editDialogProject}
open={!!editDialogProject}
onOpenChange={(open) => !open && setEditDialogProject(null)}
/>
)}
{/* New Project Modal */}
<NewProjectModal
open={showNewProjectModal}
onOpenChange={setShowNewProjectModal}
onCreateBlankProject={handleCreateBlankProject}
onCreateFromTemplate={handleCreateFromTemplate}
onCreateFromCustomUrl={handleCreateFromCustomUrl}
isCreating={isCreatingProject}
/>
{/* Onboarding Dialog */}
<OnboardingDialog
open={showOnboardingDialog}
onOpenChange={setShowOnboardingDialog}
newProjectName={newProjectName}
onSkip={handleOnboardingSkip}
onGenerateSpec={handleOnboardingSkip}
/>
</>
);
}

View File

@@ -18,7 +18,6 @@ import {
CollapseToggleButton,
SidebarHeader,
SidebarNavigation,
ProjectSelectorWithOptions,
SidebarFooter,
} from './sidebar/components';
import { TrashDialog, OnboardingDialog } from './sidebar/dialogs';
@@ -64,9 +63,6 @@ export function Sidebar() {
// Get customizable keyboard shortcuts
const shortcuts = useKeyboardShortcutsConfig();
// State for project picker (needed for keyboard shortcuts)
const [isProjectPickerOpen, setIsProjectPickerOpen] = useState(false);
// State for delete project confirmation dialog
const [showDeleteProjectDialog, setShowDeleteProjectDialog] = useState(false);
@@ -240,7 +236,6 @@ export function Sidebar() {
navigate,
toggleSidebar,
handleOpenFolder,
setIsProjectPickerOpen,
cyclePrevProject,
cycleNextProject,
unviewedValidationsCount,
@@ -258,26 +253,25 @@ export function Sidebar() {
return (
<>
{/* Mobile overlay backdrop */}
{/* Mobile backdrop overlay */}
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
className="fixed inset-0 bg-black/50 z-20 lg:hidden"
onClick={toggleSidebar}
aria-hidden="true"
data-testid="sidebar-backdrop"
/>
)}
<aside
className={cn(
'flex-shrink-0 flex flex-col z-50 relative',
'flex-shrink-0 flex flex-col z-30',
// Glass morphism background with gradient
'bg-gradient-to-b from-sidebar/95 via-sidebar/85 to-sidebar/90 backdrop-blur-2xl',
// Premium border with subtle glow
'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: hidden when closed, full width overlay when open
// Desktop: always visible, toggle between narrow and wide
sidebarOpen ? 'fixed lg:relative left-0 top-0 h-full w-72' : 'hidden lg:flex w-16'
// Mobile: overlay when open, collapsed when closed
sidebarOpen ? 'fixed inset-y-0 left-0 w-72 lg:relative lg:w-72' : 'relative w-16'
)}
data-testid="sidebar"
>
@@ -288,14 +282,7 @@ export function Sidebar() {
/>
<div className="flex-1 flex flex-col overflow-hidden">
<SidebarHeader sidebarOpen={sidebarOpen} navigate={navigate} />
<ProjectSelectorWithOptions
sidebarOpen={sidebarOpen}
isProjectPickerOpen={isProjectPickerOpen}
setIsProjectPickerOpen={setIsProjectPickerOpen}
setShowDeleteProjectDialog={setShowDeleteProjectDialog}
/>
<SidebarHeader sidebarOpen={sidebarOpen} currentProject={currentProject} />
<SidebarNavigation
currentProject={currentProject}

View File

@@ -17,9 +17,7 @@ export function CollapseToggleButton({
<button
onClick={toggleSidebar}
className={cn(
// Show on desktop always, show on mobile only when sidebar is open
sidebarOpen ? 'flex' : 'hidden lg:flex',
'absolute top-[68px] -right-3 z-9999',
'flex absolute top-[68px] -right-3 z-9999',
'group/toggle items-center justify-center w-7 h-7 rounded-full',
// Glass morphism button
'bg-card/95 backdrop-blur-sm border border-border/80',

View File

@@ -40,7 +40,7 @@ export function ProjectActions({
data-testid="new-project-button"
>
<Plus className="w-4 h-4 shrink-0 transition-transform duration-200 group-hover:rotate-90 group-hover:text-brand-500" />
<span className="ml-2 text-sm font-medium hidden lg:block whitespace-nowrap">New</span>
<span className="ml-2 text-sm font-medium block whitespace-nowrap">New</span>
</button>
<button
onClick={handleOpenFolder}
@@ -59,7 +59,7 @@ export function ProjectActions({
data-testid="open-project-button"
>
<FolderOpen className="w-4 h-4 shrink-0 transition-transform duration-200 group-hover:scale-110" />
<span className="hidden lg:flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md bg-muted/80 text-muted-foreground ml-2">
<span className="flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md bg-muted/80 text-muted-foreground ml-2">
{formatShortcut(shortcuts.openProject, true)}
</span>
</button>

View File

@@ -235,7 +235,7 @@ export function SidebarFooter({
{sidebarOpen && (
<span
className={cn(
'hidden sm:flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md transition-all duration-200',
'flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md transition-all duration-200',
isActiveRoute('settings')
? 'bg-brand-500/20 text-brand-400'
: 'bg-muted text-muted-foreground group-hover:bg-accent'

View File

@@ -1,43 +1,64 @@
import type { NavigateOptions } from '@tanstack/react-router';
import { Folder, LucideIcon } from 'lucide-react';
import * as LucideIcons from 'lucide-react';
import { cn, isMac } from '@/lib/utils';
import { AutomakerLogo } from './automaker-logo';
import { BugReportButton } from './bug-report-button';
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
import type { Project } from '@/lib/electron';
interface SidebarHeaderProps {
sidebarOpen: boolean;
navigate: (opts: NavigateOptions) => void;
currentProject: Project | null;
}
export function SidebarHeader({ sidebarOpen, navigate }: SidebarHeaderProps) {
return (
<>
{/* Logo */}
<div
className={cn(
'h-20 shrink-0 titlebar-drag-region',
// Subtle bottom border with gradient fade
'border-b border-border/40',
// Background gradient for depth
'bg-gradient-to-b from-transparent to-background/5',
'flex items-center',
sidebarOpen ? 'px-4 lg:px-5 justify-start' : 'px-3 justify-center',
// Add padding on macOS to avoid overlapping with traffic light buttons
isMac && sidebarOpen && 'pt-4',
// Smaller top padding on macOS when collapsed
isMac && !sidebarOpen && 'pt-4'
)}
>
<AutomakerLogo sidebarOpen={sidebarOpen} navigate={navigate} />
{/* Bug Report Button - Inside logo container when expanded */}
{sidebarOpen && <BugReportButton sidebarExpanded />}
</div>
export function SidebarHeader({ sidebarOpen, currentProject }: SidebarHeaderProps) {
// Get the icon component from lucide-react
const getIconComponent = (): LucideIcon => {
if (currentProject?.icon && currentProject.icon in LucideIcons) {
return (LucideIcons as Record<string, LucideIcon>)[currentProject.icon];
}
return Folder;
};
{/* Bug Report Button - Collapsed sidebar version */}
{!sidebarOpen && (
<div className="px-3 mt-1.5 flex justify-center">
<BugReportButton sidebarExpanded={false} />
const IconComponent = getIconComponent();
const hasCustomIcon = !!currentProject?.customIconPath;
return (
<div
className={cn(
'shrink-0 flex flex-col',
// Add minimal padding on macOS for traffic light buttons
isMac && 'pt-2'
)}
>
{/* Project name and icon display */}
{currentProject && (
<div
className={cn('flex items-center gap-3 px-4 py-3', !sidebarOpen && 'justify-center px-2')}
>
{/* 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>
)}
</div>
)}
</>
</div>
);
}

View File

@@ -26,7 +26,7 @@ export function SidebarNavigation({
// Placeholder when no project is selected (only in expanded state)
<div className="flex items-center justify-center h-full px-4">
<p className="text-muted-foreground text-sm text-center">
<span className="hidden lg:block">Select or create a project above</span>
<span className="block">Select or create a project above</span>
</p>
</div>
) : currentProject ? (
@@ -137,7 +137,7 @@ export function SidebarNavigation({
{item.shortcut && sidebarOpen && !item.count && (
<span
className={cn(
'hidden sm:flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md transition-all duration-200',
'flex items-center justify-center min-w-5 h-5 px-1.5 text-[10px] font-mono rounded-md transition-all duration-200',
isActive
? 'bg-brand-500/20 text-brand-400'
: 'bg-muted text-muted-foreground group-hover:bg-accent'

View File

@@ -45,7 +45,6 @@ interface UseNavigationProps {
navigate: (opts: NavigateOptions) => void;
toggleSidebar: () => void;
handleOpenFolder: () => void;
setIsProjectPickerOpen: (value: boolean | ((prev: boolean) => boolean)) => void;
cyclePrevProject: () => void;
cycleNextProject: () => void;
/** Count of unviewed validations to show on GitHub Issues nav item */
@@ -65,7 +64,6 @@ export function useNavigation({
navigate,
toggleSidebar,
handleOpenFolder,
setIsProjectPickerOpen,
cyclePrevProject,
cycleNextProject,
unviewedValidationsCount,
@@ -230,15 +228,6 @@ export function useNavigation({
description: 'Open folder selection dialog',
});
// Project picker shortcut - only when we have projects
if (projects.length > 0) {
shortcutsList.push({
key: shortcuts.projectPicker,
action: () => setIsProjectPickerOpen((prev) => !prev),
description: 'Toggle project picker',
});
}
// Project cycling shortcuts - only when we have project history
if (projectHistory.length > 1) {
shortcutsList.push({
@@ -288,7 +277,6 @@ export function useNavigation({
cyclePrevProject,
cycleNextProject,
navSections,
setIsProjectPickerOpen,
]);
return {

View File

@@ -1,6 +1,6 @@
import { useState, useCallback, useMemo } from 'react';
import { useAppStore } from '@/store/app-store';
import type { ModelAlias, CursorModelId, PhaseModelKey, PhaseModelEntry } from '@automaker/types';
import type { ModelId, PhaseModelKey, PhaseModelEntry } from '@automaker/types';
import { DEFAULT_PHASE_MODELS } from '@automaker/types';
export interface UseModelOverrideOptions {
@@ -14,7 +14,7 @@ export interface UseModelOverrideResult {
/** The effective model entry (override or global default) */
effectiveModelEntry: PhaseModelEntry;
/** The effective model string (for backward compatibility with APIs that only accept strings) */
effectiveModel: ModelAlias | CursorModelId;
effectiveModel: ModelId;
/** Whether the model is currently overridden */
isOverridden: boolean;
/** Set a model override */
@@ -32,7 +32,7 @@ export interface UseModelOverrideResult {
*/
function normalizeEntry(entry: PhaseModelEntry | string): PhaseModelEntry {
if (typeof entry === 'string') {
return { model: entry as ModelAlias | CursorModelId };
return { model: entry as ModelId };
}
return entry;
}
@@ -40,9 +40,9 @@ function normalizeEntry(entry: PhaseModelEntry | string): PhaseModelEntry {
/**
* Extract model string from PhaseModelEntry or string
*/
function extractModel(entry: PhaseModelEntry | string): ModelAlias | CursorModelId {
function extractModel(entry: PhaseModelEntry | string): ModelId {
if (typeof entry === 'string') {
return entry as ModelAlias | CursorModelId;
return entry as ModelId;
}
return entry.model;
}

View File

@@ -137,6 +137,8 @@ export function Autocomplete({
width: Math.max(triggerWidth, 200),
}}
data-testid={testId ? `${testId}-list` : undefined}
onWheel={(e) => e.stopPropagation()}
onTouchMove={(e) => e.stopPropagation()}
>
<Command shouldFilter={false}>
<CommandInput

View File

@@ -78,7 +78,14 @@ function CommandList({ className, ...props }: React.ComponentProps<typeof Comman
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn('max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto', className)}
className={cn(
'max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto',
// Mobile touch scrolling support
'touch-pan-y overscroll-contain',
// iOS Safari momentum scrolling
'[&]:[-webkit-overflow-scrolling:touch]',
className
)}
{...props}
/>
);

View File

@@ -0,0 +1,73 @@
import * as React from 'react';
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
import { cn } from '@/lib/utils';
// Type-safe wrappers for Radix UI primitives (React 19 compatibility)
const ScrollAreaRootPrimitive = ScrollAreaPrimitive.Root as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> & {
children?: React.ReactNode;
className?: string;
} & React.RefAttributes<HTMLDivElement>
>;
const ScrollAreaViewportPrimitive = ScrollAreaPrimitive.Viewport as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Viewport> & {
children?: React.ReactNode;
className?: string;
} & React.RefAttributes<HTMLDivElement>
>;
const ScrollAreaScrollbarPrimitive =
ScrollAreaPrimitive.Scrollbar as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Scrollbar> & {
children?: React.ReactNode;
className?: string;
} & React.RefAttributes<HTMLDivElement>
>;
const ScrollAreaThumbPrimitive = ScrollAreaPrimitive.Thumb as React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Thumb> & {
className?: string;
} & React.RefAttributes<HTMLDivElement>
>;
const ScrollArea = React.forwardRef<
HTMLDivElement,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaRootPrimitive
ref={ref}
className={cn('relative overflow-hidden', className)}
{...props}
>
<ScrollAreaViewportPrimitive className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaViewportPrimitive>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaRootPrimitive>
));
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
const ScrollBar = React.forwardRef<
HTMLDivElement,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Scrollbar>
>(({ className, orientation = 'vertical', ...props }, ref) => (
<ScrollAreaScrollbarPrimitive
ref={ref}
orientation={orientation}
className={cn(
'flex touch-none select-none transition-colors',
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent p-[1px]',
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent p-[1px]',
className
)}
{...props}
>
<ScrollAreaThumbPrimitive className="relative flex-1 rounded-full bg-border" />
</ScrollAreaScrollbarPrimitive>
));
ScrollBar.displayName = ScrollAreaPrimitive.Scrollbar.displayName;
export { ScrollArea, ScrollBar };

View File

@@ -72,18 +72,17 @@ export function UsagePopover() {
const [codexError, setCodexError] = useState<UsageError | null>(null);
// Check authentication status
const isClaudeCliVerified =
claudeAuthStatus?.authenticated && claudeAuthStatus?.method === 'cli_authenticated';
const isClaudeAuthenticated = !!claudeAuthStatus?.authenticated;
const isCodexAuthenticated = codexAuthStatus?.authenticated;
// Determine which tab to show by default
useEffect(() => {
if (isClaudeCliVerified) {
if (isClaudeAuthenticated) {
setActiveTab('claude');
} else if (isCodexAuthenticated) {
setActiveTab('codex');
}
}, [isClaudeCliVerified, isCodexAuthenticated]);
}, [isClaudeAuthenticated, isCodexAuthenticated]);
// Check if data is stale (older than 2 minutes)
const isClaudeStale = useMemo(() => {
@@ -174,10 +173,10 @@ export function UsagePopover() {
// Auto-fetch on mount if data is stale
useEffect(() => {
if (isClaudeStale && isClaudeCliVerified) {
if (isClaudeStale && isClaudeAuthenticated) {
fetchClaudeUsage(true);
}
}, [isClaudeStale, isClaudeCliVerified, fetchClaudeUsage]);
}, [isClaudeStale, isClaudeAuthenticated, fetchClaudeUsage]);
useEffect(() => {
if (isCodexStale && isCodexAuthenticated) {
@@ -190,7 +189,7 @@ export function UsagePopover() {
if (!open) return;
// Fetch based on active tab
if (activeTab === 'claude' && isClaudeCliVerified) {
if (activeTab === 'claude' && isClaudeAuthenticated) {
if (!claudeUsage || isClaudeStale) {
fetchClaudeUsage();
}
@@ -214,7 +213,7 @@ export function UsagePopover() {
activeTab,
claudeUsage,
isClaudeStale,
isClaudeCliVerified,
isClaudeAuthenticated,
codexUsage,
isCodexStale,
isCodexAuthenticated,
@@ -349,7 +348,7 @@ export function UsagePopover() {
);
// Determine which tabs to show
const showClaudeTab = isClaudeCliVerified;
const showClaudeTab = isClaudeAuthenticated;
const showCodexTab = isCodexAuthenticated;
return (

View File

@@ -16,11 +16,32 @@ import {
import { NoProjectState, AgentHeader, ChatArea } from './agent-view/components';
import { AgentInputArea } from './agent-view/input-area';
/** Tailwind lg breakpoint in pixels */
const LG_BREAKPOINT = 1024;
export function AgentView() {
const { currentProject } = useAppStore();
const [input, setInput] = useState('');
const [currentTool, setCurrentTool] = useState<string | null>(null);
// Initialize session manager state - starts as true to match SSR
// Then updates on mount based on actual screen size to prevent hydration mismatch
const [showSessionManager, setShowSessionManager] = useState(true);
// Update session manager visibility based on screen size after mount and on resize
useEffect(() => {
const updateVisibility = () => {
const isDesktop = window.innerWidth >= LG_BREAKPOINT;
setShowSessionManager(isDesktop);
};
// Set initial value
updateVisibility();
// Listen for resize events
window.addEventListener('resize', updateVisibility);
return () => window.removeEventListener('resize', updateVisibility);
}, []);
const [modelSelection, setModelSelection] = useState<PhaseModelEntry>({ model: 'sonnet' });
// Input ref for auto-focus
@@ -119,6 +140,13 @@ export function AgentView() {
}
}, [currentSessionId]);
// Auto-close session manager on mobile when a session is selected
useEffect(() => {
if (currentSessionId && typeof window !== 'undefined' && window.innerWidth < 1024) {
setShowSessionManager(false);
}
}, [currentSessionId]);
// Show welcome message if no messages yet
const displayMessages =
messages.length === 0
@@ -139,9 +167,18 @@ export function AgentView() {
return (
<div className="flex-1 flex overflow-hidden bg-background" data-testid="agent-view">
{/* Mobile backdrop overlay for Session Manager */}
{showSessionManager && currentProject && (
<div
className="fixed inset-0 bg-black/50 z-20 lg:hidden"
onClick={() => setShowSessionManager(false)}
data-testid="session-manager-backdrop"
/>
)}
{/* Session Manager Sidebar */}
{showSessionManager && currentProject && (
<div className="w-80 border-r border-border shrink-0 bg-card/50">
<div className="fixed inset-y-0 left-0 w-72 z-30 lg:relative lg:w-80 lg:z-auto border-r border-border shrink-0 bg-card">
<SessionManager
currentSessionId={currentSessionId}
onSelectSession={handleSelectSession}

View File

@@ -79,7 +79,7 @@ export function InputControls({
{/* Text Input and Controls */}
<div
className={cn(
'flex gap-2 transition-all duration-200 rounded-xl p-1',
'flex flex-col gap-2 transition-all duration-200 rounded-xl p-1',
isDragOver && 'bg-primary/5 ring-2 ring-primary/30'
)}
onDragEnter={onDragEnter}
@@ -87,7 +87,8 @@ export function InputControls({
onDragOver={onDragOver}
onDrop={onDrop}
>
<div className="flex-1 relative">
{/* Textarea - full width on mobile */}
<div className="relative w-full">
<Textarea
ref={inputRef}
placeholder={
@@ -105,14 +106,14 @@ export function InputControls({
data-testid="agent-input"
rows={1}
className={cn(
'min-h-11 bg-background border-border rounded-xl pl-4 pr-20 text-sm transition-all resize-none max-h-36 overflow-y-auto py-2.5',
'min-h-11 w-full bg-background border-border rounded-xl pl-4 pr-4 sm:pr-20 text-sm transition-all resize-none max-h-36 overflow-y-auto py-2.5',
'focus:ring-2 focus:ring-primary/20 focus:border-primary/50',
hasFiles && 'border-primary/30',
isDragOver && 'border-primary bg-primary/5'
)}
/>
{hasFiles && !isDragOver && (
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-xs bg-primary text-primary-foreground px-2 py-0.5 rounded-full font-medium">
<div className="hidden sm:block absolute right-3 top-1/2 -translate-y-1/2 text-xs bg-primary text-primary-foreground px-2 py-0.5 rounded-full font-medium">
files attached
</div>
)}
@@ -124,58 +125,64 @@ export function InputControls({
)}
</div>
{/* Model Selector */}
<AgentModelSelector
value={modelSelection}
onChange={onModelSelect}
disabled={!isConnected}
/>
{/* File Attachment Button */}
<Button
variant="outline"
size="icon"
onClick={onToggleImageDropZone}
disabled={!isConnected}
className={cn(
'h-11 w-11 rounded-xl border-border',
showImageDropZone && 'bg-primary/10 text-primary border-primary/30',
hasFiles && 'border-primary/30 text-primary'
)}
title="Attach files (images, .txt, .md)"
>
<Paperclip className="w-4 h-4" />
</Button>
{/* Stop Button (only when processing) */}
{isProcessing && (
<Button
onClick={onStop}
{/* Controls row - responsive layout */}
<div className="flex items-center gap-2 flex-wrap">
{/* Model Selector */}
<AgentModelSelector
value={modelSelection}
onChange={onModelSelect}
disabled={!isConnected}
className="h-11 px-4 rounded-xl"
variant="destructive"
data-testid="stop-agent"
title="Stop generation"
>
<Square className="w-4 h-4 fill-current" />
</Button>
)}
/>
{/* Send / Queue Button */}
<Button
onClick={onSend}
disabled={!canSend}
className="h-11 px-4 rounded-xl"
variant={isProcessing ? 'outline' : 'default'}
data-testid="send-message"
title={isProcessing ? 'Add to queue' : 'Send message'}
>
{isProcessing ? <ListOrdered className="w-4 h-4" /> : <Send className="w-4 h-4" />}
</Button>
{/* File Attachment Button */}
<Button
variant="outline"
size="icon"
onClick={onToggleImageDropZone}
disabled={!isConnected}
className={cn(
'h-11 w-11 rounded-xl border-border shrink-0',
showImageDropZone && 'bg-primary/10 text-primary border-primary/30',
hasFiles && 'border-primary/30 text-primary'
)}
title="Attach files (images, .txt, .md)"
>
<Paperclip className="w-4 h-4" />
</Button>
{/* Spacer to push action buttons to the right */}
<div className="flex-1" />
{/* Stop Button (only when processing) */}
{isProcessing && (
<Button
onClick={onStop}
disabled={!isConnected}
className="h-11 px-4 rounded-xl shrink-0"
variant="destructive"
data-testid="stop-agent"
title="Stop generation"
>
<Square className="w-4 h-4 fill-current" />
</Button>
)}
{/* Send / Queue Button */}
<Button
onClick={onSend}
disabled={!canSend}
className="h-11 px-4 rounded-xl shrink-0"
variant={isProcessing ? 'outline' : 'default'}
data-testid="send-message"
title={isProcessing ? 'Add to queue' : 'Send message'}
>
{isProcessing ? <ListOrdered className="w-4 h-4" /> : <Send className="w-4 h-4" />}
</Button>
</div>
</div>
{/* Keyboard hint */}
<p className="text-[11px] text-muted-foreground mt-2 text-center">
<p className="text-[11px] text-muted-foreground mt-2 text-center hidden sm:block">
Press <kbd className="px-1.5 py-0.5 bg-muted rounded text-[10px] font-medium">Enter</kbd> to
send,{' '}
<kbd className="px-1.5 py-0.5 bg-muted rounded text-[10px] font-medium">Shift+Enter</kbd>{' '}

View File

@@ -60,7 +60,7 @@ import { CreatePRDialog } from './board-view/dialogs/create-pr-dialog';
import { CreateBranchDialog } from './board-view/dialogs/create-branch-dialog';
import { WorktreePanel } from './board-view/worktree-panel';
import type { PRInfo, WorktreeInfo } from './board-view/worktree-panel/types';
import { COLUMNS } from './board-view/constants';
import { COLUMNS, getColumnsWithPipeline } from './board-view/constants';
import {
useBoardFeatures,
useBoardDragDrop,
@@ -72,8 +72,9 @@ import {
useBoardPersistence,
useFollowUpState,
useSelectionMode,
useListViewState,
} from './board-view/hooks';
import { SelectionActionBar } from './board-view/components';
import { SelectionActionBar, ListView } from './board-view/components';
import { MassEditDialog } from './board-view/dialogs';
import { InitScriptIndicator } from './board-view/init-script-indicator';
import { useInitScriptEvents } from '@/hooks/use-init-script-events';
@@ -194,6 +195,9 @@ export function BoardView() {
} = useSelectionMode();
const [showMassEditDialog, setShowMassEditDialog] = useState(false);
// View mode state (kanban vs list)
const { viewMode, setViewMode, isListView, sortConfig, setSortColumn } = useListViewState();
// Search filter for Kanban cards
const [searchQuery, setSearchQuery] = useState('');
// Plan approval loading state
@@ -1119,6 +1123,19 @@ export function BoardView() {
projectPath: currentProject?.path || null,
});
// Build columnFeaturesMap for ListView
const pipelineConfig = currentProject?.path
? pipelineConfigByProject[currentProject.path] || null
: null;
const columnFeaturesMap = useMemo(() => {
const columns = getColumnsWithPipeline(pipelineConfig);
const map: Record<string, typeof hookFeatures> = {};
for (const column of columns) {
map[column.id] = getColumnFeatures(column.id as any);
}
return map;
}, [pipelineConfig, getColumnFeatures]);
// Use background hook
const { backgroundSettings, backgroundImageStyle } = useBoardBackground({
currentProject,
@@ -1306,6 +1323,8 @@ export function BoardView() {
onShowBoardBackground={() => setShowBoardBackgroundModal(true)}
onShowCompletedModal={() => setShowCompletedModal(true)}
completedCount={completedFeatures.length}
viewMode={viewMode}
onViewModeChange={setViewMode}
/>
{/* Worktree Panel - conditionally rendered based on visibility setting */}
@@ -1344,48 +1363,89 @@ export function BoardView() {
{/* Main Content Area */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* View Content - Kanban Board */}
<KanbanBoard
sensors={sensors}
collisionDetectionStrategy={collisionDetectionStrategy}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
activeFeature={activeFeature}
getColumnFeatures={getColumnFeatures}
backgroundImageStyle={backgroundImageStyle}
backgroundSettings={backgroundSettings}
onEdit={(feature) => setEditingFeature(feature)}
onDelete={(featureId) => handleDeleteFeature(featureId)}
onViewOutput={handleViewOutput}
onVerify={handleVerifyFeature}
onResume={handleResumeFeature}
onForceStop={handleForceStopFeature}
onManualVerify={handleManualVerify}
onMoveBackToInProgress={handleMoveBackToInProgress}
onFollowUp={handleOpenFollowUp}
onComplete={handleCompleteFeature}
onImplement={handleStartImplementation}
onViewPlan={(feature) => setViewPlanFeature(feature)}
onApprovePlan={handleOpenApprovalDialog}
onSpawnTask={(feature) => {
setSpawnParentFeature(feature);
setShowAddDialog(true);
}}
featuresWithContext={featuresWithContext}
runningAutoTasks={runningAutoTasks}
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
onAddFeature={() => setShowAddDialog(true)}
pipelineConfig={
currentProject?.path ? pipelineConfigByProject[currentProject.path] || null : null
}
onOpenPipelineSettings={() => setShowPipelineSettings(true)}
isSelectionMode={isSelectionMode}
selectedFeatureIds={selectedFeatureIds}
onToggleFeatureSelection={toggleFeatureSelection}
onToggleSelectionMode={toggleSelectionMode}
isDragging={activeFeature !== null}
onAiSuggest={() => setShowPlanDialog(true)}
/>
{/* View Content - Kanban Board or List View */}
{isListView ? (
<ListView
columnFeaturesMap={columnFeaturesMap}
allFeatures={hookFeatures}
sortConfig={sortConfig}
onSortChange={setSortColumn}
actionHandlers={{
onEdit: (feature) => setEditingFeature(feature),
onDelete: (featureId) => handleDeleteFeature(featureId),
onViewOutput: handleViewOutput,
onVerify: handleVerifyFeature,
onResume: handleResumeFeature,
onForceStop: handleForceStopFeature,
onManualVerify: handleManualVerify,
onFollowUp: handleOpenFollowUp,
onImplement: handleStartImplementation,
onComplete: handleCompleteFeature,
onViewPlan: (feature) => setViewPlanFeature(feature),
onApprovePlan: handleOpenApprovalDialog,
onSpawnTask: (feature) => {
setSpawnParentFeature(feature);
setShowAddDialog(true);
},
}}
runningAutoTasks={runningAutoTasks}
pipelineConfig={pipelineConfig}
onAddFeature={() => setShowAddDialog(true)}
isSelectionMode={isSelectionMode}
selectedFeatureIds={selectedFeatureIds}
onToggleFeatureSelection={toggleFeatureSelection}
onRowClick={(feature) => {
if (feature.status === 'backlog') {
setEditingFeature(feature);
} else {
handleViewOutput(feature);
}
}}
className="transition-opacity duration-200"
/>
) : (
<KanbanBoard
sensors={sensors}
collisionDetectionStrategy={collisionDetectionStrategy}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
activeFeature={activeFeature}
getColumnFeatures={getColumnFeatures}
backgroundImageStyle={backgroundImageStyle}
backgroundSettings={backgroundSettings}
onEdit={(feature) => setEditingFeature(feature)}
onDelete={(featureId) => handleDeleteFeature(featureId)}
onViewOutput={handleViewOutput}
onVerify={handleVerifyFeature}
onResume={handleResumeFeature}
onForceStop={handleForceStopFeature}
onManualVerify={handleManualVerify}
onMoveBackToInProgress={handleMoveBackToInProgress}
onFollowUp={handleOpenFollowUp}
onComplete={handleCompleteFeature}
onImplement={handleStartImplementation}
onViewPlan={(feature) => setViewPlanFeature(feature)}
onApprovePlan={handleOpenApprovalDialog}
onSpawnTask={(feature) => {
setSpawnParentFeature(feature);
setShowAddDialog(true);
}}
featuresWithContext={featuresWithContext}
runningAutoTasks={runningAutoTasks}
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
onAddFeature={() => setShowAddDialog(true)}
pipelineConfig={pipelineConfig}
onOpenPipelineSettings={() => setShowPipelineSettings(true)}
isSelectionMode={isSelectionMode}
selectedFeatureIds={selectedFeatureIds}
onToggleFeatureSelection={toggleFeatureSelection}
onToggleSelectionMode={toggleSelectionMode}
viewMode={viewMode}
isDragging={activeFeature !== null}
onAiSuggest={() => setShowPlanDialog(true)}
className="transition-opacity duration-200"
/>
)}
</div>
{/* Selection Action Bar */}
@@ -1507,7 +1567,7 @@ export function BoardView() {
open={showPipelineSettings}
onClose={() => setShowPipelineSettings(false)}
projectPath={currentProject.path}
pipelineConfig={pipelineConfigByProject[currentProject.path] || null}
pipelineConfig={pipelineConfig}
onSave={async (config) => {
const api = getHttpApiClient();
const result = await api.pipeline.saveConfig(currentProject.path, config);

View File

@@ -19,7 +19,7 @@ export function BoardControls({
return (
<TooltipProvider>
<div className="flex items-center gap-2">
<div className="flex items-center gap-5">
{/* Board Background Button */}
<Tooltip>
<TooltipTrigger asChild>

View File

@@ -8,12 +8,17 @@ import { Bot, Wand2, Settings2, GitBranch } 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 { AutoModeSettingsDialog } from './dialogs/auto-mode-settings-dialog';
import { WorktreeSettingsDialog } from './dialogs/worktree-settings-dialog';
import { PlanSettingsDialog } from './dialogs/plan-settings-dialog';
import { getHttpApiClient } from '@/lib/http-api-client';
import { BoardSearchBar } from './board-search-bar';
import { BoardControls } from './board-controls';
import { ViewToggle, type ViewMode } from './components';
import { HeaderMobileMenu } from './header-mobile-menu';
export type { ViewMode };
interface BoardHeaderProps {
projectPath: string;
@@ -33,6 +38,9 @@ interface BoardHeaderProps {
onShowBoardBackground: () => void;
onShowCompletedModal: () => void;
completedCount: number;
// View toggle props
viewMode: ViewMode;
onViewModeChange: (mode: ViewMode) => void;
}
// Shared styles for header control containers
@@ -55,11 +63,12 @@ export function BoardHeader({
onShowBoardBackground,
onShowCompletedModal,
completedCount,
viewMode,
onViewModeChange,
}: BoardHeaderProps) {
const [showAutoModeSettings, setShowAutoModeSettings] = useState(false);
const [showWorktreeSettings, setShowWorktreeSettings] = useState(false);
const [showPlanSettings, setShowPlanSettings] = useState(false);
const apiKeys = useAppStore((state) => state.apiKeys);
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
const skipVerificationInAutoMode = useAppStore((state) => state.skipVerificationInAutoMode);
const setSkipVerificationInAutoMode = useAppStore((state) => state.setSkipVerificationInAutoMode);
@@ -98,22 +107,17 @@ export function BoardHeader({
[projectPath, setWorktreePanelVisible]
);
// Claude usage tracking visibility logic
// Hide when using API key (only show for Claude Code CLI users)
// Also hide on Windows for now (CLI usage command not supported)
const isWindows =
typeof navigator !== 'undefined' && navigator.platform?.toLowerCase().includes('win');
const hasClaudeApiKey = !!apiKeys.anthropic || !!claudeAuthStatus?.hasEnvApiKey;
const isClaudeCliVerified =
claudeAuthStatus?.authenticated && claudeAuthStatus?.method === 'cli_authenticated';
const showClaudeUsage = !hasClaudeApiKey && !isWindows && isClaudeCliVerified;
const isClaudeCliVerified = !!claudeAuthStatus?.authenticated;
const showClaudeUsage = isClaudeCliVerified;
// Codex usage tracking visibility logic
// Show if Codex is authenticated (CLI or API key)
const showCodexUsage = !!codexAuthStatus?.authenticated;
const isMobile = useIsMobile();
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 gap-5 p-4 border-b border-border bg-glass backdrop-blur-md">
<div className="flex items-center gap-4">
<BoardSearchBar
searchQuery={searchQuery}
@@ -122,6 +126,7 @@ export function BoardHeader({
creatingSpecProjectPath={creatingSpecProjectPath}
currentProjectPath={projectPath}
/>
{isMounted && <ViewToggle viewMode={viewMode} onViewModeChange={onViewModeChange} />}
<BoardControls
isMounted={isMounted}
onShowBoardBackground={onShowBoardBackground}
@@ -129,12 +134,30 @@ export function BoardHeader({
completedCount={completedCount}
/>
</div>
<div className="flex gap-2 items-center">
{/* Usage Popover - show if either provider is authenticated */}
{isMounted && (showClaudeUsage || showCodexUsage) && <UsagePopover />}
<div className="flex gap-4 items-center">
{/* Usage Popover - show if either provider is authenticated, only on desktop */}
{isMounted && !isMobile && (showClaudeUsage || showCodexUsage) && <UsagePopover />}
{/* Mobile view: show hamburger menu with all controls */}
{isMounted && isMobile && (
<HeaderMobileMenu
isWorktreePanelVisible={isWorktreePanelVisible}
onWorktreePanelToggle={handleWorktreePanelToggle}
maxConcurrency={maxConcurrency}
runningAgentsCount={runningAgentsCount}
onConcurrencyChange={onConcurrencyChange}
isAutoModeRunning={isAutoModeRunning}
onAutoModeToggle={onAutoModeToggle}
onOpenAutoModeSettings={() => setShowAutoModeSettings(true)}
onOpenPlanDialog={onOpenPlanDialog}
showClaudeUsage={showClaudeUsage}
showCodexUsage={showCodexUsage}
/>
)}
{/* Desktop view: show full controls */}
{/* Worktrees Toggle - only show after mount to prevent hydration issues */}
{isMounted && (
{isMounted && !isMobile && (
<div className={controlContainerClass} data-testid="worktrees-toggle-container">
<GitBranch className="w-4 h-4 text-muted-foreground" />
<Label htmlFor="worktrees-toggle" className="text-sm font-medium cursor-pointer">
@@ -166,7 +189,7 @@ export function BoardHeader({
/>
{/* Concurrency Control - only show after mount to prevent hydration issues */}
{isMounted && (
{isMounted && !isMobile && (
<Popover>
<PopoverTrigger asChild>
<button
@@ -209,7 +232,7 @@ export function BoardHeader({
)}
{/* Auto Mode Toggle - only show after mount to prevent hydration issues */}
{isMounted && (
{isMounted && !isMobile && (
<div className={controlContainerClass} data-testid="auto-mode-toggle-container">
<Label htmlFor="auto-mode-toggle" className="text-sm font-medium cursor-pointer">
Auto Mode
@@ -239,25 +262,27 @@ export function BoardHeader({
onSkipVerificationChange={setSkipVerificationInAutoMode}
/>
{/* Plan Button with Settings */}
<div className={controlContainerClass} data-testid="plan-button-container">
<button
onClick={onOpenPlanDialog}
className="flex items-center gap-1.5 hover:text-foreground transition-colors"
data-testid="plan-backlog-button"
>
<Wand2 className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium">Plan</span>
</button>
<button
onClick={() => setShowPlanSettings(true)}
className="p-1 rounded hover:bg-accent/50 transition-colors"
title="Plan Settings"
data-testid="plan-settings-button"
>
<Settings2 className="w-4 h-4 text-muted-foreground" />
</button>
</div>
{/* Plan Button with Settings - only show on desktop, mobile has it in the menu */}
{isMounted && !isMobile && (
<div className={controlContainerClass} data-testid="plan-button-container">
<button
onClick={onOpenPlanDialog}
className="flex items-center gap-1.5 hover:text-foreground transition-colors"
data-testid="plan-backlog-button"
>
<Wand2 className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium">Plan</span>
</button>
<button
onClick={() => setShowPlanSettings(true)}
className="p-1 rounded hover:bg-accent/50 transition-colors"
title="Plan Settings"
data-testid="plan-settings-button"
>
<Settings2 className="w-4 h-4 text-muted-foreground" />
</button>
</div>
)}
{/* Plan Settings Dialog */}
<PlanSettingsDialog

View File

@@ -2,3 +2,33 @@ export { KanbanCard } from './kanban-card/kanban-card';
export { KanbanColumn } from './kanban-column';
export { SelectionActionBar } from './selection-action-bar';
export { EmptyStateCard } from './empty-state-card';
export { ViewToggle, type ViewMode } from './view-toggle';
// List view components
export {
ListHeader,
LIST_COLUMNS,
getColumnById,
getColumnWidth,
getColumnAlign,
ListRow,
getFeatureSortValue,
sortFeatures,
ListView,
getFlatFeatures,
getTotalFeatureCount,
RowActions,
createRowActionHandlers,
StatusBadge,
getStatusLabel,
getStatusOrder,
} from './list-view';
export type {
ListHeaderProps,
ListRowProps,
ListViewProps,
ListViewActionHandlers,
RowActionsProps,
RowActionHandlers,
StatusBadgeProps,
} from './list-view';

View File

@@ -0,0 +1,20 @@
export {
ListHeader,
LIST_COLUMNS,
getColumnById,
getColumnWidth,
getColumnAlign,
} from './list-header';
export type { ListHeaderProps } from './list-header';
export { ListRow, getFeatureSortValue, sortFeatures } from './list-row';
export type { ListRowProps } from './list-row';
export { ListView, getFlatFeatures, getTotalFeatureCount } from './list-view';
export type { ListViewProps, ListViewActionHandlers } from './list-view';
export { RowActions, createRowActionHandlers } from './row-actions';
export type { RowActionsProps, RowActionHandlers } from './row-actions';
export { StatusBadge, getStatusLabel, getStatusOrder } from './status-badge';
export type { StatusBadgeProps } from './status-badge';

View File

@@ -0,0 +1,284 @@
import { memo, useCallback } from 'react';
import { ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { SortColumn, SortConfig, SortDirection } from '../../hooks/use-list-view-state';
/**
* Column definition for the list header
*/
interface ColumnDef {
id: SortColumn;
label: string;
/** Whether this column is sortable */
sortable?: boolean;
/** Minimum width for the column */
minWidth?: string;
/** Width class for the column */
width?: string;
/** Alignment of the column content */
align?: 'left' | 'center' | 'right';
/** Additional className for the column */
className?: string;
}
/**
* Default column definitions for the list view
* Only showing title column with full width for a cleaner, more spacious layout
*/
export const LIST_COLUMNS: ColumnDef[] = [
{
id: 'title',
label: 'Title',
sortable: true,
width: 'flex-1',
minWidth: 'min-w-0',
align: 'left',
},
];
export interface ListHeaderProps {
/** Current sort configuration */
sortConfig: SortConfig;
/** Callback when a sortable column is clicked */
onSortChange: (column: SortColumn) => void;
/** Whether to show a checkbox column for selection */
showCheckbox?: boolean;
/** Whether all items are selected (for checkbox state) */
allSelected?: boolean;
/** Whether some but not all items are selected */
someSelected?: boolean;
/** Callback when the select all checkbox is clicked */
onSelectAll?: () => void;
/** Custom column definitions (defaults to LIST_COLUMNS) */
columns?: ColumnDef[];
/** Additional className for the header */
className?: string;
}
/**
* SortIcon displays the current sort state for a column
*/
function SortIcon({ column, sortConfig }: { column: SortColumn; sortConfig: SortConfig }) {
if (sortConfig.column !== column) {
// Not sorted by this column - show neutral indicator
return (
<ChevronsUpDown className="w-3.5 h-3.5 text-muted-foreground/50 group-hover:text-muted-foreground transition-colors" />
);
}
// Currently sorted by this column
if (sortConfig.direction === 'asc') {
return <ChevronUp className="w-3.5 h-3.5 text-foreground" />;
}
return <ChevronDown className="w-3.5 h-3.5 text-foreground" />;
}
/**
* SortableColumnHeader renders a clickable header cell that triggers sorting
*/
const SortableColumnHeader = memo(function SortableColumnHeader({
column,
sortConfig,
onSortChange,
}: {
column: ColumnDef;
sortConfig: SortConfig;
onSortChange: (column: SortColumn) => void;
}) {
const handleClick = useCallback(() => {
onSortChange(column.id);
}, [column.id, onSortChange]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSortChange(column.id);
}
},
[column.id, onSortChange]
);
const isSorted = sortConfig.column === column.id;
const sortDirection: SortDirection | undefined = isSorted ? sortConfig.direction : undefined;
return (
<div
role="columnheader"
aria-sort={isSorted ? (sortDirection === 'asc' ? 'ascending' : 'descending') : 'none'}
tabIndex={0}
onClick={handleClick}
onKeyDown={handleKeyDown}
className={cn(
'group flex items-center gap-1.5 px-3 py-2 text-xs font-medium text-muted-foreground',
'cursor-pointer select-none transition-colors duration-200',
'hover:text-foreground hover:bg-accent/50 rounded-md',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1',
column.width,
column.minWidth,
column.align === 'center' && 'justify-center',
column.align === 'right' && 'justify-end',
isSorted && 'text-foreground',
column.className
)}
data-testid={`list-header-${column.id}`}
>
<span>{column.label}</span>
<SortIcon column={column.id} sortConfig={sortConfig} />
</div>
);
});
/**
* StaticColumnHeader renders a non-sortable header cell
*/
const StaticColumnHeader = memo(function StaticColumnHeader({ column }: { column: ColumnDef }) {
return (
<div
role="columnheader"
className={cn(
'flex items-center px-3 py-2 text-xs font-medium text-muted-foreground',
column.width,
column.minWidth,
column.align === 'center' && 'justify-center',
column.align === 'right' && 'justify-end',
column.className
)}
data-testid={`list-header-${column.id}`}
>
<span>{column.label}</span>
</div>
);
});
/**
* ListHeader displays the header row for the list view table with sortable columns.
*
* Features:
* - Clickable column headers for sorting
* - Visual sort direction indicators (chevron up/down)
* - Keyboard accessible (Tab + Enter/Space to sort)
* - ARIA attributes for screen readers
* - Optional checkbox column for bulk selection
* - Customizable column definitions
*
* @example
* ```tsx
* const { sortConfig, setSortColumn } = useListViewState();
*
* <ListHeader
* sortConfig={sortConfig}
* onSortChange={setSortColumn}
* />
* ```
*
* @example
* ```tsx
* // With selection support
* <ListHeader
* sortConfig={sortConfig}
* onSortChange={setSortColumn}
* showCheckbox
* allSelected={allSelected}
* someSelected={someSelected}
* onSelectAll={handleSelectAll}
* />
* ```
*/
export const ListHeader = memo(function ListHeader({
sortConfig,
onSortChange,
showCheckbox = false,
allSelected = false,
someSelected = false,
onSelectAll,
columns = LIST_COLUMNS,
className,
}: ListHeaderProps) {
return (
<div
role="row"
className={cn(
'flex items-center w-full border-b border-border bg-muted/30',
'sticky top-0 z-10 backdrop-blur-sm',
className
)}
data-testid="list-header"
>
{/* Checkbox column for selection */}
{showCheckbox && (
<div
role="columnheader"
className="flex items-center justify-center w-10 px-2 py-2 shrink-0"
>
<input
type="checkbox"
checked={allSelected}
ref={(el) => {
if (el) {
el.indeterminate = someSelected && !allSelected;
}
}}
onChange={onSelectAll}
className={cn(
'h-4 w-4 rounded border-border text-primary cursor-pointer',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1'
)}
aria-label={allSelected ? 'Deselect all' : 'Select all'}
data-testid="list-header-select-all"
/>
</div>
)}
{/* Column headers */}
{columns.map((column) =>
column.sortable !== false ? (
<SortableColumnHeader
key={column.id}
column={column}
sortConfig={sortConfig}
onSortChange={onSortChange}
/>
) : (
<StaticColumnHeader key={column.id} column={column} />
)
)}
{/* Actions column (placeholder for row action buttons) */}
<div
role="columnheader"
className="w-[80px] px-3 py-2 text-xs font-medium text-muted-foreground shrink-0"
aria-label="Actions"
data-testid="list-header-actions"
>
<span className="sr-only">Actions</span>
</div>
</div>
);
});
/**
* Helper function to get a column definition by ID
*/
export function getColumnById(columnId: SortColumn): ColumnDef | undefined {
return LIST_COLUMNS.find((col) => col.id === columnId);
}
/**
* Helper function to get column width class for consistent styling in rows
*/
export function getColumnWidth(columnId: SortColumn): string {
const column = getColumnById(columnId);
return cn(column?.width, column?.minWidth);
}
/**
* Helper function to get column alignment class
*/
export function getColumnAlign(columnId: SortColumn): string {
const column = getColumnById(columnId);
if (column?.align === 'center') return 'justify-center text-center';
if (column?.align === 'right') return 'justify-end text-right';
return '';
}

View File

@@ -0,0 +1,382 @@
// TODO: Remove @ts-nocheck after fixing BaseFeature's index signature issue
// The `[key: string]: unknown` in BaseFeature causes property access type errors
// @ts-nocheck
import { memo, useCallback, useState, useEffect } from 'react';
import { cn } from '@/lib/utils';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { AlertCircle, Lock, Hand, Sparkles, FileText } from 'lucide-react';
import type { Feature } from '@/store/app-store';
import { RowActions, type RowActionHandlers } from './row-actions';
import { getColumnWidth, getColumnAlign } from './list-header';
export interface ListRowProps {
/** The feature to display */
feature: Feature;
/** Action handlers for the row */
handlers: RowActionHandlers;
/** Whether this feature is the current auto task (agent is running) */
isCurrentAutoTask?: boolean;
/** Whether the row is selected */
isSelected?: boolean;
/** Whether to show the checkbox for selection */
showCheckbox?: boolean;
/** Callback when the row selection is toggled */
onToggleSelect?: () => void;
/** Callback when the row is clicked */
onClick?: () => void;
/** Blocking dependency feature IDs */
blockingDependencies?: string[];
/** Additional className for custom styling */
className?: string;
}
/**
* IndicatorBadges shows small indicator icons for special states (error, blocked, manual verification, just finished)
*/
const IndicatorBadges = memo(function IndicatorBadges({
feature,
blockingDependencies = [],
isCurrentAutoTask,
}: {
feature: Feature;
blockingDependencies?: string[];
isCurrentAutoTask?: boolean;
}) {
const hasError = feature.error && !isCurrentAutoTask;
const isBlocked =
blockingDependencies.length > 0 && !feature.error && feature.status === 'backlog';
const showManualVerification =
feature.skipTests && !feature.error && feature.status === 'backlog';
const hasPlan = feature.planSpec?.content;
// Check if just finished (within 2 minutes) - uses timer to auto-expire
const [isJustFinished, setIsJustFinished] = useState(false);
useEffect(() => {
if (!feature.justFinishedAt || feature.status !== 'waiting_approval' || feature.error) {
setIsJustFinished(false);
return;
}
const finishedTime = new Date(feature.justFinishedAt).getTime();
const twoMinutes = 2 * 60 * 1000;
const elapsed = Date.now() - finishedTime;
if (elapsed >= twoMinutes) {
setIsJustFinished(false);
return;
}
// Set as just finished
setIsJustFinished(true);
// Set a timeout to clear the "just finished" state when 2 minutes have passed
const remainingTime = twoMinutes - elapsed;
const timeoutId = setTimeout(() => {
setIsJustFinished(false);
}, remainingTime);
return () => clearTimeout(timeoutId);
}, [feature.justFinishedAt, feature.status, feature.error]);
const badges: Array<{
key: string;
icon: typeof AlertCircle;
tooltip: string;
colorClass: string;
bgClass: string;
borderClass: string;
animate?: boolean;
}> = [];
if (hasError) {
badges.push({
key: 'error',
icon: AlertCircle,
tooltip: feature.error || 'Error',
colorClass: 'text-[var(--status-error)]',
bgClass: 'bg-[var(--status-error)]/15',
borderClass: 'border-[var(--status-error)]/30',
});
}
if (isBlocked) {
badges.push({
key: 'blocked',
icon: Lock,
tooltip: `Blocked by ${blockingDependencies.length} incomplete ${blockingDependencies.length === 1 ? 'dependency' : 'dependencies'}`,
colorClass: 'text-orange-500',
bgClass: 'bg-orange-500/15',
borderClass: 'border-orange-500/30',
});
}
if (showManualVerification) {
badges.push({
key: 'manual',
icon: Hand,
tooltip: 'Manual verification required',
colorClass: 'text-[var(--status-warning)]',
bgClass: 'bg-[var(--status-warning)]/15',
borderClass: 'border-[var(--status-warning)]/30',
});
}
if (hasPlan) {
badges.push({
key: 'plan',
icon: FileText,
tooltip: 'Has implementation plan',
colorClass: 'text-[var(--status-info)]',
bgClass: 'bg-[var(--status-info)]/15',
borderClass: 'border-[var(--status-info)]/30',
});
}
if (isJustFinished) {
badges.push({
key: 'just-finished',
icon: Sparkles,
tooltip: 'Agent just finished working on this feature',
colorClass: 'text-[var(--status-success)]',
bgClass: 'bg-[var(--status-success)]/15',
borderClass: 'border-[var(--status-success)]/30',
animate: true,
});
}
if (badges.length === 0) return null;
return (
<div className="flex items-center gap-1 ml-2">
<TooltipProvider delayDuration={200}>
{badges.map((badge) => (
<Tooltip key={badge.key}>
<TooltipTrigger asChild>
<div
className={cn(
'inline-flex items-center justify-center w-5 h-5 rounded border',
badge.colorClass,
badge.bgClass,
badge.borderClass,
badge.animate && 'animate-pulse'
)}
data-testid={`list-row-badge-${badge.key}`}
>
<badge.icon className="w-3 h-3" />
</div>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs max-w-[250px]">
<p>{badge.tooltip}</p>
</TooltipContent>
</Tooltip>
))}
</TooltipProvider>
</div>
);
});
/**
* ListRow displays a single feature row in the list view table.
*
* Features:
* - Displays feature data in columns matching ListHeader
* - Hover state with highlight and action buttons
* - Click handler for opening feature details
* - Animated border for currently running auto task
* - Status badge with appropriate colors
* - Priority indicator
* - Indicator badges for errors, blocked state, manual verification, etc.
* - Selection checkbox for bulk operations
*
* @example
* ```tsx
* <ListRow
* feature={feature}
* handlers={{
* onEdit: () => handleEdit(feature.id),
* onDelete: () => handleDelete(feature.id),
* // ... other handlers
* }}
* onClick={() => handleViewDetails(feature)}
* />
* ```
*/
export const ListRow = memo(function ListRow({
feature,
handlers,
isCurrentAutoTask = false,
isSelected = false,
showCheckbox = false,
onToggleSelect,
onClick,
blockingDependencies = [],
className,
}: ListRowProps) {
const handleRowClick = useCallback(
(e: React.MouseEvent) => {
// Don't trigger row click if clicking on checkbox or actions
if ((e.target as HTMLElement).closest('[data-testid^="row-actions"]')) {
return;
}
if ((e.target as HTMLElement).closest('input[type="checkbox"]')) {
return;
}
onClick?.();
},
[onClick]
);
const handleCheckboxChange = useCallback(() => {
onToggleSelect?.();
}, [onToggleSelect]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick?.();
}
},
[onClick]
);
const hasError = feature.error && !isCurrentAutoTask;
const rowContent = (
<div
role="row"
tabIndex={onClick ? 0 : undefined}
onClick={handleRowClick}
onKeyDown={onClick ? handleKeyDown : undefined}
className={cn(
'group flex items-center w-full border-b border-border/50',
'transition-colors duration-200',
onClick && 'cursor-pointer',
'hover:bg-accent/50',
isSelected && 'bg-accent/70',
hasError && 'bg-[var(--status-error)]/5 hover:bg-[var(--status-error)]/10',
className
)}
data-testid={`list-row-${feature.id}`}
>
{/* Checkbox column */}
{showCheckbox && (
<div role="cell" className="flex items-center justify-center w-10 px-2 py-3 shrink-0">
<input
type="checkbox"
checked={isSelected}
onChange={handleCheckboxChange}
className={cn(
'h-4 w-4 rounded border-border text-primary cursor-pointer',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1'
)}
aria-label={`Select ${feature.title || feature.description}`}
data-testid={`list-row-checkbox-${feature.id}`}
/>
</div>
)}
{/* Title column - full width with margin for actions */}
<div
role="cell"
className={cn(
'flex items-center px-3 py-3 gap-2',
getColumnWidth('title'),
getColumnAlign('title')
)}
>
<div className="flex-1 min-w-0">
<div className="flex items-center">
<span
className={cn(
'font-medium truncate',
feature.titleGenerating && 'animate-pulse text-muted-foreground'
)}
title={feature.title || feature.description}
>
{feature.title || feature.description}
</span>
<IndicatorBadges
feature={feature}
blockingDependencies={blockingDependencies}
isCurrentAutoTask={isCurrentAutoTask}
/>
</div>
{/* Show description as subtitle if title exists and is different */}
{feature.title && feature.title !== feature.description && (
<p
className="text-xs text-muted-foreground truncate mt-0.5"
title={feature.description}
>
{feature.description}
</p>
)}
</div>
</div>
{/* Actions column */}
<div role="cell" className="flex items-center justify-end px-3 py-3 w-[80px] shrink-0">
<RowActions feature={feature} handlers={handlers} isCurrentAutoTask={isCurrentAutoTask} />
</div>
</div>
);
// Wrap with animated border for currently running auto task
if (isCurrentAutoTask) {
return <div className="animated-border-wrapper-row">{rowContent}</div>;
}
return rowContent;
});
/**
* Helper function to get feature sort value for a column
*/
export function getFeatureSortValue(
feature: Feature,
column: 'title' | 'status' | 'category' | 'priority' | 'createdAt' | 'updatedAt'
): string | number | Date {
switch (column) {
case 'title':
return (feature.title || feature.description).toLowerCase();
case 'status':
return feature.status;
case 'category':
return (feature.category || '').toLowerCase();
case 'priority':
return feature.priority || 999; // No priority sorts last
case 'createdAt':
return feature.createdAt ? new Date(feature.createdAt) : new Date(0);
case 'updatedAt':
return feature.updatedAt ? new Date(feature.updatedAt) : new Date(0);
default:
return '';
}
}
/**
* Helper function to sort features by a column
*/
export function sortFeatures(
features: Feature[],
column: 'title' | 'status' | 'category' | 'priority' | 'createdAt' | 'updatedAt',
direction: 'asc' | 'desc'
): Feature[] {
return [...features].sort((a, b) => {
const aValue = getFeatureSortValue(a, column);
const bValue = getFeatureSortValue(b, column);
let comparison = 0;
if (aValue instanceof Date && bValue instanceof Date) {
comparison = aValue.getTime() - bValue.getTime();
} else if (typeof aValue === 'number' && typeof bValue === 'number') {
comparison = aValue - bValue;
} else {
comparison = String(aValue).localeCompare(String(bValue));
}
return direction === 'asc' ? comparison : -comparison;
});
}

View File

@@ -0,0 +1,460 @@
import { memo, useMemo, useCallback, useState } from 'react';
import { ChevronDown, ChevronRight, Plus } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { getBlockingDependencies } from '@automaker/dependency-resolver';
import type { Feature } from '@/store/app-store';
import type { PipelineConfig, FeatureStatusWithPipeline } from '@automaker/types';
import { ListHeader } from './list-header';
import { ListRow, sortFeatures } from './list-row';
import { createRowActionHandlers, type RowActionHandlers } from './row-actions';
import { getStatusLabel, getStatusOrder } from './status-badge';
import { getColumnsWithPipeline } from '../../constants';
import type { SortConfig, SortColumn } from '../../hooks/use-list-view-state';
/** Empty set constant to avoid creating new instances on each render */
const EMPTY_SET = new Set<string>();
/**
* Status group configuration for the list view
*/
interface StatusGroup {
id: FeatureStatusWithPipeline;
title: string;
colorClass: string;
features: Feature[];
}
/**
* Props for action handlers passed from the parent board view
*/
export interface ListViewActionHandlers {
onEdit: (feature: Feature) => void;
onDelete: (featureId: string) => void;
onViewOutput?: (feature: Feature) => void;
onVerify?: (feature: Feature) => void;
onResume?: (feature: Feature) => void;
onForceStop?: (feature: Feature) => void;
onManualVerify?: (feature: Feature) => void;
onFollowUp?: (feature: Feature) => void;
onImplement?: (feature: Feature) => void;
onComplete?: (feature: Feature) => void;
onViewPlan?: (feature: Feature) => void;
onApprovePlan?: (feature: Feature) => void;
onSpawnTask?: (feature: Feature) => void;
}
export interface ListViewProps {
/** Map of column/status ID to features in that column */
columnFeaturesMap: Record<string, Feature[]>;
/** All features (for dependency checking) */
allFeatures: Feature[];
/** Current sort configuration */
sortConfig: SortConfig;
/** Callback when sort column is changed */
onSortChange: (column: SortColumn) => void;
/** Action handlers for rows */
actionHandlers: ListViewActionHandlers;
/** Set of feature IDs that are currently running */
runningAutoTasks: string[];
/** Pipeline configuration for custom statuses */
pipelineConfig?: PipelineConfig | null;
/** Callback to add a new feature */
onAddFeature?: () => void;
/** Whether selection mode is enabled */
isSelectionMode?: boolean;
/** Set of selected feature IDs */
selectedFeatureIds?: Set<string>;
/** Callback when a feature's selection is toggled */
onToggleFeatureSelection?: (featureId: string) => void;
/** Callback when the row is clicked */
onRowClick?: (feature: Feature) => void;
/** Additional className for custom styling */
className?: string;
}
/**
* StatusGroupHeader displays the header for a status group with collapse toggle
*/
const StatusGroupHeader = memo(function StatusGroupHeader({
group,
isExpanded,
onToggle,
}: {
group: StatusGroup;
isExpanded: boolean;
onToggle: () => void;
}) {
return (
<button
type="button"
onClick={onToggle}
className={cn(
'flex items-center gap-2 w-full px-3 py-2 text-left',
'bg-muted/50 hover:bg-muted/70 transition-colors duration-200',
'border-b border-border/50',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset'
)}
aria-expanded={isExpanded}
data-testid={`list-group-header-${group.id}`}
>
{/* Collapse indicator */}
<span className="text-muted-foreground">
{isExpanded ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
</span>
{/* Status color indicator */}
<span
className={cn('w-2.5 h-2.5 rounded-full shrink-0', group.colorClass)}
aria-hidden="true"
/>
{/* Group title */}
<span className="font-medium text-sm">{group.title}</span>
{/* Feature count */}
<span className="text-xs text-muted-foreground">({group.features.length})</span>
</button>
);
});
/**
* EmptyState displays a message when there are no features
*/
const EmptyState = memo(function EmptyState({ onAddFeature }: { onAddFeature?: () => void }) {
return (
<div
className={cn(
'flex flex-col items-center justify-center py-16 px-4',
'text-center text-muted-foreground'
)}
data-testid="list-view-empty"
>
<p className="text-sm mb-4">No features to display</p>
{onAddFeature && (
<Button variant="outline" size="sm" onClick={onAddFeature}>
<Plus className="w-4 h-4 mr-2" />
Add Feature
</Button>
)}
</div>
);
});
/**
* ListView displays features in a table format grouped by status.
*
* Features:
* - Groups features by status (backlog, in_progress, waiting_approval, verified, pipeline steps)
* - Collapsible status groups
* - Sortable columns (title, status, category, priority, dates)
* - Inline row actions with hover state
* - Selection support for bulk operations
* - Animated border for currently running features
* - Keyboard accessible
*
* The component receives features grouped by status via columnFeaturesMap
* and applies the current sort configuration within each group.
*
* @example
* ```tsx
* const { sortConfig, setSortColumn } = useListViewState();
* const { columnFeaturesMap } = useBoardColumnFeatures({ features, ... });
*
* <ListView
* columnFeaturesMap={columnFeaturesMap}
* allFeatures={features}
* sortConfig={sortConfig}
* onSortChange={setSortColumn}
* actionHandlers={{
* onEdit: handleEdit,
* onDelete: handleDelete,
* // ...
* }}
* runningAutoTasks={runningAutoTasks}
* pipelineConfig={pipelineConfig}
* onAddFeature={handleAddFeature}
* />
* ```
*/
export const ListView = memo(function ListView({
columnFeaturesMap,
allFeatures,
sortConfig,
onSortChange,
actionHandlers,
runningAutoTasks,
pipelineConfig = null,
onAddFeature,
isSelectionMode = false,
selectedFeatureIds = EMPTY_SET,
onToggleFeatureSelection,
onRowClick,
className,
}: ListViewProps) {
// Track collapsed state for each status group
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
// Generate status groups from columnFeaturesMap
const statusGroups = useMemo<StatusGroup[]>(() => {
const columns = getColumnsWithPipeline(pipelineConfig);
const groups: StatusGroup[] = [];
for (const column of columns) {
const features = columnFeaturesMap[column.id] || [];
if (features.length > 0) {
// Sort features within the group according to current sort config
const sortedFeatures = sortFeatures(features, sortConfig.column, sortConfig.direction);
groups.push({
id: column.id as FeatureStatusWithPipeline,
title: column.title,
colorClass: column.colorClass,
features: sortedFeatures,
});
}
}
// Sort groups by status order
return groups.sort((a, b) => getStatusOrder(a.id) - getStatusOrder(b.id));
}, [columnFeaturesMap, pipelineConfig, sortConfig]);
// Calculate total feature count
const totalFeatures = useMemo(
() => statusGroups.reduce((sum, group) => sum + group.features.length, 0),
[statusGroups]
);
// Toggle group collapse state
const toggleGroup = useCallback((groupId: string) => {
setCollapsedGroups((prev) => {
const next = new Set(prev);
if (next.has(groupId)) {
next.delete(groupId);
} else {
next.add(groupId);
}
return next;
});
}, []);
// Create row action handlers for a feature
const createHandlers = useCallback(
(feature: Feature): RowActionHandlers => {
return createRowActionHandlers(feature.id, {
editFeature: (id) => {
const f = allFeatures.find((f) => f.id === id);
if (f) actionHandlers.onEdit(f);
},
deleteFeature: (id) => actionHandlers.onDelete(id),
viewOutput: actionHandlers.onViewOutput
? (id) => {
const f = allFeatures.find((f) => f.id === id);
if (f) actionHandlers.onViewOutput?.(f);
}
: undefined,
verifyFeature: actionHandlers.onVerify
? (id) => {
const f = allFeatures.find((f) => f.id === id);
if (f) actionHandlers.onVerify?.(f);
}
: undefined,
resumeFeature: actionHandlers.onResume
? (id) => {
const f = allFeatures.find((f) => f.id === id);
if (f) actionHandlers.onResume?.(f);
}
: undefined,
forceStop: actionHandlers.onForceStop
? (id) => {
const f = allFeatures.find((f) => f.id === id);
if (f) actionHandlers.onForceStop?.(f);
}
: undefined,
manualVerify: actionHandlers.onManualVerify
? (id) => {
const f = allFeatures.find((f) => f.id === id);
if (f) actionHandlers.onManualVerify?.(f);
}
: undefined,
followUp: actionHandlers.onFollowUp
? (id) => {
const f = allFeatures.find((f) => f.id === id);
if (f) actionHandlers.onFollowUp?.(f);
}
: undefined,
implement: actionHandlers.onImplement
? (id) => {
const f = allFeatures.find((f) => f.id === id);
if (f) actionHandlers.onImplement?.(f);
}
: undefined,
complete: actionHandlers.onComplete
? (id) => {
const f = allFeatures.find((f) => f.id === id);
if (f) actionHandlers.onComplete?.(f);
}
: undefined,
viewPlan: actionHandlers.onViewPlan
? (id) => {
const f = allFeatures.find((f) => f.id === id);
if (f) actionHandlers.onViewPlan?.(f);
}
: undefined,
approvePlan: actionHandlers.onApprovePlan
? (id) => {
const f = allFeatures.find((f) => f.id === id);
if (f) actionHandlers.onApprovePlan?.(f);
}
: undefined,
spawnTask: actionHandlers.onSpawnTask
? (id) => {
const f = allFeatures.find((f) => f.id === id);
if (f) actionHandlers.onSpawnTask?.(f);
}
: undefined,
});
},
[actionHandlers, allFeatures]
);
// Get blocking dependencies for a feature
const getBlockingDeps = useCallback(
(feature: Feature): string[] => {
return getBlockingDependencies(feature, allFeatures);
},
[allFeatures]
);
// Calculate selection state for header checkbox
const selectionState = useMemo(() => {
if (!isSelectionMode || totalFeatures === 0) {
return { allSelected: false, someSelected: false };
}
const selectedCount = selectedFeatureIds.size;
return {
allSelected: selectedCount === totalFeatures && selectedCount > 0,
someSelected: selectedCount > 0 && selectedCount < totalFeatures,
};
}, [isSelectionMode, totalFeatures, selectedFeatureIds]);
// Handle select all toggle
const handleSelectAll = useCallback(() => {
if (!onToggleFeatureSelection) return;
// If all selected, deselect all; otherwise select all
if (selectionState.allSelected) {
// Clear all selections
selectedFeatureIds.forEach((id) => onToggleFeatureSelection(id));
} else {
// Select all features that aren't already selected
for (const group of statusGroups) {
for (const feature of group.features) {
if (!selectedFeatureIds.has(feature.id)) {
onToggleFeatureSelection(feature.id);
}
}
}
}
}, [onToggleFeatureSelection, selectionState.allSelected, selectedFeatureIds, statusGroups]);
// Show empty state if no features
if (totalFeatures === 0) {
return (
<div className={cn('flex flex-col h-full bg-background', className)} data-testid="list-view">
<EmptyState onAddFeature={onAddFeature} />
</div>
);
}
return (
<div
className={cn('flex flex-col h-full bg-background', className)}
role="table"
aria-label="Features list"
data-testid="list-view"
>
{/* Table header */}
<ListHeader
sortConfig={sortConfig}
onSortChange={onSortChange}
showCheckbox={isSelectionMode}
allSelected={selectionState.allSelected}
someSelected={selectionState.someSelected}
onSelectAll={handleSelectAll}
/>
{/* Table body with status groups */}
<div className="flex-1 overflow-y-auto" role="rowgroup">
{statusGroups.map((group) => {
const isExpanded = !collapsedGroups.has(group.id);
return (
<div
key={group.id}
className="border-b border-border/30"
data-testid={`list-group-${group.id}`}
>
{/* Group header */}
<StatusGroupHeader
group={group}
isExpanded={isExpanded}
onToggle={() => toggleGroup(group.id)}
/>
{/* Group rows */}
{isExpanded && (
<div role="rowgroup">
{group.features.map((feature) => (
<ListRow
key={feature.id}
feature={feature}
handlers={createHandlers(feature)}
isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
pipelineConfig={pipelineConfig}
isSelected={selectedFeatureIds.has(feature.id)}
showCheckbox={isSelectionMode}
onToggleSelect={() => onToggleFeatureSelection?.(feature.id)}
onClick={() => onRowClick?.(feature)}
blockingDependencies={getBlockingDeps(feature)}
/>
))}
</div>
)}
</div>
);
})}
</div>
{/* Footer with Add Feature button */}
{onAddFeature && (
<div className="border-t border-border px-4 py-3">
<Button
variant="outline"
size="sm"
onClick={onAddFeature}
className="w-full sm:w-auto"
data-testid="list-view-add-feature"
>
<Plus className="w-4 h-4 mr-2" />
Add Feature
</Button>
</div>
)}
</div>
);
});
/**
* Helper to get all features from the columnFeaturesMap as a flat array
*/
export function getFlatFeatures(columnFeaturesMap: Record<string, Feature[]>): Feature[] {
return Object.values(columnFeaturesMap).flat();
}
/**
* Helper to count total features across all groups
*/
export function getTotalFeatureCount(columnFeaturesMap: Record<string, Feature[]>): number {
return Object.values(columnFeaturesMap).reduce((sum, features) => sum + features.length, 0);
}

View File

@@ -0,0 +1,635 @@
import { memo, useCallback, useState } from 'react';
import {
MoreHorizontal,
Edit,
Trash2,
PlayCircle,
RotateCcw,
StopCircle,
CheckCircle2,
FileText,
Eye,
Wand2,
Archive,
GitBranch,
GitFork,
ExternalLink,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import type { Feature } from '@/store/app-store';
/**
* Action handler types for row actions
*/
export interface RowActionHandlers {
onEdit: () => void;
onDelete: () => void;
onViewOutput?: () => void;
onVerify?: () => void;
onResume?: () => void;
onForceStop?: () => void;
onManualVerify?: () => void;
onFollowUp?: () => void;
onImplement?: () => void;
onComplete?: () => void;
onViewPlan?: () => void;
onApprovePlan?: () => void;
onSpawnTask?: () => void;
}
export interface RowActionsProps {
/** The feature for this row */
feature: Feature;
/** Action handlers */
handlers: RowActionHandlers;
/** Whether this feature is the current auto task (agent is running) */
isCurrentAutoTask?: boolean;
/** Whether the dropdown menu is open */
isOpen?: boolean;
/** Callback when the dropdown open state changes */
onOpenChange?: (open: boolean) => void;
/** Additional className for custom styling */
className?: string;
}
/**
* MenuItem is a helper component for dropdown menu items with consistent styling
*/
const MenuItem = memo(function MenuItem({
icon: Icon,
label,
onClick,
variant = 'default',
disabled = false,
}: {
icon: React.ComponentType<{ className?: string }>;
label: string;
onClick: () => void;
variant?: 'default' | 'destructive' | 'primary' | 'success' | 'warning';
disabled?: boolean;
}) {
const variantClasses = {
default: '',
destructive: 'text-destructive focus:text-destructive focus:bg-destructive/10',
primary: 'text-primary focus:text-primary focus:bg-primary/10',
success:
'text-[var(--status-success)] focus:text-[var(--status-success)] focus:bg-[var(--status-success)]/10',
warning:
'text-[var(--status-waiting)] focus:text-[var(--status-waiting)] focus:bg-[var(--status-waiting)]/10',
};
return (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onClick();
}}
disabled={disabled}
className={cn('gap-2', variantClasses[variant])}
>
<Icon className="w-4 h-4" />
<span>{label}</span>
</DropdownMenuItem>
);
});
/**
* Get the primary action for quick access button based on feature status
*/
function getPrimaryAction(
feature: Feature,
handlers: RowActionHandlers,
isCurrentAutoTask: boolean
): {
icon: React.ComponentType<{ className?: string }>;
label: string;
onClick: () => void;
variant?: 'default' | 'destructive' | 'primary' | 'success' | 'warning';
} | null {
// Running task - force stop is primary
if (isCurrentAutoTask) {
if (handlers.onForceStop) {
return {
icon: StopCircle,
label: 'Stop',
onClick: handlers.onForceStop,
variant: 'destructive',
};
}
return null;
}
// Backlog - implement is primary
if (feature.status === 'backlog' && handlers.onImplement) {
return {
icon: PlayCircle,
label: 'Make',
onClick: handlers.onImplement,
variant: 'primary',
};
}
// In progress with plan approval pending
if (
feature.status === 'in_progress' &&
feature.planSpec?.status === 'generated' &&
handlers.onApprovePlan
) {
return {
icon: FileText,
label: 'Approve',
onClick: handlers.onApprovePlan,
variant: 'warning',
};
}
// In progress - resume is primary
if (feature.status === 'in_progress' && handlers.onResume) {
return {
icon: RotateCcw,
label: 'Resume',
onClick: handlers.onResume,
variant: 'success',
};
}
// Waiting approval - verify is primary
if (feature.status === 'waiting_approval' && handlers.onManualVerify) {
return {
icon: CheckCircle2,
label: 'Verify',
onClick: handlers.onManualVerify,
variant: 'success',
};
}
// Verified - complete is primary
if (feature.status === 'verified' && handlers.onComplete) {
return {
icon: Archive,
label: 'Complete',
onClick: handlers.onComplete,
variant: 'primary',
};
}
return null;
}
/**
* Get secondary actions for inline display based on feature status
*/
function getSecondaryActions(
feature: Feature,
handlers: RowActionHandlers
): Array<{
icon: React.ComponentType<{ className?: string }>;
label: string;
onClick: () => void;
}> {
const actions = [];
// Refine action for waiting_approval status
if (feature.status === 'waiting_approval' && handlers.onFollowUp) {
actions.push({
icon: Wand2,
label: 'Refine',
onClick: handlers.onFollowUp,
});
}
return actions;
}
/**
* RowActions provides an inline action menu for list view rows.
*
* Features:
* - Quick access button for primary action (Make, Resume, Verify, etc.)
* - Dropdown menu with all available actions
* - Context-aware actions based on feature status
* - Support for running task actions (view logs, force stop)
* - Keyboard accessible (focus, Enter/Space to open)
*
* Actions by status:
* - Backlog: Edit, Delete, Make (implement), View Plan, Spawn Sub-Task
* - In Progress: View Logs, Resume, Approve Plan, Manual Verify, Edit, Spawn Sub-Task, Delete
* - Waiting Approval: Refine (inline secondary), Verify, View Logs, View PR, Edit, Spawn Sub-Task, Delete
* - Verified: View Logs, View PR, View Branch, Complete, Edit, Spawn Sub-Task, Delete
* - Running (auto task): View Logs, Approve Plan, Edit, Spawn Sub-Task, Force Stop
* - Pipeline statuses: View Logs, Edit, Spawn Sub-Task, Delete
*
* @example
* ```tsx
* <RowActions
* feature={feature}
* handlers={{
* onEdit: () => handleEdit(feature.id),
* onDelete: () => handleDelete(feature.id),
* onImplement: () => handleImplement(feature.id),
* // ... other handlers
* }}
* />
* ```
*/
export const RowActions = memo(function RowActions({
feature,
handlers,
isCurrentAutoTask = false,
isOpen,
onOpenChange,
className,
}: RowActionsProps) {
const [internalOpen, setInternalOpen] = useState(false);
// Use controlled or uncontrolled state
const open = isOpen ?? internalOpen;
const setOpen = (value: boolean) => {
if (onOpenChange) {
onOpenChange(value);
} else {
setInternalOpen(value);
}
};
const handleOpenChange = useCallback(
(newOpen: boolean) => {
setOpen(newOpen);
},
[setOpen]
);
const primaryAction = getPrimaryAction(feature, handlers, isCurrentAutoTask);
const secondaryActions = getSecondaryActions(feature, handlers);
// Helper to close menu after action
const withClose = useCallback(
(handler: () => void) => () => {
setOpen(false);
handler();
},
[setOpen]
);
return (
<div
className={cn('flex items-center gap-1', className)}
onClick={(e) => e.stopPropagation()}
data-testid={`row-actions-${feature.id}`}
>
{/* Primary action quick button */}
{primaryAction && (
<Button
variant="ghost"
size="icon-sm"
className={cn(
'h-7 w-7',
primaryAction.variant === 'destructive' &&
'hover:bg-destructive/10 hover:text-destructive',
primaryAction.variant === 'primary' && 'hover:bg-primary/10 hover:text-primary',
primaryAction.variant === 'success' &&
'hover:bg-[var(--status-success)]/10 hover:text-[var(--status-success)]',
primaryAction.variant === 'warning' &&
'hover:bg-[var(--status-waiting)]/10 hover:text-[var(--status-waiting)]'
)}
onClick={(e) => {
e.stopPropagation();
primaryAction.onClick();
}}
title={primaryAction.label}
data-testid={`row-action-primary-${feature.id}`}
>
<primaryAction.icon className="w-4 h-4" />
</Button>
)}
{/* Secondary action buttons */}
{secondaryActions.map((action, index) => (
<Button
key={`secondary-action-${index}`}
variant="ghost"
size="icon-sm"
className={cn('h-7 w-7', 'text-muted-foreground', 'hover:bg-muted hover:text-foreground')}
onClick={(e) => {
e.stopPropagation();
action.onClick();
}}
title={action.label}
data-testid={`row-action-secondary-${feature.id}-${action.label.toLowerCase()}`}
>
<action.icon className="w-4 h-4" />
</Button>
))}
{/* Dropdown menu */}
<DropdownMenu open={open} onOpenChange={handleOpenChange}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="h-7 w-7 text-muted-foreground hover:text-foreground"
data-testid={`row-actions-trigger-${feature.id}`}
>
<MoreHorizontal className="w-4 h-4" />
<span className="sr-only">Open actions menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{/* Running task actions */}
{isCurrentAutoTask && (
<>
{handlers.onViewOutput && (
<MenuItem
icon={FileText}
label="View Logs"
onClick={withClose(handlers.onViewOutput)}
/>
)}
{feature.planSpec?.status === 'generated' && handlers.onApprovePlan && (
<MenuItem
icon={FileText}
label="Approve Plan"
onClick={withClose(handlers.onApprovePlan)}
variant="warning"
/>
)}
<MenuItem icon={Edit} label="Edit" onClick={withClose(handlers.onEdit)} />
{handlers.onSpawnTask && (
<MenuItem
icon={GitFork}
label="Spawn Sub-Task"
onClick={withClose(handlers.onSpawnTask)}
/>
)}
{handlers.onForceStop && (
<>
<DropdownMenuSeparator />
<MenuItem
icon={StopCircle}
label="Force Stop"
onClick={withClose(handlers.onForceStop)}
variant="destructive"
/>
</>
)}
</>
)}
{/* Backlog actions */}
{!isCurrentAutoTask && feature.status === 'backlog' && (
<>
<MenuItem icon={Edit} label="Edit" onClick={withClose(handlers.onEdit)} />
{feature.planSpec?.content && handlers.onViewPlan && (
<MenuItem icon={Eye} label="View Plan" onClick={withClose(handlers.onViewPlan)} />
)}
{handlers.onImplement && (
<MenuItem
icon={PlayCircle}
label="Make"
onClick={withClose(handlers.onImplement)}
variant="primary"
/>
)}
{handlers.onSpawnTask && (
<MenuItem
icon={GitFork}
label="Spawn Sub-Task"
onClick={withClose(handlers.onSpawnTask)}
/>
)}
<DropdownMenuSeparator />
<MenuItem
icon={Trash2}
label="Delete"
onClick={withClose(handlers.onDelete)}
variant="destructive"
/>
</>
)}
{/* In Progress actions */}
{!isCurrentAutoTask && feature.status === 'in_progress' && (
<>
{handlers.onViewOutput && (
<MenuItem
icon={FileText}
label="View Logs"
onClick={withClose(handlers.onViewOutput)}
/>
)}
{feature.planSpec?.status === 'generated' && handlers.onApprovePlan && (
<MenuItem
icon={FileText}
label="Approve Plan"
onClick={withClose(handlers.onApprovePlan)}
variant="warning"
/>
)}
{feature.skipTests && handlers.onManualVerify ? (
<MenuItem
icon={CheckCircle2}
label="Verify"
onClick={withClose(handlers.onManualVerify)}
variant="success"
/>
) : handlers.onResume ? (
<MenuItem
icon={RotateCcw}
label="Resume"
onClick={withClose(handlers.onResume)}
variant="success"
/>
) : null}
<DropdownMenuSeparator />
<MenuItem icon={Edit} label="Edit" onClick={withClose(handlers.onEdit)} />
{handlers.onSpawnTask && (
<MenuItem
icon={GitFork}
label="Spawn Sub-Task"
onClick={withClose(handlers.onSpawnTask)}
/>
)}
<MenuItem
icon={Trash2}
label="Delete"
onClick={withClose(handlers.onDelete)}
variant="destructive"
/>
</>
)}
{/* Waiting Approval actions */}
{!isCurrentAutoTask && feature.status === 'waiting_approval' && (
<>
{handlers.onViewOutput && (
<MenuItem
icon={FileText}
label="View Logs"
onClick={withClose(handlers.onViewOutput)}
/>
)}
{handlers.onFollowUp && (
<MenuItem icon={Wand2} label="Refine" onClick={withClose(handlers.onFollowUp)} />
)}
{feature.prUrl && (
<MenuItem
icon={ExternalLink}
label="View PR"
onClick={withClose(() => window.open(feature.prUrl, '_blank'))}
/>
)}
{handlers.onManualVerify && (
<MenuItem
icon={CheckCircle2}
label={feature.prUrl ? 'Verify' : 'Mark as Verified'}
onClick={withClose(handlers.onManualVerify)}
variant="success"
/>
)}
<DropdownMenuSeparator />
<MenuItem icon={Edit} label="Edit" onClick={withClose(handlers.onEdit)} />
{handlers.onSpawnTask && (
<MenuItem
icon={GitFork}
label="Spawn Sub-Task"
onClick={withClose(handlers.onSpawnTask)}
/>
)}
<MenuItem
icon={Trash2}
label="Delete"
onClick={withClose(handlers.onDelete)}
variant="destructive"
/>
</>
)}
{/* Verified actions */}
{!isCurrentAutoTask && feature.status === 'verified' && (
<>
{handlers.onViewOutput && (
<MenuItem
icon={FileText}
label="View Logs"
onClick={withClose(handlers.onViewOutput)}
/>
)}
{feature.prUrl && (
<MenuItem
icon={ExternalLink}
label="View PR"
onClick={withClose(() => window.open(feature.prUrl, '_blank'))}
/>
)}
{feature.worktree && (
<MenuItem
icon={GitBranch}
label="View Branch"
onClick={withClose(() => {})}
disabled
/>
)}
{handlers.onComplete && (
<MenuItem
icon={Archive}
label="Complete"
onClick={withClose(handlers.onComplete)}
variant="primary"
/>
)}
<DropdownMenuSeparator />
<MenuItem icon={Edit} label="Edit" onClick={withClose(handlers.onEdit)} />
{handlers.onSpawnTask && (
<MenuItem
icon={GitFork}
label="Spawn Sub-Task"
onClick={withClose(handlers.onSpawnTask)}
/>
)}
<MenuItem
icon={Trash2}
label="Delete"
onClick={withClose(handlers.onDelete)}
variant="destructive"
/>
</>
)}
{/* Pipeline status actions (generic fallback) */}
{!isCurrentAutoTask && feature.status.startsWith('pipeline_') && (
<>
{handlers.onViewOutput && (
<MenuItem
icon={FileText}
label="View Logs"
onClick={withClose(handlers.onViewOutput)}
/>
)}
<MenuItem icon={Edit} label="Edit" onClick={withClose(handlers.onEdit)} />
{handlers.onSpawnTask && (
<MenuItem
icon={GitFork}
label="Spawn Sub-Task"
onClick={withClose(handlers.onSpawnTask)}
/>
)}
<DropdownMenuSeparator />
<MenuItem
icon={Trash2}
label="Delete"
onClick={withClose(handlers.onDelete)}
variant="destructive"
/>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
});
/**
* Helper function to create action handlers from common patterns
*/
export function createRowActionHandlers(
featureId: string,
actions: {
editFeature?: (id: string) => void;
deleteFeature?: (id: string) => void;
viewOutput?: (id: string) => void;
verifyFeature?: (id: string) => void;
resumeFeature?: (id: string) => void;
forceStop?: (id: string) => void;
manualVerify?: (id: string) => void;
followUp?: (id: string) => void;
implement?: (id: string) => void;
complete?: (id: string) => void;
viewPlan?: (id: string) => void;
approvePlan?: (id: string) => void;
spawnTask?: (id: string) => void;
}
): RowActionHandlers {
return {
onEdit: () => actions.editFeature?.(featureId),
onDelete: () => actions.deleteFeature?.(featureId),
onViewOutput: actions.viewOutput ? () => actions.viewOutput!(featureId) : undefined,
onVerify: actions.verifyFeature ? () => actions.verifyFeature!(featureId) : undefined,
onResume: actions.resumeFeature ? () => actions.resumeFeature!(featureId) : undefined,
onForceStop: actions.forceStop ? () => actions.forceStop!(featureId) : undefined,
onManualVerify: actions.manualVerify ? () => actions.manualVerify!(featureId) : undefined,
onFollowUp: actions.followUp ? () => actions.followUp!(featureId) : undefined,
onImplement: actions.implement ? () => actions.implement!(featureId) : undefined,
onComplete: actions.complete ? () => actions.complete!(featureId) : undefined,
onViewPlan: actions.viewPlan ? () => actions.viewPlan!(featureId) : undefined,
onApprovePlan: actions.approvePlan ? () => actions.approvePlan!(featureId) : undefined,
onSpawnTask: actions.spawnTask ? () => actions.spawnTask!(featureId) : undefined,
};
}

View File

@@ -0,0 +1,218 @@
import { memo, useMemo } from 'react';
import { cn } from '@/lib/utils';
import { COLUMNS, isPipelineStatus } from '../../constants';
import type { FeatureStatusWithPipeline, PipelineConfig } from '@automaker/types';
/**
* Status display configuration
*/
interface StatusDisplay {
label: string;
colorClass: string;
bgClass: string;
borderClass: string;
}
/**
* Base status display configurations using CSS variables
*/
const BASE_STATUS_DISPLAY: Record<string, StatusDisplay> = {
backlog: {
label: 'Backlog',
colorClass: 'text-[var(--status-backlog)]',
bgClass: 'bg-[var(--status-backlog)]/15',
borderClass: 'border-[var(--status-backlog)]/30',
},
in_progress: {
label: 'In Progress',
colorClass: 'text-[var(--status-in-progress)]',
bgClass: 'bg-[var(--status-in-progress)]/15',
borderClass: 'border-[var(--status-in-progress)]/30',
},
waiting_approval: {
label: 'Waiting Approval',
colorClass: 'text-[var(--status-waiting)]',
bgClass: 'bg-[var(--status-waiting)]/15',
borderClass: 'border-[var(--status-waiting)]/30',
},
verified: {
label: 'Verified',
colorClass: 'text-[var(--status-success)]',
bgClass: 'bg-[var(--status-success)]/15',
borderClass: 'border-[var(--status-success)]/30',
},
};
/**
* Get display configuration for a pipeline status
*/
function getPipelineStatusDisplay(
status: string,
pipelineConfig: PipelineConfig | null
): StatusDisplay | null {
if (!isPipelineStatus(status) || !pipelineConfig?.steps) {
return null;
}
const stepId = status.replace('pipeline_', '');
const step = pipelineConfig.steps.find((s) => s.id === stepId);
if (!step) {
return null;
}
// Extract the color variable from the colorClass (e.g., "bg-[var(--status-in-progress)]")
// and use it for the badge styling
const colorVar = step.colorClass?.match(/var\(([^)]+)\)/)?.[1] || '--status-in-progress';
return {
label: step.name || 'Pipeline Step',
colorClass: `text-[var(${colorVar})]`,
bgClass: `bg-[var(${colorVar})]/15`,
borderClass: `border-[var(${colorVar})]/30`,
};
}
/**
* Get the display configuration for a status
*/
function getStatusDisplay(
status: FeatureStatusWithPipeline,
pipelineConfig: PipelineConfig | null
): StatusDisplay {
// Check for pipeline status first
if (isPipelineStatus(status)) {
const pipelineDisplay = getPipelineStatusDisplay(status, pipelineConfig);
if (pipelineDisplay) {
return pipelineDisplay;
}
// Fallback for unknown pipeline status
return {
label: status.replace('pipeline_', '').replace(/_/g, ' '),
colorClass: 'text-[var(--status-in-progress)]',
bgClass: 'bg-[var(--status-in-progress)]/15',
borderClass: 'border-[var(--status-in-progress)]/30',
};
}
// Check base status
const baseDisplay = BASE_STATUS_DISPLAY[status];
if (baseDisplay) {
return baseDisplay;
}
// Try to find from COLUMNS constant
const column = COLUMNS.find((c) => c.id === status);
if (column) {
return {
label: column.title,
colorClass: 'text-muted-foreground',
bgClass: 'bg-muted/50',
borderClass: 'border-border/50',
};
}
// Fallback for unknown status
return {
label: status.replace(/_/g, ' '),
colorClass: 'text-muted-foreground',
bgClass: 'bg-muted/50',
borderClass: 'border-border/50',
};
}
export interface StatusBadgeProps {
/** The status to display */
status: FeatureStatusWithPipeline;
/** Optional pipeline configuration for custom pipeline steps */
pipelineConfig?: PipelineConfig | null;
/** Size variant for the badge */
size?: 'sm' | 'default' | 'lg';
/** Additional className for custom styling */
className?: string;
}
/**
* StatusBadge displays a feature status as a colored chip/badge for use in the list view table.
*
* Features:
* - Displays status with appropriate color based on status type
* - Supports base statuses (backlog, in_progress, waiting_approval, verified)
* - Supports pipeline statuses with custom colors from pipeline configuration
* - Size variants (sm, default, lg)
* - Uses CSS variables for consistent theming
*
* @example
* ```tsx
* // Basic usage
* <StatusBadge status="backlog" />
*
* // With pipeline configuration
* <StatusBadge status="pipeline_review" pipelineConfig={pipelineConfig} />
*
* // Small size
* <StatusBadge status="verified" size="sm" />
* ```
*/
export const StatusBadge = memo(function StatusBadge({
status,
pipelineConfig = null,
size = 'default',
className,
}: StatusBadgeProps) {
const display = useMemo(() => getStatusDisplay(status, pipelineConfig), [status, pipelineConfig]);
const sizeClasses = {
sm: 'px-1.5 py-0.5 text-[10px]',
default: 'px-2 py-0.5 text-xs',
lg: 'px-2.5 py-1 text-sm',
};
return (
<span
className={cn(
'inline-flex items-center rounded-full border font-medium whitespace-nowrap',
'transition-colors duration-200',
sizeClasses[size],
display.colorClass,
display.bgClass,
display.borderClass,
className
)}
data-testid={`status-badge-${status}`}
>
{display.label}
</span>
);
});
/**
* Helper function to get the status label without rendering the badge
* Useful for sorting or filtering operations
*/
export function getStatusLabel(
status: FeatureStatusWithPipeline,
pipelineConfig: PipelineConfig | null = null
): string {
return getStatusDisplay(status, pipelineConfig).label;
}
/**
* Helper function to get the status order for sorting
* Returns a numeric value representing the status position in the workflow
*/
export function getStatusOrder(status: FeatureStatusWithPipeline): number {
const baseOrder: Record<string, number> = {
backlog: 0,
in_progress: 1,
waiting_approval: 2,
verified: 3,
};
if (isPipelineStatus(status)) {
// Pipeline statuses come after in_progress but before waiting_approval
return 1.5;
}
return baseOrder[status] ?? 0;
}

View File

@@ -0,0 +1,62 @@
import { LayoutGrid, List } from 'lucide-react';
import { cn } from '@/lib/utils';
export type ViewMode = 'kanban' | 'list';
interface ViewToggleProps {
viewMode: ViewMode;
onViewModeChange: (mode: ViewMode) => void;
className?: string;
}
/**
* A segmented control component for switching between kanban (grid) and list views.
* Uses icons to represent each view mode with clear visual feedback.
*/
export function ViewToggle({ viewMode, onViewModeChange, className }: ViewToggleProps) {
return (
<div
className={cn(
'inline-flex h-8 items-center rounded-md bg-muted p-[3px] border border-border',
className
)}
role="tablist"
aria-label="View mode"
>
<button
role="tab"
aria-selected={viewMode === 'kanban'}
aria-label="Kanban view"
onClick={() => onViewModeChange('kanban')}
className={cn(
'inline-flex h-[calc(100%-1px)] items-center justify-center gap-1.5 rounded-md px-2.5 py-1 text-sm font-medium transition-all duration-200 cursor-pointer',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
viewMode === 'kanban'
? 'bg-primary text-primary-foreground shadow-md'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
)}
data-testid="view-toggle-kanban"
>
<LayoutGrid className="w-4 h-4" />
<span className="sr-only sm:not-sr-only">Kanban</span>
</button>
<button
role="tab"
aria-selected={viewMode === 'list'}
aria-label="List view"
onClick={() => onViewModeChange('list')}
className={cn(
'inline-flex h-[calc(100%-1px)] items-center justify-center gap-1.5 rounded-md px-2.5 py-1 text-sm font-medium transition-all duration-200 cursor-pointer',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
viewMode === 'list'
? 'bg-primary text-primary-foreground shadow-md'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
)}
data-testid="view-toggle-list"
>
<List className="w-4 h-4" />
<span className="sr-only sm:not-sr-only">List</span>
</button>
</div>
);
}

View File

@@ -304,22 +304,22 @@ export function AgentOutputModal({
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent
className="w-[60vw] max-w-[60vw] max-h-[80vh] flex flex-col"
className="w-full h-full max-w-full max-h-full sm:w-[60vw] sm:max-w-[60vw] sm:max-h-[80vh] sm:h-auto sm:rounded-xl rounded-none flex flex-col"
data-testid="agent-output-modal"
>
<DialogHeader className="shrink-0">
<div className="flex items-center justify-between pr-8">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 pr-8">
<DialogTitle className="flex items-center gap-2">
{featureStatus !== 'verified' && featureStatus !== 'waiting_approval' && (
<Loader2 className="w-5 h-5 text-primary animate-spin" />
)}
Agent Output
</DialogTitle>
<div className="flex items-center gap-1 bg-muted rounded-lg p-1">
<div className="flex items-center gap-1 bg-muted rounded-lg p-1 overflow-x-auto">
{summary && (
<button
onClick={() => setViewMode('summary')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all whitespace-nowrap ${
effectiveViewMode === 'summary'
? 'bg-primary/20 text-primary shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
@@ -332,7 +332,7 @@ export function AgentOutputModal({
)}
<button
onClick={() => setViewMode('parsed')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all whitespace-nowrap ${
effectiveViewMode === 'parsed'
? 'bg-primary/20 text-primary shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
@@ -344,7 +344,7 @@ export function AgentOutputModal({
</button>
<button
onClick={() => setViewMode('changes')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all whitespace-nowrap ${
effectiveViewMode === 'changes'
? 'bg-primary/20 text-primary shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
@@ -356,7 +356,7 @@ export function AgentOutputModal({
</button>
<button
onClick={() => setViewMode('raw')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all whitespace-nowrap ${
effectiveViewMode === 'raw'
? 'bg-primary/20 text-primary shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
@@ -384,7 +384,7 @@ export function AgentOutputModal({
/>
{effectiveViewMode === 'changes' ? (
<div className="flex-1 min-h-[400px] max-h-[60vh] overflow-y-auto scrollbar-visible">
<div className="flex-1 min-h-0 sm:min-h-[400px] sm:max-h-[60vh] overflow-y-auto scrollbar-visible">
{projectPath ? (
<GitDiffPanel
projectPath={projectPath}
@@ -401,7 +401,7 @@ export function AgentOutputModal({
)}
</div>
) : effectiveViewMode === 'summary' && summary ? (
<div className="flex-1 overflow-y-auto bg-zinc-950 rounded-lg p-4 min-h-[400px] max-h-[60vh] scrollbar-visible">
<div className="flex-1 min-h-0 sm:min-h-[400px] sm:max-h-[60vh] overflow-y-auto bg-card border border-border/50 rounded-lg p-4 scrollbar-visible">
<Markdown>{summary}</Markdown>
</div>
) : (
@@ -409,7 +409,7 @@ export function AgentOutputModal({
<div
ref={scrollRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto bg-zinc-950 rounded-lg p-4 font-mono text-xs min-h-[400px] max-h-[60vh] scrollbar-visible"
className="flex-1 min-h-0 sm:min-h-[400px] sm:max-h-[60vh] overflow-y-auto bg-zinc-950 rounded-lg p-4 font-mono text-xs scrollbar-visible"
>
{isLoading && !output ? (
<div className="flex items-center justify-center h-full text-muted-foreground">

View File

@@ -419,7 +419,7 @@ export function BacklogPlanDialog({
</DialogDescription>
</DialogHeader>
<div className="py-4">{renderContent()}</div>
<div className="py-4 overflow-y-auto">{renderContent()}</div>
<DialogFooter>
{mode === 'input' && (

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
@@ -10,9 +10,10 @@ import {
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { GitCommit, Loader2 } from 'lucide-react';
import { GitCommit, Loader2, Sparkles } from 'lucide-react';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
import { useAppStore } from '@/store/app-store';
interface WorktreeInfo {
path: string;
@@ -37,7 +38,9 @@ export function CommitWorktreeDialog({
}: CommitWorktreeDialogProps) {
const [message, setMessage] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isGenerating, setIsGenerating] = useState(false);
const [error, setError] = useState<string | null>(null);
const enableAiCommitMessages = useAppStore((state) => state.enableAiCommitMessages);
const handleCommit = async () => {
if (!worktree || !message.trim()) return;
@@ -77,11 +80,68 @@ export function CommitWorktreeDialog({
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && e.metaKey && !isLoading && message.trim()) {
// Prevent commit while loading or while AI is generating a message
if (e.key === 'Enter' && e.metaKey && !isLoading && !isGenerating && message.trim()) {
handleCommit();
}
};
// Generate AI commit message when dialog opens (if enabled)
useEffect(() => {
if (open && worktree) {
// Reset state
setMessage('');
setError(null);
// Only generate AI commit message if enabled
if (!enableAiCommitMessages) {
return;
}
setIsGenerating(true);
let cancelled = false;
const generateMessage = async () => {
try {
const api = getElectronAPI();
if (!api?.worktree?.generateCommitMessage) {
if (!cancelled) {
setIsGenerating(false);
}
return;
}
const result = await api.worktree.generateCommitMessage(worktree.path);
if (cancelled) return;
if (result.success && result.message) {
setMessage(result.message);
} else {
// Don't show error toast, just log it and leave message empty
console.warn('Failed to generate commit message:', result.error);
setMessage('');
}
} catch (err) {
if (cancelled) return;
// Don't show error toast for generation failures
console.warn('Error generating commit message:', err);
setMessage('');
} finally {
if (!cancelled) {
setIsGenerating(false);
}
}
};
generateMessage();
return () => {
cancelled = true;
};
}
}, [open, worktree, enableAiCommitMessages]);
if (!worktree) return null;
return (
@@ -106,10 +166,20 @@ export function CommitWorktreeDialog({
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="commit-message">Commit Message</Label>
<Label htmlFor="commit-message" className="flex items-center gap-2">
Commit Message
{isGenerating && (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Sparkles className="w-3 h-3 animate-pulse" />
Generating...
</span>
)}
</Label>
<Textarea
id="commit-message"
placeholder="Describe your changes..."
placeholder={
isGenerating ? 'Generating commit message...' : 'Describe your changes...'
}
value={message}
onChange={(e) => {
setMessage(e.target.value);
@@ -118,6 +188,7 @@ export function CommitWorktreeDialog({
onKeyDown={handleKeyDown}
className="min-h-[100px] font-mono text-sm"
autoFocus
disabled={isGenerating}
/>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
@@ -128,10 +199,14 @@ export function CommitWorktreeDialog({
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={isLoading}>
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={isLoading || isGenerating}
>
Cancel
</Button>
<Button onClick={handleCommit} disabled={isLoading || !message.trim()}>
<Button onClick={handleCommit} disabled={isLoading || isGenerating || !message.trim()}>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />

View File

@@ -117,7 +117,7 @@ export function CreatePRDialog({
description: `PR already exists for ${result.result.branch}`,
action: {
label: 'View PR',
onClick: () => window.open(result.result!.prUrl!, '_blank'),
onClick: () => window.open(result.result!.prUrl!, '_blank', 'noopener,noreferrer'),
},
});
} else {
@@ -125,7 +125,7 @@ export function CreatePRDialog({
description: `PR created from ${result.result.branch}`,
action: {
label: 'View PR',
onClick: () => window.open(result.result!.prUrl!, '_blank'),
onClick: () => window.open(result.result!.prUrl!, '_blank', 'noopener,noreferrer'),
},
});
}
@@ -251,7 +251,10 @@ export function CreatePRDialog({
<p className="text-sm text-muted-foreground mt-1">Your PR is ready for review</p>
</div>
<div className="flex gap-2 justify-center">
<Button onClick={() => window.open(prUrl, '_blank')} className="gap-2">
<Button
onClick={() => window.open(prUrl, '_blank', 'noopener,noreferrer')}
className="gap-2"
>
<ExternalLink className="w-4 h-4" />
View Pull Request
</Button>
@@ -277,7 +280,7 @@ export function CreatePRDialog({
<Button
onClick={() => {
if (browserUrl) {
window.open(browserUrl, '_blank');
window.open(browserUrl, '_blank', 'noopener,noreferrer');
}
}}
className="gap-2 w-full"

View File

@@ -0,0 +1,174 @@
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';
import { cn } from '@/lib/utils';
import { MobileUsageBar } from './mobile-usage-bar';
interface HeaderMobileMenuProps {
// Worktree panel visibility
isWorktreePanelVisible: boolean;
onWorktreePanelToggle: (visible: boolean) => void;
// Concurrency control
maxConcurrency: number;
runningAgentsCount: number;
onConcurrencyChange: (value: number) => void;
// Auto mode
isAutoModeRunning: boolean;
onAutoModeToggle: (enabled: boolean) => void;
onOpenAutoModeSettings: () => void;
// Plan button
onOpenPlanDialog: () => void;
// Usage bar visibility
showClaudeUsage: boolean;
showCodexUsage: boolean;
}
export function HeaderMobileMenu({
isWorktreePanelVisible,
onWorktreePanelToggle,
maxConcurrency,
runningAgentsCount,
onConcurrencyChange,
isAutoModeRunning,
onAutoModeToggle,
onOpenAutoModeSettings,
onOpenPlanDialog,
showClaudeUsage,
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">
{/* Usage Bar - show if either provider is authenticated */}
{(showClaudeUsage || showCodexUsage) && (
<>
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
Usage
</DropdownMenuLabel>
<MobileUsageBar showClaudeUsage={showClaudeUsage} showCodexUsage={showCodexUsage} />
<DropdownMenuSeparator />
</>
)}
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
Controls
</DropdownMenuLabel>
<DropdownMenuSeparator />
{/* 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>
</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>
<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>
</div>
<Switch
id="mobile-worktrees-toggle"
checked={isWorktreePanelVisible}
onCheckedChange={onWorktreePanelToggle}
onClick={(e) => e.stopPropagation()}
data-testid="mobile-worktrees-toggle"
/>
</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>
);
}

View File

@@ -8,3 +8,4 @@ export { useBoardBackground } from './use-board-background';
export { useBoardPersistence } from './use-board-persistence';
export { useFollowUpState } from './use-follow-up-state';
export { useSelectionMode } from './use-selection-mode';
export { useListViewState } from './use-list-view-state';

View File

@@ -0,0 +1,223 @@
import { useState, useCallback, useEffect, useMemo } from 'react';
import { getJSON, setJSON } from '@/lib/storage';
import type { ViewMode } from '../components/view-toggle';
// Re-export ViewMode for convenience
export type { ViewMode };
/** Columns that can be sorted in the list view */
export type SortColumn = 'title' | 'status' | 'category' | 'priority' | 'createdAt' | 'updatedAt';
/** Sort direction */
export type SortDirection = 'asc' | 'desc';
/** Sort configuration */
export interface SortConfig {
column: SortColumn;
direction: SortDirection;
}
/** Persisted state for the list view */
interface ListViewPersistedState {
viewMode: ViewMode;
sortConfig: SortConfig;
}
/** Storage key for list view preferences */
const STORAGE_KEY = 'automaker:list-view-state';
/** Default sort configuration */
const DEFAULT_SORT_CONFIG: SortConfig = {
column: 'createdAt',
direction: 'desc',
};
/** Default persisted state */
const DEFAULT_STATE: ListViewPersistedState = {
viewMode: 'kanban',
sortConfig: DEFAULT_SORT_CONFIG,
};
/**
* Validates and returns a valid ViewMode, defaulting to 'kanban' if invalid
*/
function validateViewMode(value: unknown): ViewMode {
if (value === 'kanban' || value === 'list') {
return value;
}
return 'kanban';
}
/**
* Validates and returns a valid SortColumn, defaulting to 'createdAt' if invalid
*/
function validateSortColumn(value: unknown): SortColumn {
const validColumns: SortColumn[] = [
'title',
'status',
'category',
'priority',
'createdAt',
'updatedAt',
];
if (typeof value === 'string' && validColumns.includes(value as SortColumn)) {
return value as SortColumn;
}
return 'createdAt';
}
/**
* Validates and returns a valid SortDirection, defaulting to 'desc' if invalid
*/
function validateSortDirection(value: unknown): SortDirection {
if (value === 'asc' || value === 'desc') {
return value;
}
return 'desc';
}
/**
* Load persisted state from localStorage with validation
*/
function loadPersistedState(): ListViewPersistedState {
const stored = getJSON<Partial<ListViewPersistedState>>(STORAGE_KEY);
if (!stored) {
return DEFAULT_STATE;
}
return {
viewMode: validateViewMode(stored.viewMode),
sortConfig: {
column: validateSortColumn(stored.sortConfig?.column),
direction: validateSortDirection(stored.sortConfig?.direction),
},
};
}
/**
* Save state to localStorage
*/
function savePersistedState(state: ListViewPersistedState): void {
setJSON(STORAGE_KEY, state);
}
export interface UseListViewStateReturn {
/** Current view mode (kanban or list) */
viewMode: ViewMode;
/** Set the view mode */
setViewMode: (mode: ViewMode) => void;
/** Toggle between kanban and list views */
toggleViewMode: () => void;
/** Whether the current view is list mode */
isListView: boolean;
/** Whether the current view is kanban mode */
isKanbanView: boolean;
/** Current sort configuration */
sortConfig: SortConfig;
/** Set the sort column (toggles direction if same column) */
setSortColumn: (column: SortColumn) => void;
/** Set the full sort configuration */
setSortConfig: (config: SortConfig) => void;
/** Reset sort to default */
resetSort: () => void;
}
/**
* Hook for managing list view state including view mode, sorting, and localStorage persistence.
*
* Features:
* - View mode toggle between kanban and list views
* - Sort configuration with column and direction
* - Automatic persistence to localStorage
* - Validated state restoration on mount
*
* @example
* ```tsx
* const { viewMode, setViewMode, sortConfig, setSortColumn } = useListViewState();
*
* // Toggle view mode
* <ViewToggle viewMode={viewMode} onViewModeChange={setViewMode} />
*
* // Sort by column (clicking same column toggles direction)
* <TableHeader onClick={() => setSortColumn('title')}>Title</TableHeader>
* ```
*/
export function useListViewState(): UseListViewStateReturn {
// Initialize state from localStorage
const [viewMode, setViewModeState] = useState<ViewMode>(() => loadPersistedState().viewMode);
const [sortConfig, setSortConfigState] = useState<SortConfig>(
() => loadPersistedState().sortConfig
);
// Derived state
const isListView = viewMode === 'list';
const isKanbanView = viewMode === 'kanban';
// Persist state changes to localStorage
useEffect(() => {
savePersistedState({ viewMode, sortConfig });
}, [viewMode, sortConfig]);
// Set view mode
const setViewMode = useCallback((mode: ViewMode) => {
setViewModeState(mode);
}, []);
// Toggle between kanban and list views
const toggleViewMode = useCallback(() => {
setViewModeState((prev) => (prev === 'kanban' ? 'list' : 'kanban'));
}, []);
// Set sort column - toggles direction if same column is clicked
const setSortColumn = useCallback((column: SortColumn) => {
setSortConfigState((prev) => {
if (prev.column === column) {
// Toggle direction if same column
return {
column,
direction: prev.direction === 'asc' ? 'desc' : 'asc',
};
}
// New column - default to descending for dates, ascending for others
const defaultDirection: SortDirection =
column === 'createdAt' || column === 'updatedAt' ? 'desc' : 'asc';
return { column, direction: defaultDirection };
});
}, []);
// Set full sort configuration
const setSortConfig = useCallback((config: SortConfig) => {
setSortConfigState(config);
}, []);
// Reset sort to default
const resetSort = useCallback(() => {
setSortConfigState(DEFAULT_SORT_CONFIG);
}, []);
return useMemo(
() => ({
viewMode,
setViewMode,
toggleViewMode,
isListView,
isKanbanView,
sortConfig,
setSortColumn,
setSortConfig,
resetSort,
}),
[
viewMode,
setViewMode,
toggleViewMode,
isListView,
isKanbanView,
sortConfig,
setSortColumn,
setSortConfig,
resetSort,
]
);
}

View File

@@ -8,7 +8,7 @@ import { Archive, Settings2, CheckSquare, GripVertical, Plus } from 'lucide-reac
import { useResponsiveKanban } from '@/hooks/use-responsive-kanban';
import { getColumnsWithPipeline, type ColumnId } from './constants';
import type { PipelineConfig } from '@automaker/types';
import { cn } from '@/lib/utils';
interface KanbanBoardProps {
sensors: any;
collisionDetectionStrategy: (args: any) => any;
@@ -57,6 +57,8 @@ interface KanbanBoardProps {
isDragging?: boolean;
/** Whether the board is in read-only mode */
isReadOnly?: boolean;
/** Additional className for custom styling (e.g., transition classes) */
className?: string;
}
export function KanbanBoard({
@@ -95,6 +97,7 @@ export function KanbanBoard({
onAiSuggest,
isDragging = false,
isReadOnly = false,
className,
}: KanbanBoardProps) {
// Generate columns including pipeline steps
const columns = useMemo(() => getColumnsWithPipeline(pipelineConfig), [pipelineConfig]);
@@ -108,7 +111,14 @@ export function KanbanBoard({
const { columnWidth, containerStyle } = useResponsiveKanban(columns.length);
return (
<div className="flex-1 overflow-x-auto px-5 pt-4 pb-4 relative" style={backgroundImageStyle}>
<div
className={cn(
'flex-1 overflow-x-auto px-5 pt-4 pb-4 relative',
'transition-opacity duration-200',
className
)}
style={backgroundImageStyle}
>
<DndContext
sensors={sensors}
collisionDetection={collisionDetectionStrategy}

View File

@@ -0,0 +1,229 @@
import { useEffect, useCallback, useState, type ComponentType, type ReactNode } from 'react';
import { RefreshCw } from 'lucide-react';
import { cn } from '@/lib/utils';
import { getElectronAPI } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
import { AnthropicIcon, OpenAIIcon } from '@/components/ui/provider-icon';
interface MobileUsageBarProps {
showClaudeUsage: boolean;
showCodexUsage: boolean;
}
// Helper to get progress bar color based on percentage
function getProgressBarColor(percentage: number): string {
if (percentage >= 80) return 'bg-red-500';
if (percentage >= 50) return 'bg-yellow-500';
return 'bg-green-500';
}
// Individual usage bar component
function UsageBar({
label,
percentage,
isStale,
}: {
label: string;
percentage: number;
isStale: boolean;
}) {
return (
<div className="mt-1.5 first:mt-0">
<div className="flex items-center justify-between mb-0.5">
<span className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">
{label}
</span>
<span
className={cn(
'text-[10px] font-mono font-bold',
percentage >= 80
? 'text-red-500'
: percentage >= 50
? 'text-yellow-500'
: 'text-green-500'
)}
>
{Math.round(percentage)}%
</span>
</div>
<div
className={cn(
'h-1 w-full bg-muted-foreground/10 rounded-full overflow-hidden transition-opacity',
isStale && 'opacity-60'
)}
>
<div
className={cn('h-full transition-all duration-500', getProgressBarColor(percentage))}
style={{ width: `${Math.min(percentage, 100)}%` }}
/>
</div>
</div>
);
}
// Container for a provider's usage info
function UsageItem({
icon: Icon,
label,
isLoading,
onRefresh,
children,
}: {
icon: ComponentType<{ className?: string }>;
label: string;
isLoading: boolean;
onRefresh: () => void;
children: ReactNode;
}) {
return (
<div className="px-2 py-2">
<div className="flex items-center justify-between mb-1.5">
<div className="flex items-center gap-2">
<Icon className="w-4 h-4 text-muted-foreground shrink-0" />
<span className="text-sm font-semibold">{label}</span>
</div>
<button
onClick={(e) => {
e.stopPropagation();
onRefresh();
}}
className="p-1 rounded hover:bg-accent/50 transition-colors"
title="Refresh usage"
>
<RefreshCw
className={cn('w-3.5 h-3.5 text-muted-foreground', isLoading && 'animate-spin')}
/>
</button>
</div>
<div className="pl-6 space-y-2">{children}</div>
</div>
);
}
export function MobileUsageBar({ showClaudeUsage, showCodexUsage }: MobileUsageBarProps) {
const { claudeUsage, claudeUsageLastUpdated, setClaudeUsage } = useAppStore();
const { codexUsage, codexUsageLastUpdated, setCodexUsage } = useAppStore();
const [isClaudeLoading, setIsClaudeLoading] = useState(false);
const [isCodexLoading, setIsCodexLoading] = useState(false);
// Check if data is stale (older than 2 minutes)
const isClaudeStale =
!claudeUsageLastUpdated || Date.now() - claudeUsageLastUpdated > 2 * 60 * 1000;
const isCodexStale = !codexUsageLastUpdated || Date.now() - codexUsageLastUpdated > 2 * 60 * 1000;
const fetchClaudeUsage = useCallback(async () => {
setIsClaudeLoading(true);
try {
const api = getElectronAPI();
if (!api.claude) return;
const data = await api.claude.getUsage();
if (!('error' in data)) {
setClaudeUsage(data);
}
} catch {
// Silently fail - usage display is optional
} finally {
setIsClaudeLoading(false);
}
}, [setClaudeUsage]);
const fetchCodexUsage = useCallback(async () => {
setIsCodexLoading(true);
try {
const api = getElectronAPI();
if (!api.codex) return;
const data = await api.codex.getUsage();
if (!('error' in data)) {
setCodexUsage(data);
}
} catch {
// Silently fail - usage display is optional
} finally {
setIsCodexLoading(false);
}
}, [setCodexUsage]);
const getCodexWindowLabel = (durationMins: number) => {
if (durationMins < 60) return `${durationMins}m Window`;
if (durationMins < 1440) return `${Math.round(durationMins / 60)}h Window`;
return `${Math.round(durationMins / 1440)}d Window`;
};
// Auto-fetch on mount if data is stale
useEffect(() => {
if (showClaudeUsage && isClaudeStale) {
fetchClaudeUsage();
}
}, [showClaudeUsage, isClaudeStale, fetchClaudeUsage]);
useEffect(() => {
if (showCodexUsage && isCodexStale) {
fetchCodexUsage();
}
}, [showCodexUsage, isCodexStale, fetchCodexUsage]);
// Don't render if there's nothing to show
if (!showClaudeUsage && !showCodexUsage) {
return null;
}
return (
<div className="space-y-2 py-1" data-testid="mobile-usage-bar">
{showClaudeUsage && (
<UsageItem
icon={AnthropicIcon}
label="Claude"
isLoading={isClaudeLoading}
onRefresh={fetchClaudeUsage}
>
{claudeUsage ? (
<>
<UsageBar
label="Session"
percentage={claudeUsage.sessionPercentage}
isStale={isClaudeStale}
/>
<UsageBar
label="Weekly"
percentage={claudeUsage.weeklyPercentage}
isStale={isClaudeStale}
/>
</>
) : (
<p className="text-[10px] text-muted-foreground italic">Loading usage data...</p>
)}
</UsageItem>
)}
{showCodexUsage && (
<UsageItem
icon={OpenAIIcon}
label="Codex"
isLoading={isCodexLoading}
onRefresh={fetchCodexUsage}
>
{codexUsage?.rateLimits ? (
<>
{codexUsage.rateLimits.primary && (
<UsageBar
label={getCodexWindowLabel(codexUsage.rateLimits.primary.windowDurationMins)}
percentage={codexUsage.rateLimits.primary.usedPercent}
isStale={isCodexStale}
/>
)}
{codexUsage.rateLimits.secondary && (
<UsageBar
label={getCodexWindowLabel(codexUsage.rateLimits.secondary.windowDurationMins)}
percentage={codexUsage.rateLimits.secondary.usedPercent}
isStale={isCodexStale}
/>
)}
</>
) : (
<p className="text-[10px] text-muted-foreground italic">Loading usage data...</p>
)}
</UsageItem>
)}
</div>
);
}

View File

@@ -17,7 +17,7 @@ export function PrioritySelector({
type="button"
onClick={() => onPrioritySelect(1)}
className={cn(
'flex-1 px-3 py-2 rounded-md text-sm font-medium transition-colors',
'flex-1 px-2 sm:px-3 py-2 rounded-md text-xs sm:text-sm font-medium transition-colors',
selectedPriority === 1
? 'bg-red-500/20 text-red-500 border-2 border-red-500/50'
: 'bg-muted/50 text-muted-foreground border border-border hover:bg-muted'
@@ -30,7 +30,7 @@ export function PrioritySelector({
type="button"
onClick={() => onPrioritySelect(2)}
className={cn(
'flex-1 px-3 py-2 rounded-md text-sm font-medium transition-colors',
'flex-1 px-2 sm:px-3 py-2 rounded-md text-xs sm:text-sm font-medium transition-colors',
selectedPriority === 2
? 'bg-yellow-500/20 text-yellow-500 border-2 border-yellow-500/50'
: 'bg-muted/50 text-muted-foreground border border-border hover:bg-muted'
@@ -43,7 +43,7 @@ export function PrioritySelector({
type="button"
onClick={() => onPrioritySelect(3)}
className={cn(
'flex-1 px-3 py-2 rounded-md text-sm font-medium transition-colors',
'flex-1 px-2 sm:px-3 py-2 rounded-md text-xs sm:text-sm font-medium transition-colors',
selectedPriority === 3
? 'bg-blue-500/20 text-blue-500 border-2 border-blue-500/50'
: 'bg-muted/50 text-muted-foreground border border-border hover:bg-muted'

View File

@@ -20,6 +20,8 @@ interface BranchSwitchDropdownProps {
branchFilter: string;
isLoadingBranches: boolean;
isSwitching: boolean;
/** When true, renders as a standalone button (not attached to another element) */
standalone?: boolean;
onOpenChange: (open: boolean) => void;
onFilterChange: (value: string) => void;
onSwitchBranch: (worktree: WorktreeInfo, branchName: string) => void;
@@ -33,6 +35,7 @@ export function BranchSwitchDropdown({
branchFilter,
isLoadingBranches,
isSwitching,
standalone = false,
onOpenChange,
onFilterChange,
onSwitchBranch,
@@ -42,16 +45,18 @@ export function BranchSwitchDropdown({
<DropdownMenu onOpenChange={onOpenChange}>
<DropdownMenuTrigger asChild>
<Button
variant={isSelected ? 'default' : 'outline'}
variant={standalone ? 'outline' : isSelected ? 'default' : 'outline'}
size="sm"
className={cn(
'h-7 w-7 p-0 rounded-none border-r-0',
isSelected && 'bg-primary text-primary-foreground',
!isSelected && 'bg-secondary/50 hover:bg-secondary'
'h-7 w-7 p-0',
!standalone && 'rounded-none border-r-0',
standalone && 'h-8 w-8 shrink-0',
!standalone && isSelected && 'bg-primary text-primary-foreground',
!standalone && !isSelected && 'bg-secondary/50 hover:bg-secondary'
)}
title="Switch branch"
>
<GitBranch className="w-3 h-3" />
<GitBranch className={standalone ? 'w-3.5 h-3.5' : 'w-3 h-3'} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64">

View File

@@ -1,4 +1,5 @@
export { BranchSwitchDropdown } from './branch-switch-dropdown';
export { DevServerLogsPanel } from './dev-server-logs-panel';
export { WorktreeActionsDropdown } from './worktree-actions-dropdown';
export { WorktreeMobileDropdown } from './worktree-mobile-dropdown';
export { WorktreeTab } from './worktree-tab';

View File

@@ -45,6 +45,8 @@ interface WorktreeActionsDropdownProps {
isDevServerRunning: boolean;
devServerInfo?: DevServerInfo;
gitRepoStatus: GitRepoStatus;
/** When true, renders as a standalone button (not attached to another element) */
standalone?: boolean;
onOpenChange: (open: boolean) => void;
onPull: (worktree: WorktreeInfo) => void;
onPush: (worktree: WorktreeInfo) => void;
@@ -73,6 +75,7 @@ export function WorktreeActionsDropdown({
isDevServerRunning,
devServerInfo,
gitRepoStatus,
standalone = false,
onOpenChange,
onPull,
onPush,
@@ -118,15 +121,17 @@ export function WorktreeActionsDropdown({
<DropdownMenu onOpenChange={onOpenChange}>
<DropdownMenuTrigger asChild>
<Button
variant={isSelected ? 'default' : 'outline'}
variant={standalone ? 'outline' : isSelected ? 'default' : 'outline'}
size="sm"
className={cn(
'h-7 w-7 p-0 rounded-l-none',
isSelected && 'bg-primary text-primary-foreground',
!isSelected && 'bg-secondary/50 hover:bg-secondary'
'h-7 w-7 p-0',
!standalone && 'rounded-l-none',
standalone && 'h-8 w-8 shrink-0',
!standalone && isSelected && 'bg-primary text-primary-foreground',
!standalone && !isSelected && 'bg-secondary/50 hover:bg-secondary'
)}
>
<MoreHorizontal className="w-3 h-3" />
<MoreHorizontal className="w-3.5 h-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
@@ -146,8 +151,12 @@ export function WorktreeActionsDropdown({
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
Dev Server Running (:{devServerInfo?.port})
</DropdownMenuLabel>
<DropdownMenuItem onClick={() => onOpenDevServerUrl(worktree)} className="text-xs">
<Globe className="w-3.5 h-3.5 mr-2" />
<DropdownMenuItem
onClick={() => onOpenDevServerUrl(worktree)}
className="text-xs"
aria-label={`Open dev server on port ${devServerInfo?.port} in browser`}
>
<Globe className="w-3.5 h-3.5 mr-2" aria-hidden="true" />
Open in Browser
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onViewDevServerLogs(worktree)} className="text-xs">
@@ -327,7 +336,7 @@ export function WorktreeActionsDropdown({
<>
<DropdownMenuItem
onClick={() => {
window.open(worktree.pr!.url, '_blank');
window.open(worktree.pr!.url, '_blank', 'noopener,noreferrer');
}}
className="text-xs"
>

View File

@@ -0,0 +1,112 @@
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { GitBranch, ChevronDown, Loader2, CircleDot, Check } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { WorktreeInfo } from '../types';
interface WorktreeMobileDropdownProps {
worktrees: WorktreeInfo[];
isWorktreeSelected: (worktree: WorktreeInfo) => boolean;
hasRunningFeatures: (worktree: WorktreeInfo) => boolean;
isActivating: boolean;
branchCardCounts?: Record<string, number>;
onSelectWorktree: (worktree: WorktreeInfo) => void;
}
export function WorktreeMobileDropdown({
worktrees,
isWorktreeSelected,
hasRunningFeatures,
isActivating,
branchCardCounts,
onSelectWorktree,
}: WorktreeMobileDropdownProps) {
// Find the currently selected worktree to display in the trigger
const selectedWorktree = worktrees.find((w) => isWorktreeSelected(w));
const displayBranch = selectedWorktree?.branch || 'Select branch';
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-8 px-3 gap-2 font-mono text-xs bg-secondary/50 hover:bg-secondary flex-1 min-w-0"
disabled={isActivating}
>
<GitBranch className="w-3.5 h-3.5 shrink-0" />
<span className="truncate">{displayBranch}</span>
{isActivating ? (
<Loader2 className="w-3 h-3 animate-spin shrink-0" />
) : (
<ChevronDown className="w-3 h-3 shrink-0 ml-auto" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64 max-h-80 overflow-y-auto">
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
Branches & Worktrees
</DropdownMenuLabel>
<DropdownMenuSeparator />
{worktrees.map((worktree) => {
const isSelected = isWorktreeSelected(worktree);
const isRunning = hasRunningFeatures(worktree);
const cardCount = branchCardCounts?.[worktree.branch];
const hasChanges = worktree.hasChanges;
const changedFilesCount = worktree.changedFilesCount;
return (
<DropdownMenuItem
key={worktree.path}
onClick={() => onSelectWorktree(worktree)}
className={cn('flex items-center gap-2 cursor-pointer', isSelected && 'bg-accent')}
>
<div className="flex items-center gap-2 flex-1 min-w-0">
{isSelected ? (
<Check className="w-3.5 h-3.5 shrink-0 text-primary" />
) : (
<div className="w-3.5 h-3.5 shrink-0" />
)}
{isRunning && <Loader2 className="w-3 h-3 animate-spin shrink-0" />}
<span className={cn('font-mono text-xs truncate', isSelected && 'font-medium')}>
{worktree.branch}
</span>
{worktree.isMain && (
<span className="text-[10px] px-1 py-0.5 rounded bg-muted text-muted-foreground shrink-0">
main
</span>
)}
</div>
<div className="flex items-center gap-1.5 shrink-0">
{cardCount !== undefined && cardCount > 0 && (
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
{cardCount}
</span>
)}
{hasChanges && (
<span
className={cn(
'inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded border',
'bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-500/30'
)}
title={`${changedFilesCount ?? 'Some'} uncommitted file${changedFilesCount !== 1 ? 's' : ''}`}
>
<CircleDot className="w-2.5 h-2.5 mr-0.5" />
{changedFilesCount ?? '!'}
</span>
)}
</div>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -300,20 +300,29 @@ export function WorktreeTab({
)}
{isDevServerRunning && (
<Button
variant={isSelected ? 'default' : 'outline'}
size="sm"
className={cn(
'h-7 w-7 p-0 rounded-none border-r-0',
isSelected && 'bg-primary text-primary-foreground',
!isSelected && 'bg-secondary/50 hover:bg-secondary',
'text-green-500'
)}
onClick={() => onOpenDevServerUrl(worktree)}
title={`Open dev server (port ${devServerInfo?.port})`}
>
<Globe className="w-3 h-3" />
</Button>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={isSelected ? 'default' : 'outline'}
size="sm"
className={cn(
'h-7 w-7 p-0 rounded-none border-r-0',
isSelected && 'bg-primary text-primary-foreground',
!isSelected && 'bg-secondary/50 hover:bg-secondary',
'text-green-500'
)}
onClick={() => onOpenDevServerUrl(worktree)}
aria-label={`Open dev server on port ${devServerInfo?.port} in browser`}
>
<Globe className="w-3 h-3" aria-hidden="true" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Open dev server (:{devServerInfo?.port})</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<WorktreeActionsDropdown

View File

@@ -118,8 +118,37 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
const handleOpenDevServerUrl = useCallback(
(worktree: WorktreeInfo) => {
const serverInfo = runningDevServers.get(getWorktreeKey(worktree));
if (serverInfo) {
window.open(serverInfo.url, '_blank');
if (!serverInfo) {
logger.warn('No dev server info found for worktree:', getWorktreeKey(worktree));
toast.error('Dev server not found', {
description: 'The dev server may have stopped. Try starting it again.',
});
return;
}
try {
// Rewrite URL hostname to match the current browser's hostname.
// This ensures dev server URLs work when accessing Automaker from
// remote machines (e.g., 192.168.x.x or hostname.local instead of localhost).
const devServerUrl = new URL(serverInfo.url);
// Security: Only allow http/https protocols to prevent potential attacks
// via data:, javascript:, file:, or other dangerous URL schemes
if (devServerUrl.protocol !== 'http:' && devServerUrl.protocol !== 'https:') {
logger.error('Invalid dev server URL protocol:', devServerUrl.protocol);
toast.error('Invalid dev server URL', {
description: 'The server returned an unsupported URL protocol.',
});
return;
}
devServerUrl.hostname = window.location.hostname;
window.open(devServerUrl.toString(), '_blank', 'noopener,noreferrer');
} catch (error) {
logger.error('Failed to parse dev server URL:', error);
toast.error('Failed to open dev server', {
description: 'The server URL could not be processed. Please try again.',
});
}
},
[runningDevServers, getWorktreeKey]

View File

@@ -4,6 +4,7 @@ import { GitBranch, Plus, RefreshCw } from 'lucide-react';
import { cn, pathsEqual } from '@/lib/utils';
import { toast } from 'sonner';
import { getHttpApiClient } from '@/lib/http-api-client';
import { useIsMobile } from '@/hooks/use-media-query';
import type { WorktreePanelProps, WorktreeInfo } from './types';
import {
useWorktrees,
@@ -12,7 +13,13 @@ import {
useWorktreeActions,
useRunningFeatures,
} from './hooks';
import { WorktreeTab, DevServerLogsPanel } from './components';
import {
WorktreeTab,
DevServerLogsPanel,
WorktreeMobileDropdown,
WorktreeActionsDropdown,
BranchSwitchDropdown,
} from './components';
export function WorktreePanel({
projectPath,
@@ -107,6 +114,8 @@ export function WorktreePanel({
checkInitScript();
}, [projectPath]);
const isMobile = useIsMobile();
// Periodic interval check (5 seconds) to detect branch changes on disk
// Reduced from 1s to 5s to minimize GPU/CPU usage from frequent re-renders
const intervalRef = useRef<NodeJS.Timeout | null>(null);
@@ -183,6 +192,105 @@ export function WorktreePanel({
const mainWorktree = worktrees.find((w) => w.isMain);
const nonMainWorktrees = worktrees.filter((w) => !w.isMain);
// Mobile view: single dropdown for all worktrees
if (isMobile) {
// Find the currently selected worktree for the actions menu
const selectedWorktree = worktrees.find((w) => isWorktreeSelected(w)) || mainWorktree;
return (
<div className="flex items-center gap-2 px-3 py-2 border-b border-border bg-glass/50 backdrop-blur-sm">
<WorktreeMobileDropdown
worktrees={worktrees}
isWorktreeSelected={isWorktreeSelected}
hasRunningFeatures={hasRunningFeatures}
isActivating={isActivating}
branchCardCounts={branchCardCounts}
onSelectWorktree={handleSelectWorktree}
/>
{/* Branch switch dropdown for the selected worktree */}
{selectedWorktree && (
<BranchSwitchDropdown
worktree={selectedWorktree}
isSelected={true}
standalone={true}
branches={branches}
filteredBranches={filteredBranches}
branchFilter={branchFilter}
isLoadingBranches={isLoadingBranches}
isSwitching={isSwitching}
onOpenChange={handleBranchDropdownOpenChange(selectedWorktree)}
onFilterChange={setBranchFilter}
onSwitchBranch={handleSwitchBranch}
onCreateBranch={onCreateBranch}
/>
)}
{/* Actions menu for the selected worktree */}
{selectedWorktree && (
<WorktreeActionsDropdown
worktree={selectedWorktree}
isSelected={true}
standalone={true}
aheadCount={aheadCount}
behindCount={behindCount}
isPulling={isPulling}
isPushing={isPushing}
isStartingDevServer={isStartingDevServer}
isDevServerRunning={isDevServerRunning(selectedWorktree)}
devServerInfo={getDevServerInfo(selectedWorktree)}
gitRepoStatus={gitRepoStatus}
onOpenChange={handleActionsDropdownOpenChange(selectedWorktree)}
onPull={handlePull}
onPush={handlePush}
onOpenInEditor={handleOpenInEditor}
onCommit={onCommit}
onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments}
onResolveConflicts={onResolveConflicts}
onDeleteWorktree={onDeleteWorktree}
onStartDevServer={handleStartDevServer}
onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl}
onRunInitScript={handleRunInitScript}
hasInitScript={hasInitScript}
/>
)}
{useWorktreesEnabled && (
<>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground shrink-0"
onClick={onCreateWorktree}
title="Create new worktree"
>
<Plus className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground shrink-0"
onClick={async () => {
const removedWorktrees = await fetchWorktrees();
if (removedWorktrees && removedWorktrees.length > 0 && onRemovedWorktrees) {
onRemovedWorktrees(removedWorktrees);
}
}}
disabled={isLoading}
title="Refresh worktrees"
>
<RefreshCw className={cn('w-3.5 h-3.5', isLoading && 'animate-spin')} />
</Button>
</>
)}
</div>
);
}
// Desktop view: full tabs layout
return (
<div className="flex items-center gap-2 px-4 py-2 border-b border-border bg-glass/50 backdrop-blur-sm">
<GitBranch className="w-4 h-4 text-muted-foreground" />

View File

@@ -9,7 +9,7 @@ import {
IssueValidationEvent,
StoredValidation,
} from '@/lib/electron';
import type { LinkedPRInfo, PhaseModelEntry, ModelAlias, CursorModelId } from '@automaker/types';
import type { LinkedPRInfo, PhaseModelEntry, ModelId } from '@automaker/types';
import { useAppStore } from '@/store/app-store';
import { toast } from 'sonner';
import { isValidationStale } from '../utils';
@@ -19,12 +19,10 @@ const logger = createLogger('IssueValidation');
/**
* Extract model string from PhaseModelEntry or string (handles both formats)
*/
function extractModel(
entry: PhaseModelEntry | string | undefined
): ModelAlias | CursorModelId | undefined {
function extractModel(entry: PhaseModelEntry | string | undefined): ModelId | undefined {
if (!entry) return undefined;
if (typeof entry === 'string') {
return entry as ModelAlias | CursorModelId;
return entry as ModelId;
}
return entry.model;
}
@@ -228,8 +226,8 @@ export function useIssueValidation({
issue: GitHubIssue,
options: {
forceRevalidate?: boolean;
model?: string | PhaseModelEntry; // Accept either string (backward compat) or PhaseModelEntry
modelEntry?: PhaseModelEntry; // New preferred way to pass model with thinking level
model?: ModelId | PhaseModelEntry; // Accept either model ID (backward compat) or PhaseModelEntry
modelEntry?: PhaseModelEntry; // New preferred way to pass model with thinking/reasoning
comments?: GitHubComment[];
linkedPRs?: LinkedPRInfo[];
} = {}
@@ -267,15 +265,16 @@ export function useIssueValidation({
? modelEntry
: model
? typeof model === 'string'
? { model: model as ModelAlias | CursorModelId }
? { model: model as ModelId }
: model
: phaseModels.validationModel;
const normalizedEntry =
typeof effectiveModelEntry === 'string'
? { model: effectiveModelEntry as ModelAlias | CursorModelId }
? { model: effectiveModelEntry as ModelId }
: effectiveModelEntry;
const modelToUse = normalizedEntry.model;
const thinkingLevelToUse = normalizedEntry.thinkingLevel;
const reasoningEffortToUse = normalizedEntry.reasoningEffort;
try {
const api = getElectronAPI();
@@ -292,7 +291,8 @@ export function useIssueValidation({
currentProject.path,
validationInput,
modelToUse,
thinkingLevelToUse
thinkingLevelToUse,
reasoningEffortToUse
);
if (!result.success) {

View File

@@ -1,5 +1,5 @@
import type { GitHubIssue, StoredValidation, GitHubComment } from '@/lib/electron';
import type { ModelAlias, CursorModelId, LinkedPRInfo, PhaseModelEntry } from '@automaker/types';
import type { ModelId, LinkedPRInfo, PhaseModelEntry } from '@automaker/types';
export interface IssueRowProps {
issue: GitHubIssue;
@@ -37,7 +37,7 @@ export interface IssueDetailPanelProps {
/** Model override state */
modelOverride: {
effectiveModelEntry: PhaseModelEntry;
effectiveModel: ModelAlias | CursorModelId;
effectiveModel: ModelId;
isOverridden: boolean;
setOverride: (entry: PhaseModelEntry | null) => void;
};

View File

@@ -8,6 +8,7 @@ import {
useNodesState,
useEdgesState,
ReactFlowProvider,
useReactFlow,
SelectionMode,
ConnectionMode,
Node,
@@ -244,6 +245,82 @@ function GraphCanvasInner({
[]
);
// Get fitView from React Flow for orientation change handling
const { fitView } = useReactFlow();
// Handle orientation changes on mobile devices
// When rotating from landscape to portrait, the view may incorrectly zoom in
// This effect listens for orientation changes and calls fitView to correct the viewport
useEffect(() => {
if (typeof window === 'undefined') return;
// Track the previous orientation to detect changes
let previousWidth = window.innerWidth;
let previousHeight = window.innerHeight;
// Track timeout IDs for cleanup
let orientationTimeoutId: ReturnType<typeof setTimeout> | null = null;
let resizeTimeoutId: ReturnType<typeof setTimeout> | null = null;
const handleOrientationChange = () => {
// Clear any pending timeout
if (orientationTimeoutId) {
clearTimeout(orientationTimeoutId);
}
// Small delay to allow the browser to complete the orientation change
orientationTimeoutId = setTimeout(() => {
fitView({ padding: 0.2, duration: 300 });
orientationTimeoutId = null;
}, 100);
};
const handleResize = () => {
const currentWidth = window.innerWidth;
const currentHeight = window.innerHeight;
// Detect orientation change by checking if width and height swapped significantly
// This happens when device rotates between portrait and landscape
const widthDiff = Math.abs(currentWidth - previousHeight);
const heightDiff = Math.abs(currentHeight - previousWidth);
// If the dimensions are close to being swapped (within 100px tolerance)
// it's likely an orientation change
const isOrientationChange = widthDiff < 100 && heightDiff < 100;
if (isOrientationChange) {
// Clear any pending timeout
if (resizeTimeoutId) {
clearTimeout(resizeTimeoutId);
}
// Delay fitView to allow browser to complete the layout
resizeTimeoutId = setTimeout(() => {
fitView({ padding: 0.2, duration: 300 });
resizeTimeoutId = null;
}, 150);
}
previousWidth = currentWidth;
previousHeight = currentHeight;
};
// Listen for orientation change event (mobile specific)
window.addEventListener('orientationchange', handleOrientationChange);
// Also listen for resize as a fallback (some browsers don't fire orientationchange)
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('orientationchange', handleOrientationChange);
window.removeEventListener('resize', handleResize);
// Clear any pending timeouts
if (orientationTimeoutId) {
clearTimeout(orientationTimeoutId);
}
if (resizeTimeoutId) {
clearTimeout(resizeTimeoutId);
}
};
}, [fitView]);
// MiniMap node color based on status
const minimapNodeColor = useCallback((node: Node<TaskNodeData>) => {
const data = node.data as TaskNodeData | undefined;

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useSearch } from '@tanstack/react-router';
import { useAppStore } from '@/store/app-store';
@@ -30,6 +30,9 @@ import { PromptCustomizationSection } from './settings-view/prompts';
import type { Project as SettingsProject, Theme } from './settings-view/shared/types';
import type { Project as ElectronProject } from '@/lib/electron';
// Breakpoint constant for mobile (matches Tailwind lg breakpoint)
const LG_BREAKPOINT = 1024;
export function SettingsView() {
const {
theme,
@@ -41,6 +44,8 @@ export function SettingsView() {
setEnableDependencyBlocking,
skipVerificationInAutoMode,
setSkipVerificationInAutoMode,
enableAiCommitMessages,
setEnableAiCommitMessages,
useWorktrees,
setUseWorktrees,
muteDoneSound,
@@ -69,6 +74,8 @@ export function SettingsView() {
name: project.name,
path: project.path,
theme: project.theme as Theme | undefined,
icon: project.icon,
customIconPath: project.customIconPath,
};
};
@@ -106,6 +113,33 @@ export function SettingsView() {
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [showKeyboardMapDialog, setShowKeyboardMapDialog] = useState(false);
// Mobile navigation state - default to showing on desktop, hidden on mobile
const [showNavigation, setShowNavigation] = useState(() => {
if (typeof window !== 'undefined') {
return window.innerWidth >= LG_BREAKPOINT;
}
return true; // Default to showing on SSR
});
// Auto-close navigation on mobile when a section is selected
useEffect(() => {
if (typeof window !== 'undefined' && window.innerWidth < LG_BREAKPOINT) {
setShowNavigation(false);
}
}, [activeView]);
// Handle window resize to show/hide navigation appropriately
useEffect(() => {
const handleResize = () => {
if (window.innerWidth >= LG_BREAKPOINT) {
setShowNavigation(true);
}
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
// Render the active section based on current view
const renderActiveSection = () => {
switch (activeView) {
@@ -157,12 +191,14 @@ export function SettingsView() {
skipVerificationInAutoMode={skipVerificationInAutoMode}
defaultPlanningMode={defaultPlanningMode}
defaultRequirePlanApproval={defaultRequirePlanApproval}
enableAiCommitMessages={enableAiCommitMessages}
defaultFeatureModel={defaultFeatureModel}
onDefaultSkipTestsChange={setDefaultSkipTests}
onEnableDependencyBlockingChange={setEnableDependencyBlocking}
onSkipVerificationInAutoModeChange={setSkipVerificationInAutoMode}
onDefaultPlanningModeChange={setDefaultPlanningMode}
onDefaultRequirePlanApprovalChange={setDefaultRequirePlanApproval}
onEnableAiCommitMessagesChange={setEnableAiCommitMessages}
onDefaultFeatureModelChange={setDefaultFeatureModel}
/>
);
@@ -194,20 +230,25 @@ export function SettingsView() {
return (
<div className="flex-1 flex flex-col overflow-hidden content-bg" data-testid="settings-view">
{/* Header Section */}
<SettingsHeader />
<SettingsHeader
showNavigation={showNavigation}
onToggleNavigation={() => setShowNavigation(!showNavigation)}
/>
{/* Content Area with Sidebar */}
<div className="flex-1 flex overflow-hidden">
{/* Side Navigation - No longer scrolls, just switches views */}
{/* Side Navigation - Overlay on mobile, sidebar on desktop */}
<SettingsNavigation
navItems={NAV_ITEMS}
activeSection={activeView}
currentProject={currentProject}
onNavigate={handleNavigate}
isOpen={showNavigation}
onClose={() => setShowNavigation(false)}
/>
{/* Content Panel - Shows only the active section */}
<div className="flex-1 overflow-y-auto p-8">
<div className="flex-1 overflow-y-auto p-4 lg:p-8">
<div className="max-w-4xl mx-auto">{renderActiveSection()}</div>
</div>
</div>

View File

@@ -1,6 +1,157 @@
import { useCallback, useEffect, useState } from 'react';
import { cn } from '@/lib/utils';
import { getElectronAPI } from '@/lib/electron';
import { useSetupStore } from '@/store/setup-store';
import { useAppStore } from '@/store/app-store';
import { Button } from '@/components/ui/button';
import { RefreshCw, AlertCircle } from 'lucide-react';
const ERROR_NO_API = 'Claude usage API not available';
const CLAUDE_USAGE_TITLE = 'Claude Usage';
const CLAUDE_USAGE_SUBTITLE = 'Shows usage limits reported by the Claude CLI.';
const CLAUDE_AUTH_WARNING = 'Authenticate Claude CLI to view usage limits.';
const CLAUDE_LOGIN_COMMAND = 'claude login';
const CLAUDE_NO_USAGE_MESSAGE =
'Usage limits are not available yet. Try refreshing if this persists.';
const UPDATED_LABEL = 'Updated';
const CLAUDE_FETCH_ERROR = 'Failed to fetch usage';
const CLAUDE_REFRESH_LABEL = 'Refresh Claude usage';
const WARNING_THRESHOLD = 75;
const CAUTION_THRESHOLD = 50;
const MAX_PERCENTAGE = 100;
const REFRESH_INTERVAL_MS = 60_000;
const STALE_THRESHOLD_MS = 2 * 60_000;
// Using purple/indigo for Claude branding
const USAGE_COLOR_CRITICAL = 'bg-red-500';
const USAGE_COLOR_WARNING = 'bg-amber-500';
const USAGE_COLOR_OK = 'bg-indigo-500';
/**
* Get the appropriate color class for a usage percentage
*/
function getUsageColor(percentage: number): string {
if (percentage >= WARNING_THRESHOLD) {
return USAGE_COLOR_CRITICAL;
}
if (percentage >= CAUTION_THRESHOLD) {
return USAGE_COLOR_WARNING;
}
return USAGE_COLOR_OK;
}
/**
* Individual usage card displaying a usage metric with progress bar
*/
function UsageCard({
title,
subtitle,
percentage,
resetText,
}: {
title: string;
subtitle: string;
percentage: number;
resetText?: string;
}) {
const safePercentage = Math.min(Math.max(percentage, 0), MAX_PERCENTAGE);
return (
<div className="rounded-xl border border-border/60 bg-card/50 p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-semibold text-foreground">{title}</p>
<p className="text-xs text-muted-foreground">{subtitle}</p>
</div>
<span className="text-sm font-semibold text-foreground">{Math.round(safePercentage)}%</span>
</div>
<div className="mt-3 h-2 w-full rounded-full bg-secondary/60">
<div
className={cn(
'h-full rounded-full transition-all duration-300',
getUsageColor(safePercentage)
)}
style={{ width: `${safePercentage}%` }}
/>
</div>
{resetText && <p className="mt-2 text-xs text-muted-foreground">{resetText}</p>}
</div>
);
}
export function ClaudeUsageSection() {
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
const { claudeUsage, claudeUsageLastUpdated, setClaudeUsage } = useAppStore();
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const canFetchUsage = !!claudeAuthStatus?.authenticated;
// If we have usage data, we can show it even if auth status is unsure
const hasUsage = !!claudeUsage;
const lastUpdatedLabel = claudeUsageLastUpdated
? new Date(claudeUsageLastUpdated).toLocaleString()
: null;
const showAuthWarning =
(!canFetchUsage && !hasUsage && !isLoading) ||
(error && error.includes('Authentication required'));
const isStale =
!claudeUsageLastUpdated || Date.now() - claudeUsageLastUpdated > STALE_THRESHOLD_MS;
const fetchUsage = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const api = getElectronAPI();
if (!api.claude) {
setError(ERROR_NO_API);
return;
}
const result = await api.claude.getUsage();
if ('error' in result) {
// Check for auth errors specifically
if (
result.message?.includes('Authentication required') ||
result.error?.includes('Authentication required')
) {
// We'll show the auth warning UI instead of a generic error
} else {
setError(result.message || result.error);
}
return;
}
setClaudeUsage(result);
} catch (fetchError) {
const message = fetchError instanceof Error ? fetchError.message : CLAUDE_FETCH_ERROR;
setError(message);
} finally {
setIsLoading(false);
}
}, [setClaudeUsage]);
useEffect(() => {
// Initial fetch if authenticated and stale
// Compute staleness inside effect to avoid re-running when Date.now() changes
const isDataStale =
!claudeUsageLastUpdated || Date.now() - claudeUsageLastUpdated > STALE_THRESHOLD_MS;
if (canFetchUsage && isDataStale) {
void fetchUsage();
}
}, [fetchUsage, canFetchUsage, claudeUsageLastUpdated]);
useEffect(() => {
if (!canFetchUsage) return undefined;
const intervalId = setInterval(() => {
void fetchUsage();
}, REFRESH_INTERVAL_MS);
return () => clearInterval(intervalId);
}, [fetchUsage, canFetchUsage]);
return (
<div
className={cn(
@@ -12,30 +163,73 @@ export function ClaudeUsageSection() {
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center gap-3 mb-2">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-green-500/20 to-green-600/10 flex items-center justify-center border border-green-500/20">
<div className="w-5 h-5 rounded-full bg-green-500/50" />
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-indigo-500/20 to-indigo-600/10 flex items-center justify-center border border-indigo-500/20">
<div className="w-5 h-5 rounded-full bg-indigo-500/50" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">
Claude Usage Tracking
{CLAUDE_USAGE_TITLE}
</h2>
<Button
variant="ghost"
size="icon"
onClick={fetchUsage}
disabled={isLoading}
className="ml-auto h-9 w-9 rounded-lg hover:bg-accent/50"
data-testid="refresh-claude-usage"
title={CLAUDE_REFRESH_LABEL}
>
<RefreshCw className={cn('w-4 h-4', isLoading && 'animate-spin')} />
</Button>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Track your Claude Code usage limits. Uses the Claude CLI for data.
</p>
<p className="text-sm text-muted-foreground/80 ml-12">{CLAUDE_USAGE_SUBTITLE}</p>
</div>
<div className="p-6 space-y-6">
{/* Info about CLI requirement */}
<div className="rounded-lg bg-secondary/30 p-3 text-xs text-muted-foreground space-y-2 border border-border/50">
<p>Usage tracking requires Claude Code CLI to be installed and authenticated:</p>
<ol className="list-decimal list-inside space-y-1 ml-1">
<li>Install Claude Code CLI if not already installed</li>
<li>
Run <code className="font-mono bg-muted px-1 rounded">claude login</code> to
authenticate
</li>
<li>Usage data will be fetched automatically every ~minute</li>
</ol>
</div>
<div className="p-6 space-y-4">
{showAuthWarning && (
<div className="flex items-start gap-3 p-4 rounded-xl bg-amber-500/10 border border-amber-500/20">
<AlertCircle className="w-5 h-5 text-amber-500 mt-0.5" />
<div className="text-sm text-amber-400">
{CLAUDE_AUTH_WARNING} Run <span className="font-mono">{CLAUDE_LOGIN_COMMAND}</span>.
</div>
</div>
)}
{error && !showAuthWarning && (
<div className="flex items-start gap-3 p-4 rounded-xl bg-red-500/10 border border-red-500/20">
<AlertCircle className="w-5 h-5 text-red-500 mt-0.5" />
<div className="text-sm text-red-400">{error}</div>
</div>
)}
{hasUsage && (
<div className="grid gap-3 sm:grid-cols-2">
<UsageCard
title="Session Limit"
subtitle="5-hour rolling window"
percentage={claudeUsage.sessionPercentage}
resetText={claudeUsage.sessionResetText}
/>
<UsageCard
title="Weekly Limit"
subtitle="Resets every Thursday"
percentage={claudeUsage.weeklyPercentage}
resetText={claudeUsage.weeklyResetText}
/>
</div>
)}
{!hasUsage && !error && !showAuthWarning && !isLoading && (
<div className="rounded-xl border border-border/60 bg-secondary/20 p-4 text-xs text-muted-foreground">
{CLAUDE_NO_USAGE_MESSAGE}
</div>
)}
{lastUpdatedLabel && (
<div className="text-[10px] text-muted-foreground text-right">
{UPDATED_LABEL} {lastUpdatedLabel}
</div>
)}
</div>
</div>
);

View File

@@ -1,8 +1,14 @@
import { useState } from 'react';
import { useState, useRef, useEffect } from 'react';
import { Label } from '@/components/ui/label';
import { Palette, Moon, Sun } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Palette, Moon, Sun, Upload, X, ImageIcon } from 'lucide-react';
import { darkThemes, lightThemes } from '@/config/theme-options';
import { cn } from '@/lib/utils';
import { useAppStore } from '@/store/app-store';
import { IconPicker } from '@/components/layout/project-switcher/components/icon-picker';
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
import { getHttpApiClient } from '@/lib/http-api-client';
import type { Theme, Project } from '../shared/types';
interface AppearanceSectionProps {
@@ -16,10 +22,97 @@ export function AppearanceSection({
currentProject,
onThemeChange,
}: AppearanceSectionProps) {
const { setProjectIcon, setProjectName, setProjectCustomIcon } = useAppStore();
const [activeTab, setActiveTab] = useState<'dark' | 'light'>('dark');
const [projectName, setProjectNameLocal] = useState(currentProject?.name || '');
const [projectIcon, setProjectIconLocal] = useState<string | null>(currentProject?.icon || null);
const [customIconPath, setCustomIconPathLocal] = useState<string | null>(
currentProject?.customIconPath || null
);
const [isUploadingIcon, setIsUploadingIcon] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Sync local state when currentProject changes
useEffect(() => {
setProjectNameLocal(currentProject?.name || '');
setProjectIconLocal(currentProject?.icon || null);
setCustomIconPathLocal(currentProject?.customIconPath || null);
}, [currentProject]);
const themesToShow = activeTab === 'dark' ? darkThemes : lightThemes;
// Auto-save when values change
const handleNameChange = (name: string) => {
setProjectNameLocal(name);
if (currentProject && name.trim() && name.trim() !== currentProject.name) {
setProjectName(currentProject.id, name.trim());
}
};
const handleIconChange = (icon: string | null) => {
setProjectIconLocal(icon);
if (currentProject) {
setProjectIcon(currentProject.id, icon);
}
};
const handleCustomIconChange = (path: string | null) => {
setCustomIconPathLocal(path);
if (currentProject) {
setProjectCustomIcon(currentProject.id, path);
// Clear Lucide icon when custom icon is set
if (path) {
setProjectIconLocal(null);
setProjectIcon(currentProject.id, null);
}
}
};
const handleCustomIconUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !currentProject) return;
// Validate file type
const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if (!validTypes.includes(file.type)) {
return;
}
// Validate file size (max 2MB for icons)
if (file.size > 2 * 1024 * 1024) {
return;
}
setIsUploadingIcon(true);
try {
// Convert to base64
const reader = new FileReader();
reader.onload = async () => {
const base64Data = reader.result as string;
const result = await getHttpApiClient().saveImageToTemp(
base64Data,
`project-icon-${file.name}`,
file.type,
currentProject.path
);
if (result.success && result.path) {
handleCustomIconChange(result.path);
}
setIsUploadingIcon(false);
};
reader.readAsDataURL(file);
} catch {
setIsUploadingIcon(false);
}
};
const handleRemoveCustomIcon = () => {
handleCustomIconChange(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
return (
<div
className={cn(
@@ -40,7 +133,87 @@ export function AppearanceSection({
Customize the look and feel of your application.
</p>
</div>
<div className="p-6 space-y-4">
<div className="p-6 space-y-6">
{/* Project Details Section */}
{currentProject && (
<div className="space-y-4 pb-6 border-b border-border/50">
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="project-name-settings">Project Name</Label>
<Input
id="project-name-settings"
value={projectName}
onChange={(e) => handleNameChange(e.target.value)}
placeholder="Enter project name"
/>
</div>
<div className="space-y-2">
<Label>Project Icon</Label>
<p className="text-xs text-muted-foreground mb-2">
Choose a preset icon or upload a custom image
</p>
{/* Custom Icon Upload */}
<div className="mb-4">
<div className="flex items-center gap-3">
{customIconPath ? (
<div className="relative">
<img
src={getAuthenticatedImageUrl(customIconPath, currentProject.path)}
alt="Custom project icon"
className="w-12 h-12 rounded-lg object-cover border border-border"
/>
<button
type="button"
onClick={handleRemoveCustomIcon}
className="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full bg-destructive text-destructive-foreground flex items-center justify-center hover:bg-destructive/90"
>
<X className="w-3 h-3" />
</button>
</div>
) : (
<div className="w-12 h-12 rounded-lg border border-dashed border-border flex items-center justify-center bg-accent/30">
<ImageIcon className="w-5 h-5 text-muted-foreground" />
</div>
)}
<div className="flex-1">
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
onChange={handleCustomIconUpload}
className="hidden"
id="custom-icon-upload"
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={isUploadingIcon}
className="gap-1.5"
>
<Upload className="w-3.5 h-3.5" />
{isUploadingIcon ? 'Uploading...' : 'Upload Custom Icon'}
</Button>
<p className="text-xs text-muted-foreground mt-1">
PNG, JPG, GIF or WebP. Max 2MB.
</p>
</div>
</div>
</div>
{/* Preset Icon Picker - only show if no custom icon */}
{!customIconPath && (
<IconPicker selectedIcon={projectIcon} onSelectIcon={handleIconChange} />
)}
</div>
</div>
</div>
)}
{/* Theme Section */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label className="text-foreground font-medium">

View File

@@ -1,14 +1,19 @@
import { Settings } from 'lucide-react';
import { Settings, PanelLeft, PanelLeftClose } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
interface SettingsHeaderProps {
title?: string;
description?: string;
showNavigation?: boolean;
onToggleNavigation?: () => void;
}
export function SettingsHeader({
title = 'Settings',
description = 'Configure your API keys and preferences',
showNavigation,
onToggleNavigation,
}: SettingsHeaderProps) {
return (
<div
@@ -18,21 +23,39 @@ export function SettingsHeader({
'bg-gradient-to-r from-card/90 via-card/70 to-card/80 backdrop-blur-xl'
)}
>
<div className="px-8 py-6">
<div className="flex items-center gap-4">
<div className="px-4 py-4 lg:px-8 lg:py-6">
<div className="flex items-center gap-3 lg:gap-4">
{/* Mobile menu toggle button - only visible on mobile */}
{onToggleNavigation && (
<Button
variant="ghost"
size="sm"
onClick={onToggleNavigation}
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" />
)}
</Button>
)}
<div
className={cn(
'w-12 h-12 rounded-2xl flex items-center justify-center',
'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-6 h-6 text-white" />
<Settings className="w-5 h-5 lg:w-6 lg:h-6 text-white" />
</div>
<div>
<h1 className="text-2xl font-bold text-foreground tracking-tight">{title}</h1>
<p className="text-sm text-muted-foreground/80 mt-0.5">{description}</p>
<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>

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
import { ChevronDown, ChevronRight } from 'lucide-react';
import { ChevronDown, ChevronRight, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import type { Project } from '@/lib/electron';
import type { NavigationItem, NavigationGroup } from '../config/navigation';
import { GLOBAL_NAV_GROUPS, PROJECT_NAV_ITEMS } from '../config/navigation';
@@ -13,6 +14,8 @@ interface SettingsNavigationProps {
activeSection: SettingsViewId;
currentProject: Project | null;
onNavigate: (sectionId: SettingsViewId) => void;
isOpen?: boolean;
onClose?: () => void;
}
function NavButton({
@@ -167,75 +170,116 @@ export function SettingsNavigation({
activeSection,
currentProject,
onNavigate,
isOpen = true,
onClose,
}: SettingsNavigationProps) {
// On mobile, only show when isOpen is true
// On desktop (lg+), always show regardless of isOpen
// The desktop visibility is handled by CSS, but we need to render on mobile only when open
return (
<nav
className={cn(
'hidden lg:block w-64 shrink-0 overflow-y-auto',
'border-r border-border/50',
'bg-gradient-to-b from-card/80 via-card/60 to-card/40 backdrop-blur-xl'
<>
{/* Mobile backdrop overlay - only shown when isOpen is true on mobile */}
{isOpen && (
<div
className="fixed inset-0 bg-black/50 z-20 lg:hidden"
onClick={onClose}
data-testid="settings-nav-backdrop"
/>
)}
>
<div className="sticky top-0 p-4 space-y-1">
{/* Global Settings Groups */}
{GLOBAL_NAV_GROUPS.map((group, groupIndex) => (
<div key={group.label}>
{/* Group divider (except for first group) */}
{groupIndex > 0 && <div className="my-3 border-t border-border/50" />}
{/* Group Label */}
<div className="px-3 py-2 text-xs font-semibold text-muted-foreground/70 uppercase tracking-wider">
{group.label}
{/* Navigation sidebar */}
<nav
className={cn(
// Mobile: fixed position overlay with slide transition
'fixed inset-y-0 left-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',
// 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',
'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'
)}
>
{/* Mobile close button */}
<div className="lg:hidden flex items-center justify-between px-4 py-3 border-b border-border/50">
<span className="text-sm font-semibold text-foreground">Navigation</span>
<Button
variant="ghost"
size="sm"
onClick={onClose}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
aria-label="Close navigation menu"
>
<X className="w-4 h-4" />
</Button>
</div>
<div className="sticky top-0 p-4 space-y-1">
{/* Global Settings Groups */}
{GLOBAL_NAV_GROUPS.map((group, groupIndex) => (
<div key={group.label}>
{/* Group divider (except for first group) */}
{groupIndex > 0 && <div className="my-3 border-t border-border/50" />}
{/* Group Label */}
<div className="px-3 py-2 text-xs font-semibold text-muted-foreground/70 uppercase tracking-wider">
{group.label}
</div>
{/* Group Items */}
<div className="space-y-1">
{group.items.map((item) =>
item.subItems ? (
<NavItemWithSubItems
key={item.id}
item={item}
activeSection={activeSection}
onNavigate={onNavigate}
/>
) : (
<NavButton
key={item.id}
item={item}
isActive={activeSection === item.id}
onNavigate={onNavigate}
/>
)
)}
</div>
</div>
))}
{/* Group Items */}
<div className="space-y-1">
{group.items.map((item) =>
item.subItems ? (
<NavItemWithSubItems
key={item.id}
item={item}
activeSection={activeSection}
onNavigate={onNavigate}
/>
) : (
{/* Project Settings - only show when a project is selected */}
{currentProject && (
<>
{/* Divider */}
<div className="my-3 border-t border-border/50" />
{/* Project Settings Label */}
<div className="px-3 py-2 text-xs font-semibold text-muted-foreground/70 uppercase tracking-wider">
Project Settings
</div>
{/* Project Settings Items */}
<div className="space-y-1">
{PROJECT_NAV_ITEMS.map((item) => (
<NavButton
key={item.id}
item={item}
isActive={activeSection === item.id}
onNavigate={onNavigate}
/>
)
)}
</div>
</div>
))}
{/* Project Settings - only show when a project is selected */}
{currentProject && (
<>
{/* Divider */}
<div className="my-3 border-t border-border/50" />
{/* Project Settings Label */}
<div className="px-3 py-2 text-xs font-semibold text-muted-foreground/70 uppercase tracking-wider">
Project Settings
</div>
{/* Project Settings Items */}
<div className="space-y-1">
{PROJECT_NAV_ITEMS.map((item) => (
<NavButton
key={item.id}
item={item}
isActive={activeSection === item.id}
onNavigate={onNavigate}
/>
))}
</div>
</>
)}
</div>
</nav>
))}
</div>
</>
)}
</div>
</nav>
</>
);
}

View File

@@ -10,6 +10,7 @@ import {
ScrollText,
ShieldCheck,
FastForward,
Sparkles,
Cpu,
} from 'lucide-react';
import { cn } from '@/lib/utils';
@@ -31,12 +32,14 @@ interface FeatureDefaultsSectionProps {
skipVerificationInAutoMode: boolean;
defaultPlanningMode: PlanningMode;
defaultRequirePlanApproval: boolean;
enableAiCommitMessages: boolean;
defaultFeatureModel: PhaseModelEntry;
onDefaultSkipTestsChange: (value: boolean) => void;
onEnableDependencyBlockingChange: (value: boolean) => void;
onSkipVerificationInAutoModeChange: (value: boolean) => void;
onDefaultPlanningModeChange: (value: PlanningMode) => void;
onDefaultRequirePlanApprovalChange: (value: boolean) => void;
onEnableAiCommitMessagesChange: (value: boolean) => void;
onDefaultFeatureModelChange: (value: PhaseModelEntry) => void;
}
@@ -46,12 +49,14 @@ export function FeatureDefaultsSection({
skipVerificationInAutoMode,
defaultPlanningMode,
defaultRequirePlanApproval,
enableAiCommitMessages,
defaultFeatureModel,
onDefaultSkipTestsChange,
onEnableDependencyBlockingChange,
onSkipVerificationInAutoModeChange,
onDefaultPlanningModeChange,
onDefaultRequirePlanApprovalChange,
onEnableAiCommitMessagesChange,
onDefaultFeatureModelChange,
}: FeatureDefaultsSectionProps) {
return (
@@ -281,6 +286,34 @@ export function FeatureDefaultsSection({
</p>
</div>
</div>
{/* Separator */}
<div className="border-t border-border/30" />
{/* AI Commit Messages Setting */}
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
<Checkbox
id="enable-ai-commit-messages"
checked={enableAiCommitMessages}
onCheckedChange={(checked) => onEnableAiCommitMessagesChange(checked === true)}
className="mt-1"
data-testid="enable-ai-commit-messages-checkbox"
/>
<div className="space-y-1.5">
<Label
htmlFor="enable-ai-commit-messages"
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
>
<Sparkles className="w-4 h-4 text-brand-500" />
Generate AI commit messages
</Label>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
When enabled, opening the commit dialog will automatically generate a commit message
using AI based on your staged or unstaged changes. You can configure the model used in
Model Defaults.
</p>
</div>
</div>
</div>
</div>
);

View File

@@ -48,7 +48,7 @@ export function AddEditServerDialog({
Configure an MCP server to extend agent capabilities with custom tools.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-4 py-4 overflow-y-auto">
<div className="space-y-2">
<Label htmlFor="server-name">Name</Label>
<Input

View File

@@ -28,6 +28,11 @@ const QUICK_TASKS: PhaseConfig[] = [
label: 'Image Descriptions',
description: 'Analyzes and describes context images',
},
{
key: 'commitMessageModel',
label: 'Commit Messages',
description: 'Generates git commit messages from diffs',
},
];
const VALIDATION_TASKS: PhaseConfig[] = [

View File

@@ -1,6 +1,7 @@
import { Fragment, useEffect, useMemo, useRef, useState } from 'react';
import { cn } from '@/lib/utils';
import { useAppStore } from '@/store/app-store';
import { useIsMobile } from '@/hooks/use-media-query';
import type {
ModelAlias,
CursorModelId,
@@ -167,6 +168,9 @@ export function PhaseModelSelector({
dynamicOpencodeModels,
} = useAppStore();
// Detect mobile devices to use inline expansion instead of nested popovers
const isMobile = useIsMobile();
// Extract model and thinking/reasoning levels from value
const selectedModel = value.model;
const selectedThinkingLevel = value.thinkingLevel || 'none';
@@ -585,6 +589,107 @@ export function PhaseModelSelector({
}
// Model supports reasoning - show popover with reasoning effort options
// On mobile, render inline expansion instead of nested popover
if (isMobile) {
return (
<div key={model.id}>
<CommandItem
value={model.label}
onSelect={() => setExpandedCodexModel(isExpanded ? null : (model.id as CodexModelId))}
className="group flex items-center justify-between py-2"
>
<div className="flex items-center gap-3 overflow-hidden">
<OpenAIIcon
className={cn(
'h-4 w-4 shrink-0',
isSelected ? 'text-primary' : 'text-muted-foreground'
)}
/>
<div className="flex flex-col truncate">
<span className={cn('truncate font-medium', isSelected && 'text-primary')}>
{model.label}
</span>
<span className="truncate text-xs text-muted-foreground">
{isSelected && currentReasoning !== 'none'
? `Reasoning: ${REASONING_EFFORT_LABELS[currentReasoning]}`
: model.description}
</span>
</div>
</div>
<div className="flex items-center gap-1 ml-2">
<Button
variant="ghost"
size="icon"
className={cn(
'h-6 w-6 hover:bg-transparent hover:text-yellow-500 focus:ring-0',
isFavorite
? 'text-yellow-500 opacity-100'
: 'opacity-0 group-hover:opacity-100 text-muted-foreground'
)}
onClick={(e) => {
e.stopPropagation();
toggleFavoriteModel(model.id);
}}
>
<Star className={cn('h-3.5 w-3.5', isFavorite && 'fill-current')} />
</Button>
{isSelected && !isExpanded && <Check className="h-4 w-4 text-primary shrink-0" />}
<ChevronRight
className={cn(
'h-4 w-4 text-muted-foreground transition-transform',
isExpanded && 'rotate-90'
)}
/>
</div>
</CommandItem>
{/* Inline reasoning effort options on mobile */}
{isExpanded && (
<div className="pl-6 pr-2 pb-2 space-y-1">
<div className="px-2 py-1 text-xs font-medium text-muted-foreground">
Reasoning Effort
</div>
{REASONING_EFFORT_LEVELS.map((effort) => (
<button
key={effort}
onClick={() => {
onChange({
model: model.id as CodexModelId,
reasoningEffort: effort,
});
setExpandedCodexModel(null);
setOpen(false);
}}
className={cn(
'w-full flex items-center justify-between px-2 py-2 rounded-sm text-sm',
'hover:bg-accent cursor-pointer transition-colors',
isSelected && currentReasoning === effort && 'bg-accent text-accent-foreground'
)}
>
<div className="flex flex-col items-start">
<span className="font-medium text-xs">{REASONING_EFFORT_LABELS[effort]}</span>
<span className="text-[10px] text-muted-foreground">
{effort === 'none' && 'No reasoning capability'}
{effort === 'minimal' && 'Minimal reasoning'}
{effort === 'low' && 'Light reasoning'}
{effort === 'medium' && 'Moderate reasoning'}
{effort === 'high' && 'Deep reasoning'}
{effort === 'xhigh' && 'Maximum reasoning'}
</span>
</div>
{isSelected && currentReasoning === effort && (
<Check className="h-3.5 w-3.5 text-primary" />
)}
</button>
))}
</div>
)}
</div>
);
}
// Desktop: Use nested popover
return (
<CommandItem
key={model.id}
@@ -829,6 +934,106 @@ export function PhaseModelSelector({
const isExpanded = expandedClaudeModel === model.id;
const currentThinking = isSelected ? selectedThinkingLevel : 'none';
// On mobile, render inline expansion instead of nested popover
if (isMobile) {
return (
<div key={model.id}>
<CommandItem
value={model.label}
onSelect={() => setExpandedClaudeModel(isExpanded ? null : (model.id as ModelAlias))}
className="group flex items-center justify-between py-2"
>
<div className="flex items-center gap-3 overflow-hidden">
<AnthropicIcon
className={cn(
'h-4 w-4 shrink-0',
isSelected ? 'text-primary' : 'text-muted-foreground'
)}
/>
<div className="flex flex-col truncate">
<span className={cn('truncate font-medium', isSelected && 'text-primary')}>
{model.label}
</span>
<span className="truncate text-xs text-muted-foreground">
{isSelected && currentThinking !== 'none'
? `Thinking: ${THINKING_LEVEL_LABELS[currentThinking]}`
: model.description}
</span>
</div>
</div>
<div className="flex items-center gap-1 ml-2">
<Button
variant="ghost"
size="icon"
className={cn(
'h-6 w-6 hover:bg-transparent hover:text-yellow-500 focus:ring-0',
isFavorite
? 'text-yellow-500 opacity-100'
: 'opacity-0 group-hover:opacity-100 text-muted-foreground'
)}
onClick={(e) => {
e.stopPropagation();
toggleFavoriteModel(model.id);
}}
>
<Star className={cn('h-3.5 w-3.5', isFavorite && 'fill-current')} />
</Button>
{isSelected && !isExpanded && <Check className="h-4 w-4 text-primary shrink-0" />}
<ChevronRight
className={cn(
'h-4 w-4 text-muted-foreground transition-transform',
isExpanded && 'rotate-90'
)}
/>
</div>
</CommandItem>
{/* Inline thinking level options on mobile */}
{isExpanded && (
<div className="pl-6 pr-2 pb-2 space-y-1">
<div className="px-2 py-1 text-xs font-medium text-muted-foreground">
Thinking Level
</div>
{THINKING_LEVELS.map((level) => (
<button
key={level}
onClick={() => {
onChange({
model: model.id as ModelAlias,
thinkingLevel: level,
});
setExpandedClaudeModel(null);
setOpen(false);
}}
className={cn(
'w-full flex items-center justify-between px-2 py-2 rounded-sm text-sm',
'hover:bg-accent cursor-pointer transition-colors',
isSelected && currentThinking === level && 'bg-accent text-accent-foreground'
)}
>
<div className="flex flex-col items-start">
<span className="font-medium text-xs">{THINKING_LEVEL_LABELS[level]}</span>
<span className="text-[10px] text-muted-foreground">
{level === 'none' && 'No extended thinking'}
{level === 'low' && 'Light reasoning (1k tokens)'}
{level === 'medium' && 'Moderate reasoning (10k tokens)'}
{level === 'high' && 'Deep reasoning (16k tokens)'}
{level === 'ultrathink' && 'Maximum reasoning (32k tokens)'}
</span>
</div>
{isSelected && currentThinking === level && (
<Check className="h-3.5 w-3.5 text-primary" />
)}
</button>
))}
</div>
)}
</div>
);
}
// Desktop: Use nested popover
return (
<CommandItem
key={model.id}
@@ -963,6 +1168,90 @@ export function PhaseModelSelector({
? 'Reasoning Mode'
: 'Capacity Options';
// On mobile, render inline expansion instead of nested popover
if (isMobile) {
return (
<div key={group.baseId}>
<CommandItem
value={group.label}
onSelect={() => setExpandedGroup(isExpanded ? null : group.baseId)}
className="group flex items-center justify-between py-2"
>
<div className="flex items-center gap-3 overflow-hidden">
<CursorIcon
className={cn(
'h-4 w-4 shrink-0',
groupIsSelected ? 'text-primary' : 'text-muted-foreground'
)}
/>
<div className="flex flex-col truncate">
<span className={cn('truncate font-medium', groupIsSelected && 'text-primary')}>
{group.label}
</span>
<span className="truncate text-xs text-muted-foreground">
{selectedVariant ? `Selected: ${selectedVariant.label}` : group.description}
</span>
</div>
</div>
<div className="flex items-center gap-1 ml-2">
{groupIsSelected && !isExpanded && (
<Check className="h-4 w-4 text-primary shrink-0" />
)}
<ChevronRight
className={cn(
'h-4 w-4 text-muted-foreground transition-transform',
isExpanded && 'rotate-90'
)}
/>
</div>
</CommandItem>
{/* Inline variant options on mobile */}
{isExpanded && (
<div className="pl-6 pr-2 pb-2 space-y-1">
<div className="px-2 py-1 text-xs font-medium text-muted-foreground">
{variantTypeLabel}
</div>
{group.variants.map((variant) => (
<button
key={variant.id}
onClick={() => {
onChange({ model: variant.id });
setExpandedGroup(null);
setOpen(false);
}}
className={cn(
'w-full flex items-center justify-between px-2 py-2 rounded-sm text-sm',
'hover:bg-accent cursor-pointer transition-colors',
selectedModel === variant.id && 'bg-accent text-accent-foreground'
)}
>
<div className="flex flex-col items-start">
<span className="font-medium text-xs">{variant.label}</span>
{variant.description && (
<span className="text-[10px] text-muted-foreground">
{variant.description}
</span>
)}
</div>
<div className="flex items-center gap-1.5">
{variant.badge && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
{variant.badge}
</span>
)}
{selectedModel === variant.id && <Check className="h-3.5 w-3.5 text-primary" />}
</div>
</button>
))}
</div>
)}
</div>
);
}
// Desktop: Use nested popover
return (
<CommandItem
key={group.baseId}
@@ -1111,6 +1400,7 @@ export function PhaseModelSelector({
className="w-[320px] p-0"
align={align}
onWheel={(e) => e.stopPropagation()}
onTouchMove={(e) => e.stopPropagation()}
onPointerDownOutside={(e) => {
// Only prevent close if clicking inside a nested popover (thinking level panel)
const target = e.target as HTMLElement;
@@ -1123,7 +1413,7 @@ export function PhaseModelSelector({
<CommandInput placeholder="Search models..." />
<CommandList
ref={commandListRef}
className="max-h-[300px] overflow-y-auto overscroll-contain"
className="max-h-[300px] overflow-y-auto overscroll-contain touch-pan-y"
>
<CommandEmpty>No model found.</CommandEmpty>

View File

@@ -12,6 +12,7 @@ import {
RotateCcw,
Info,
AlertTriangle,
GitCommitHorizontal,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import type { PromptCustomization, CustomPrompt } from '@automaker/types';
@@ -20,6 +21,7 @@ import {
DEFAULT_AGENT_PROMPTS,
DEFAULT_BACKLOG_PLAN_PROMPTS,
DEFAULT_ENHANCEMENT_PROMPTS,
DEFAULT_COMMIT_MESSAGE_PROMPTS,
} from '@automaker/prompts';
interface PromptCustomizationSectionProps {
@@ -219,7 +221,7 @@ export function PromptCustomizationSection({
{/* Tabs */}
<div className="p-6">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid grid-cols-4 w-full">
<TabsList className="grid grid-cols-5 w-full">
<TabsTrigger value="auto-mode" className="gap-2">
<Bot className="w-4 h-4" />
Auto Mode
@@ -236,6 +238,10 @@ export function PromptCustomizationSection({
<Sparkles className="w-4 h-4" />
Enhancement
</TabsTrigger>
<TabsTrigger value="commit-message" className="gap-2">
<GitCommitHorizontal className="w-4 h-4" />
Commit
</TabsTrigger>
</TabsList>
{/* Auto Mode Tab */}
@@ -443,6 +449,34 @@ export function PromptCustomizationSection({
/>
</div>
</TabsContent>
{/* Commit Message Tab */}
<TabsContent value="commit-message" className="space-y-6 mt-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium text-foreground">Commit Message Prompts</h3>
<Button
variant="ghost"
size="sm"
onClick={() => resetToDefaults('commitMessage')}
className="gap-2"
>
<RotateCcw className="w-3 h-3" />
Reset Section
</Button>
</div>
<div className="space-y-4">
<PromptField
label="System Prompt"
description="Instructions for generating git commit messages from diffs. The AI will receive the git diff and generate a conventional commit message."
defaultValue={DEFAULT_COMMIT_MESSAGE_PROMPTS.systemPrompt}
customValue={promptCustomization?.commitMessage?.systemPrompt}
onCustomValueChange={(value) =>
updatePrompt('commitMessage', 'systemPrompt', value)
}
/>
</div>
</TabsContent>
</Tabs>
</div>
</div>

View File

@@ -26,6 +26,8 @@ export interface Project {
name: string;
path: string;
theme?: string;
icon?: string;
customIconPath?: string;
}
export interface ApiKeys {

View File

@@ -50,7 +50,7 @@ export function CreateSpecDialog({
<DialogDescription className="text-muted-foreground">{description}</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-4 py-4 overflow-y-auto">
<div className="space-y-2">
<label className="text-sm font-medium">Project Overview</label>
<p className="text-xs text-muted-foreground">

View File

@@ -51,7 +51,7 @@ export function RegenerateSpecDialog({
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-4 py-4 overflow-y-auto">
<div className="space-y-2">
<label className="text-sm font-medium">Describe your project</label>
<p className="text-xs text-muted-foreground">