Merge branch 'v0.13.0rc' into feat/react-query

Merged latest changes from v0.13.0rc into feat/react-query while preserving
React Query migration. Key merge decisions:

- Kept React Query hooks for data fetching (useRunningAgents, useStopFeature, etc.)
- Added backlog plan handling to running-agents-view stop functionality
- Imported both SkeletonPulse and Spinner for CLI status components
- Used Spinner for refresh buttons across all settings sections
- Preserved isBacklogPlan check in agent-output-modal TaskProgressPanel
- Added handleOpenInIntegratedTerminal to worktree actions while keeping React Query mutations
This commit is contained in:
Shirone
2026-01-19 13:28:43 +01:00
387 changed files with 28102 additions and 6881 deletions

View File

@@ -8,6 +8,7 @@ import { useState, useMemo } from 'react';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Button } from '@/components/ui/button';
import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock, ExternalLink } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import { useSetupStore } from '@/store/setup-store';
import { useClaudeUsage } from '@/hooks/queries';
@@ -193,7 +194,7 @@ export function ClaudeUsagePopover() {
</div>
) : isLoading || !claudeUsage ? (
<div className="flex flex-col items-center justify-center py-8 space-y-2">
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground/50" />
<Spinner size="lg" />
<p className="text-xs text-muted-foreground">Loading usage data...</p>
</div>
) : (

View File

@@ -2,6 +2,7 @@ import { useState, useMemo } from 'react';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Button } from '@/components/ui/button';
import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock, ExternalLink } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import { useSetupStore } from '@/store/setup-store';
import { useCodexUsage } from '@/hooks/queries';
@@ -273,7 +274,7 @@ export function CodexUsagePopover() {
) : !codexUsage ? (
// Loading state
<div className="flex flex-col items-center justify-center py-8 space-y-2">
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground/50" />
<Spinner size="lg" />
<p className="text-xs text-muted-foreground">Loading usage data...</p>
</div>
) : codexUsage.rateLimits ? (

View File

@@ -1,6 +1,7 @@
import { useState, useRef, useCallback, useEffect } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { ImageIcon, Upload, Loader2, Trash2 } from 'lucide-react';
import { ImageIcon, Upload, Trash2 } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
const logger = createLogger('BoardBackgroundModal');
import {
@@ -313,7 +314,7 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
/>
{isProcessing && (
<div className="absolute inset-0 flex items-center justify-center bg-background/80">
<Loader2 className="w-6 h-6 animate-spin text-brand-500" />
<Spinner size="lg" />
</div>
)}
</div>
@@ -353,7 +354,7 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa
)}
>
{isProcessing ? (
<Upload className="h-6 w-6 animate-spin text-muted-foreground" />
<Spinner size="lg" />
) : (
<ImageIcon className="h-6 w-6 text-muted-foreground" />
)}

View File

@@ -14,16 +14,8 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Badge } from '@/components/ui/badge';
import {
FolderPlus,
FolderOpen,
Rocket,
ExternalLink,
Check,
Loader2,
Link,
Folder,
} from 'lucide-react';
import { FolderPlus, FolderOpen, Rocket, ExternalLink, Check, Link, Folder } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { starterTemplates, type StarterTemplate } from '@/lib/templates';
import { getElectronAPI } from '@/lib/electron';
import { cn } from '@/lib/utils';
@@ -451,7 +443,7 @@ export function NewProjectModal({
>
{isCreating ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
<Spinner size="sm" className="mr-2" />
{activeTab === 'template' ? 'Cloning...' : 'Creating...'}
</>
) : (

View File

@@ -7,7 +7,8 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Folder, Loader2, FolderOpen, AlertCircle } from 'lucide-react';
import { Folder, FolderOpen, AlertCircle } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { useWorkspaceDirectories } from '@/hooks/queries';
interface WorkspaceDirectory {
@@ -47,7 +48,7 @@ export function WorkspacePickerModal({ open, onOpenChange, onSelect }: Workspace
<div className="flex-1 overflow-y-auto py-4 min-h-[200px]">
{isLoading && (
<div className="flex flex-col items-center justify-center h-full gap-3">
<Loader2 className="w-8 h-8 text-brand-500 animate-spin" />
<Spinner size="xl" />
<p className="text-sm text-muted-foreground">Loading projects...</p>
</div>
)}

View File

@@ -0,0 +1,213 @@
import type { ComponentType, ComponentProps } from 'react';
import { Terminal } from 'lucide-react';
type IconProps = ComponentProps<'svg'>;
type IconComponent = ComponentType<IconProps>;
/**
* iTerm2 logo icon
*/
export function ITerm2Icon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M2.586 0a2.56 2.56 0 00-2.56 2.56v18.88A2.56 2.56 0 002.586 24h18.88a2.56 2.56 0 002.56-2.56V2.56A2.56 2.56 0 0021.466 0H2.586zm8.143 4.027h2.543v15.946h-2.543V4.027zm-3.816 0h2.544v15.946H6.913V4.027zm7.633 0h2.543v15.946h-2.543V4.027z" />
</svg>
);
}
/**
* Warp terminal logo icon
*/
export function WarpIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M9.2 4.8L7.6 7.6 4.8 5.6l1.6-2.8L9.2 4.8zm5.6 0l1.6 2.8 2.8-2-1.6-2.8-2.8 2zM2.4 12l2.8 1.6L3.6 16 .8 14.4 2.4 12zm19.2 0l1.6 2.4-2.8 1.6-1.6-2.4 2.8-1.6zM7.6 16.4l1.6 2.8-2.8 2-1.6-2.8 2.8-2zm8.8 0l2.8 2-1.6 2.8-2.8-2 1.6-2.8zM12 0L8.4 2 12 4l3.6-2L12 0zm0 20l-3.6 2 3.6 2 3.6-2-3.6-2z" />
</svg>
);
}
/**
* Ghostty terminal logo icon
*/
export function GhosttyIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M12 2C6.48 2 2 6.48 2 12v8c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2v-8c0-5.52-4.48-10-10-10zm-3.5 12a1.5 1.5 0 110-3 1.5 1.5 0 010 3zm7 0a1.5 1.5 0 110-3 1.5 1.5 0 010 3zM12 19c-1.5 0-3-.5-4-1.5v-1c2 1 6 1 8 0v1c-1 1-2.5 1.5-4 1.5z" />
</svg>
);
}
/**
* Alacritty terminal logo icon
*/
export function AlacrittyIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M12 0L1.608 21.6h3.186l1.46-3.032h11.489l1.46 3.032h3.189L12 0zm0 7.29l3.796 7.882H8.204L12 7.29z" />
</svg>
);
}
/**
* WezTerm terminal logo icon
*/
export function WezTermIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M2 4h20v16H2V4zm2 2v12h16V6H4zm2 2h12v2H6V8zm0 4h8v2H6v-2z" />
</svg>
);
}
/**
* Kitty terminal logo icon
*/
export function KittyIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M3.5 7.5L1 5V2.5L3.5 5V7.5zM20.5 7.5L23 5V2.5L20.5 5V7.5zM12 4L6 8v8l6 4 6-4V8l-6-4zm0 2l4 2.67v5.33L12 16.67 8 14V8.67L12 6z" />
</svg>
);
}
/**
* Hyper terminal logo icon
*/
export function HyperIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M11.857 23.995v-7.125H6.486l5.765-10.856-.363-1.072L7.803.001 0 12.191h5.75L0 23.995h11.857zm.286 0h5.753l5.679-11.804h-5.679l5.679-11.804L17.896.388l-5.753 11.803h5.753L12.143 24z" />
</svg>
);
}
/**
* Tabby terminal logo icon
*/
export function TabbyIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M12 2L4 6v12l8 4 8-4V6l-8-4zm0 2l6 3v10l-6 3-6-3V7l6-3z" />
</svg>
);
}
/**
* Rio terminal logo icon
*/
export function RioIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm-1-13h2v6h-2zm0 8h2v2h-2z" />
</svg>
);
}
/**
* Windows Terminal logo icon
*/
export function WindowsTerminalIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M8.165 6L0 9.497v5.006L8.165 18l.413-.206v-4.025L3.197 12l5.381-1.769V6.206L8.165 6zm7.67 0l-.413.206v4.025L20.803 12l-5.381 1.769v4.025l.413.206L24 14.503V9.497L15.835 6z" />
</svg>
);
}
/**
* PowerShell logo icon
*/
export function PowerShellIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M23.181 2.974c.568 0 .923.463.792 1.035l-3.659 15.982c-.13.572-.697 1.035-1.265 1.035H.819c-.568 0-.923-.463-.792-1.035L3.686 4.009c.13-.572.697-1.035 1.265-1.035h18.23zM8.958 16.677c0 .334.276.611.611.611h3.673a.615.615 0 00.611-.611.615.615 0 00-.611-.611h-3.673a.615.615 0 00-.611.611zm5.126-7.016L9.025 14.72c-.241.241-.241.63 0 .872.241.241.63.241.872 0l5.059-5.059c.241-.241.241-.63 0-.872l-5.059-5.059c-.241-.241-.63-.241-.872 0-.241.241-.241.63 0 .872l5.059 5.059c-.334.334-.334.334 0 0z" />
</svg>
);
}
/**
* Command Prompt (cmd) logo icon
*/
export function CmdIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M2 4h20v16H2V4zm2 2v12h16V6H4zm2.5 1.5l3 3-3 3L5 12l3-3zm5.5 5h6v1.5h-6V12z" />
</svg>
);
}
/**
* Git Bash logo icon
*/
export function GitBashIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M23.546 10.93L13.067.452c-.604-.603-1.582-.603-2.188 0L8.708 2.627l2.76 2.76c.645-.215 1.379-.07 1.889.441.516.515.658 1.258.438 1.9l2.658 2.66c.645-.223 1.387-.078 1.9.435.721.72.721 1.884 0 2.604-.719.719-1.881.719-2.6 0-.539-.541-.674-1.337-.404-1.996L12.86 8.955v6.525c.176.086.342.203.488.348.713.721.713 1.883 0 2.6-.719.721-1.889.721-2.609 0-.719-.719-.719-1.879 0-2.598.182-.18.387-.316.605-.406V8.835c-.217-.091-.424-.222-.6-.401-.545-.545-.676-1.342-.396-2.009L7.636 3.7.45 10.881c-.6.605-.6 1.584 0 2.189l10.48 10.477c.604.604 1.582.604 2.186 0l10.43-10.43c.605-.603.605-1.582 0-2.187" />
</svg>
);
}
/**
* GNOME Terminal logo icon
*/
export function GnomeTerminalIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M2 4a2 2 0 00-2 2v12a2 2 0 002 2h20a2 2 0 002-2V6a2 2 0 00-2-2H2zm0 2h20v12H2V6zm2 2v2h2V8H4zm4 0v2h12V8H8zm-4 4v2h2v-2H4zm4 0v2h8v-2H8z" />
</svg>
);
}
/**
* Konsole logo icon
*/
export function KonsoleIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M3 3h18a2 2 0 012 2v14a2 2 0 01-2 2H3a2 2 0 01-2-2V5a2 2 0 012-2zm0 2v14h18V5H3zm2 2l4 4-4 4V7zm6 6h8v2h-8v-2z" />
</svg>
);
}
/**
* macOS Terminal logo icon
*/
export function MacOSTerminalIcon(props: IconProps) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M3 4a2 2 0 00-2 2v12a2 2 0 002 2h18a2 2 0 002-2V6a2 2 0 00-2-2H3zm0 2h18v12H3V6zm2 2l5 4-5 4V8zm7 6h7v2h-7v-2z" />
</svg>
);
}
/**
* Get the appropriate icon component for a terminal ID
*/
export function getTerminalIcon(terminalId: string): IconComponent {
const terminalIcons: Record<string, IconComponent> = {
iterm2: ITerm2Icon,
warp: WarpIcon,
ghostty: GhosttyIcon,
alacritty: AlacrittyIcon,
wezterm: WezTermIcon,
kitty: KittyIcon,
hyper: HyperIcon,
tabby: TabbyIcon,
rio: RioIcon,
'windows-terminal': WindowsTerminalIcon,
powershell: PowerShellIcon,
cmd: CmdIcon,
'git-bash': GitBashIcon,
'gnome-terminal': GnomeTerminalIcon,
konsole: KonsoleIcon,
'terminal-macos': MacOSTerminalIcon,
// Linux terminals - use generic terminal icon
'xfce4-terminal': Terminal,
tilix: Terminal,
terminator: Terminal,
foot: Terminal,
xterm: Terminal,
};
return terminalIcons[terminalId] ?? Terminal;
}

View File

@@ -448,7 +448,9 @@ export function IconPicker({ selectedIcon, onSelectIcon }: IconPickerProps) {
);
const getIconComponent = (iconName: string) => {
return (LucideIcons as Record<string, React.ComponentType<{ className?: string }>>)[iconName];
return (LucideIcons as unknown as Record<string, React.ComponentType<{ className?: string }>>)[
iconName
];
};
return (

View File

@@ -0,0 +1,207 @@
/**
* Notification Bell - Bell icon with unread count and popover
*/
import { useCallback } from 'react';
import { Bell, Check, Trash2, ExternalLink } from 'lucide-react';
import { useNavigate } from '@tanstack/react-router';
import { useNotificationsStore } from '@/store/notifications-store';
import { useLoadNotifications, useNotificationEvents } from '@/hooks/use-notification-events';
import { getHttpApiClient } from '@/lib/http-api-client';
import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import type { Notification } from '@automaker/types';
import { cn } from '@/lib/utils';
/**
* Format a date as relative time (e.g., "2 minutes ago", "3 hours ago")
*/
function formatRelativeTime(date: Date): string {
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
if (diffSec < 60) return 'just now';
if (diffMin < 60) return `${diffMin} minute${diffMin === 1 ? '' : 's'} ago`;
if (diffHour < 24) return `${diffHour} hour${diffHour === 1 ? '' : 's'} ago`;
if (diffDay < 7) return `${diffDay} day${diffDay === 1 ? '' : 's'} ago`;
return date.toLocaleDateString();
}
interface NotificationBellProps {
projectPath: string | null;
}
export function NotificationBell({ projectPath }: NotificationBellProps) {
const navigate = useNavigate();
const {
notifications,
unreadCount,
isPopoverOpen,
setPopoverOpen,
markAsRead,
dismissNotification,
} = useNotificationsStore();
// Load notifications and subscribe to events
useLoadNotifications(projectPath);
useNotificationEvents(projectPath);
const handleMarkAsRead = useCallback(
async (notificationId: string) => {
if (!projectPath) return;
// Optimistic update
markAsRead(notificationId);
// Sync with server
const api = getHttpApiClient();
await api.notifications.markAsRead(projectPath, notificationId);
},
[projectPath, markAsRead]
);
const handleDismiss = useCallback(
async (notificationId: string) => {
if (!projectPath) return;
// Optimistic update
dismissNotification(notificationId);
// Sync with server
const api = getHttpApiClient();
await api.notifications.dismiss(projectPath, notificationId);
},
[projectPath, dismissNotification]
);
const handleNotificationClick = useCallback(
(notification: Notification) => {
// Mark as read
handleMarkAsRead(notification.id);
setPopoverOpen(false);
// Navigate to the relevant view based on notification type
if (notification.featureId) {
navigate({ to: '/board' });
}
},
[handleMarkAsRead, setPopoverOpen, navigate]
);
const handleViewAll = useCallback(() => {
setPopoverOpen(false);
navigate({ to: '/notifications' });
}, [setPopoverOpen, navigate]);
const getNotificationIcon = (type: string) => {
switch (type) {
case 'feature_waiting_approval':
return <Bell className="h-4 w-4 text-yellow-500" />;
case 'feature_verified':
return <Check className="h-4 w-4 text-green-500" />;
case 'spec_regeneration_complete':
return <Check className="h-4 w-4 text-blue-500" />;
default:
return <Bell className="h-4 w-4" />;
}
};
// Show recent 3 notifications in popover
const recentNotifications = notifications.slice(0, 3);
if (!projectPath) {
return null;
}
return (
<Popover open={isPopoverOpen} onOpenChange={setPopoverOpen}>
<PopoverTrigger asChild>
<button
className={cn(
'relative flex items-center justify-center w-8 h-8 rounded-md',
'hover:bg-accent transition-colors',
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2'
)}
title="Notifications"
>
<Bell className="h-4 w-4" />
{unreadCount > 0 && (
<span className="absolute -top-1 -right-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-medium text-primary-foreground">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</button>
</PopoverTrigger>
<PopoverContent className="w-80 p-0" align="start" side="right">
<div className="flex items-center justify-between px-4 py-3 border-b">
<h4 className="font-medium text-sm">Notifications</h4>
{unreadCount > 0 && (
<span className="text-xs text-muted-foreground">{unreadCount} unread</span>
)}
</div>
{recentNotifications.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 px-4">
<Bell className="h-8 w-8 text-muted-foreground/50 mb-2" />
<p className="text-sm text-muted-foreground">No notifications</p>
</div>
) : (
<div className="max-h-[300px] overflow-y-auto">
{recentNotifications.map((notification) => (
<div
key={notification.id}
className={cn(
'flex items-start gap-3 px-4 py-3 cursor-pointer hover:bg-accent/50 border-b last:border-b-0',
!notification.read && 'bg-primary/5'
)}
onClick={() => handleNotificationClick(notification)}
>
<div className="flex-shrink-0 mt-0.5">{getNotificationIcon(notification.type)}</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<p className="text-sm font-medium truncate">{notification.title}</p>
{!notification.read && (
<span className="h-1.5 w-1.5 rounded-full bg-primary flex-shrink-0" />
)}
</div>
<p className="text-xs text-muted-foreground line-clamp-2 mt-0.5">
{notification.message}
</p>
<p className="text-[10px] text-muted-foreground mt-1">
{formatRelativeTime(new Date(notification.createdAt))}
</p>
</div>
<div className="flex-shrink-0 flex flex-col gap-1">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={(e) => {
e.stopPropagation();
handleDismiss(notification.id);
}}
title="Dismiss"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
))}
</div>
)}
{notifications.length > 0 && (
<div className="border-t px-4 py-2">
<Button variant="ghost" size="sm" className="w-full text-xs" onClick={handleViewAll}>
View all notifications
</Button>
</div>
)}
</PopoverContent>
</Popover>
);
}

View File

@@ -1,6 +1,7 @@
import { useEffect, useRef, useState, memo } from 'react';
import { useEffect, useRef, useState, memo, useCallback } from 'react';
import type { LucideIcon } from 'lucide-react';
import { Edit2, Trash2, Palette, ChevronRight, Moon, Sun, Monitor } from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import { type ThemeMode, useAppStore } from '@/store/app-store';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
@@ -8,6 +9,9 @@ import type { Project } from '@/lib/electron';
import { PROJECT_DARK_THEMES, PROJECT_LIGHT_THEMES } from '@/components/layout/sidebar/constants';
import { useThemePreview } from '@/components/layout/sidebar/hooks';
// Constant for "use global theme" option
const USE_GLOBAL_THEME = '' as const;
// Constants for z-index values
const Z_INDEX = {
CONTEXT_MENU: 100,
@@ -124,19 +128,26 @@ export function ProjectContextMenu({
} = useAppStore();
const [showRemoveDialog, setShowRemoveDialog] = useState(false);
const [showThemeSubmenu, setShowThemeSubmenu] = useState(false);
const [removeConfirmed, setRemoveConfirmed] = useState(false);
const themeSubmenuRef = useRef<HTMLDivElement>(null);
const { handlePreviewEnter, handlePreviewLeave } = useThemePreview({ setPreviewTheme });
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
const handleClickOutside = (event: globalThis.MouseEvent) => {
// Don't close if a confirmation dialog is open (dialog is in a portal)
if (showRemoveDialog) return;
if (menuRef.current && !menuRef.current.contains(event.target as globalThis.Node)) {
setPreviewTheme(null);
onClose();
}
};
const handleEscape = (event: KeyboardEvent) => {
const handleEscape = (event: globalThis.KeyboardEvent) => {
// Don't close if a confirmation dialog is open (let the dialog handle escape)
if (showRemoveDialog) return;
if (event.key === 'Escape') {
setPreviewTheme(null);
onClose();
@@ -150,7 +161,7 @@ export function ProjectContextMenu({
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleEscape);
};
}, [onClose, setPreviewTheme]);
}, [onClose, setPreviewTheme, showRemoveDialog]);
const handleEdit = () => {
onEdit(project);
@@ -160,166 +171,187 @@ export function ProjectContextMenu({
setShowRemoveDialog(true);
};
const handleThemeSelect = (value: ThemeMode | '') => {
setPreviewTheme(null);
if (value !== '') {
setTheme(value);
} else {
setTheme(globalTheme);
}
setProjectTheme(project.id, value === '' ? null : value);
setShowThemeSubmenu(false);
};
const handleThemeSelect = useCallback(
(value: ThemeMode | typeof USE_GLOBAL_THEME) => {
setPreviewTheme(null);
const isUsingGlobal = value === USE_GLOBAL_THEME;
setTheme(isUsingGlobal ? globalTheme : value);
setProjectTheme(project.id, isUsingGlobal ? null : value);
setShowThemeSubmenu(false);
},
[globalTheme, project.id, setPreviewTheme, setProjectTheme, setTheme]
);
const handleConfirmRemove = () => {
const handleConfirmRemove = useCallback(() => {
moveProjectToTrash(project.id);
onClose();
};
toast.success('Project removed', {
description: `${project.name} has been removed from your projects list`,
});
setRemoveConfirmed(true);
}, [moveProjectToTrash, project.id, project.name]);
const handleDialogClose = useCallback(
(isOpen: boolean) => {
setShowRemoveDialog(isOpen);
// Close the context menu when dialog closes (whether confirmed or cancelled)
// This prevents the context menu from reappearing after dialog interaction
if (!isOpen) {
// Reset confirmation state
setRemoveConfirmed(false);
// Always close the context menu when dialog closes
onClose();
}
},
[onClose]
);
return (
<>
<div
ref={menuRef}
className={cn(
'fixed 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,
zIndex: Z_INDEX.CONTEXT_MENU,
}}
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>
{/* Theme Submenu Trigger */}
<div
className="relative"
onMouseEnter={() => setShowThemeSubmenu(true)}
onMouseLeave={() => {
setShowThemeSubmenu(false);
setPreviewTheme(null);
}}
>
{/* Hide context menu when confirm dialog is open */}
{!showRemoveDialog && (
<div
ref={menuRef}
className={cn(
'fixed 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,
zIndex: Z_INDEX.CONTEXT_MENU,
}}
data-testid="project-context-menu"
>
<div className="p-1">
<button
onClick={() => setShowThemeSubmenu(!showThemeSubmenu)}
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="theme-project-button"
data-testid="edit-project-button"
>
<Palette className="w-4 h-4" />
<span className="flex-1">Project Theme</span>
{project.theme && (
<span className="text-[10px] text-muted-foreground capitalize">
{project.theme}
</span>
)}
<ChevronRight className="w-4 h-4 text-muted-foreground" />
<Edit2 className="w-4 h-4" />
<span>Edit Name & Icon</span>
</button>
{/* Theme Submenu */}
{showThemeSubmenu && (
<div
ref={themeSubmenuRef}
{/* Theme Submenu Trigger */}
<div
className="relative"
onMouseEnter={() => setShowThemeSubmenu(true)}
onMouseLeave={() => {
setShowThemeSubmenu(false);
setPreviewTheme(null);
}}
>
<button
onClick={() => setShowThemeSubmenu(!showThemeSubmenu)}
className={cn(
'absolute left-full top-0 ml-1 min-w-[420px] rounded-lg',
'bg-popover text-popover-foreground',
'border border-border shadow-lg',
'animate-in fade-in zoom-in-95 duration-100'
'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'
)}
style={{ zIndex: Z_INDEX.THEME_SUBMENU }}
data-testid="project-theme-submenu"
data-testid="theme-project-button"
>
<div className="p-2">
{/* Use Global Option */}
<button
onPointerEnter={() => handlePreviewEnter(globalTheme)}
onPointerLeave={handlePreviewLeave}
onClick={() => handleThemeSelect('')}
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',
!project.theme && 'bg-accent'
)}
data-testid="project-theme-global"
>
<Monitor className="w-4 h-4" />
<span>Use Global</span>
<span className="text-[10px] text-muted-foreground ml-1 capitalize">
({globalTheme})
</span>
</button>
<Palette className="w-4 h-4" />
<span className="flex-1">Project Theme</span>
{project.theme && (
<span className="text-[10px] text-muted-foreground capitalize">
{project.theme}
</span>
)}
<ChevronRight className="w-4 h-4 text-muted-foreground" />
</button>
<div className="h-px bg-border my-2" />
{/* Theme Submenu */}
{showThemeSubmenu && (
<div
ref={themeSubmenuRef}
className={cn(
'absolute left-full top-0 ml-1 min-w-[420px] rounded-lg',
'bg-popover text-popover-foreground',
'border border-border shadow-lg',
'animate-in fade-in zoom-in-95 duration-100'
)}
style={{ zIndex: Z_INDEX.THEME_SUBMENU }}
data-testid="project-theme-submenu"
>
<div className="p-2">
{/* Use Global Option */}
<button
onPointerEnter={() => handlePreviewEnter(globalTheme)}
onPointerLeave={handlePreviewLeave}
onClick={() => handleThemeSelect(USE_GLOBAL_THEME)}
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',
!project.theme && 'bg-accent'
)}
data-testid="project-theme-global"
>
<Monitor className="w-4 h-4" />
<span>Use Global</span>
<span className="text-[10px] text-muted-foreground ml-1 capitalize">
({globalTheme})
</span>
</button>
{/* Two Column Layout - Using reusable ThemeColumn component */}
<div className="flex gap-2">
<ThemeColumn
title="Dark"
icon={Moon}
themes={PROJECT_DARK_THEMES as ThemeOption[]}
selectedTheme={project.theme as ThemeMode | null}
onPreviewEnter={handlePreviewEnter}
onPreviewLeave={handlePreviewLeave}
onSelect={handleThemeSelect}
/>
<ThemeColumn
title="Light"
icon={Sun}
themes={PROJECT_LIGHT_THEMES as ThemeOption[]}
selectedTheme={project.theme as ThemeMode | null}
onPreviewEnter={handlePreviewEnter}
onPreviewLeave={handlePreviewLeave}
onSelect={handleThemeSelect}
/>
<div className="h-px bg-border my-2" />
{/* Two Column Layout - Using reusable ThemeColumn component */}
<div className="flex gap-2">
<ThemeColumn
title="Dark"
icon={Moon}
themes={PROJECT_DARK_THEMES as ThemeOption[]}
selectedTheme={project.theme as ThemeMode | null}
onPreviewEnter={handlePreviewEnter}
onPreviewLeave={handlePreviewLeave}
onSelect={handleThemeSelect}
/>
<ThemeColumn
title="Light"
icon={Sun}
themes={PROJECT_LIGHT_THEMES as ThemeOption[]}
selectedTheme={project.theme as ThemeMode | null}
onPreviewEnter={handlePreviewEnter}
onPreviewLeave={handlePreviewLeave}
onSelect={handleThemeSelect}
/>
</div>
</div>
</div>
</div>
)}
</div>
)}
</div>
<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>
<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>
</div>
)}
<ConfirmDialog
open={showRemoveDialog}
onOpenChange={setShowRemoveDialog}
onOpenChange={handleDialogClose}
onConfirm={handleConfirmRemove}
title="Remove Project"
description={`Are you sure you want to remove "${project.name}" from the project list? This won't delete any files on disk.`}

View File

@@ -1,6 +1,6 @@
import { Folder, LucideIcon } from 'lucide-react';
import * as LucideIcons from 'lucide-react';
import { cn } from '@/lib/utils';
import { cn, sanitizeForTestId } from '@/lib/utils';
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
import type { Project } from '@/lib/electron';
@@ -29,7 +29,7 @@ export function ProjectSwitcherItem({
// 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 (LucideIcons as unknown as Record<string, LucideIcon>)[project.icon];
}
return Folder;
};
@@ -37,10 +37,15 @@ export function ProjectSwitcherItem({
const IconComponent = getIconComponent();
const hasCustomIcon = !!project.customIconPath;
// Combine project.id with sanitized name for uniqueness and readability
// Format: project-switcher-{id}-{sanitizedName}
const testId = `project-switcher-${project.id}-${sanitizeForTestId(project.name)}`;
return (
<button
onClick={onClick}
onContextMenu={onContextMenu}
data-testid={testId}
className={cn(
'group w-full aspect-square rounded-xl flex items-center justify-center relative overflow-hidden',
'transition-all duration-200 ease-out',
@@ -60,7 +65,6 @@ export function ProjectSwitcherItem({
'hover:scale-105 active:scale-95'
)}
title={project.name}
data-testid={`project-switcher-${project.id}`}
>
{hasCustomIcon ? (
<img

View File

@@ -1,20 +1,23 @@
import { useState, useCallback, useEffect } from 'react';
import { Plus, Bug, FolderOpen } from 'lucide-react';
import { useNavigate } from '@tanstack/react-router';
import { Plus, Bug, FolderOpen, BookOpen } from 'lucide-react';
import { useNavigate, useLocation } from '@tanstack/react-router';
import { cn } from '@/lib/utils';
import { useAppStore, type ThemeMode } from '@/store/app-store';
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 { NotificationBell } from './components/notification-bell';
import { NewProjectModal } from '@/components/dialogs/new-project-modal';
import { OnboardingDialog } from '@/components/layout/sidebar/dialogs';
import { useProjectCreation, useProjectTheme } from '@/components/layout/sidebar/hooks';
import { useProjectCreation } from '@/components/layout/sidebar/hooks';
import { SIDEBAR_FEATURE_FLAGS } from '@/components/layout/sidebar/constants';
import type { Project } from '@/lib/electron';
import { getElectronAPI } from '@/lib/electron';
import { initializeProject, hasAppSpec, hasAutomakerDir } from '@/lib/project-init';
import { toast } from 'sonner';
import { CreateSpecDialog } from '@/components/views/spec-view/dialogs';
import type { FeatureCount } from '@/components/views/spec-view/types';
function getOSAbbreviation(os: string): string {
switch (os) {
@@ -31,11 +34,13 @@ function getOSAbbreviation(os: string): string {
export function ProjectSwitcher() {
const navigate = useNavigate();
const location = useLocation();
const { hideWiki } = SIDEBAR_FEATURE_FLAGS;
const isWikiActive = location.pathname === '/wiki';
const {
projects,
currentProject,
setCurrentProject,
trashedProjects,
upsertAndSetCurrentProject,
specCreatingForProject,
setSpecCreatingForProject,
@@ -52,7 +57,7 @@ export function ProjectSwitcher() {
const [projectOverview, setProjectOverview] = useState('');
const [generateFeatures, setGenerateFeatures] = useState(true);
const [analyzeProject, setAnalyzeProject] = useState(true);
const [featureCount, setFeatureCount] = useState(5);
const [featureCount, setFeatureCount] = useState<FeatureCount>(50);
// Derive isCreatingSpec from store state
const isCreatingSpec = specCreatingForProject !== null;
@@ -63,9 +68,6 @@ export function ProjectSwitcher() {
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,
@@ -78,9 +80,6 @@ export function ProjectSwitcher() {
handleCreateFromTemplate,
handleCreateFromCustomUrl,
} = useProjectCreation({
trashedProjects,
currentProject,
globalTheme,
upsertAndSetCurrentProject,
});
@@ -124,6 +123,10 @@ export function ProjectSwitcher() {
api.openExternalLink('https://github.com/AutoMaker-Org/automaker/issues');
}, []);
const handleWikiClick = useCallback(() => {
navigate({ to: '/wiki' });
}, [navigate]);
/**
* Opens the system folder selection dialog and initializes the selected project.
*/
@@ -151,13 +154,8 @@ export function ProjectSwitcher() {
}
// Upsert project and set as current (handles both create and update cases)
// Theme preservation is handled by the store action
const trashedProject = trashedProjects.find((p) => p.path === path);
const effectiveTheme =
(trashedProject?.theme as ThemeMode | undefined) ||
(currentProject?.theme as ThemeMode | undefined) ||
globalTheme;
upsertAndSetCurrentProject(path, name, effectiveTheme);
// Theme handling (trashed project recovery or undefined for global) is done by the store
upsertAndSetCurrentProject(path, name);
// Check if app_spec.txt exists
const specExists = await hasAppSpec(path);
@@ -188,7 +186,7 @@ export function ProjectSwitcher() {
});
}
}
}, [trashedProjects, upsertAndSetCurrentProject, currentProject, globalTheme, navigate]);
}, [upsertAndSetCurrentProject, navigate]);
// Handler for creating initial spec from the setup dialog
const handleCreateInitialSpec = useCallback(async () => {
@@ -199,13 +197,18 @@ export function ProjectSwitcher() {
try {
const api = getElectronAPI();
await api.generateAppSpec({
projectPath: setupProjectPath,
if (!api.specRegeneration) {
toast.error('Spec regeneration not available');
setSpecCreatingForProject(null);
return;
}
await api.specRegeneration.create(
setupProjectPath,
projectOverview,
generateFeatures,
analyzeProject,
featureCount,
});
featureCount
);
} catch (error) {
console.error('Failed to generate spec:', error);
toast.error('Failed to generate spec', {
@@ -319,6 +322,11 @@ export function ProjectSwitcher() {
v{appVersion} {versionSuffix}
</span>
</button>
{/* Notification Bell */}
<div className="flex justify-center mt-2">
<NotificationBell projectPath={currentProject?.path ?? null} />
</div>
<div className="w-full h-px bg-border mt-3" />
</div>
@@ -405,8 +413,37 @@ export function ProjectSwitcher() {
)}
</div>
{/* Bug Report Button at the very bottom */}
<div className="p-2 border-t border-border/40">
{/* Wiki and Bug Report Buttons at the very bottom */}
<div className="p-2 border-t border-border/40 space-y-2">
{/* Wiki Button */}
{!hideWiki && (
<button
onClick={handleWikiClick}
className={cn(
'w-full aspect-square rounded-xl flex items-center justify-center',
'transition-all duration-200 ease-out',
isWikiActive
? [
'bg-gradient-to-r from-brand-500/20 via-brand-500/15 to-brand-600/10',
'text-foreground',
'border border-brand-500/30',
'shadow-md shadow-brand-500/10',
]
: [
'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="Wiki"
data-testid="wiki-button"
>
<BookOpen
className={cn('w-5 h-5', isWikiActive && 'text-brand-500 drop-shadow-sm')}
/>
</button>
)}
{/* Bug Report Button */}
<button
onClick={handleBugReportClick}
className={cn(

View File

@@ -4,7 +4,8 @@ import { useNavigate, useLocation } from '@tanstack/react-router';
const logger = createLogger('Sidebar');
import { cn } from '@/lib/utils';
import { useAppStore, type ThemeMode } from '@/store/app-store';
import { useAppStore } from '@/store/app-store';
import { useNotificationsStore } from '@/store/notifications-store';
import { useKeyboardShortcuts, useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
import { getElectronAPI } from '@/lib/electron';
import { initializeProject, hasAppSpec, hasAutomakerDir } from '@/lib/project-init';
@@ -19,7 +20,10 @@ import {
SidebarHeader,
SidebarNavigation,
SidebarFooter,
MobileSidebarToggle,
} from './sidebar/components';
import { useIsCompact } from '@/hooks/use-media-query';
import { PanelLeftClose } from 'lucide-react';
import { TrashDialog, OnboardingDialog } from './sidebar/dialogs';
import { SIDEBAR_FEATURE_FLAGS } from './sidebar/constants';
import {
@@ -30,7 +34,6 @@ import {
useProjectCreation,
useSetupDialog,
useTrashOperations,
useProjectTheme,
useUnviewedValidations,
} from './sidebar/hooks';
@@ -43,9 +46,11 @@ export function Sidebar() {
trashedProjects,
currentProject,
sidebarOpen,
mobileSidebarHidden,
projectHistory,
upsertAndSetCurrentProject,
toggleSidebar,
toggleMobileSidebarHidden,
restoreTrashedProject,
deleteTrashedProject,
emptyTrash,
@@ -56,22 +61,23 @@ export function Sidebar() {
setSpecCreatingForProject,
} = useAppStore();
const isCompact = useIsCompact();
// Environment variable flags for hiding sidebar items
const { hideTerminal, hideWiki, hideRunningAgents, hideContext, hideSpecEditor } =
SIDEBAR_FEATURE_FLAGS;
const { hideTerminal, hideRunningAgents, hideContext, hideSpecEditor } = SIDEBAR_FEATURE_FLAGS;
// Get customizable keyboard shortcuts
const shortcuts = useKeyboardShortcutsConfig();
// Get unread notifications count
const unreadNotificationsCount = useNotificationsStore((s) => s.unreadCount);
// State for delete project confirmation dialog
const [showDeleteProjectDialog, setShowDeleteProjectDialog] = useState(false);
// State for trash dialog
const [showTrashDialog, setShowTrashDialog] = useState(false);
// Project theme management (must come before useProjectCreation which uses globalTheme)
const { globalTheme } = useProjectTheme();
// Project creation state and handlers
const {
showNewProjectModal,
@@ -87,9 +93,6 @@ export function Sidebar() {
handleCreateFromTemplate,
handleCreateFromCustomUrl,
} = useProjectCreation({
trashedProjects,
currentProject,
globalTheme,
upsertAndSetCurrentProject,
});
@@ -188,13 +191,8 @@ export function Sidebar() {
}
// Upsert project and set as current (handles both create and update cases)
// Theme preservation is handled by the store action
const trashedProject = trashedProjects.find((p) => p.path === path);
const effectiveTheme =
(trashedProject?.theme as ThemeMode | undefined) ||
(currentProject?.theme as ThemeMode | undefined) ||
globalTheme;
upsertAndSetCurrentProject(path, name, effectiveTheme);
// Theme handling (trashed project recovery or undefined for global) is done by the store
upsertAndSetCurrentProject(path, name);
// Check if app_spec.txt exists
const specExists = await hasAppSpec(path);
@@ -222,7 +220,7 @@ export function Sidebar() {
});
}
}
}, [trashedProjects, upsertAndSetCurrentProject, currentProject, globalTheme]);
}, [upsertAndSetCurrentProject]);
// Navigation sections and keyboard shortcuts (defined after handlers)
const { navSections, navigationShortcuts } = useNavigation({
@@ -239,6 +237,7 @@ export function Sidebar() {
cyclePrevProject,
cycleNextProject,
unviewedValidationsCount,
unreadNotificationsCount,
isSpecGenerating: isCurrentProjectGeneratingSpec,
});
@@ -251,10 +250,16 @@ export function Sidebar() {
return location.pathname === routePath;
};
// Check if sidebar should be completely hidden on mobile
const shouldHideSidebar = isCompact && mobileSidebarHidden;
return (
<>
{/* Floating toggle to show sidebar on mobile when hidden */}
<MobileSidebarToggle />
{/* Mobile backdrop overlay */}
{sidebarOpen && (
{sidebarOpen && !shouldHideSidebar && (
<div
className="fixed inset-0 bg-black/50 z-20 lg:hidden"
onClick={toggleSidebar}
@@ -270,8 +275,11 @@ export function Sidebar() {
'border-r border-border/60 shadow-[1px_0_20px_-5px_rgba(0,0,0,0.1)]',
// Smooth width transition
'transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]',
// Mobile: completely hidden when mobileSidebarHidden is true
shouldHideSidebar && 'hidden',
// Mobile: overlay when open, collapsed when closed
sidebarOpen ? 'fixed inset-y-0 left-0 w-72 lg:relative lg:w-72' : 'relative w-16'
!shouldHideSidebar &&
(sidebarOpen ? 'fixed inset-y-0 left-0 w-72 lg:relative lg:w-72' : 'relative w-16')
)}
data-testid="sidebar"
>
@@ -281,8 +289,33 @@ export function Sidebar() {
shortcut={shortcuts.toggleSidebar}
/>
{/* Floating hide button on right edge - only visible on compact screens when sidebar is collapsed */}
{!sidebarOpen && isCompact && (
<button
onClick={toggleMobileSidebarHidden}
className={cn(
'absolute -right-6 top-1/2 -translate-y-1/2 z-40',
'flex items-center justify-center w-6 h-10 rounded-r-lg',
'bg-card/95 backdrop-blur-sm border border-l-0 border-border/80',
'text-muted-foreground hover:text-brand-500 hover:bg-accent/80',
'shadow-lg hover:shadow-xl hover:shadow-brand-500/10',
'transition-all duration-200',
'hover:w-8 active:scale-95'
)}
aria-label="Hide sidebar"
data-testid="sidebar-mobile-hide"
>
<PanelLeftClose className="w-3.5 h-3.5" />
</button>
)}
<div className="flex-1 flex flex-col overflow-hidden">
<SidebarHeader sidebarOpen={sidebarOpen} currentProject={currentProject} />
<SidebarHeader
sidebarOpen={sidebarOpen}
currentProject={currentProject}
onClose={toggleSidebar}
onExpand={toggleSidebar}
/>
<SidebarNavigation
currentProject={currentProject}
@@ -297,7 +330,6 @@ export function Sidebar() {
sidebarOpen={sidebarOpen}
isActiveRoute={isActiveRoute}
navigate={navigate}
hideWiki={hideWiki}
hideRunningAgents={hideRunningAgents}
runningAgentsCount={runningAgentsCount}
shortcuts={{ settings: shortcuts.settings }}

View File

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

View File

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

View File

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

View File

@@ -1,13 +1,12 @@
import type { NavigateOptions } from '@tanstack/react-router';
import { cn } from '@/lib/utils';
import { formatShortcut } from '@/store/app-store';
import { BookOpen, Activity, Settings } from 'lucide-react';
import { Activity, Settings } from 'lucide-react';
interface SidebarFooterProps {
sidebarOpen: boolean;
isActiveRoute: (id: string) => boolean;
navigate: (opts: NavigateOptions) => void;
hideWiki: boolean;
hideRunningAgents: boolean;
runningAgentsCount: number;
shortcuts: {
@@ -19,7 +18,6 @@ export function SidebarFooter({
sidebarOpen,
isActiveRoute,
navigate,
hideWiki,
hideRunningAgents,
runningAgentsCount,
shortcuts,
@@ -34,66 +32,6 @@ export function SidebarFooter({
'bg-gradient-to-t from-background/10 via-sidebar/50 to-transparent'
)}
>
{/* Wiki Link */}
{!hideWiki && (
<div className="p-2 pb-0">
<button
onClick={() => navigate({ to: '/wiki' })}
className={cn(
'group flex items-center w-full px-3 py-2.5 rounded-xl relative overflow-hidden titlebar-no-drag',
'transition-all duration-200 ease-out',
isActiveRoute('wiki')
? [
'bg-gradient-to-r from-brand-500/20 via-brand-500/15 to-brand-600/10',
'text-foreground font-medium',
'border border-brand-500/30',
'shadow-md shadow-brand-500/10',
]
: [
'text-muted-foreground hover:text-foreground',
'hover:bg-accent/50',
'border border-transparent hover:border-border/40',
'hover:shadow-sm',
],
sidebarOpen ? 'justify-start' : 'justify-center',
'hover:scale-[1.02] active:scale-[0.97]'
)}
title={!sidebarOpen ? 'Wiki' : undefined}
data-testid="wiki-link"
>
<BookOpen
className={cn(
'w-[18px] h-[18px] shrink-0 transition-all duration-200',
isActiveRoute('wiki')
? 'text-brand-500 drop-shadow-sm'
: 'group-hover:text-brand-400 group-hover:scale-110'
)}
/>
<span
className={cn(
'ml-3 font-medium text-sm flex-1 text-left',
sidebarOpen ? 'block' : 'hidden'
)}
>
Wiki
</span>
{!sidebarOpen && (
<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'
)}
>
Wiki
</span>
)}
</button>
</div>
)}
{/* Running Agents Link */}
{!hideRunningAgents && (
<div className="p-2 pb-0">
@@ -213,7 +151,7 @@ export function SidebarFooter({
sidebarOpen ? 'justify-start' : 'justify-center',
'hover:scale-[1.02] active:scale-[0.97]'
)}
title={!sidebarOpen ? 'Settings' : undefined}
title={!sidebarOpen ? 'Global Settings' : undefined}
data-testid="settings-button"
>
<Settings
@@ -230,7 +168,7 @@ export function SidebarFooter({
sidebarOpen ? 'block' : 'hidden'
)}
>
Settings
Global Settings
</span>
{sidebarOpen && (
<span
@@ -256,7 +194,7 @@ export function SidebarFooter({
'translate-x-1 group-hover:translate-x-0'
)}
>
Settings
Global Settings
<span className="ml-2 px-1.5 py-0.5 bg-muted rounded text-[10px] font-mono text-muted-foreground">
{formatShortcut(shortcuts.settings, true)}
</span>

View File

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

View File

@@ -1,9 +1,9 @@
import type { NavigateOptions } from '@tanstack/react-router';
import { Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { formatShortcut } from '@/store/app-store';
import type { NavSection } from '../types';
import type { Project } from '@/lib/electron';
import { Spinner } from '@/components/ui/spinner';
interface SidebarNavigationProps {
currentProject: Project | null;
@@ -21,7 +21,12 @@ export function SidebarNavigation({
navigate,
}: SidebarNavigationProps) {
return (
<nav className={cn('flex-1 overflow-y-auto px-3 pb-2', sidebarOpen ? 'mt-1' : 'mt-1')}>
<nav
className={cn(
'flex-1 overflow-y-auto scrollbar-hide px-3 pb-2',
sidebarOpen ? 'mt-1' : 'mt-1'
)}
>
{!currentProject && sidebarOpen ? (
// Placeholder when no project is selected (only in expanded state)
<div className="flex items-center justify-center h-full px-4">
@@ -41,7 +46,13 @@ export function SidebarNavigation({
</span>
</div>
)}
{section.label && !sidebarOpen && <div className="h-px bg-border/30 mx-2 my-1.5"></div>}
{/* Separator for sections without label (visual separation) */}
{!section.label && sectionIdx > 0 && sidebarOpen && (
<div className="h-px bg-border/40 mx-3 mb-4"></div>
)}
{(section.label || sectionIdx > 0) && !sidebarOpen && (
<div className="h-px bg-border/30 mx-2 my-1.5"></div>
)}
{/* Nav Items */}
<div className="space-y-1.5">
@@ -82,9 +93,10 @@ export function SidebarNavigation({
>
<div className="relative">
{item.isLoading ? (
<Loader2
<Spinner
size="md"
className={cn(
'w-[18px] h-[18px] shrink-0 animate-spin',
'shrink-0',
isActive ? 'text-brand-500' : 'text-muted-foreground'
)}
/>

View File

@@ -11,6 +11,8 @@ import {
Lightbulb,
Brain,
Network,
Bell,
Settings,
} from 'lucide-react';
import type { NavSection, NavItem } from '../types';
import type { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts';
@@ -32,9 +34,11 @@ interface UseNavigationProps {
agent: string;
terminal: string;
settings: string;
projectSettings: string;
ideation: string;
githubIssues: string;
githubPrs: string;
notifications: string;
};
hideSpecEditor: boolean;
hideContext: boolean;
@@ -49,6 +53,8 @@ interface UseNavigationProps {
cycleNextProject: () => void;
/** Count of unviewed validations to show on GitHub Issues nav item */
unviewedValidationsCount?: number;
/** Count of unread notifications to show on Notifications nav item */
unreadNotificationsCount?: number;
/** Whether spec generation is currently running for the current project */
isSpecGenerating?: boolean;
}
@@ -67,6 +73,7 @@ export function useNavigation({
cyclePrevProject,
cycleNextProject,
unviewedValidationsCount,
unreadNotificationsCount,
isSpecGenerating,
}: UseNavigationProps) {
// Track if current project has a GitHub remote
@@ -199,6 +206,26 @@ export function useNavigation({
});
}
// Add Notifications and Project Settings as a standalone section (no label for visual separation)
sections.push({
label: '',
items: [
{
id: 'notifications',
label: 'Notifications',
icon: Bell,
shortcut: shortcuts.notifications,
count: unreadNotificationsCount,
},
{
id: 'project-settings',
label: 'Project Settings',
icon: Settings,
shortcut: shortcuts.projectSettings,
},
],
});
return sections;
}, [
shortcuts,
@@ -207,6 +234,7 @@ export function useNavigation({
hideTerminal,
hasGitHubRemote,
unviewedValidationsCount,
unreadNotificationsCount,
isSpecGenerating,
]);
@@ -257,11 +285,11 @@ export function useNavigation({
});
});
// Add settings shortcut
// Add global settings shortcut
shortcutsList.push({
key: shortcuts.settings,
action: () => navigate({ to: '/settings' }),
description: 'Navigate to Settings',
description: 'Navigate to Global Settings',
});
}

View File

@@ -6,22 +6,13 @@ const logger = createLogger('ProjectCreation');
import { initializeProject } from '@/lib/project-init';
import { toast } from 'sonner';
import type { StarterTemplate } from '@/lib/templates';
import type { ThemeMode } from '@/store/app-store';
import type { TrashedProject, Project } from '@/lib/electron';
import type { Project } from '@/lib/electron';
interface UseProjectCreationProps {
trashedProjects: TrashedProject[];
currentProject: Project | null;
globalTheme: ThemeMode;
upsertAndSetCurrentProject: (path: string, name: string, theme: ThemeMode) => Project;
upsertAndSetCurrentProject: (path: string, name: string) => Project;
}
export function useProjectCreation({
trashedProjects,
currentProject,
globalTheme,
upsertAndSetCurrentProject,
}: UseProjectCreationProps) {
export function useProjectCreation({ upsertAndSetCurrentProject }: UseProjectCreationProps) {
// Modal state
const [showNewProjectModal, setShowNewProjectModal] = useState(false);
const [isCreatingProject, setIsCreatingProject] = useState(false);
@@ -67,14 +58,8 @@ export function useProjectCreation({
</project_specification>`
);
// Determine theme: try trashed project theme, then current project theme, then global
const trashedProject = trashedProjects.find((p) => p.path === projectPath);
const effectiveTheme =
(trashedProject?.theme as ThemeMode | undefined) ||
(currentProject?.theme as ThemeMode | undefined) ||
globalTheme;
upsertAndSetCurrentProject(projectPath, projectName, effectiveTheme);
// Let the store handle theme (trashed project recovery or undefined for global)
upsertAndSetCurrentProject(projectPath, projectName);
setShowNewProjectModal(false);
@@ -92,7 +77,7 @@ export function useProjectCreation({
throw error;
}
},
[trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject]
[upsertAndSetCurrentProject]
);
/**
@@ -169,14 +154,8 @@ export function useProjectCreation({
</project_specification>`
);
// Determine theme
const trashedProject = trashedProjects.find((p) => p.path === projectPath);
const effectiveTheme =
(trashedProject?.theme as ThemeMode | undefined) ||
(currentProject?.theme as ThemeMode | undefined) ||
globalTheme;
upsertAndSetCurrentProject(projectPath, projectName, effectiveTheme);
// Let the store handle theme (trashed project recovery or undefined for global)
upsertAndSetCurrentProject(projectPath, projectName);
setShowNewProjectModal(false);
setNewProjectName(projectName);
setNewProjectPath(projectPath);
@@ -194,7 +173,7 @@ export function useProjectCreation({
setIsCreatingProject(false);
}
},
[trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject]
[upsertAndSetCurrentProject]
);
/**
@@ -244,14 +223,8 @@ export function useProjectCreation({
</project_specification>`
);
// Determine theme
const trashedProject = trashedProjects.find((p) => p.path === projectPath);
const effectiveTheme =
(trashedProject?.theme as ThemeMode | undefined) ||
(currentProject?.theme as ThemeMode | undefined) ||
globalTheme;
upsertAndSetCurrentProject(projectPath, projectName, effectiveTheme);
// Let the store handle theme (trashed project recovery or undefined for global)
upsertAndSetCurrentProject(projectPath, projectName);
setShowNewProjectModal(false);
setNewProjectName(projectName);
setNewProjectPath(projectPath);
@@ -269,7 +242,7 @@ export function useProjectCreation({
setIsCreatingProject(false);
}
},
[trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject]
[upsertAndSetCurrentProject]
);
return {

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { getElectronAPI } from '@/lib/electron';
@@ -6,26 +6,48 @@ const logger = createLogger('RunningAgents');
export function useRunningAgents() {
const [runningAgentsCount, setRunningAgentsCount] = useState(0);
const fetchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Fetch running agents count function - used for initial load and event-driven updates
const fetchRunningAgentsCount = useCallback(async () => {
try {
const api = getElectronAPI();
if (api.runningAgents) {
logger.debug('Fetching running agents count');
const result = await api.runningAgents.getAll();
if (result.success && result.runningAgents) {
logger.debug('Running agents count fetched', {
count: result.runningAgents.length,
});
setRunningAgentsCount(result.runningAgents.length);
} else {
logger.debug('Running agents count fetch returned empty/failed', {
success: result.success,
});
}
} else {
logger.debug('Running agents API not available');
}
} catch (error) {
logger.error('Error fetching running agents count:', error);
}
}, []);
// Debounced fetch to avoid excessive API calls from frequent events
const debouncedFetchRunningAgentsCount = useCallback(() => {
if (fetchTimeoutRef.current) {
clearTimeout(fetchTimeoutRef.current);
}
fetchTimeoutRef.current = setTimeout(() => {
fetchRunningAgentsCount();
}, 300);
}, [fetchRunningAgentsCount]);
// Subscribe to auto-mode events to update running agents count in real-time
useEffect(() => {
const api = getElectronAPI();
if (!api.autoMode) {
logger.debug('Auto mode API not available for running agents hook');
// If autoMode is not available, still fetch initial count
fetchRunningAgentsCount();
return;
@@ -35,6 +57,9 @@ export function useRunningAgents() {
fetchRunningAgentsCount();
const unsubscribe = api.autoMode.onEvent((event) => {
logger.debug('Auto mode event for running agents hook', {
type: event.type,
});
// When a feature starts, completes, or errors, refresh the count
if (
event.type === 'auto_mode_feature_complete' ||
@@ -50,6 +75,57 @@ export function useRunningAgents() {
};
}, [fetchRunningAgentsCount]);
// Subscribe to backlog plan events to update running agents count
useEffect(() => {
const api = getElectronAPI();
if (!api.backlogPlan) return;
fetchRunningAgentsCount();
const unsubscribe = api.backlogPlan.onEvent(() => {
fetchRunningAgentsCount();
});
return () => {
unsubscribe();
};
}, [fetchRunningAgentsCount]);
// Subscribe to spec regeneration events to update running agents count
useEffect(() => {
const api = getElectronAPI();
if (!api.specRegeneration) return;
fetchRunningAgentsCount();
const unsubscribe = api.specRegeneration.onEvent((event) => {
logger.debug('Spec regeneration event for running agents hook', {
type: event.type,
});
// When spec regeneration completes or errors, refresh immediately
if (event.type === 'spec_regeneration_complete' || event.type === 'spec_regeneration_error') {
fetchRunningAgentsCount();
}
// For progress events, use debounced fetch to avoid excessive calls
else if (event.type === 'spec_regeneration_progress') {
debouncedFetchRunningAgentsCount();
}
});
return () => {
unsubscribe();
};
}, [fetchRunningAgentsCount, debouncedFetchRunningAgentsCount]);
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (fetchTimeoutRef.current) {
clearTimeout(fetchTimeoutRef.current);
}
};
}, []);
return {
runningAgentsCount,
};

View File

@@ -17,8 +17,8 @@ import {
Check,
X,
ArchiveRestore,
Loader2,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import type { SessionListItem } from '@/types/electron';
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
@@ -475,7 +475,7 @@ export function SessionManager({
{/* Show loading indicator if this session is running (either current session thinking or any session in runningSessions) */}
{(currentSessionId === session.id && isCurrentSessionThinking) ||
runningSessions.has(session.id) ? (
<Loader2 className="w-4 h-4 text-primary animate-spin shrink-0" />
<Spinner size="sm" className="shrink-0" />
) : (
<MessageSquare className="w-4 h-4 text-muted-foreground shrink-0" />
)}

View File

@@ -0,0 +1,47 @@
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { DEFAULT_FONT_VALUE } from '@/config/ui-font-options';
interface FontOption {
value: string;
label: string;
}
interface FontSelectorProps {
id: string;
value: string;
options: readonly FontOption[];
placeholder: string;
onChange: (value: string) => void;
}
/**
* Reusable font selector component with live preview styling
*/
export function FontSelector({ id, value, options, placeholder, onChange }: FontSelectorProps) {
return (
<Select value={value} onValueChange={onChange}>
<SelectTrigger id={id} className="w-full">
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
<span
style={{
fontFamily: option.value === DEFAULT_FONT_VALUE ? undefined : option.value,
}}
>
{option.label}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
);
}

View File

@@ -5,3 +5,6 @@ export {
type UseModelOverrideOptions,
type UseModelOverrideResult,
} from './use-model-override';
// Font Components
export { FontSelector } from './font-selector';

View File

@@ -1,9 +1,9 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Spinner } from '@/components/ui/spinner';
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all duration-200 cursor-pointer disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive active:scale-[0.98]",
@@ -39,7 +39,7 @@ const buttonVariants = cva(
// Loading spinner component
function ButtonSpinner({ className }: { className?: string }) {
return <Loader2 className={cn('size-4 animate-spin', className)} aria-hidden="true" />;
return <Spinner size="sm" className={className} />;
}
function Button({

View File

@@ -0,0 +1,245 @@
import * as React from 'react';
import { ChevronsUpDown, X, GitBranch, ArrowUp, ArrowDown } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Badge } from '@/components/ui/badge';
import { wouldCreateCircularDependency } from '@automaker/dependency-resolver';
import type { Feature } from '@automaker/types';
interface DependencySelectorProps {
/** The current feature being edited (null for add mode) */
currentFeatureId?: string;
/** Selected feature IDs */
value: string[];
/** Callback when selection changes */
onChange: (ids: string[]) => void;
/** All available features to select from */
features: Feature[];
/** Type of dependency - 'parent' means features this depends on, 'child' means features that depend on this */
type: 'parent' | 'child';
/** Placeholder text */
placeholder?: string;
/** Disabled state */
disabled?: boolean;
/** Test ID for testing */
'data-testid'?: string;
}
export function DependencySelector({
currentFeatureId,
value,
onChange,
features,
type,
placeholder,
disabled = false,
'data-testid': testId,
}: DependencySelectorProps) {
const [open, setOpen] = React.useState(false);
const [inputValue, setInputValue] = React.useState('');
const [triggerWidth, setTriggerWidth] = React.useState<number>(0);
const triggerRef = React.useRef<HTMLButtonElement>(null);
// Update trigger width when component mounts or value changes
React.useEffect(() => {
if (triggerRef.current) {
const updateWidth = () => {
setTriggerWidth(triggerRef.current?.offsetWidth || 0);
};
updateWidth();
const resizeObserver = new ResizeObserver(updateWidth);
resizeObserver.observe(triggerRef.current);
return () => {
resizeObserver.disconnect();
};
}
}, [value]);
// Get display label for a feature
const getFeatureLabel = (feature: Feature): string => {
if (feature.title && feature.title.trim()) {
return feature.title;
}
// Truncate description to 50 chars
const desc = feature.description || '';
return desc.length > 50 ? desc.slice(0, 47) + '...' : desc;
};
// Filter out current feature and already selected features from options
const availableFeatures = React.useMemo(() => {
return features.filter((f) => {
// Don't show current feature
if (currentFeatureId && f.id === currentFeatureId) return false;
// Don't show already selected features
if (value.includes(f.id)) return false;
return true;
});
}, [features, currentFeatureId, value]);
// Filter by search input
const filteredFeatures = React.useMemo(() => {
if (!inputValue) return availableFeatures;
const lower = inputValue.toLowerCase();
return availableFeatures.filter((f) => {
const label = getFeatureLabel(f).toLowerCase();
return label.includes(lower) || f.id.toLowerCase().includes(lower);
});
}, [availableFeatures, inputValue]);
// Check if selecting a feature would create a circular dependency
const wouldCreateCycle = React.useCallback(
(featureId: string): boolean => {
if (!currentFeatureId) return false;
// For parent dependencies: we're adding featureId to currentFeature.dependencies
// This would create a cycle if featureId already depends on currentFeatureId
if (type === 'parent') {
return wouldCreateCircularDependency(features, featureId, currentFeatureId);
}
// For child dependencies: we're adding currentFeatureId to featureId.dependencies
// This would create a cycle if currentFeatureId already depends on featureId
return wouldCreateCircularDependency(features, currentFeatureId, featureId);
},
[features, currentFeatureId, type]
);
// Get selected features for display
const selectedFeatures = React.useMemo(() => {
return value
.map((id) => features.find((f) => f.id === id))
.filter((f): f is Feature => f !== undefined);
}, [value, features]);
const handleSelect = (featureId: string) => {
if (!value.includes(featureId)) {
onChange([...value, featureId]);
}
setInputValue('');
};
const handleRemove = (featureId: string) => {
onChange(value.filter((id) => id !== featureId));
};
const defaultPlaceholder =
type === 'parent' ? 'Select parent dependencies...' : 'Select child dependencies...';
const Icon = type === 'parent' ? ArrowUp : ArrowDown;
return (
<div className="space-y-2">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
ref={triggerRef}
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className={cn('w-full justify-between min-h-[40px]')}
data-testid={testId}
>
<span className="flex items-center gap-2 truncate text-muted-foreground">
<Icon className="w-4 h-4 shrink-0" />
{placeholder || defaultPlaceholder}
</span>
<ChevronsUpDown className="opacity-50 shrink-0" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{
width: Math.max(triggerWidth, 300),
}}
data-testid={testId ? `${testId}-list` : undefined}
onWheel={(e) => e.stopPropagation()}
onTouchMove={(e) => e.stopPropagation()}
>
<Command shouldFilter={false}>
<CommandInput
placeholder="Search features..."
className="h-9"
value={inputValue}
onValueChange={setInputValue}
/>
<CommandList>
<CommandEmpty>No features found.</CommandEmpty>
<CommandGroup>
{filteredFeatures.map((feature) => {
const willCreateCycle = wouldCreateCycle(feature.id);
const label = getFeatureLabel(feature);
return (
<CommandItem
key={feature.id}
value={feature.id}
onSelect={() => {
if (!willCreateCycle) {
handleSelect(feature.id);
}
}}
disabled={willCreateCycle}
className={cn(willCreateCycle && 'opacity-50 cursor-not-allowed')}
data-testid={`${testId}-option-${feature.id}`}
>
<GitBranch className="w-4 h-4 mr-2 text-muted-foreground" />
<span className="flex-1 truncate">{label}</span>
{willCreateCycle && (
<span className="ml-2 text-xs text-destructive">(circular)</span>
)}
{feature.status && (
<Badge variant="outline" className="ml-2 text-xs">
{feature.status}
</Badge>
)}
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{/* Selected items as badges */}
{selectedFeatures.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{selectedFeatures.map((feature) => (
<Badge
key={feature.id}
variant="secondary"
className="flex items-center gap-1 pr-1 text-xs"
>
<span className="truncate max-w-[150px]">{getFeatureLabel(feature)}</span>
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleRemove(feature.id);
}}
className="ml-1 rounded-full hover:bg-muted-foreground/20 p-0.5"
disabled={disabled}
>
<X className="w-3 h-3" />
</button>
</Badge>
))}
</div>
)}
</div>
);
}

View File

@@ -3,7 +3,8 @@ import { createLogger } from '@automaker/utils/logger';
import { cn } from '@/lib/utils';
const logger = createLogger('DescriptionImageDropZone');
import { ImageIcon, X, Loader2, FileText } from 'lucide-react';
import { ImageIcon, X, FileText } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { Textarea } from '@/components/ui/textarea';
import { getElectronAPI } from '@/lib/electron';
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
@@ -431,7 +432,7 @@ export function DescriptionImageDropZone({
{/* Processing indicator */}
{isProcessing && (
<div className="flex items-center gap-2 mt-2 text-sm text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" />
<Spinner size="sm" />
<span>Processing files...</span>
</div>
)}

View File

@@ -3,7 +3,8 @@ import { createLogger } from '@automaker/utils/logger';
import { cn } from '@/lib/utils';
const logger = createLogger('FeatureImageUpload');
import { ImageIcon, X, Upload } from 'lucide-react';
import { ImageIcon, X } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import {
fileToBase64,
generateImageId,
@@ -196,7 +197,7 @@ export function FeatureImageUpload({
)}
>
{isProcessing ? (
<Upload className="h-5 w-5 animate-spin text-muted-foreground" />
<Spinner size="md" />
) : (
<ImageIcon className="h-5 w-5 text-muted-foreground" />
)}

View File

@@ -8,11 +8,11 @@ import {
FilePen,
ChevronDown,
ChevronRight,
Loader2,
RefreshCw,
GitBranch,
AlertCircle,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { Button } from './button';
import { useWorktreeDiffs, useGitDiffs } from '@/hooks/queries';
import type { FileStatus } from '@/types/electron';
@@ -472,7 +472,7 @@ export function GitDiffPanel({
<div className="border-t border-border">
{isLoading ? (
<div className="flex items-center justify-center gap-2 py-8 text-muted-foreground">
<Loader2 className="w-5 h-5 animate-spin" />
<Spinner size="md" />
<span className="text-sm">Loading changes...</span>
</div>
) : error ? (

View File

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

View File

@@ -3,7 +3,8 @@ import { createLogger } from '@automaker/utils/logger';
import { cn } from '@/lib/utils';
const logger = createLogger('ImageDropZone');
import { ImageIcon, X, Upload } from 'lucide-react';
import { ImageIcon, X } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import type { ImageAttachment } from '@/store/app-store';
import {
fileToBase64,
@@ -204,7 +205,7 @@ export function ImageDropZone({
)}
>
{isProcessing ? (
<Upload className="h-6 w-6 animate-spin text-muted-foreground" />
<Spinner size="lg" />
) : (
<ImageIcon className="h-6 w-6 text-muted-foreground" />
)}

View File

@@ -0,0 +1,140 @@
import CodeMirror from '@uiw/react-codemirror';
import { StreamLanguage } from '@codemirror/language';
import { javascript } from '@codemirror/legacy-modes/mode/javascript';
import { EditorView } from '@codemirror/view';
import { Extension } from '@codemirror/state';
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
import { tags as t } from '@lezer/highlight';
import { cn } from '@/lib/utils';
interface JsonSyntaxEditorProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
minHeight?: string;
maxHeight?: string;
readOnly?: boolean;
'data-testid'?: string;
}
// Syntax highlighting using CSS variables for theme compatibility
const syntaxColors = HighlightStyle.define([
// Property names (keys)
{ tag: t.propertyName, color: 'var(--chart-2, oklch(0.6 0.118 184.704))' },
// Strings (values)
{ tag: t.string, color: 'var(--chart-1, oklch(0.646 0.222 41.116))' },
// Numbers
{ tag: t.number, color: 'var(--chart-3, oklch(0.7 0.15 150))' },
// Booleans and null
{ tag: t.bool, color: 'var(--chart-4, oklch(0.7 0.15 280))' },
{ tag: t.null, color: 'var(--chart-4, oklch(0.7 0.15 280))' },
{ tag: t.keyword, color: 'var(--chart-4, oklch(0.7 0.15 280))' },
// Brackets and punctuation
{ tag: t.bracket, color: 'var(--muted-foreground)' },
{ tag: t.punctuation, color: 'var(--muted-foreground)' },
// Default text
{ tag: t.content, color: 'var(--foreground)' },
]);
// Editor theme using CSS variables
const editorTheme = EditorView.theme({
'&': {
height: '100%',
fontSize: '0.8125rem',
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
backgroundColor: 'transparent',
color: 'var(--foreground)',
},
'.cm-scroller': {
overflow: 'auto',
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
},
'.cm-content': {
padding: '0.75rem',
minHeight: '100%',
caretColor: 'var(--primary)',
},
'.cm-cursor, .cm-dropCursor': {
borderLeftColor: 'var(--primary)',
},
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': {
backgroundColor: 'oklch(0.55 0.25 265 / 0.3)',
},
'.cm-activeLine': {
backgroundColor: 'var(--accent)',
opacity: '0.3',
},
'.cm-line': {
padding: '0 0.25rem',
},
'&.cm-focused': {
outline: 'none',
},
'.cm-gutters': {
backgroundColor: 'transparent',
color: 'var(--muted-foreground)',
border: 'none',
paddingRight: '0.5rem',
},
'.cm-lineNumbers .cm-gutterElement': {
minWidth: '2.5rem',
textAlign: 'right',
paddingRight: '0.5rem',
},
'.cm-placeholder': {
color: 'var(--muted-foreground)',
fontStyle: 'italic',
},
});
// JavaScript language in JSON mode
const jsonLanguage = StreamLanguage.define(javascript);
// Combine all extensions
const extensions: Extension[] = [jsonLanguage, syntaxHighlighting(syntaxColors), editorTheme];
export function JsonSyntaxEditor({
value,
onChange,
placeholder,
className,
minHeight = '300px',
maxHeight,
readOnly = false,
'data-testid': testId,
}: JsonSyntaxEditorProps) {
return (
<div
className={cn('w-full rounded-lg border border-border bg-muted/30', className)}
style={{ minHeight }}
data-testid={testId}
>
<CodeMirror
value={value}
onChange={onChange}
extensions={extensions}
theme="none"
placeholder={placeholder}
height={maxHeight}
minHeight={minHeight}
readOnly={readOnly}
className="[&_.cm-editor]:min-h-[inherit]"
basicSetup={{
lineNumbers: true,
foldGutter: true,
highlightActiveLine: true,
highlightSelectionMatches: true,
autocompletion: false,
bracketMatching: true,
indentOnInput: true,
}}
/>
</div>
);
}

View File

@@ -90,8 +90,10 @@ const SHORTCUT_LABELS: Record<keyof KeyboardShortcuts, string> = {
context: 'Context',
memory: 'Memory',
settings: 'Settings',
projectSettings: 'Project Settings',
terminal: 'Terminal',
ideation: 'Ideation',
notifications: 'Notifications',
githubIssues: 'GitHub Issues',
githubPrs: 'Pull Requests',
toggleSidebar: 'Toggle Sidebar',
@@ -118,8 +120,10 @@ const SHORTCUT_CATEGORIES: Record<keyof KeyboardShortcuts, 'navigation' | 'ui' |
context: 'navigation',
memory: 'navigation',
settings: 'navigation',
projectSettings: 'navigation',
terminal: 'navigation',
ideation: 'navigation',
notifications: 'navigation',
githubIssues: 'navigation',
githubPrs: 'navigation',
toggleSidebar: 'ui',

View File

@@ -1,17 +1,15 @@
import { Loader2 } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
interface LoadingStateProps {
/** Optional custom message to display below the spinner */
message?: string;
/** Optional custom size class for the spinner (default: h-8 w-8) */
size?: string;
}
export function LoadingState({ message, size = 'h-8 w-8' }: LoadingStateProps) {
export function LoadingState({ message }: LoadingStateProps) {
return (
<div className="flex-1 flex flex-col items-center justify-center">
<Loader2 className={`${size} animate-spin text-muted-foreground`} />
{message && <p className="mt-4 text-sm text-muted-foreground">{message}</p>}
<Spinner size="xl" />
{message && <p className="mt-4 text-sm font-medium text-primary">{message}</p>}
</div>
);
}

View File

@@ -22,8 +22,8 @@ import {
Filter,
Circle,
Play,
Loader2,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import {
parseLogOutput,
@@ -108,7 +108,7 @@ const getToolCategoryColor = (category: ToolCategory | undefined): string => {
case 'task':
return 'text-indigo-400 bg-indigo-500/10 border-indigo-500/30';
default:
return 'text-zinc-400 bg-zinc-500/10 border-zinc-500/30';
return 'text-muted-foreground bg-muted/30 border-border';
}
};
@@ -148,11 +148,11 @@ function TodoListRenderer({ todos }: { todos: TodoItem[] }) {
case 'completed':
return <CheckCircle2 className="w-4 h-4 text-emerald-400" />;
case 'in_progress':
return <Loader2 className="w-4 h-4 text-amber-400 animate-spin" />;
return <Spinner size="sm" />;
case 'pending':
return <Circle className="w-4 h-4 text-zinc-500" />;
return <Circle className="w-4 h-4 text-muted-foreground/70" />;
default:
return <Circle className="w-4 h-4 text-zinc-500" />;
return <Circle className="w-4 h-4 text-muted-foreground/70" />;
}
};
@@ -163,9 +163,9 @@ function TodoListRenderer({ todos }: { todos: TodoItem[] }) {
case 'in_progress':
return 'text-amber-300';
case 'pending':
return 'text-zinc-400';
return 'text-muted-foreground';
default:
return 'text-zinc-400';
return 'text-muted-foreground';
}
};
@@ -197,7 +197,7 @@ function TodoListRenderer({ todos }: { todos: TodoItem[] }) {
'flex items-start gap-2 p-2 rounded-md transition-colors',
todo.status === 'in_progress' && 'bg-amber-500/5 border border-amber-500/20',
todo.status === 'completed' && 'bg-emerald-500/5',
todo.status === 'pending' && 'bg-zinc-800/30'
todo.status === 'pending' && 'bg-muted/30'
)}
>
<div className="mt-0.5 flex-shrink-0">{getStatusIcon(todo.status)}</div>
@@ -313,9 +313,9 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) {
// Get colors - use tool category colors for tool_call entries
const colorParts = toolCategoryColors.split(' ');
const textColor = isToolCall ? colorParts[0] || 'text-zinc-400' : colors.text;
const bgColor = isToolCall ? colorParts[1] || 'bg-zinc-500/10' : colors.bg;
const borderColor = isToolCall ? colorParts[2] || 'border-zinc-500/30' : colors.border;
const textColor = isToolCall ? colorParts[0] || 'text-muted-foreground' : colors.text;
const bgColor = isToolCall ? colorParts[1] || 'bg-muted/30' : colors.bg;
const borderColor = isToolCall ? colorParts[2] || 'border-border' : colors.border;
return (
<div
@@ -334,9 +334,9 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) {
>
{hasContent ? (
isExpanded ? (
<ChevronDown className="w-4 h-4 text-zinc-400 flex-shrink-0" />
<ChevronDown className="w-4 h-4 text-muted-foreground flex-shrink-0" />
) : (
<ChevronRight className="w-4 h-4 text-zinc-400 flex-shrink-0" />
<ChevronRight className="w-4 h-4 text-muted-foreground flex-shrink-0" />
)
) : (
<span className="w-4 flex-shrink-0" />
@@ -361,7 +361,9 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) {
{entry.title}
</span>
<span className="text-xs text-zinc-400 truncate flex-1 ml-2">{collapsedPreview}</span>
<span className="text-xs text-muted-foreground truncate flex-1 ml-2">
{collapsedPreview}
</span>
</button>
{(isExpanded || !hasContent) && (
@@ -374,7 +376,7 @@ function LogEntryItem({ entry, isExpanded, onToggle }: LogEntryItemProps) {
{formattedContent.map((part, index) => (
<div key={index}>
{part.type === 'json' ? (
<pre className="bg-zinc-900/50 rounded p-2 overflow-x-auto scrollbar-styled text-xs text-primary">
<pre className="bg-muted/50 rounded p-2 overflow-x-auto scrollbar-styled text-xs text-primary">
{part.content}
</pre>
) : (
@@ -576,7 +578,7 @@ export function LogViewer({ output, className }: LogViewerProps) {
<Info className="w-8 h-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">No log entries yet. Logs will appear here as the process runs.</p>
{output && output.trim() && (
<div className="mt-4 p-3 bg-zinc-900/50 rounded text-xs font-mono text-left max-h-40 overflow-auto scrollbar-styled">
<div className="mt-4 p-3 bg-muted/50 rounded text-xs font-mono text-left max-h-40 overflow-auto scrollbar-styled">
<pre className="whitespace-pre-wrap">{output}</pre>
</div>
)}
@@ -610,23 +612,23 @@ export function LogViewer({ output, className }: LogViewerProps) {
<div className={cn('flex flex-col', className)}>
{/* Sticky header with search, stats, and filters */}
{/* Use -top-4 to compensate for parent's p-4 padding, pt-4 to restore visual spacing */}
<div className="sticky -top-4 z-10 bg-zinc-950/95 backdrop-blur-sm pt-4 pb-2 space-y-2 -mx-4 px-4">
<div className="sticky -top-4 z-10 bg-popover/95 backdrop-blur-sm pt-4 pb-2 space-y-2 -mx-4 px-4">
{/* Search bar */}
<div className="flex items-center gap-2 px-1" data-testid="log-search-bar">
<div className="relative flex-1">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" />
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground/70" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search logs..."
className="w-full pl-8 pr-8 py-1.5 text-xs bg-zinc-900/50 border border-zinc-700/50 rounded-md text-zinc-200 placeholder:text-zinc-500 focus:outline-none focus:border-zinc-600"
className="w-full pl-8 pr-8 py-1.5 text-xs bg-muted/50 border border-border rounded-md text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-ring"
data-testid="log-search-input"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300"
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
data-testid="log-search-clear"
>
<X className="w-3 h-3" />
@@ -636,7 +638,7 @@ export function LogViewer({ output, className }: LogViewerProps) {
{hasActiveFilters && (
<button
onClick={clearFilters}
className="text-xs text-zinc-400 hover:text-zinc-200 px-2 py-1 rounded hover:bg-zinc-800/50 transition-colors flex items-center gap-1"
className="text-xs text-muted-foreground hover:text-foreground px-2 py-1 rounded hover:bg-muted transition-colors flex items-center gap-1"
data-testid="log-clear-filters"
>
<X className="w-3 h-3" />
@@ -648,7 +650,7 @@ export function LogViewer({ output, className }: LogViewerProps) {
{/* Tool category stats bar */}
{stats.total > 0 && (
<div className="flex items-center gap-1 px-1 flex-wrap" data-testid="log-stats-bar">
<span className="text-xs text-zinc-500 mr-1">
<span className="text-xs text-muted-foreground/70 mr-1">
<Wrench className="w-3 h-3 inline mr-1" />
{stats.total} tools:
</span>
@@ -686,7 +688,7 @@ export function LogViewer({ output, className }: LogViewerProps) {
{/* Header with type filters and controls */}
<div className="flex items-center justify-between px-1" data-testid="log-viewer-header">
<div className="flex items-center gap-1 flex-wrap">
<Filter className="w-3 h-3 text-zinc-500 mr-1" />
<Filter className="w-3 h-3 text-muted-foreground/70 mr-1" />
{Object.entries(typeCounts).map(([type, count]) => {
const colors = getLogTypeColors(type as LogEntryType);
const isHidden = hiddenTypes.has(type as LogEntryType);
@@ -708,7 +710,7 @@ export function LogViewer({ output, className }: LogViewerProps) {
})}
</div>
<div className="flex items-center gap-1">
<span className="text-xs text-zinc-500">
<span className="text-xs text-muted-foreground/70">
{filteredEntries.length}/{entries.length}
</span>
<button
@@ -717,7 +719,7 @@ export function LogViewer({ output, className }: LogViewerProps) {
'text-xs px-2 py-1 rounded transition-colors',
expandAllMode
? 'text-primary bg-primary/20 hover:bg-primary/30'
: 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800/50'
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
)}
data-testid="log-expand-all"
title={
@@ -728,7 +730,7 @@ export function LogViewer({ output, className }: LogViewerProps) {
</button>
<button
onClick={collapseAll}
className="text-xs text-zinc-400 hover:text-zinc-200 px-2 py-1 rounded hover:bg-zinc-800/50 transition-colors"
className="text-xs text-muted-foreground hover:text-foreground px-2 py-1 rounded hover:bg-muted transition-colors"
data-testid="log-collapse-all"
>
Collapse All
@@ -740,7 +742,7 @@ export function LogViewer({ output, className }: LogViewerProps) {
{/* Log entries */}
<div className="space-y-2 mt-2" data-testid="log-entries-container">
{filteredEntries.length === 0 ? (
<div className="text-center py-4 text-zinc-500 text-sm">
<div className="text-center py-4 text-muted-foreground text-sm">
No entries match your filters.
{hasActiveFilters && (
<button onClick={clearFilters} className="ml-2 text-primary hover:underline">

View File

@@ -536,7 +536,15 @@ function getUnderlyingModelIcon(model?: AgentModel | string): ProviderIconKey {
if (modelStr.includes('grok')) {
return 'grok';
}
if (modelStr.includes('cursor') || modelStr === 'auto' || modelStr === 'composer-1') {
// Cursor models - canonical format includes 'cursor-' prefix
// Also support legacy IDs for backward compatibility
if (
modelStr.includes('cursor') ||
modelStr === 'auto' ||
modelStr === 'composer-1' ||
modelStr === 'cursor-auto' ||
modelStr === 'cursor-composer-1'
) {
return 'cursor';
}

View File

@@ -70,8 +70,7 @@ const editorTheme = EditorView.theme({
backgroundColor: 'oklch(0.55 0.25 265 / 0.3)',
},
'.cm-activeLine': {
backgroundColor: 'var(--accent)',
opacity: '0.3',
backgroundColor: 'transparent',
},
'.cm-line': {
padding: '0 0.25rem',
@@ -114,7 +113,7 @@ export function ShellSyntaxEditor({
}: ShellSyntaxEditorProps) {
return (
<div
className={cn('w-full rounded-lg border border-border bg-muted/30', className)}
className={cn('w-full rounded-lg border border-border bg-background', className)}
style={{ minHeight }}
data-testid={testId}
>

View File

@@ -0,0 +1,32 @@
import { Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
type SpinnerSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
const sizeClasses: Record<SpinnerSize, string> = {
xs: 'h-3 w-3',
sm: 'h-4 w-4',
md: 'h-5 w-5',
lg: 'h-6 w-6',
xl: 'h-8 w-8',
};
interface SpinnerProps {
/** Size of the spinner */
size?: SpinnerSize;
/** Additional class names */
className?: string;
}
/**
* Themed spinner component using the primary brand color.
* Use this for all loading indicators throughout the app for consistency.
*/
export function Spinner({ size = 'md', className }: SpinnerProps) {
return (
<Loader2
className={cn(sizeClasses[size], 'animate-spin text-primary', className)}
aria-hidden="true"
/>
);
}

View File

@@ -5,7 +5,8 @@ import { createLogger } from '@automaker/utils/logger';
import { cn } from '@/lib/utils';
const logger = createLogger('TaskProgressPanel');
import { Check, Loader2, Circle, ChevronDown, ChevronRight, FileCode } from 'lucide-react';
import { Check, Circle, ChevronDown, ChevronRight, FileCode } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI } from '@/lib/electron';
import type { AutoModeEvent } from '@/types/electron';
import { Badge } from '@/components/ui/badge';
@@ -260,7 +261,7 @@ export function TaskProgressPanel({
)}
>
{isCompleted && <Check className="h-3.5 w-3.5" />}
{isActive && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
{isActive && <Spinner size="xs" />}
{isPending && <Circle className="h-2 w-2 fill-current opacity-50" />}
</div>

View File

@@ -1,9 +1,6 @@
import CodeMirror from '@uiw/react-codemirror';
import { xml } from '@codemirror/lang-xml';
import { EditorView } from '@codemirror/view';
import { Extension } from '@codemirror/state';
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
import { tags as t } from '@lezer/highlight';
import { cn } from '@/lib/utils';
interface XmlSyntaxEditorProps {
@@ -14,52 +11,19 @@ interface XmlSyntaxEditorProps {
'data-testid'?: string;
}
// Syntax highlighting that uses CSS variables from the app's theme system
// This automatically adapts to any theme (dark, light, dracula, nord, etc.)
const syntaxColors = HighlightStyle.define([
// XML tags - use primary color
{ tag: t.tagName, color: 'var(--primary)' },
{ tag: t.angleBracket, color: 'var(--muted-foreground)' },
// Attributes
{ tag: t.attributeName, color: 'var(--chart-2, oklch(0.6 0.118 184.704))' },
{ tag: t.attributeValue, color: 'var(--chart-1, oklch(0.646 0.222 41.116))' },
// Strings and content
{ tag: t.string, color: 'var(--chart-1, oklch(0.646 0.222 41.116))' },
{ tag: t.content, color: 'var(--foreground)' },
// Comments
{ tag: t.comment, color: 'var(--muted-foreground)', fontStyle: 'italic' },
// Special
{ tag: t.processingInstruction, color: 'var(--muted-foreground)' },
{ tag: t.documentMeta, color: 'var(--muted-foreground)' },
]);
// Editor theme using CSS variables
// Simple editor theme - inherits text color from parent
const editorTheme = EditorView.theme({
'&': {
height: '100%',
fontSize: '0.875rem',
fontFamily: 'ui-monospace, monospace',
backgroundColor: 'transparent',
color: 'var(--foreground)',
},
'.cm-scroller': {
overflow: 'auto',
fontFamily: 'ui-monospace, monospace',
},
'.cm-content': {
padding: '1rem',
minHeight: '100%',
caretColor: 'var(--primary)',
},
'.cm-cursor, .cm-dropCursor': {
borderLeftColor: 'var(--primary)',
},
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': {
backgroundColor: 'oklch(0.55 0.25 265 / 0.3)',
},
'.cm-activeLine': {
backgroundColor: 'transparent',
@@ -73,15 +37,8 @@ const editorTheme = EditorView.theme({
'.cm-gutters': {
display: 'none',
},
'.cm-placeholder': {
color: 'var(--muted-foreground)',
fontStyle: 'italic',
},
});
// Combine all extensions
const extensions: Extension[] = [xml(), syntaxHighlighting(syntaxColors), editorTheme];
export function XmlSyntaxEditor({
value,
onChange,
@@ -94,16 +51,16 @@ export function XmlSyntaxEditor({
<CodeMirror
value={value}
onChange={onChange}
extensions={extensions}
extensions={[xml(), editorTheme]}
theme="none"
placeholder={placeholder}
className="h-full [&_.cm-editor]:h-full"
className="h-full [&_.cm-editor]:h-full [&_.cm-content]:text-foreground"
basicSetup={{
lineNumbers: false,
foldGutter: false,
highlightActiveLine: false,
highlightSelectionMatches: true,
autocompletion: true,
highlightSelectionMatches: false,
autocompletion: false,
bracketMatching: true,
indentOnInput: true,
}}

View File

@@ -1,6 +1,6 @@
import { useEffect, useRef, useCallback, useState, forwardRef, useImperativeHandle } from 'react';
import { useAppStore } from '@/store/app-store';
import { getTerminalTheme, DEFAULT_TERMINAL_FONT } from '@/config/terminal-themes';
import { getTerminalTheme, getTerminalFontFamily } from '@/config/terminal-themes';
// Types for dynamically imported xterm modules
type XTerminal = InstanceType<typeof import('@xterm/xterm').Terminal>;
@@ -20,7 +20,7 @@ export interface XtermLogViewerRef {
export interface XtermLogViewerProps {
/** Initial content to display */
initialContent?: string;
/** Font size in pixels (default: 13) */
/** Font size in pixels (uses terminal settings if not provided) */
fontSize?: number;
/** Whether to auto-scroll to bottom when new content is added (default: true) */
autoScroll?: boolean;
@@ -42,7 +42,7 @@ export const XtermLogViewer = forwardRef<XtermLogViewerRef, XtermLogViewerProps>
(
{
initialContent,
fontSize = 13,
fontSize,
autoScroll = true,
className,
minHeight = 300,
@@ -58,9 +58,14 @@ export const XtermLogViewer = forwardRef<XtermLogViewerRef, XtermLogViewerProps>
const autoScrollRef = useRef(autoScroll);
const pendingContentRef = useRef<string[]>([]);
// Get theme from store
// Get theme and font settings from store
const getEffectiveTheme = useAppStore((state) => state.getEffectiveTheme);
const effectiveTheme = getEffectiveTheme();
const terminalFontFamily = useAppStore((state) => state.terminalState.fontFamily);
const terminalFontSize = useAppStore((state) => state.terminalState.defaultFontSize);
// Use prop if provided, otherwise use store value, fallback to 13
const effectiveFontSize = fontSize ?? terminalFontSize ?? 13;
// Track system dark mode for "system" theme
const [systemIsDark, setSystemIsDark] = useState(() => {
@@ -102,12 +107,17 @@ export const XtermLogViewer = forwardRef<XtermLogViewerRef, XtermLogViewerProps>
const terminalTheme = getTerminalTheme(resolvedTheme);
// Get font settings from store at initialization time
const terminalState = useAppStore.getState().terminalState;
const fontFamily = getTerminalFontFamily(terminalState.fontFamily);
const initFontSize = fontSize ?? terminalState.defaultFontSize ?? 13;
const terminal = new Terminal({
cursorBlink: false,
cursorStyle: 'underline',
cursorInactiveStyle: 'none',
fontSize,
fontFamily: DEFAULT_TERMINAL_FONT,
fontSize: initFontSize,
fontFamily,
lineHeight: 1.2,
theme: terminalTheme,
disableStdin: true, // Read-only mode
@@ -181,10 +191,18 @@ export const XtermLogViewer = forwardRef<XtermLogViewerRef, XtermLogViewerProps>
// Update font size when it changes
useEffect(() => {
if (xtermRef.current && isReady) {
xtermRef.current.options.fontSize = fontSize;
xtermRef.current.options.fontSize = effectiveFontSize;
fitAddonRef.current?.fit();
}
}, [fontSize, isReady]);
}, [effectiveFontSize, isReady]);
// Update font family when it changes
useEffect(() => {
if (xtermRef.current && isReady) {
xtermRef.current.options.fontFamily = getTerminalFontFamily(terminalFontFamily);
fitAddonRef.current?.fit();
}
}, [terminalFontFamily, isReady]);
// Handle resize
useEffect(() => {

View File

@@ -3,6 +3,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock, ExternalLink } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import { useSetupStore } from '@/store/setup-store';
import { AnthropicIcon, OpenAIIcon } from '@/components/ui/provider-icon';
@@ -245,19 +246,43 @@ export function UsagePopover() {
return 'bg-green-500';
};
// Determine which provider icon and percentage to show based on active tab
const getTabInfo = () => {
if (activeTab === 'claude') {
return {
icon: AnthropicIcon,
percentage: claudeMaxPercentage,
isStale: isClaudeStale,
};
}
return {
icon: OpenAIIcon,
percentage: codexMaxPercentage,
isStale: isCodexStale,
};
};
const tabInfo = getTabInfo();
const statusColor = getStatusInfo(tabInfo.percentage).color;
const ProviderIcon = tabInfo.icon;
const trigger = (
<Button variant="ghost" size="sm" className="h-9 gap-3 bg-secondary border border-border px-3">
<Button variant="ghost" size="sm" className="h-9 gap-2 bg-secondary border border-border px-3">
{(claudeUsage || codexUsage) && <ProviderIcon className={cn('w-4 h-4', statusColor)} />}
<span className="text-sm font-medium">Usage</span>
{(claudeUsage || codexUsage) && (
<div
className={cn(
'h-1.5 w-16 bg-muted-foreground/20 rounded-full overflow-hidden transition-opacity',
isStale && 'opacity-60'
tabInfo.isStale && 'opacity-60'
)}
>
<div
className={cn('h-full transition-all duration-500', getProgressBarColor(maxPercentage))}
style={{ width: `${Math.min(maxPercentage, 100)}%` }}
className={cn(
'h-full transition-all duration-500',
getProgressBarColor(tabInfo.percentage)
)}
style={{ width: `${Math.min(tabInfo.percentage, 100)}%` }}
/>
</div>
)}
@@ -337,7 +362,7 @@ export function UsagePopover() {
</div>
) : !claudeUsage ? (
<div className="flex flex-col items-center justify-center py-8 space-y-2">
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground/50" />
<Spinner size="lg" />
<p className="text-xs text-muted-foreground">Loading usage data...</p>
</div>
) : (
@@ -456,7 +481,7 @@ export function UsagePopover() {
</div>
) : !codexUsage ? (
<div className="flex flex-col items-center justify-center py-8 space-y-2">
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground/50" />
<Spinner size="lg" />
<p className="text-xs text-muted-foreground">Loading usage data...</p>
</div>
) : codexUsage.rateLimits ? (

View File

@@ -11,12 +11,12 @@ import {
Terminal,
CheckCircle,
XCircle,
Loader2,
Play,
File,
Pencil,
Wrench,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import { getElectronAPI } from '@/lib/electron';
@@ -236,7 +236,7 @@ export function AgentToolsView() {
>
{isReadingFile ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
<Spinner size="sm" className="mr-2" />
Reading...
</>
) : (
@@ -315,7 +315,7 @@ export function AgentToolsView() {
>
{isWritingFile ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
<Spinner size="sm" className="mr-2" />
Writing...
</>
) : (
@@ -383,7 +383,7 @@ export function AgentToolsView() {
>
{isRunningCommand ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
<Spinner size="sm" className="mr-2" />
Running...
</>
) : (

View File

@@ -42,7 +42,7 @@ export function AgentView() {
return () => window.removeEventListener('resize', updateVisibility);
}, []);
const [modelSelection, setModelSelection] = useState<PhaseModelEntry>({ model: 'sonnet' });
const [modelSelection, setModelSelection] = useState<PhaseModelEntry>({ model: 'claude-sonnet' });
// Input ref for auto-focus
const inputRef = useRef<HTMLTextAreaElement>(null);

View File

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

View File

@@ -1,4 +1,5 @@
import { Bot } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
export function ThinkingIndicator() {
return (
@@ -8,20 +9,7 @@ export function ThinkingIndicator() {
</div>
<div className="bg-card border border-border rounded-2xl px-4 py-3 shadow-sm">
<div className="flex items-center gap-3">
<div className="flex items-center gap-1">
<span
className="w-2 h-2 rounded-full bg-primary animate-pulse"
style={{ animationDelay: '0ms' }}
/>
<span
className="w-2 h-2 rounded-full bg-primary animate-pulse"
style={{ animationDelay: '150ms' }}
/>
<span
className="w-2 h-2 rounded-full bg-primary animate-pulse"
style={{ animationDelay: '300ms' }}
/>
</div>
<Spinner size="sm" />
<span className="text-sm text-muted-foreground">Thinking...</span>
</div>
</div>

View File

@@ -16,13 +16,13 @@ import {
RefreshCw,
BarChart3,
FileCode,
Loader2,
FileText,
CheckCircle,
AlertCircle,
ListChecks,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Spinner } from '@/components/ui/spinner';
import { cn, generateUUID } from '@/lib/utils';
const logger = createLogger('AnalysisView');
@@ -641,7 +641,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
for (const detectedFeature of detectedFeatures) {
await api.features.create(currentProject.path, {
id: crypto.randomUUID(),
id: generateUUID(),
category: detectedFeature.category,
description: detectedFeature.description,
status: 'backlog',
@@ -750,7 +750,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
<Button onClick={runAnalysis} disabled={isAnalyzing} data-testid="analyze-project-button">
{isAnalyzing ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
<Spinner size="sm" className="mr-2" />
Analyzing...
</>
) : (
@@ -779,7 +779,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
</div>
) : isAnalyzing ? (
<div className="flex flex-col items-center justify-center h-full">
<Loader2 className="w-12 h-12 animate-spin text-primary mb-4" />
<Spinner size="xl" className="mb-4" />
<p className="text-muted-foreground">Scanning project files...</p>
</div>
) : projectAnalysis ? (
@@ -858,7 +858,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
>
{isGeneratingSpec ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
<Spinner size="sm" className="mr-2" />
Generating...
</>
) : (
@@ -911,7 +911,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
>
{isGeneratingFeatureList ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
<Spinner size="sm" className="mr-2" />
Generating...
</>
) : (

View File

@@ -34,7 +34,7 @@ import { pathsEqual } from '@/lib/utils';
import { toast } from 'sonner';
import { getBlockingDependencies } from '@automaker/dependency-resolver';
import { BoardBackgroundModal } from '@/components/dialogs/board-background-modal';
import { RefreshCw } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { useAutoMode } from '@/hooks/use-auto-mode';
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
import { useWindowState } from '@/hooks/use-window-state';
@@ -195,6 +195,7 @@ export function BoardView() {
// Selection mode hook for mass editing
const {
isSelectionMode,
selectionTarget,
selectedFeatureIds,
selectedCount,
toggleSelectionMode,
@@ -509,9 +510,9 @@ export function BoardView() {
// Empty string clears the branch assignment, moving features to main/current branch
finalBranchName = '';
} else if (workMode === 'auto') {
// Auto-generate a branch name based on current branch and timestamp
const baseBranch =
currentWorktreeBranch || getPrimaryWorktreeBranch(currentProject.path) || 'main';
// Auto-generate a branch name based on primary branch (main/master) and timestamp
// Always use primary branch to avoid nested feature/feature/... paths
const baseBranch = getPrimaryWorktreeBranch(currentProject.path) || 'main';
const timestamp = Date.now();
const randomSuffix = Math.random().toString(36).substring(2, 6);
finalBranchName = `feature/${baseBranch}-${timestamp}-${randomSuffix}`;
@@ -591,7 +592,6 @@ export function BoardView() {
selectedFeatureIds,
updateFeature,
exitSelectionMode,
currentWorktreeBranch,
getPrimaryWorktreeBranch,
addAndSelectWorktree,
setWorktreeRefreshKey,
@@ -673,6 +673,67 @@ export function BoardView() {
isPrimaryWorktreeBranch,
]);
// Get waiting_approval feature IDs in current branch for "Select All"
const allSelectableWaitingApprovalFeatureIds = useMemo(() => {
return hookFeatures
.filter((f) => {
// Only waiting_approval features
if (f.status !== 'waiting_approval') return false;
// Filter by current worktree branch
const featureBranch = f.branchName;
if (!featureBranch) {
// No branch assigned - only selectable on primary worktree
return currentWorktreePath === null;
}
if (currentWorktreeBranch === null) {
// Viewing main but branch hasn't been initialized
return currentProject?.path
? isPrimaryWorktreeBranch(currentProject.path, featureBranch)
: false;
}
// Match by branch name
return featureBranch === currentWorktreeBranch;
})
.map((f) => f.id);
}, [
hookFeatures,
currentWorktreePath,
currentWorktreeBranch,
currentProject?.path,
isPrimaryWorktreeBranch,
]);
// Handler for bulk verifying multiple features
const handleBulkVerify = useCallback(async () => {
if (!currentProject || selectedFeatureIds.size === 0) return;
try {
const api = getHttpApiClient();
const featureIds = Array.from(selectedFeatureIds);
const updates = { status: 'verified' as const };
// Use bulk update API for efficient batch processing
const result = await api.features.bulkUpdate(currentProject.path, featureIds, updates);
if (result.success) {
// Update local state for all features
featureIds.forEach((featureId) => {
updateFeature(featureId, updates);
});
toast.success(`Verified ${result.updatedCount} features`);
exitSelectionMode();
} else {
toast.error('Failed to verify some features', {
description: `${result.failedCount} features failed to verify`,
});
}
} catch (error) {
logger.error('Bulk verify failed:', error);
toast.error('Failed to verify features');
}
}, [currentProject, selectedFeatureIds, updateFeature, exitSelectionMode]);
// Handler for addressing PR comments - creates a feature and starts it automatically
const handleAddressPRComments = useCallback(
async (worktree: WorktreeInfo, prInfo: PRInfo) => {
@@ -784,68 +845,9 @@ export function BoardView() {
[handleAddFeature, handleStartImplementation]
);
// Client-side auto mode: periodically check for backlog items and move them to in-progress
// Use a ref to track the latest auto mode state so async operations always check the current value
const autoModeRunningRef = useRef(autoMode.isRunning);
useEffect(() => {
autoModeRunningRef.current = autoMode.isRunning;
}, [autoMode.isRunning]);
// Use a ref to track the latest features to avoid effect re-runs when features change
const hookFeaturesRef = useRef(hookFeatures);
useEffect(() => {
hookFeaturesRef.current = hookFeatures;
}, [hookFeatures]);
// Use a ref to track running tasks to avoid effect re-runs that clear pendingFeaturesRef
const runningAutoTasksRef = useRef(runningAutoTasks);
useEffect(() => {
runningAutoTasksRef.current = runningAutoTasks;
}, [runningAutoTasks]);
// Keep latest start handler without retriggering the auto mode effect
const handleStartImplementationRef = useRef(handleStartImplementation);
useEffect(() => {
handleStartImplementationRef.current = handleStartImplementation;
}, [handleStartImplementation]);
// Track features that are pending (started but not yet confirmed running)
const pendingFeaturesRef = useRef<Set<string>>(new Set());
// Listen to auto mode events to remove features from pending when they start running
useEffect(() => {
const api = getElectronAPI();
if (!api?.autoMode) return;
const unsubscribe = api.autoMode.onEvent((event: AutoModeEvent) => {
if (!currentProject) return;
// Only process events for the current project
const eventProjectPath = 'projectPath' in event ? event.projectPath : undefined;
if (eventProjectPath && eventProjectPath !== currentProject.path) {
return;
}
switch (event.type) {
case 'auto_mode_feature_start':
// Feature is now confirmed running - remove from pending
if (event.featureId) {
pendingFeaturesRef.current.delete(event.featureId);
}
break;
case 'auto_mode_feature_complete':
case 'auto_mode_error':
// Feature completed or errored - remove from pending if still there
if (event.featureId) {
pendingFeaturesRef.current.delete(event.featureId);
}
break;
}
});
return unsubscribe;
}, [currentProject]);
// NOTE: Auto mode polling loop has been moved to the backend.
// The frontend now just toggles the backend's auto loop via API calls.
// See use-auto-mode.ts for the start/stop logic that calls the backend.
// Listen for backlog plan events (for background generation)
useEffect(() => {
@@ -878,218 +880,31 @@ export function BoardView() {
return unsubscribe;
}, []);
// Load any saved plan from disk when opening the board
useEffect(() => {
logger.info(
'[AutoMode] Effect triggered - isRunning:',
autoMode.isRunning,
'hasProject:',
!!currentProject
);
if (!autoMode.isRunning || !currentProject) {
return;
}
if (!currentProject || pendingBacklogPlan) return;
logger.info('[AutoMode] Starting auto mode polling loop for project:', currentProject.path);
let isChecking = false;
let isActive = true; // Track if this effect is still active
let isActive = true;
const loadSavedPlan = async () => {
const api = getElectronAPI();
if (!api?.backlogPlan) return;
const checkAndStartFeatures = async () => {
// Check if auto mode is still running and effect is still active
// Use ref to get the latest value, not the closure value
if (!isActive || !autoModeRunningRef.current || !currentProject) {
return;
}
// Prevent concurrent executions
if (isChecking) {
return;
}
isChecking = true;
try {
// Double-check auto mode is still running before proceeding
if (!isActive || !autoModeRunningRef.current || !currentProject) {
logger.debug(
'[AutoMode] Skipping check - isActive:',
isActive,
'autoModeRunning:',
autoModeRunningRef.current,
'hasProject:',
!!currentProject
);
return;
}
// Count currently running tasks + pending features
// Use ref to get the latest running tasks without causing effect re-runs
const currentRunning = runningAutoTasksRef.current.length + pendingFeaturesRef.current.size;
const availableSlots = maxConcurrency - currentRunning;
logger.debug(
'[AutoMode] Checking features - running:',
currentRunning,
'available slots:',
availableSlots
);
// No available slots, skip check
if (availableSlots <= 0) {
return;
}
// Filter backlog features by the currently selected worktree branch
// This logic mirrors use-board-column-features.ts for consistency.
// HOWEVER: auto mode should still run even if the user is viewing a non-primary worktree,
// so we fall back to "all backlog features" when none are visible in the current view.
// Use ref to get the latest features without causing effect re-runs
const currentFeatures = hookFeaturesRef.current;
const backlogFeaturesInView = currentFeatures.filter((f) => {
if (f.status !== 'backlog') return false;
const featureBranch = f.branchName;
// Features without branchName are considered unassigned (show only on primary worktree)
if (!featureBranch) {
// No branch assigned - show only when viewing primary worktree
const isViewingPrimary = currentWorktreePath === null;
return isViewingPrimary;
}
if (currentWorktreeBranch === null) {
// We're viewing main but branch hasn't been initialized yet
// Show features assigned to primary worktree's branch
return currentProject.path
? isPrimaryWorktreeBranch(currentProject.path, featureBranch)
: false;
}
// Match by branch name
return featureBranch === currentWorktreeBranch;
});
const backlogFeatures =
backlogFeaturesInView.length > 0
? backlogFeaturesInView
: currentFeatures.filter((f) => f.status === 'backlog');
logger.debug(
'[AutoMode] Features - total:',
currentFeatures.length,
'backlog in view:',
backlogFeaturesInView.length,
'backlog total:',
backlogFeatures.length
);
if (backlogFeatures.length === 0) {
logger.debug(
'[AutoMode] No backlog features found, statuses:',
currentFeatures.map((f) => f.status).join(', ')
);
return;
}
// Sort by priority (lower number = higher priority, priority 1 is highest)
const sortedBacklog = [...backlogFeatures].sort(
(a, b) => (a.priority || 999) - (b.priority || 999)
);
// Filter out features with blocking dependencies if dependency blocking is enabled
// NOTE: skipVerificationInAutoMode means "ignore unmet dependency verification" so we
// should NOT exclude blocked features in that mode.
const eligibleFeatures =
enableDependencyBlocking && !skipVerificationInAutoMode
? sortedBacklog.filter((f) => {
const blockingDeps = getBlockingDependencies(f, currentFeatures);
if (blockingDeps.length > 0) {
logger.debug('[AutoMode] Feature', f.id, 'blocked by deps:', blockingDeps);
}
return blockingDeps.length === 0;
})
: sortedBacklog;
logger.debug(
'[AutoMode] Eligible features after dep check:',
eligibleFeatures.length,
'dependency blocking enabled:',
enableDependencyBlocking
);
// Start features up to available slots
const featuresToStart = eligibleFeatures.slice(0, availableSlots);
const startImplementation = handleStartImplementationRef.current;
if (!startImplementation) {
return;
}
logger.info(
'[AutoMode] Starting',
featuresToStart.length,
'features:',
featuresToStart.map((f) => f.id).join(', ')
);
for (const feature of featuresToStart) {
// Check again before starting each feature
if (!isActive || !autoModeRunningRef.current || !currentProject) {
return;
}
// Simplified: No worktree creation on client - server derives workDir from feature.branchName
// If feature has no branchName, assign it to the primary branch so it can run consistently
// even when the user is viewing a non-primary worktree.
if (!feature.branchName) {
const primaryBranch =
(currentProject.path ? getPrimaryWorktreeBranch(currentProject.path) : null) ||
'main';
await persistFeatureUpdate(feature.id, {
branchName: primaryBranch,
});
}
// Final check before starting implementation
if (!isActive || !autoModeRunningRef.current || !currentProject) {
return;
}
// Start the implementation - server will derive workDir from feature.branchName
const started = await startImplementation(feature);
// If successfully started, track it as pending until we receive the start event
if (started) {
pendingFeaturesRef.current.add(feature.id);
}
}
} finally {
isChecking = false;
const result = await api.backlogPlan.status(currentProject.path);
if (
isActive &&
result.success &&
result.savedPlan?.result &&
result.savedPlan.result.changes?.length > 0
) {
setPendingBacklogPlan(result.savedPlan.result);
}
};
// Check immediately, then every 3 seconds
checkAndStartFeatures();
const interval = setInterval(checkAndStartFeatures, 3000);
loadSavedPlan();
return () => {
// Mark as inactive to prevent any pending async operations from continuing
isActive = false;
clearInterval(interval);
// Clear pending features when effect unmounts or dependencies change
pendingFeaturesRef.current.clear();
};
}, [
autoMode.isRunning,
currentProject,
// runningAutoTasks is accessed via runningAutoTasksRef to prevent effect re-runs
// that would clear pendingFeaturesRef and cause concurrency issues
maxConcurrency,
// hookFeatures is accessed via hookFeaturesRef to prevent effect re-runs
currentWorktreeBranch,
currentWorktreePath,
getPrimaryWorktreeBranch,
isPrimaryWorktreeBranch,
enableDependencyBlocking,
skipVerificationInAutoMode,
persistFeatureUpdate,
]);
}, [currentProject, pendingBacklogPlan]);
// Use keyboard shortcuts hook (after actions hook)
useBoardKeyboardShortcuts({
@@ -1284,7 +1099,7 @@ export function BoardView() {
if (isLoading) {
return (
<div className="flex-1 flex items-center justify-center" data-testid="board-view-loading">
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground" />
<Spinner size="lg" />
</div>
);
}
@@ -1303,12 +1118,18 @@ export function BoardView() {
isAutoModeRunning={autoMode.isRunning}
onAutoModeToggle={(enabled) => {
if (enabled) {
autoMode.start();
autoMode.start().catch((error) => {
logger.error('[AutoMode] Failed to start:', error);
});
} else {
autoMode.stop();
autoMode.stop().catch((error) => {
logger.error('[AutoMode] Failed to stop:', error);
});
}
}}
onOpenPlanDialog={() => setShowPlanDialog(true)}
hasPendingPlan={Boolean(pendingBacklogPlan)}
onOpenPendingPlan={() => setShowPlanDialog(true)}
isMounted={isMounted}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
@@ -1435,6 +1256,7 @@ export function BoardView() {
pipelineConfig={pipelineConfig}
onOpenPipelineSettings={() => setShowPipelineSettings(true)}
isSelectionMode={isSelectionMode}
selectionTarget={selectionTarget}
selectedFeatureIds={selectedFeatureIds}
onToggleFeatureSelection={toggleFeatureSelection}
onToggleSelectionMode={toggleSelectionMode}
@@ -1450,11 +1272,23 @@ export function BoardView() {
{isSelectionMode && (
<SelectionActionBar
selectedCount={selectedCount}
totalCount={allSelectableFeatureIds.length}
onEdit={() => setShowMassEditDialog(true)}
onDelete={handleBulkDelete}
totalCount={
selectionTarget === 'waiting_approval'
? allSelectableWaitingApprovalFeatureIds.length
: allSelectableFeatureIds.length
}
onEdit={selectionTarget === 'backlog' ? () => setShowMassEditDialog(true) : undefined}
onDelete={selectionTarget === 'backlog' ? handleBulkDelete : undefined}
onVerify={selectionTarget === 'waiting_approval' ? handleBulkVerify : undefined}
onClear={clearSelection}
onSelectAll={() => selectAll(allSelectableFeatureIds)}
onSelectAll={() =>
selectAll(
selectionTarget === 'waiting_approval'
? allSelectableWaitingApprovalFeatureIds
: allSelectableFeatureIds
)
}
mode={selectionTarget === 'waiting_approval' ? 'waiting_approval' : 'backlog'}
/>
)}

View File

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

View File

@@ -1,6 +1,7 @@
import { useRef, useEffect } from 'react';
import { Input } from '@/components/ui/input';
import { Search, X, Loader2 } from 'lucide-react';
import { Search, X } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
interface BoardSearchBarProps {
searchQuery: string;
@@ -75,7 +76,7 @@ export function BoardSearchBar({
title="Creating App Specification"
data-testid="spec-creation-badge"
>
<Loader2 className="w-3 h-3 animate-spin text-brand-500 shrink-0" />
<Spinner size="xs" className="shrink-0" />
<span className="text-xs font-medium text-brand-500 whitespace-nowrap">
Creating spec
</span>

View File

@@ -10,16 +10,8 @@ import {
} from '@/lib/agent-context-parser';
import { cn } from '@/lib/utils';
import type { AutoModeEvent } from '@/types/electron';
import {
Brain,
ListTodo,
Sparkles,
Expand,
CheckCircle2,
Circle,
Loader2,
Wrench,
} from 'lucide-react';
import { Brain, ListTodo, Sparkles, Expand, CheckCircle2, Circle, Wrench } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI } from '@/lib/electron';
import { SummaryDialog } from './summary-dialog';
import { getProviderIconForModel } from '@/components/ui/provider-icon';
@@ -303,7 +295,7 @@ export function AgentInfoPanel({
{todo.status === 'completed' ? (
<CheckCircle2 className="w-2.5 h-2.5 text-[var(--status-success)] shrink-0" />
) : todo.status === 'in_progress' ? (
<Loader2 className="w-2.5 h-2.5 text-[var(--status-warning)] animate-spin shrink-0" />
<Spinner size="xs" className="w-2.5 h-2.5 shrink-0" />
) : (
<Circle className="w-2.5 h-2.5 text-muted-foreground/50 shrink-0" />
)}

View File

@@ -13,7 +13,6 @@ import {
import {
GripVertical,
Edit,
Loader2,
Trash2,
FileText,
MoreVertical,
@@ -21,6 +20,7 @@ import {
ChevronUp,
GitFork,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { CountUpTimer } from '@/components/ui/count-up-timer';
import { formatModelName, DEFAULT_MODEL } from '@/lib/agent-context-parser';
import { DeleteConfirmDialog } from '@/components/ui/delete-confirm-dialog';
@@ -65,7 +65,7 @@ export function CardHeaderSection({
{isCurrentAutoTask && !isSelectionMode && (
<div className="absolute top-2 right-2 flex items-center gap-1">
<div className="flex items-center justify-center gap-2 bg-[var(--status-in-progress)]/15 border border-[var(--status-in-progress)]/50 rounded-md px-2 py-0.5">
<Loader2 className="w-3.5 h-3.5 text-[var(--status-in-progress)] animate-spin" />
<Spinner size="xs" />
{feature.startedAt && (
<CountUpTimer
startedAt={feature.startedAt}
@@ -324,7 +324,7 @@ export function CardHeaderSection({
<div className="flex-1 min-w-0 overflow-hidden">
{feature.titleGenerating ? (
<div className="flex items-center gap-1.5 mb-1">
<Loader2 className="w-3 h-3 animate-spin text-muted-foreground" />
<Spinner size="xs" />
<span className="text-xs text-muted-foreground italic">Generating title...</span>
</div>
) : feature.title ? (

View File

@@ -65,6 +65,7 @@ interface KanbanCardProps {
isSelectionMode?: boolean;
isSelected?: boolean;
onToggleSelect?: () => void;
selectionTarget?: 'backlog' | 'waiting_approval' | null;
}
export const KanbanCard = memo(function KanbanCard({
@@ -96,6 +97,7 @@ export const KanbanCard = memo(function KanbanCard({
isSelectionMode = false,
isSelected = false,
onToggleSelect,
selectionTarget = null,
}: KanbanCardProps) {
const { useWorktrees, currentProject } = useAppStore();
const [isLifted, setIsLifted] = useState(false);
@@ -125,8 +127,8 @@ export const KanbanCard = memo(function KanbanCard({
const cardStyle = getCardBorderStyle(cardBorderEnabled, cardBorderOpacity);
// Only allow selection for backlog features
const isSelectable = isSelectionMode && feature.status === 'backlog';
// Only allow selection for features matching the selection target
const isSelectable = isSelectionMode && feature.status === selectionTarget;
const wrapperClasses = cn(
'relative select-none outline-none touch-none transition-transform duration-200 ease-out',
@@ -180,7 +182,7 @@ export const KanbanCard = memo(function KanbanCard({
{/* Category row with selection checkbox */}
<div className="px-3 pt-3 flex items-center gap-2">
{isSelectionMode && !isOverlay && feature.status === 'backlog' && (
{isSelectable && !isOverlay && (
<Checkbox
checked={isSelected}
onCheckedChange={() => onToggleSelect?.()}

View File

@@ -411,7 +411,6 @@ export const ListView = memo(function ListView({
feature={feature}
handlers={createHandlers(feature)}
isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
pipelineConfig={pipelineConfig}
isSelected={selectedFeatureIds.has(feature.id)}
showCheckbox={isSelectionMode}
onToggleSelect={() => onToggleFeatureSelection?.(feature.id)}

View File

@@ -1,6 +1,6 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Pencil, X, CheckSquare, Trash2 } from 'lucide-react';
import { Pencil, X, CheckSquare, Trash2, CheckCircle2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
Dialog,
@@ -11,13 +11,17 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
export type SelectionActionMode = 'backlog' | 'waiting_approval';
interface SelectionActionBarProps {
selectedCount: number;
totalCount: number;
onEdit: () => void;
onDelete: () => void;
onEdit?: () => void;
onDelete?: () => void;
onVerify?: () => void;
onClear: () => void;
onSelectAll: () => void;
mode?: SelectionActionMode;
}
export function SelectionActionBar({
@@ -25,10 +29,13 @@ export function SelectionActionBar({
totalCount,
onEdit,
onDelete,
onVerify,
onClear,
onSelectAll,
mode = 'backlog',
}: SelectionActionBarProps) {
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [showVerifyDialog, setShowVerifyDialog] = useState(false);
const allSelected = selectedCount === totalCount && totalCount > 0;
@@ -38,7 +45,16 @@ export function SelectionActionBar({
const handleConfirmDelete = () => {
setShowDeleteDialog(false);
onDelete();
onDelete?.();
};
const handleVerifyClick = () => {
setShowVerifyDialog(true);
};
const handleConfirmVerify = () => {
setShowVerifyDialog(false);
onVerify?.();
};
return (
@@ -54,36 +70,56 @@ export function SelectionActionBar({
>
<span className="text-sm font-medium text-foreground">
{selectedCount === 0
? 'Select features to edit'
? mode === 'waiting_approval'
? 'Select features to verify'
: 'Select features to edit'
: `${selectedCount} feature${selectedCount !== 1 ? 's' : ''} selected`}
</span>
<div className="h-4 w-px bg-border" />
<div className="flex items-center gap-2">
<Button
variant="default"
size="sm"
onClick={onEdit}
disabled={selectedCount === 0}
className="h-8 bg-brand-500 hover:bg-brand-600 disabled:opacity-50"
data-testid="selection-edit-button"
>
<Pencil className="w-4 h-4 mr-1.5" />
Edit Selected
</Button>
{mode === 'backlog' && (
<>
<Button
variant="default"
size="sm"
onClick={onEdit}
disabled={selectedCount === 0}
className="h-8 bg-brand-500 hover:bg-brand-600 disabled:opacity-50"
data-testid="selection-edit-button"
>
<Pencil className="w-4 h-4 mr-1.5" />
Edit Selected
</Button>
<Button
variant="outline"
size="sm"
onClick={handleDeleteClick}
disabled={selectedCount === 0}
className="h-8 text-destructive hover:text-destructive hover:bg-destructive/10 disabled:opacity-50"
data-testid="selection-delete-button"
>
<Trash2 className="w-4 h-4 mr-1.5" />
Delete
</Button>
<Button
variant="outline"
size="sm"
onClick={handleDeleteClick}
disabled={selectedCount === 0}
className="h-8 text-destructive hover:text-destructive hover:bg-destructive/10 disabled:opacity-50"
data-testid="selection-delete-button"
>
<Trash2 className="w-4 h-4 mr-1.5" />
Delete
</Button>
</>
)}
{mode === 'waiting_approval' && (
<Button
variant="default"
size="sm"
onClick={handleVerifyClick}
disabled={selectedCount === 0}
className="h-8 bg-green-600 hover:bg-green-700 disabled:opacity-50"
data-testid="selection-verify-button"
>
<CheckCircle2 className="w-4 h-4 mr-1.5" />
Verify Selected
</Button>
)}
{!allSelected && (
<Button
@@ -146,6 +182,42 @@ export function SelectionActionBar({
</DialogFooter>
</DialogContent>
</Dialog>
{/* Verify Confirmation Dialog */}
<Dialog open={showVerifyDialog} onOpenChange={setShowVerifyDialog}>
<DialogContent data-testid="bulk-verify-confirmation-dialog">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-green-600">
<CheckCircle2 className="w-5 h-5" />
Verify Selected Features?
</DialogTitle>
<DialogDescription>
Are you sure you want to mark {selectedCount} feature
{selectedCount !== 1 ? 's' : ''} as verified?
<span className="block mt-2 text-muted-foreground">
This will move them to the Verified column.
</span>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setShowVerifyDialog(false)}
data-testid="cancel-bulk-verify-button"
>
Cancel
</Button>
<Button
className="bg-green-600 hover:bg-green-700"
onClick={handleConfirmVerify}
data-testid="confirm-bulk-verify-button"
>
<CheckCircle2 className="w-4 h-4 mr-2" />
Verify
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -15,6 +15,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { CategoryAutocomplete } from '@/components/ui/category-autocomplete';
import { DependencySelector } from '@/components/ui/dependency-selector';
import {
DescriptionImageDropZone,
FeatureImagePath as DescriptionImagePath,
@@ -99,6 +100,7 @@ type FeatureData = {
planningMode: PlanningMode;
requirePlanApproval: boolean;
dependencies?: string[];
childDependencies?: string[]; // Feature IDs that should depend on this feature
workMode: WorkMode;
};
@@ -168,7 +170,7 @@ export function AddFeatureDialog({
const [priority, setPriority] = useState(2);
// Model selection state
const [modelEntry, setModelEntry] = useState<PhaseModelEntry>({ model: 'opus' });
const [modelEntry, setModelEntry] = useState<PhaseModelEntry>({ model: 'claude-opus' });
// Check if current model supports planning mode (Claude/Anthropic only)
const modelSupportsPlanningMode = isClaudeModel(modelEntry.model);
@@ -188,6 +190,10 @@ export function AddFeatureDialog({
const [ancestors, setAncestors] = useState<AncestorContext[]>([]);
const [selectedAncestorIds, setSelectedAncestorIds] = useState<Set<string>>(new Set());
// Dependency selection state (not in spawn mode)
const [parentDependencies, setParentDependencies] = useState<string[]>([]);
const [childDependencies, setChildDependencies] = useState<string[]>([]);
// Get defaults from store
const { defaultPlanningMode, defaultRequirePlanApproval, useWorktrees, defaultFeatureModel } =
useAppStore();
@@ -224,6 +230,10 @@ export function AddFeatureDialog({
setAncestors([]);
setSelectedAncestorIds(new Set());
}
// Reset dependency selections
setParentDependencies([]);
setChildDependencies([]);
}
}, [
open,
@@ -291,6 +301,16 @@ export function AddFeatureDialog({
}
}
// Determine final dependencies
// In spawn mode, use parent feature as dependency
// Otherwise, use manually selected parent dependencies
const finalDependencies =
isSpawnMode && parentFeature
? [parentFeature.id]
: parentDependencies.length > 0
? parentDependencies
: undefined;
return {
title,
category: finalCategory,
@@ -306,7 +326,8 @@ export function AddFeatureDialog({
priority,
planningMode,
requirePlanApproval,
dependencies: isSpawnMode && parentFeature ? [parentFeature.id] : undefined,
dependencies: finalDependencies,
childDependencies: childDependencies.length > 0 ? childDependencies : undefined,
workMode,
};
};
@@ -331,6 +352,8 @@ export function AddFeatureDialog({
setPreviewMap(new Map());
setDescriptionError(false);
setDescriptionHistory([]);
setParentDependencies([]);
setChildDependencies([]);
onOpenChange(false);
};
@@ -641,6 +664,38 @@ export function AddFeatureDialog({
testIdPrefix="feature-work-mode"
/>
</div>
{/* Dependencies - only show when not in spawn mode */}
{!isSpawnMode && allFeatures.length > 0 && (
<div className="pt-2 space-y-3">
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">
Parent Dependencies (this feature depends on)
</Label>
<DependencySelector
value={parentDependencies}
onChange={setParentDependencies}
features={allFeatures}
type="parent"
placeholder="Select features this depends on..."
data-testid="add-feature-parent-deps"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">
Child Dependencies (features that depend on this)
</Label>
<DependencySelector
value={childDependencies}
onChange={setChildDependencies}
features={allFeatures}
type="child"
placeholder="Select features that will depend on this..."
data-testid="add-feature-child-deps"
/>
</div>
</div>
)}
</div>
</div>

View File

@@ -6,7 +6,8 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Loader2, List, FileText, GitBranch, ClipboardList } from 'lucide-react';
import { List, FileText, GitBranch, ClipboardList } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI } from '@/lib/electron';
import { LogViewer } from '@/components/ui/log-viewer';
import { GitDiffPanel } from '@/components/ui/git-diff-panel';
@@ -41,6 +42,8 @@ export function AgentOutputModal({
onNumberKeyPress,
projectPath: projectPathProp,
}: AgentOutputModalProps) {
const isBacklogPlan = featureId.startsWith('backlog-plan:');
// Resolve project path - prefer prop, fallback to window.__currentProject
const resolvedProjectPath = projectPathProp || (window as any).__currentProject?.path || '';
@@ -86,7 +89,7 @@ export function AgentOutputModal({
if (!open) return;
const api = getElectronAPI();
if (!api?.autoMode) return;
if (!api?.autoMode || isBacklogPlan) return;
console.log('[AgentOutputModal] Subscribing to events for featureId:', featureId);
@@ -247,7 +250,43 @@ export function AgentOutputModal({
return () => {
unsubscribe();
};
}, [open, featureId]);
}, [open, featureId, isBacklogPlan]);
// Listen to backlog plan events and update output
useEffect(() => {
if (!open || !isBacklogPlan) return;
const api = getElectronAPI();
if (!api?.backlogPlan) return;
const unsubscribe = api.backlogPlan.onEvent((event: any) => {
if (!event?.type) return;
let newContent = '';
switch (event.type) {
case 'backlog_plan_progress':
newContent = `\n🧭 ${event.content || 'Backlog plan progress update'}\n`;
break;
case 'backlog_plan_error':
newContent = `\n❌ Backlog plan error: ${event.error || 'Unknown error'}\n`;
break;
case 'backlog_plan_complete':
newContent = `\n✅ Backlog plan completed\n`;
break;
default:
newContent = `\n ${event.type}\n`;
break;
}
if (newContent) {
setOutput((prev) => `${prev}${newContent}`);
}
});
return () => {
unsubscribe();
};
}, [open, isBacklogPlan]);
// Handle scroll to detect if user scrolled up
const handleScroll = () => {
@@ -286,7 +325,7 @@ export function AgentOutputModal({
<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" />
<Spinner size="md" />
)}
Agent Output
</DialogTitle>
@@ -344,7 +383,7 @@ export function AgentOutputModal({
</div>
</div>
<DialogDescription
className="mt-1 max-h-24 overflow-y-auto break-words"
className="mt-1 max-h-24 overflow-y-auto wrap-break-word"
data-testid="agent-output-description"
>
{featureDescription}
@@ -352,11 +391,13 @@ export function AgentOutputModal({
</DialogHeader>
{/* Task Progress Panel - shows when tasks are being executed */}
<TaskProgressPanel
featureId={featureId}
projectPath={resolvedProjectPath}
className="flex-shrink-0 mx-3 my-2"
/>
{!isBacklogPlan && (
<TaskProgressPanel
featureId={featureId}
projectPath={resolvedProjectPath}
className="shrink-0 mx-3 my-2"
/>
)}
{effectiveViewMode === 'changes' ? (
<div className="flex-1 min-h-0 sm:min-h-[200px] sm:max-h-[60vh] overflow-y-auto scrollbar-visible">
@@ -370,7 +411,7 @@ export function AgentOutputModal({
/>
) : (
<div className="flex items-center justify-center h-full text-muted-foreground">
<Loader2 className="w-6 h-6 animate-spin mr-2" />
<Spinner size="lg" className="mr-2" />
Loading...
</div>
)}
@@ -384,11 +425,11 @@ export function AgentOutputModal({
<div
ref={scrollRef}
onScroll={handleScroll}
className="flex-1 min-h-0 sm:min-h-[200px] sm:max-h-[60vh] overflow-y-auto bg-zinc-950 rounded-lg p-4 font-mono text-xs scrollbar-visible"
className="flex-1 min-h-0 sm:min-h-[200px] sm:max-h-[60vh] overflow-y-auto bg-popover border border-border/50 rounded-lg p-4 font-mono text-xs scrollbar-visible"
>
{isLoading && !output ? (
<div className="flex items-center justify-center h-full text-muted-foreground">
<Loader2 className="w-6 h-6 animate-spin mr-2" />
<Spinner size="lg" className="mr-2" />
Loading output...
</div>
) : !output ? (
@@ -398,11 +439,13 @@ export function AgentOutputModal({
) : effectiveViewMode === 'parsed' ? (
<LogViewer output={output} />
) : (
<div className="whitespace-pre-wrap break-words text-zinc-300">{output}</div>
<div className="whitespace-pre-wrap wrap-break-word text-foreground/80">
{output}
</div>
)}
</div>
<div className="text-xs text-muted-foreground text-center flex-shrink-0">
<div className="text-xs text-muted-foreground text-center shrink-0">
{autoScrollRef.current
? 'Auto-scrolling enabled'
: 'Scroll to bottom to enable auto-scroll'}

View File

@@ -1,4 +1,5 @@
import { useEffect, useState, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger';
import {
Dialog,
DialogContent,
@@ -10,16 +11,8 @@ import {
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Checkbox } from '@/components/ui/checkbox';
import {
Loader2,
Wand2,
Check,
Plus,
Pencil,
Trash2,
ChevronDown,
ChevronRight,
} from 'lucide-react';
import { Wand2, Check, Plus, Pencil, Trash2, ChevronDown, ChevronRight } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
@@ -43,16 +36,6 @@ function normalizeEntry(entry: PhaseModelEntry | string): PhaseModelEntry {
return entry;
}
/**
* Extract model string from PhaseModelEntry or string
*/
function extractModel(entry: PhaseModelEntry | string): ModelAlias | CursorModelId {
if (typeof entry === 'string') {
return entry as ModelAlias | CursorModelId;
}
return entry.model;
}
interface BacklogPlanDialogProps {
open: boolean;
onClose: () => void;
@@ -80,6 +63,7 @@ export function BacklogPlanDialog({
setIsGeneratingPlan,
currentBranch,
}: BacklogPlanDialogProps) {
const logger = createLogger('BacklogPlanDialog');
const [mode, setMode] = useState<DialogMode>('input');
const [prompt, setPrompt] = useState('');
const [expandedChanges, setExpandedChanges] = useState<Set<number>>(new Set());
@@ -110,11 +94,17 @@ export function BacklogPlanDialog({
const api = getElectronAPI();
if (!api?.backlogPlan) {
logger.warn('Backlog plan API not available');
toast.error('API not available');
return;
}
// Start generation in background
logger.debug('Starting backlog plan generation', {
projectPath,
promptLength: prompt.length,
hasModelOverride: Boolean(modelOverride),
});
setIsGeneratingPlan(true);
// Use model override if set, otherwise use global default (extract model string from PhaseModelEntry)
@@ -122,12 +112,20 @@ export function BacklogPlanDialog({
const effectiveModel = effectiveModelEntry.model;
const result = await api.backlogPlan.generate(projectPath, prompt, effectiveModel);
if (!result.success) {
logger.error('Backlog plan generation failed to start', {
error: result.error,
projectPath,
});
setIsGeneratingPlan(false);
toast.error(result.error || 'Failed to start plan generation');
return;
}
// Show toast and close dialog - generation runs in background
logger.debug('Backlog plan generation started', {
projectPath,
model: effectiveModel,
});
toast.info('Generating plan... This will be ready soon!', {
duration: 3000,
});
@@ -194,10 +192,15 @@ export function BacklogPlanDialog({
currentBranch,
]);
const handleDiscard = useCallback(() => {
const handleDiscard = useCallback(async () => {
setPendingPlanResult(null);
setMode('input');
}, [setPendingPlanResult]);
const api = getElectronAPI();
if (api?.backlogPlan) {
await api.backlogPlan.clear(projectPath);
}
}, [setPendingPlanResult, projectPath]);
const toggleChangeExpanded = (index: number) => {
setExpandedChanges((prev) => {
@@ -260,11 +263,11 @@ export function BacklogPlanDialog({
return (
<div className="space-y-4">
<div className="text-sm text-muted-foreground">
Describe the changes you want to make to your backlog. The AI will analyze your
current features and propose additions, updates, or deletions.
Describe the changes you want to make across your features. The AI will analyze your
current feature list and propose additions, updates, deletions, or restructuring.
</div>
<Textarea
placeholder="e.g., Add authentication features with login, signup, and password reset. Also add a dashboard feature that depends on authentication."
placeholder="e.g., Refactor onboarding into smaller features, add a dashboard feature that depends on authentication, and remove the legacy tour task."
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
className="min-h-[150px] resize-none"
@@ -276,14 +279,13 @@ export function BacklogPlanDialog({
</div>
{isGeneratingPlan && (
<div className="flex items-center gap-2 text-sm text-muted-foreground bg-muted/50 rounded-lg p-3">
<Loader2 className="w-4 h-4 animate-spin" />A plan is currently being generated in
the background...
<Spinner size="sm" />A plan is currently being generated in the background...
</div>
)}
</div>
);
case 'review':
case 'review': {
if (!pendingPlanResult) return null;
const additions = pendingPlanResult.changes.filter((c) => c.type === 'add');
@@ -389,11 +391,12 @@ export function BacklogPlanDialog({
</div>
</div>
);
}
case 'applying':
return (
<div className="flex flex-col items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-primary mb-4" />
<Spinner size="xl" className="mb-4" />
<p className="text-muted-foreground">Applying changes...</p>
</div>
);
@@ -402,7 +405,6 @@ export function BacklogPlanDialog({
// Get effective model entry (override or global default)
const effectiveModelEntry = modelOverride || normalizeEntry(phaseModels.backlogPlanningModel);
const effectiveModel = effectiveModelEntry.model;
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
@@ -410,12 +412,12 @@ export function BacklogPlanDialog({
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Wand2 className="w-5 h-5 text-primary" />
{mode === 'review' ? 'Review Plan' : 'Plan Backlog Changes'}
{mode === 'review' ? 'Review Plan' : 'Plan Feature Changes'}
</DialogTitle>
<DialogDescription>
{mode === 'review'
? 'Select which changes to apply to your backlog'
: 'Use AI to add, update, or remove features from your backlog'}
? 'Select which changes to apply to your features'
: 'Use AI to add, update, remove, or restructure your features'}
</DialogDescription>
</DialogHeader>
@@ -441,13 +443,13 @@ export function BacklogPlanDialog({
<Button onClick={handleGenerate} disabled={!prompt.trim() || isGeneratingPlan}>
{isGeneratingPlan ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
<Spinner size="sm" className="mr-2" />
Generating...
</>
) : (
<>
<Wand2 className="w-4 h-4 mr-2" />
Generate Plan
Apply Changes
</>
)}
</Button>

View File

@@ -10,7 +10,8 @@ import {
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { GitCommit, Loader2, Sparkles } from 'lucide-react';
import { GitCommit, Sparkles } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
import { useAppStore } from '@/store/app-store';
@@ -209,7 +210,7 @@ export function CommitWorktreeDialog({
<Button onClick={handleCommit} disabled={isLoading || isGenerating || !message.trim()}>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
<Spinner size="sm" className="mr-2" />
Committing...
</>
) : (

View File

@@ -13,7 +13,8 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
import { GitBranchPlus, Loader2 } from 'lucide-react';
import { GitBranchPlus } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
interface WorktreeInfo {
path: string;
@@ -133,7 +134,7 @@ export function CreateBranchDialog({
<Button onClick={handleCreate} disabled={!branchName.trim() || isCreating}>
{isCreating ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
<Spinner size="sm" className="mr-2" />
Creating...
</>
) : (

View File

@@ -13,7 +13,8 @@ import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { BranchAutocomplete } from '@/components/ui/branch-autocomplete';
import { GitPullRequest, Loader2, ExternalLink } from 'lucide-react';
import { GitPullRequest, ExternalLink } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
import { useWorktreeBranches } from '@/hooks/queries';
@@ -384,7 +385,7 @@ export function CreatePRDialog({
<Button onClick={handleCreate} disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
<Spinner size="sm" className="mr-2" />
Creating...
</>
) : (

View File

@@ -10,7 +10,8 @@ import {
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { GitBranch, Loader2, AlertCircle } from 'lucide-react';
import { GitBranch, AlertCircle } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
@@ -216,7 +217,7 @@ export function CreateWorktreeDialog({
<Button onClick={handleCreate} disabled={isLoading || !branchName.trim()}>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
<Spinner size="sm" className="mr-2" />
Creating...
</>
) : (

View File

@@ -10,7 +10,8 @@ import {
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { Loader2, Trash2, AlertTriangle, FileWarning } from 'lucide-react';
import { Trash2, AlertTriangle, FileWarning } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
@@ -147,7 +148,7 @@ export function DeleteWorktreeDialog({
<Button variant="destructive" onClick={handleDelete} disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
<Spinner size="sm" className="mr-2" />
Deleting...
</>
) : (

View File

@@ -15,6 +15,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { CategoryAutocomplete } from '@/components/ui/category-autocomplete';
import { DependencySelector } from '@/components/ui/dependency-selector';
import {
DescriptionImageDropZone,
FeatureImagePath as DescriptionImagePath,
@@ -27,6 +28,7 @@ import { toast } from 'sonner';
import { cn, modelSupportsThinking } from '@/lib/utils';
import { Feature, ModelAlias, ThinkingLevel, useAppStore, PlanningMode } from '@/store/app-store';
import type { ReasoningEffort, PhaseModelEntry, DescriptionHistoryEntry } from '@automaker/types';
import { migrateModelId } from '@automaker/types';
import {
TestingTabContent,
PrioritySelector,
@@ -63,6 +65,8 @@ interface EditFeatureDialogProps {
priority: number;
planningMode: PlanningMode;
requirePlanApproval: boolean;
dependencies?: string[];
childDependencies?: string[]; // Feature IDs that should depend on this feature
},
descriptionHistorySource?: 'enhance' | 'edit',
enhancementMode?: EnhancementMode,
@@ -104,9 +108,9 @@ export function EditFeatureDialog({
feature?.requirePlanApproval ?? false
);
// Model selection state
// Model selection state - migrate legacy model IDs to canonical format
const [modelEntry, setModelEntry] = useState<PhaseModelEntry>(() => ({
model: (feature?.model as ModelAlias) || 'opus',
model: migrateModelId(feature?.model) || 'claude-opus',
thinkingLevel: feature?.thinkingLevel || 'none',
reasoningEffort: feature?.reasoningEffort || 'none',
}));
@@ -127,6 +131,21 @@ export function EditFeatureDialog({
feature?.descriptionHistory ?? []
);
// Dependency state
const [parentDependencies, setParentDependencies] = useState<string[]>(
feature?.dependencies ?? []
);
// Child dependencies are features that have this feature in their dependencies
const [childDependencies, setChildDependencies] = useState<string[]>(() => {
if (!feature) return [];
return allFeatures.filter((f) => f.dependencies?.includes(feature.id)).map((f) => f.id);
});
// Track original child dependencies to detect changes
const [originalChildDependencies, setOriginalChildDependencies] = useState<string[]>(() => {
if (!feature) return [];
return allFeatures.filter((f) => f.dependencies?.includes(feature.id)).map((f) => f.id);
});
useEffect(() => {
setEditingFeature(feature);
if (feature) {
@@ -139,19 +158,29 @@ export function EditFeatureDialog({
setDescriptionChangeSource(null);
setPreEnhancementDescription(null);
setLocalHistory(feature.descriptionHistory ?? []);
// Reset model entry
// Reset model entry - migrate legacy model IDs
setModelEntry({
model: (feature.model as ModelAlias) || 'opus',
model: migrateModelId(feature.model) || 'claude-opus',
thinkingLevel: feature.thinkingLevel || 'none',
reasoningEffort: feature.reasoningEffort || 'none',
});
// Reset dependency state
setParentDependencies(feature.dependencies ?? []);
const childDeps = allFeatures
.filter((f) => f.dependencies?.includes(feature.id))
.map((f) => f.id);
setChildDependencies(childDeps);
setOriginalChildDependencies(childDeps);
} else {
setEditFeaturePreviewMap(new Map());
setDescriptionChangeSource(null);
setPreEnhancementDescription(null);
setLocalHistory([]);
setParentDependencies([]);
setChildDependencies([]);
setOriginalChildDependencies([]);
}
}, [feature]);
}, [feature, allFeatures]);
const handleModelChange = (entry: PhaseModelEntry) => {
setModelEntry(entry);
@@ -180,6 +209,12 @@ export function EditFeatureDialog({
// For 'custom' mode, use the specified branch name
const finalBranchName = workMode === 'custom' ? editingFeature.branchName || '' : '';
// Check if child dependencies changed
const childDepsChanged =
childDependencies.length !== originalChildDependencies.length ||
childDependencies.some((id) => !originalChildDependencies.includes(id)) ||
originalChildDependencies.some((id) => !childDependencies.includes(id));
const updates = {
title: editingFeature.title ?? '',
category: editingFeature.category,
@@ -195,6 +230,8 @@ export function EditFeatureDialog({
planningMode,
requirePlanApproval,
workMode,
dependencies: parentDependencies,
childDependencies: childDepsChanged ? childDependencies : undefined,
};
// Determine if description changed and what source to use
@@ -547,6 +584,40 @@ export function EditFeatureDialog({
testIdPrefix="edit-feature-work-mode"
/>
</div>
{/* Dependencies */}
{allFeatures.length > 1 && (
<div className="pt-2 space-y-3">
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">
Parent Dependencies (this feature depends on)
</Label>
<DependencySelector
currentFeatureId={editingFeature.id}
value={parentDependencies}
onChange={setParentDependencies}
features={allFeatures}
type="parent"
placeholder="Select features this depends on..."
data-testid="edit-feature-parent-deps"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">
Child Dependencies (features that depend on this)
</Label>
<DependencySelector
currentFeatureId={editingFeature.id}
value={childDependencies}
onChange={setChildDependencies}
features={allFeatures}
type="child"
placeholder="Select features that depend on this..."
data-testid="edit-feature-child-deps"
/>
</div>
</div>
)}
</div>
</div>

View File

@@ -126,7 +126,7 @@ export function MassEditDialog({
});
// Field values
const [model, setModel] = useState<ModelAlias>('sonnet');
const [model, setModel] = useState<ModelAlias>('claude-sonnet');
const [thinkingLevel, setThinkingLevel] = useState<ThinkingLevel>('none');
const [planningMode, setPlanningMode] = useState<PlanningMode>('skip');
const [requirePlanApproval, setRequirePlanApproval] = useState(false);
@@ -160,7 +160,7 @@ export function MassEditDialog({
skipTests: false,
branchName: false,
});
setModel(getInitialValue(selectedFeatures, 'model', 'sonnet') as ModelAlias);
setModel(getInitialValue(selectedFeatures, 'model', 'claude-sonnet') as ModelAlias);
setThinkingLevel(getInitialValue(selectedFeatures, 'thinkingLevel', 'none') as ThinkingLevel);
setPlanningMode(getInitialValue(selectedFeatures, 'planningMode', 'skip') as PlanningMode);
setRequirePlanApproval(getInitialValue(selectedFeatures, 'requirePlanApproval', false));

View File

@@ -10,7 +10,8 @@ import {
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Loader2, GitMerge, AlertTriangle, CheckCircle2 } from 'lucide-react';
import { GitMerge, AlertTriangle, CheckCircle2 } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
@@ -217,7 +218,7 @@ export function MergeWorktreeDialog({
>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
<Spinner size="sm" className="mr-2" />
Merging...
</>
) : (

View File

@@ -14,7 +14,8 @@ import { Textarea } from '@/components/ui/textarea';
import { Markdown } from '@/components/ui/markdown';
import { Label } from '@/components/ui/label';
import { Feature } from '@/store/app-store';
import { Check, RefreshCw, Edit2, Eye, Loader2 } from 'lucide-react';
import { Check, RefreshCw, Edit2, Eye } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
interface PlanApprovalDialogProps {
open: boolean;
@@ -171,7 +172,7 @@ export function PlanApprovalDialog({
</Button>
<Button variant="secondary" onClick={handleReject} disabled={isLoading}>
{isLoading ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
<Spinner size="sm" className="mr-2" />
) : (
<RefreshCw className="w-4 h-4 mr-2" />
)}
@@ -190,7 +191,7 @@ export function PlanApprovalDialog({
className="bg-green-600 hover:bg-green-700 text-white"
>
{isLoading ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
<Spinner size="sm" className="mr-2" />
) : (
<Check className="w-4 h-4 mr-2" />
)}

View File

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

View File

@@ -7,5 +7,5 @@ export { useBoardEffects } from './use-board-effects';
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 { useSelectionMode, type SelectionTarget } from './use-selection-mode';
export { useListViewState } from './use-list-view-state';

View File

@@ -117,6 +117,7 @@ export function useBoardActions({
planningMode: PlanningMode;
requirePlanApproval: boolean;
dependencies?: string[];
childDependencies?: string[]; // Feature IDs that should depend on this feature
workMode?: 'current' | 'auto' | 'custom';
}) => {
const workMode = featureData.workMode || 'current';
@@ -131,8 +132,10 @@ export function useBoardActions({
// No worktree isolation - work directly on current branch
finalBranchName = undefined;
} else if (workMode === 'auto') {
// Auto-generate a branch name based on current branch and timestamp
const baseBranch = currentWorktreeBranch || 'main';
// Auto-generate a branch name based on primary branch (main/master) and timestamp
// Always use primary branch to avoid nested feature/feature/... paths
const baseBranch =
(currentProject?.path ? getPrimaryWorktreeBranch(currentProject.path) : null) || 'main';
const timestamp = Date.now();
const randomSuffix = Math.random().toString(36).substring(2, 6);
finalBranchName = `feature/${baseBranch}-${timestamp}-${randomSuffix}`;
@@ -194,6 +197,21 @@ export function useBoardActions({
await persistFeatureCreate(createdFeature);
saveCategory(featureData.category);
// Handle child dependencies - update other features to depend on this new feature
if (featureData.childDependencies && featureData.childDependencies.length > 0) {
for (const childId of featureData.childDependencies) {
const childFeature = features.find((f) => f.id === childId);
if (childFeature) {
const childDeps = childFeature.dependencies || [];
if (!childDeps.includes(createdFeature.id)) {
const newDeps = [...childDeps, createdFeature.id];
updateFeature(childId, { dependencies: newDeps });
persistFeatureUpdate(childId, { dependencies: newDeps });
}
}
}
}
// Generate title in the background if needed (non-blocking)
if (needsTitleGeneration) {
const api = getElectronAPI();
@@ -234,7 +252,8 @@ export function useBoardActions({
currentProject,
onWorktreeCreated,
onWorktreeAutoSelect,
currentWorktreeBranch,
getPrimaryWorktreeBranch,
features,
]
);
@@ -255,6 +274,8 @@ export function useBoardActions({
planningMode?: PlanningMode;
requirePlanApproval?: boolean;
workMode?: 'current' | 'auto' | 'custom';
dependencies?: string[];
childDependencies?: string[]; // Feature IDs that should depend on this feature
},
descriptionHistorySource?: 'enhance' | 'edit',
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer',
@@ -268,7 +289,10 @@ export function useBoardActions({
if (workMode === 'current') {
finalBranchName = undefined;
} else if (workMode === 'auto') {
const baseBranch = currentWorktreeBranch || 'main';
// Auto-generate a branch name based on primary branch (main/master) and timestamp
// Always use primary branch to avoid nested feature/feature/... paths
const baseBranch =
(currentProject?.path ? getPrimaryWorktreeBranch(currentProject.path) : null) || 'main';
const timestamp = Date.now();
const randomSuffix = Math.random().toString(36).substring(2, 6);
finalBranchName = `feature/${baseBranch}-${timestamp}-${randomSuffix}`;
@@ -308,8 +332,11 @@ export function useBoardActions({
}
}
// Separate child dependencies from the main updates (they affect other features)
const { childDependencies, ...restUpdates } = updates;
const finalUpdates = {
...updates,
...restUpdates,
title: updates.title,
branchName: finalBranchName,
};
@@ -322,6 +349,45 @@ export function useBoardActions({
enhancementMode,
preEnhancementDescription
);
// Handle child dependency changes
// This updates other features' dependencies arrays
if (childDependencies !== undefined) {
// Find current child dependencies (features that have this feature in their dependencies)
const currentChildDeps = features
.filter((f) => f.dependencies?.includes(featureId))
.map((f) => f.id);
// Find features to add this feature as a dependency (new child deps)
const toAdd = childDependencies.filter((id) => !currentChildDeps.includes(id));
// Find features to remove this feature as a dependency (removed child deps)
const toRemove = currentChildDeps.filter((id) => !childDependencies.includes(id));
// Add this feature as a dependency to new child features
for (const childId of toAdd) {
const childFeature = features.find((f) => f.id === childId);
if (childFeature) {
const childDeps = childFeature.dependencies || [];
if (!childDeps.includes(featureId)) {
const newDeps = [...childDeps, featureId];
updateFeature(childId, { dependencies: newDeps });
persistFeatureUpdate(childId, { dependencies: newDeps });
}
}
}
// Remove this feature as a dependency from removed child features
for (const childId of toRemove) {
const childFeature = features.find((f) => f.id === childId);
if (childFeature) {
const childDeps = childFeature.dependencies || [];
const newDeps = childDeps.filter((depId) => depId !== featureId);
updateFeature(childId, { dependencies: newDeps });
persistFeatureUpdate(childId, { dependencies: newDeps });
}
}
}
if (updates.category) {
saveCategory(updates.category);
}
@@ -334,7 +400,8 @@ export function useBoardActions({
setEditingFeature,
currentProject,
onWorktreeCreated,
currentWorktreeBranch,
getPrimaryWorktreeBranch,
features,
]
);

View File

@@ -34,6 +34,7 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
return;
}
logger.info('Calling API features.update', { featureId, updates });
const result = await api.features.update(
currentProject.path,
featureId,
@@ -42,12 +43,18 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
enhancementMode,
preEnhancementDescription
);
logger.info('API features.update result', {
success: result.success,
feature: result.feature,
});
if (result.success && result.feature) {
updateFeature(result.feature.id, result.feature);
// Invalidate React Query cache to sync UI
queryClient.invalidateQueries({
queryKey: queryKeys.features.all(currentProject.path),
});
} else if (!result.success) {
logger.error('API features.update failed', result);
}
} catch (error) {
logger.error('Failed to persist feature update:', error);

View File

@@ -1,10 +1,13 @@
import { useState, useCallback, useEffect } from 'react';
export type SelectionTarget = 'backlog' | 'waiting_approval' | null;
interface UseSelectionModeReturn {
isSelectionMode: boolean;
selectionTarget: SelectionTarget;
selectedFeatureIds: Set<string>;
selectedCount: number;
toggleSelectionMode: () => void;
toggleSelectionMode: (target?: SelectionTarget) => void;
toggleFeatureSelection: (featureId: string) => void;
selectAll: (featureIds: string[]) => void;
clearSelection: () => void;
@@ -13,21 +16,26 @@ interface UseSelectionModeReturn {
}
export function useSelectionMode(): UseSelectionModeReturn {
const [isSelectionMode, setIsSelectionMode] = useState(false);
const [selectionTarget, setSelectionTarget] = useState<SelectionTarget>(null);
const [selectedFeatureIds, setSelectedFeatureIds] = useState<Set<string>>(new Set());
const toggleSelectionMode = useCallback(() => {
setIsSelectionMode((prev) => {
if (prev) {
const isSelectionMode = selectionTarget !== null;
const toggleSelectionMode = useCallback((target: SelectionTarget = 'backlog') => {
setSelectionTarget((prev) => {
if (prev === target) {
// Exiting selection mode - clear selection
setSelectedFeatureIds(new Set());
return null;
}
return !prev;
// Switching to a different target or entering selection mode
setSelectedFeatureIds(new Set());
return target;
});
}, []);
const exitSelectionMode = useCallback(() => {
setIsSelectionMode(false);
setSelectionTarget(null);
setSelectedFeatureIds(new Set());
}, []);
@@ -70,6 +78,7 @@ export function useSelectionMode(): UseSelectionModeReturn {
return {
isSelectionMode,
selectionTarget,
selectedFeatureIds,
selectedCount: selectedFeatureIds.size,
toggleSelectionMode,

View File

@@ -1,5 +1,6 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { Terminal, Check, X, Loader2, ChevronDown, ChevronUp } from 'lucide-react';
import { Terminal, Check, X, ChevronDown, ChevronUp } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import { useAppStore, type InitScriptState } from '@/store/app-store';
import { AnsiOutput } from '@/components/ui/ansi-output';
@@ -65,7 +66,7 @@ function SingleIndicator({
{/* Header */}
<div className="flex items-center justify-between p-3 border-b border-border/50">
<div className="flex items-center gap-2">
{status === 'running' && <Loader2 className="w-4 h-4 animate-spin text-blue-500" />}
{status === 'running' && <Spinner size="sm" />}
{status === 'success' && <Check className="w-4 h-4 text-green-500" />}
{status === 'failed' && <X className="w-4 h-4 text-red-500" />}
<span className="font-medium text-sm">

View File

@@ -50,9 +50,10 @@ interface KanbanBoardProps {
onOpenPipelineSettings?: () => void;
// Selection mode props
isSelectionMode?: boolean;
selectionTarget?: 'backlog' | 'waiting_approval' | null;
selectedFeatureIds?: Set<string>;
onToggleFeatureSelection?: (featureId: string) => void;
onToggleSelectionMode?: () => void;
onToggleSelectionMode?: (target?: 'backlog' | 'waiting_approval') => void;
// Empty state action props
onAiSuggest?: () => void;
/** Whether currently dragging (hides empty states during drag) */
@@ -95,6 +96,7 @@ export function KanbanBoard({
pipelineConfig,
onOpenPipelineSettings,
isSelectionMode = false,
selectionTarget = null,
selectedFeatureIds = new Set(),
onToggleFeatureSelection,
onToggleSelectionMode,
@@ -189,12 +191,14 @@ export function KanbanBoard({
<Button
variant="ghost"
size="sm"
className={`h-6 px-2 text-xs ${isSelectionMode ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground'}`}
onClick={onToggleSelectionMode}
title={isSelectionMode ? 'Switch to Drag Mode' : 'Select Multiple'}
className={`h-6 px-2 text-xs ${selectionTarget === 'backlog' ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground'}`}
onClick={() => onToggleSelectionMode?.('backlog')}
title={
selectionTarget === 'backlog' ? 'Switch to Drag Mode' : 'Select Multiple'
}
data-testid="selection-mode-button"
>
{isSelectionMode ? (
{selectionTarget === 'backlog' ? (
<>
<GripVertical className="w-3.5 h-3.5 mr-1" />
Drag
@@ -207,6 +211,31 @@ export function KanbanBoard({
)}
</Button>
</div>
) : column.id === 'waiting_approval' ? (
<Button
variant="ghost"
size="sm"
className={`h-6 px-2 text-xs ${selectionTarget === 'waiting_approval' ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground'}`}
onClick={() => onToggleSelectionMode?.('waiting_approval')}
title={
selectionTarget === 'waiting_approval'
? 'Switch to Drag Mode'
: 'Select Multiple'
}
data-testid="waiting-approval-selection-mode-button"
>
{selectionTarget === 'waiting_approval' ? (
<>
<GripVertical className="w-3.5 h-3.5 mr-1" />
Drag
</>
) : (
<>
<CheckSquare className="w-3.5 h-3.5 mr-1" />
Select
</>
)}
</Button>
) : column.id === 'in_progress' ? (
<Button
variant="ghost"
@@ -305,6 +334,7 @@ export function KanbanBoard({
cardBorderEnabled={backgroundSettings.cardBorderEnabled}
cardBorderOpacity={backgroundSettings.cardBorderOpacity}
isSelectionMode={isSelectionMode}
selectionTarget={selectionTarget}
isSelected={selectedFeatureIds.has(feature.id)}
onToggleSelect={() => onToggleFeatureSelection?.(feature.id)}
/>

View File

@@ -1,6 +1,7 @@
import { useEffect, useCallback, useState, type ComponentType, type ReactNode } from 'react';
import { RefreshCw } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
import { AnthropicIcon, OpenAIIcon } from '@/components/ui/provider-icon';
@@ -90,9 +91,11 @@ function UsageItem({
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')}
/>
{isLoading ? (
<Spinner size="xs" />
) : (
<RefreshCw className="w-3.5 h-3.5 text-muted-foreground" />
)}
</button>
</div>
<div className="pl-6 space-y-2">{children}</div>

View File

@@ -9,7 +9,7 @@ import { Brain, Zap, Scale, Cpu, Rocket, Sparkles } from 'lucide-react';
import { AnthropicIcon, CursorIcon, OpenAIIcon, OpenCodeIcon } from '@/components/ui/provider-icon';
export type ModelOption = {
id: string; // Claude models use ModelAlias, Cursor models use "cursor-{id}"
id: string; // All model IDs use canonical prefixed format (e.g., "claude-sonnet", "cursor-auto")
label: string;
description: string;
badge?: string;
@@ -17,23 +17,27 @@ export type ModelOption = {
hasThinking?: boolean;
};
/**
* Claude models with canonical prefixed IDs
* UI displays short labels but stores full canonical IDs
*/
export const CLAUDE_MODELS: ModelOption[] = [
{
id: 'haiku',
id: 'claude-haiku', // Canonical prefixed ID
label: 'Claude Haiku',
description: 'Fast and efficient for simple tasks.',
badge: 'Speed',
provider: 'claude',
},
{
id: 'sonnet',
id: 'claude-sonnet', // Canonical prefixed ID
label: 'Claude Sonnet',
description: 'Balanced performance with strong reasoning.',
badge: 'Balanced',
provider: 'claude',
},
{
id: 'opus',
id: 'claude-opus', // Canonical prefixed ID
label: 'Claude Opus',
description: 'Most capable model for complex work.',
badge: 'Premium',
@@ -43,11 +47,11 @@ export const CLAUDE_MODELS: ModelOption[] = [
/**
* Cursor models derived from CURSOR_MODEL_MAP
* ID is prefixed with "cursor-" for ProviderFactory routing
* IDs already have 'cursor-' prefix in the canonical format
*/
export const CURSOR_MODELS: ModelOption[] = Object.entries(CURSOR_MODEL_MAP).map(
([id, config]) => ({
id: `cursor-${id}`,
id, // Already prefixed in canonical format
label: config.label,
description: config.description,
provider: 'cursor' as ModelProvider,

View File

@@ -11,7 +11,7 @@ import { getModelProvider, PROVIDER_PREFIXES, stripProviderPrefix } from '@autom
import type { ModelProvider } from '@automaker/types';
import { CLAUDE_MODELS, CURSOR_MODELS, ModelOption } from './model-constants';
import { useEffect } from 'react';
import { RefreshCw } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
interface ModelSelectorProps {
selectedModel: string; // Can be ModelAlias or "cursor-{id}"
@@ -31,6 +31,7 @@ export function ModelSelector({
codexModelsLoading,
codexModelsError,
fetchCodexModels,
disabledProviders,
} = useAppStore();
const { cursorCliStatus, codexCliStatus } = useSetupStore();
@@ -69,79 +70,106 @@ export function ModelSelector({
// Filter Cursor models based on enabled models from global settings
const filteredCursorModels = CURSOR_MODELS.filter((model) => {
// Extract the cursor model ID from the prefixed ID (e.g., "cursor-auto" -> "auto")
const cursorModelId = stripProviderPrefix(model.id);
return enabledCursorModels.includes(cursorModelId as any);
// enabledCursorModels stores CursorModelIds which may or may not have "cursor-" prefix
// (e.g., 'auto', 'sonnet-4.5' without prefix, but 'cursor-gpt-5.2' with prefix)
// CURSOR_MODELS always has the "cursor-" prefix added in model-constants.ts
// Check both the full ID (for GPT models) and the unprefixed version (for non-GPT models)
const unprefixedId = model.id.startsWith('cursor-') ? model.id.slice(7) : model.id;
return (
enabledCursorModels.includes(model.id as any) ||
enabledCursorModels.includes(unprefixedId as any)
);
});
const handleProviderChange = (provider: ModelProvider) => {
if (provider === 'cursor' && selectedProvider !== 'cursor') {
// Switch to Cursor's default model (from global settings)
onModelSelect(`${PROVIDER_PREFIXES.cursor}${cursorDefaultModel}`);
// cursorDefaultModel is now canonical (e.g., 'cursor-auto'), so use directly
onModelSelect(cursorDefaultModel);
} else if (provider === 'codex' && selectedProvider !== 'codex') {
// Switch to Codex's default model (use isDefault flag from dynamic models)
const defaultModel = codexModels.find((m) => m.isDefault);
const defaultModelId = defaultModel?.id || codexModels[0]?.id || 'codex-gpt-5.2-codex';
onModelSelect(defaultModelId);
} else if (provider === 'claude' && selectedProvider !== 'claude') {
// Switch to Claude's default model
onModelSelect('sonnet');
// Switch to Claude's default model (canonical format)
onModelSelect('claude-sonnet');
}
};
// Check which providers are disabled
const isClaudeDisabled = disabledProviders.includes('claude');
const isCursorDisabled = disabledProviders.includes('cursor');
const isCodexDisabled = disabledProviders.includes('codex');
// Count available providers
const availableProviders = [
!isClaudeDisabled && 'claude',
!isCursorDisabled && 'cursor',
!isCodexDisabled && 'codex',
].filter(Boolean) as ModelProvider[];
return (
<div className="space-y-4">
{/* Provider Selection */}
<div className="space-y-2">
<Label>AI Provider</Label>
<div className="flex gap-2">
<button
type="button"
onClick={() => handleProviderChange('claude')}
className={cn(
'flex-1 px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-center gap-2',
selectedProvider === 'claude'
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-accent border-border'
{availableProviders.length > 1 && (
<div className="space-y-2">
<Label>AI Provider</Label>
<div className="flex gap-2">
{!isClaudeDisabled && (
<button
type="button"
onClick={() => handleProviderChange('claude')}
className={cn(
'flex-1 px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-center gap-2',
selectedProvider === 'claude'
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-accent border-border'
)}
data-testid={`${testIdPrefix}-provider-claude`}
>
<AnthropicIcon className="w-4 h-4" />
Claude
</button>
)}
data-testid={`${testIdPrefix}-provider-claude`}
>
<AnthropicIcon className="w-4 h-4" />
Claude
</button>
<button
type="button"
onClick={() => handleProviderChange('cursor')}
className={cn(
'flex-1 px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-center gap-2',
selectedProvider === 'cursor'
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-accent border-border'
{!isCursorDisabled && (
<button
type="button"
onClick={() => handleProviderChange('cursor')}
className={cn(
'flex-1 px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-center gap-2',
selectedProvider === 'cursor'
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-accent border-border'
)}
data-testid={`${testIdPrefix}-provider-cursor`}
>
<CursorIcon className="w-4 h-4" />
Cursor CLI
</button>
)}
data-testid={`${testIdPrefix}-provider-cursor`}
>
<CursorIcon className="w-4 h-4" />
Cursor CLI
</button>
<button
type="button"
onClick={() => handleProviderChange('codex')}
className={cn(
'flex-1 px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-center gap-2',
selectedProvider === 'codex'
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-accent border-border'
{!isCodexDisabled && (
<button
type="button"
onClick={() => handleProviderChange('codex')}
className={cn(
'flex-1 px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-center gap-2',
selectedProvider === 'codex'
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-accent border-border'
)}
data-testid={`${testIdPrefix}-provider-codex`}
>
<OpenAIIcon className="w-4 h-4" />
Codex CLI
</button>
)}
data-testid={`${testIdPrefix}-provider-codex`}
>
<OpenAIIcon className="w-4 h-4" />
Codex CLI
</button>
</div>
</div>
</div>
)}
{/* Claude Models */}
{selectedProvider === 'claude' && (
{selectedProvider === 'claude' && !isClaudeDisabled && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="flex items-center gap-2">
@@ -179,7 +207,7 @@ export function ModelSelector({
)}
{/* Cursor Models */}
{selectedProvider === 'cursor' && (
{selectedProvider === 'cursor' && !isCursorDisabled && (
<div className="space-y-3">
{/* Warning when Cursor CLI is not available */}
{!isCursorAvailable && (
@@ -248,7 +276,7 @@ export function ModelSelector({
)}
{/* Codex Models */}
{selectedProvider === 'codex' && (
{selectedProvider === 'codex' && !isCodexDisabled && (
<div className="space-y-3">
{/* Warning when Codex CLI is not available */}
{!isCodexAvailable && (
@@ -274,7 +302,7 @@ export function ModelSelector({
{/* Loading state */}
{codexModelsLoading && dynamicCodexModels.length === 0 && (
<div className="flex items-center justify-center gap-2 p-6 text-sm text-muted-foreground">
<RefreshCw className="w-4 h-4 animate-spin" />
<Spinner size="sm" />
Loading models...
</div>
)}

View File

@@ -6,12 +6,12 @@ import {
ClipboardList,
FileText,
ScrollText,
Loader2,
Check,
Eye,
RefreshCw,
Sparkles,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
@@ -236,7 +236,7 @@ export function PlanningModeSelector({
<div className="flex items-center gap-2">
{isGenerating ? (
<>
<Loader2 className="h-4 w-4 animate-spin text-primary" />
<Spinner size="sm" />
<span className="text-sm text-muted-foreground">
Generating {mode === 'full' ? 'comprehensive spec' : 'spec'}...
</span>

View File

@@ -8,7 +8,8 @@ import {
DropdownMenuTrigger,
DropdownMenuLabel,
} from '@/components/ui/dropdown-menu';
import { GitBranch, RefreshCw, GitBranchPlus, Check, Search } from 'lucide-react';
import { GitBranch, GitBranchPlus, Check, Search } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import type { WorktreeInfo, BranchInfo } from '../types';
@@ -81,7 +82,7 @@ export function BranchSwitchDropdown({
<div className="max-h-[250px] overflow-y-auto">
{isLoadingBranches ? (
<DropdownMenuItem disabled className="text-xs">
<RefreshCw className="w-3.5 h-3.5 mr-2 animate-spin" />
<Spinner size="xs" className="mr-2" />
Loading branches...
</DropdownMenuItem>
) : filteredBranches.length === 0 ? (

View File

@@ -2,7 +2,6 @@ import { useEffect, useRef, useCallback, useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import {
Loader2,
Terminal,
ArrowDown,
ExternalLink,
@@ -12,6 +11,7 @@ import {
Clock,
GitBranch,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import { XtermLogViewer, type XtermLogViewerRef } from '@/components/ui/xterm-log-viewer';
import { useDevServerLogs } from '../hooks/use-dev-server-logs';
@@ -183,7 +183,7 @@ export function DevServerLogsPanel({
onClick={() => fetchLogs()}
title="Refresh logs"
>
<RefreshCw className={cn('w-3.5 h-3.5', isLoading && 'animate-spin')} />
{isLoading ? <Spinner size="xs" /> : <RefreshCw className="w-3.5 h-3.5" />}
</Button>
</div>
</div>
@@ -234,7 +234,7 @@ export function DevServerLogsPanel({
>
{isLoading && !logs ? (
<div className="flex items-center justify-center h-full min-h-[300px] text-muted-foreground">
<Loader2 className="w-5 h-5 animate-spin mr-2" />
<Spinner size="md" className="mr-2" />
<span className="text-sm">Loading logs...</span>
</div>
) : !logs && !isRunning ? (
@@ -245,7 +245,7 @@ export function DevServerLogsPanel({
</div>
) : !logs ? (
<div className="flex flex-col items-center justify-center h-full min-h-[300px] text-muted-foreground p-8">
<div className="w-8 h-8 mb-3 rounded-full border-2 border-muted-foreground/20 border-t-muted-foreground/60 animate-spin" />
<Spinner size="xl" className="mb-3" />
<p className="text-sm">Waiting for output...</p>
<p className="text-xs mt-1 opacity-60">
Logs will appear as the server generates output
@@ -256,7 +256,6 @@ export function DevServerLogsPanel({
ref={xtermRef}
className="h-full"
minHeight={280}
fontSize={13}
autoScroll={autoScrollEnabled}
onScrollAwayFromBottom={() => setAutoScrollEnabled(false)}
onScrollToBottom={() => setAutoScrollEnabled(true)}

View File

@@ -26,13 +26,22 @@ import {
RefreshCw,
Copy,
ScrollText,
Terminal,
SquarePlus,
SplitSquareHorizontal,
} from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import type { WorktreeInfo, DevServerInfo, PRInfo, GitRepoStatus } from '../types';
import { TooltipWrapper } from './tooltip-wrapper';
import { useAvailableEditors, useEffectiveDefaultEditor } from '../hooks/use-available-editors';
import {
useAvailableTerminals,
useEffectiveDefaultTerminal,
} from '../hooks/use-available-terminals';
import { getEditorIcon } from '@/components/icons/editor-icons';
import { getTerminalIcon } from '@/components/icons/terminal-icons';
import { useAppStore } from '@/store/app-store';
interface WorktreeActionsDropdownProps {
worktree: WorktreeInfo;
@@ -51,6 +60,8 @@ interface WorktreeActionsDropdownProps {
onPull: (worktree: WorktreeInfo) => void;
onPush: (worktree: WorktreeInfo) => void;
onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void;
onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void;
onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void;
onCommit: (worktree: WorktreeInfo) => void;
onCreatePR: (worktree: WorktreeInfo) => void;
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
@@ -81,6 +92,8 @@ export function WorktreeActionsDropdown({
onPull,
onPush,
onOpenInEditor,
onOpenInIntegratedTerminal,
onOpenInExternalTerminal,
onCommit,
onCreatePR,
onAddressPRComments,
@@ -108,6 +121,20 @@ export function WorktreeActionsDropdown({
? getEditorIcon(effectiveDefaultEditor.command)
: null;
// Get available terminals for the "Open In Terminal" submenu
const { terminals, hasExternalTerminals } = useAvailableTerminals();
// Use shared hook for effective default terminal (null = integrated terminal)
const effectiveDefaultTerminal = useEffectiveDefaultTerminal(terminals);
// Get the user's preferred mode for opening terminals (new tab vs split)
const openTerminalMode = useAppStore((s) => s.terminalState.openTerminalMode);
// Get icon component for the effective terminal
const DefaultTerminalIcon = effectiveDefaultTerminal
? getTerminalIcon(effectiveDefaultTerminal.id)
: Terminal;
// Check if there's a PR associated with this worktree from stored metadata
const hasPR = !!worktree.pr;
@@ -303,6 +330,77 @@ export function WorktreeActionsDropdown({
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
{/* Open in terminal - always show with integrated + external options */}
<DropdownMenuSub>
<div className="flex items-center">
{/* Main clickable area - opens in default terminal (integrated or external) */}
<DropdownMenuItem
onClick={() => {
if (effectiveDefaultTerminal) {
// External terminal is the default
onOpenInExternalTerminal(worktree, effectiveDefaultTerminal.id);
} else {
// Integrated terminal is the default - use user's preferred mode
const mode = openTerminalMode === 'newTab' ? 'tab' : 'split';
onOpenInIntegratedTerminal(worktree, mode);
}
}}
className="text-xs flex-1 pr-0 rounded-r-none"
>
<DefaultTerminalIcon className="w-3.5 h-3.5 mr-2" />
Open in {effectiveDefaultTerminal?.name ?? 'Terminal'}
</DropdownMenuItem>
{/* Chevron trigger for submenu with all terminals */}
<DropdownMenuSubTrigger className="text-xs px-1 rounded-l-none border-l border-border/30 h-8" />
</div>
<DropdownMenuSubContent>
{/* Automaker Terminal - with submenu for new tab vs split */}
<DropdownMenuSub>
<DropdownMenuSubTrigger className="text-xs">
<Terminal className="w-3.5 h-3.5 mr-2" />
Terminal
{!effectiveDefaultTerminal && (
<span className="ml-auto mr-2 text-[10px] text-muted-foreground">(default)</span>
)}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem
onClick={() => onOpenInIntegratedTerminal(worktree, 'tab')}
className="text-xs"
>
<SquarePlus className="w-3.5 h-3.5 mr-2" />
New Tab
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onOpenInIntegratedTerminal(worktree, 'split')}
className="text-xs"
>
<SplitSquareHorizontal className="w-3.5 h-3.5 mr-2" />
Split
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
{/* External terminals */}
{terminals.length > 0 && <DropdownMenuSeparator />}
{terminals.map((terminal) => {
const TerminalIcon = getTerminalIcon(terminal.id);
const isDefault = terminal.id === effectiveDefaultTerminal?.id;
return (
<DropdownMenuItem
key={terminal.id}
onClick={() => onOpenInExternalTerminal(worktree, terminal.id)}
className="text-xs"
>
<TerminalIcon className="w-3.5 h-3.5 mr-2" />
{terminal.name}
{isDefault && (
<span className="ml-auto text-[10px] text-muted-foreground">(default)</span>
)}
</DropdownMenuItem>
);
})}
</DropdownMenuSubContent>
</DropdownMenuSub>
{!worktree.isMain && hasInitScript && (
<DropdownMenuItem onClick={() => onRunInitScript(worktree)} className="text-xs">
<RefreshCw className="w-3.5 h-3.5 mr-2" />

View File

@@ -7,7 +7,8 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { GitBranch, ChevronDown, Loader2, CircleDot, Check } from 'lucide-react';
import { GitBranch, ChevronDown, CircleDot, Check } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import type { WorktreeInfo } from '../types';
@@ -44,7 +45,7 @@ export function WorktreeMobileDropdown({
<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" />
<Spinner size="xs" className="shrink-0" />
) : (
<ChevronDown className="w-3 h-3 shrink-0 ml-auto" />
)}
@@ -74,7 +75,7 @@ export function WorktreeMobileDropdown({
) : (
<div className="w-3.5 h-3.5 shrink-0" />
)}
{isRunning && <Loader2 className="w-3 h-3 animate-spin shrink-0" />}
{isRunning && <Spinner size="xs" className="shrink-0" />}
<span className={cn('font-mono text-xs truncate', isSelected && 'font-medium')}>
{worktree.branch}
</span>

View File

@@ -1,6 +1,7 @@
import type { JSX } from 'react';
import { Button } from '@/components/ui/button';
import { RefreshCw, Globe, Loader2, CircleDot, GitPullRequest } from 'lucide-react';
import { Globe, CircleDot, GitPullRequest } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import type { WorktreeInfo, BranchInfo, DevServerInfo, PRInfo, GitRepoStatus } from '../types';
@@ -37,6 +38,8 @@ interface WorktreeTabProps {
onPull: (worktree: WorktreeInfo) => void;
onPush: (worktree: WorktreeInfo) => void;
onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void;
onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void;
onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void;
onCommit: (worktree: WorktreeInfo) => void;
onCreatePR: (worktree: WorktreeInfo) => void;
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
@@ -81,6 +84,8 @@ export function WorktreeTab({
onPull,
onPush,
onOpenInEditor,
onOpenInIntegratedTerminal,
onOpenInExternalTerminal,
onCommit,
onCreatePR,
onAddressPRComments,
@@ -197,8 +202,8 @@ export function WorktreeTab({
aria-label={worktree.branch}
data-testid={`worktree-branch-${worktree.branch}`}
>
{isRunning && <Loader2 className="w-3 h-3 animate-spin" />}
{isActivating && !isRunning && <RefreshCw className="w-3 h-3 animate-spin" />}
{isRunning && <Spinner size="xs" />}
{isActivating && !isRunning && <Spinner size="xs" />}
{worktree.branch}
{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">
@@ -264,8 +269,8 @@ export function WorktreeTab({
: 'Click to switch to this branch'
}
>
{isRunning && <Loader2 className="w-3 h-3 animate-spin" />}
{isActivating && !isRunning && <RefreshCw className="w-3 h-3 animate-spin" />}
{isRunning && <Spinner size="xs" />}
{isActivating && !isRunning && <Spinner size="xs" />}
{worktree.branch}
{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">
@@ -342,6 +347,8 @@ export function WorktreeTab({
onPull={onPull}
onPush={onPush}
onOpenInEditor={onOpenInEditor}
onOpenInIntegratedTerminal={onOpenInIntegratedTerminal}
onOpenInExternalTerminal={onOpenInExternalTerminal}
onCommit={onCommit}
onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments}

View File

@@ -0,0 +1,99 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { getElectronAPI } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
import type { TerminalInfo } from '@automaker/types';
const logger = createLogger('AvailableTerminals');
// Re-export TerminalInfo for convenience
export type { TerminalInfo };
export function useAvailableTerminals() {
const [terminals, setTerminals] = useState<TerminalInfo[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isRefreshing, setIsRefreshing] = useState(false);
const fetchAvailableTerminals = useCallback(async () => {
try {
const api = getElectronAPI();
if (!api?.worktree?.getAvailableTerminals) {
setIsLoading(false);
return;
}
const result = await api.worktree.getAvailableTerminals();
if (result.success && result.result?.terminals) {
setTerminals(result.result.terminals);
}
} catch (error) {
logger.error('Failed to fetch available terminals:', error);
} finally {
setIsLoading(false);
}
}, []);
/**
* Refresh terminals by clearing the server cache and re-detecting
* Use this when the user has installed/uninstalled terminals
*/
const refresh = useCallback(async () => {
setIsRefreshing(true);
try {
const api = getElectronAPI();
if (!api?.worktree?.refreshTerminals) {
// Fallback to regular fetch if refresh not available
await fetchAvailableTerminals();
return;
}
const result = await api.worktree.refreshTerminals();
if (result.success && result.result?.terminals) {
setTerminals(result.result.terminals);
logger.info(`Terminal cache refreshed, found ${result.result.terminals.length} terminals`);
}
} catch (error) {
logger.error('Failed to refresh terminals:', error);
} finally {
setIsRefreshing(false);
}
}, [fetchAvailableTerminals]);
useEffect(() => {
fetchAvailableTerminals();
}, [fetchAvailableTerminals]);
return {
terminals,
isLoading,
isRefreshing,
refresh,
// Convenience property: has external terminals available
hasExternalTerminals: terminals.length > 0,
// The first terminal is the "default" one (highest priority)
defaultTerminal: terminals[0] ?? null,
};
}
/**
* Hook to get the effective default terminal based on user settings
* Returns null if user prefers integrated terminal (defaultTerminalId is null)
* Falls back to: user preference > first available external terminal
*/
export function useEffectiveDefaultTerminal(terminals: TerminalInfo[]): TerminalInfo | null {
const defaultTerminalId = useAppStore((s) => s.defaultTerminalId);
return useMemo(() => {
// If user hasn't set a preference (null/undefined), they prefer integrated terminal
if (defaultTerminalId == null) {
return null;
}
// If user has set a preference, find it in available terminals
if (defaultTerminalId) {
const found = terminals.find((t) => t.id === defaultTerminalId);
if (found) return found;
}
// If the saved preference doesn't exist anymore, fall back to first available
return terminals[0] ?? null;
}, [terminals, defaultTerminalId]);
}

View File

@@ -93,7 +93,7 @@ export function useDevServerLogs({ worktreePath, autoSubscribe = true }: UseDevS
isRunning: true,
isLoading: false,
port: result.result!.port,
url: `http://localhost:${result.result!.port}`,
url: result.result!.url,
startedAt: result.result!.startedAt,
error: null,
}));

View File

@@ -1,4 +1,8 @@
import { useState, useCallback } from 'react';
import { useNavigate } from '@tanstack/react-router';
import { createLogger } from '@automaker/utils/logger';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
import {
useSwitchBranch,
usePullWorktree,
@@ -7,7 +11,10 @@ import {
} from '@/hooks/mutations';
import type { WorktreeInfo } from '../types';
const logger = createLogger('WorktreeActions');
export function useWorktreeActions() {
const navigate = useNavigate();
const [isActivating, setIsActivating] = useState(false);
// Use React Query mutations
@@ -45,6 +52,19 @@ export function useWorktreeActions() {
[pushMutation]
);
const handleOpenInIntegratedTerminal = useCallback(
(worktree: WorktreeInfo, mode?: 'tab' | 'split') => {
// Navigate to the terminal view with the worktree path and branch name
// The terminal view will handle creating the terminal with the specified cwd
// Include nonce to allow opening the same worktree multiple times
navigate({
to: '/terminal',
search: { cwd: worktree.path, branch: worktree.branch, mode, nonce: Date.now() },
});
},
[navigate]
);
const handleOpenInEditor = useCallback(
async (worktree: WorktreeInfo, editorCommand?: string) => {
openInEditorMutation.mutate({
@@ -55,6 +75,27 @@ export function useWorktreeActions() {
[openInEditorMutation]
);
const handleOpenInExternalTerminal = useCallback(
async (worktree: WorktreeInfo, terminalId?: string) => {
try {
const api = getElectronAPI();
if (!api?.worktree?.openInExternalTerminal) {
logger.warn('Open in external terminal API not available');
return;
}
const result = await api.worktree.openInExternalTerminal(worktree.path, terminalId);
if (result.success && result.result) {
toast.success(result.result.message);
} else if (result.error) {
toast.error(result.error);
}
} catch (error) {
logger.error('Open in external terminal failed:', error);
}
},
[]
);
return {
isPulling: pullMutation.isPending,
isPushing: pushMutation.isPending,
@@ -64,6 +105,8 @@ export function useWorktreeActions() {
handleSwitchBranch,
handlePull,
handlePush,
handleOpenInIntegratedTerminal,
handleOpenInEditor,
handleOpenInExternalTerminal,
};
}

View File

@@ -1,10 +1,6 @@
export interface WorktreePRInfo {
number: number;
url: string;
title: string;
state: string;
createdAt: string;
}
// Re-export shared types from @automaker/types
export type { PRState, WorktreePRInfo } from '@automaker/types';
import type { PRState, WorktreePRInfo } from '@automaker/types';
export interface WorktreeInfo {
path: string;
@@ -43,7 +39,8 @@ export interface PRInfo {
number: number;
title: string;
url: string;
state: string;
/** PR state: OPEN, MERGED, or CLOSED */
state: PRState;
author: string;
body: string;
comments: Array<{

View File

@@ -1,6 +1,7 @@
import { useEffect, useRef, useCallback, useState } from 'react';
import { Button } from '@/components/ui/button';
import { GitBranch, Plus, RefreshCw } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn, pathsEqual } from '@/lib/utils';
import { toast } from 'sonner';
import { getHttpApiClient } from '@/lib/http-api-client';
@@ -79,7 +80,9 @@ export function WorktreePanel({
handleSwitchBranch,
handlePull,
handlePush,
handleOpenInIntegratedTerminal,
handleOpenInEditor,
handleOpenInExternalTerminal,
} = useWorktreeActions();
const { hasRunningFeatures } = useRunningFeatures({
@@ -225,6 +228,8 @@ export function WorktreePanel({
onPull={handlePull}
onPush={handlePush}
onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
onOpenInExternalTerminal={handleOpenInExternalTerminal}
onCommit={onCommit}
onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments}
@@ -265,7 +270,7 @@ export function WorktreePanel({
disabled={isLoading}
title="Refresh worktrees"
>
<RefreshCw className={cn('w-3.5 h-3.5', isLoading && 'animate-spin')} />
{isLoading ? <Spinner size="xs" /> : <RefreshCw className="w-3.5 h-3.5" />}
</Button>
</>
)}
@@ -312,6 +317,8 @@ export function WorktreePanel({
onPull={handlePull}
onPush={handlePush}
onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
onOpenInExternalTerminal={handleOpenInExternalTerminal}
onCommit={onCommit}
onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments}
@@ -370,6 +377,8 @@ export function WorktreePanel({
onPull={handlePull}
onPush={handlePush}
onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
onOpenInExternalTerminal={handleOpenInExternalTerminal}
onCommit={onCommit}
onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments}
@@ -409,7 +418,7 @@ export function WorktreePanel({
disabled={isLoading}
title="Refresh worktrees"
>
<RefreshCw className={cn('w-3.5 h-3.5', isLoading && 'animate-spin')} />
{isLoading ? <Spinner size="xs" /> : <RefreshCw className="w-3.5 h-3.5" />}
</Button>
</div>
</>

View File

@@ -4,7 +4,8 @@ import { useAppStore } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { File, Folder, FolderOpen, ChevronRight, ChevronDown, RefreshCw, Code } from 'lucide-react';
import { File, Folder, FolderOpen, ChevronRight, ChevronDown, Code } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
const logger = createLogger('CodeView');
@@ -206,7 +207,7 @@ export function CodeView() {
if (isLoading) {
return (
<div className="flex-1 flex items-center justify-center" data-testid="code-view-loading">
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground" />
<Spinner size="lg" />
</div>
);
}

View File

@@ -8,7 +8,10 @@ import { Button } from '@/components/ui/button';
import { HotkeyButton } from '@/components/ui/hotkey-button';
import { Card } from '@/components/ui/card';
import {
RefreshCw,
HeaderActionsPanel,
HeaderActionsPanelTrigger,
} from '@/components/ui/header-actions-panel';
import {
FileText,
Image as ImageIcon,
Trash2,
@@ -20,9 +23,9 @@ import {
Pencil,
FilePlus,
FileUp,
Loader2,
MoreVertical,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import {
useKeyboardShortcuts,
useKeyboardShortcutsConfig,
@@ -94,6 +97,9 @@ export function ContextView() {
const [editDescriptionValue, setEditDescriptionValue] = useState('');
const [editDescriptionFileName, setEditDescriptionFileName] = useState('');
// Actions panel state (for tablet/mobile)
const [showActionsPanel, setShowActionsPanel] = useState(false);
// File input ref for import
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -663,7 +669,7 @@ export function ContextView() {
if (isLoading) {
return (
<div className="flex-1 flex items-center justify-center" data-testid="context-view-loading">
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground" />
<Spinner size="lg" />
</div>
);
}
@@ -691,30 +697,70 @@ export function ContextView() {
</p>
</div>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={handleImportClick}
disabled={isUploading}
data-testid="import-file-button"
>
<FileUp className="w-4 h-4 mr-2" />
Import File
</Button>
<HotkeyButton
size="sm"
onClick={() => setIsCreateMarkdownOpen(true)}
hotkey={shortcuts.addContextFile}
hotkeyActive={false}
data-testid="create-markdown-button"
>
<FilePlus className="w-4 h-4 mr-2" />
Create Markdown
</HotkeyButton>
<div className="flex items-center gap-2">
{/* Desktop: show actions inline */}
<div className="hidden lg:flex gap-2">
<Button
variant="outline"
size="sm"
onClick={handleImportClick}
disabled={isUploading}
data-testid="import-file-button"
>
<FileUp className="w-4 h-4 mr-2" />
Import File
</Button>
<HotkeyButton
size="sm"
onClick={() => setIsCreateMarkdownOpen(true)}
hotkey={shortcuts.addContextFile}
hotkeyActive={false}
data-testid="create-markdown-button"
>
<FilePlus className="w-4 h-4 mr-2" />
Create Markdown
</HotkeyButton>
</div>
{/* Tablet/Mobile: show trigger for actions panel */}
<HeaderActionsPanelTrigger
isOpen={showActionsPanel}
onToggle={() => setShowActionsPanel(!showActionsPanel)}
/>
</div>
</div>
{/* Actions Panel (tablet/mobile) */}
<HeaderActionsPanel
isOpen={showActionsPanel}
onClose={() => setShowActionsPanel(false)}
title="Context Actions"
>
<Button
variant="outline"
className="w-full justify-start"
onClick={() => {
handleImportClick();
setShowActionsPanel(false);
}}
disabled={isUploading}
data-testid="import-file-button-mobile"
>
<FileUp className="w-4 h-4 mr-2" />
Import File
</Button>
<Button
className="w-full justify-start"
onClick={() => {
setIsCreateMarkdownOpen(true);
setShowActionsPanel(false);
}}
data-testid="create-markdown-button-mobile"
>
<FilePlus className="w-4 h-4 mr-2" />
Create Markdown
</Button>
</HeaderActionsPanel>
{/* Main content area with file list and editor */}
<div
className={cn(
@@ -743,7 +789,7 @@ export function ContextView() {
{isUploading && (
<div className="absolute inset-0 bg-background/80 z-50 flex items-center justify-center">
<div className="flex flex-col items-center">
<Loader2 className="w-8 h-8 animate-spin text-primary mb-2" />
<Spinner size="xl" className="mb-2" />
<span className="text-sm font-medium">Uploading {uploadingFileName}...</span>
</div>
</div>
@@ -791,7 +837,7 @@ export function ContextView() {
<span className="truncate text-sm block">{file.name}</span>
{isGenerating ? (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Loader2 className="w-3 h-3 animate-spin" />
<Spinner size="xs" />
Generating description...
</span>
) : file.description ? (
@@ -908,7 +954,7 @@ export function ContextView() {
</span>
{generatingDescriptions.has(selectedFile.name) ? (
<div className="flex items-center gap-2 mt-1 text-sm text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" />
<Spinner size="sm" />
<span>Generating description with AI...</span>
</div>
) : selectedFile.description ? (

View File

@@ -1,7 +1,7 @@
import { useState, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { useNavigate } from '@tanstack/react-router';
import { useAppStore, type ThemeMode } from '@/store/app-store';
import { useAppStore } from '@/store/app-store';
import { useOSDetection } from '@/hooks/use-os-detection';
import { getElectronAPI, isElectron } from '@/lib/electron';
import { initializeProject } from '@/lib/project-init';
@@ -18,13 +18,18 @@ import {
Folder,
Star,
Clock,
Loader2,
ChevronDown,
MessageSquare,
Settings,
MoreVertical,
Trash2,
Search,
X,
type LucideIcon,
} from 'lucide-react';
import * as LucideIcons from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { Input } from '@/components/ui/input';
import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
import {
DropdownMenu,
DropdownMenuContent,
@@ -55,6 +60,13 @@ function getOSAbbreviation(os: string): string {
}
}
function getIconComponent(iconName?: string): LucideIcon {
if (iconName && iconName in LucideIcons) {
return (LucideIcons as unknown as Record<string, LucideIcon>)[iconName];
}
return Folder;
}
export function DashboardView() {
const navigate = useNavigate();
const { os } = useOSDetection();
@@ -64,14 +76,11 @@ export function DashboardView() {
const {
projects,
trashedProjects,
currentProject,
upsertAndSetCurrentProject,
addProject,
setCurrentProject,
toggleProjectFavorite,
moveProjectToTrash,
theme: globalTheme,
} = useAppStore();
const [showNewProjectModal, setShowNewProjectModal] = useState(false);
@@ -79,6 +88,7 @@ export function DashboardView() {
const [isCreating, setIsCreating] = useState(false);
const [isOpening, setIsOpening] = useState(false);
const [projectToRemove, setProjectToRemove] = useState<{ id: string; name: string } | null>(null);
const [searchQuery, setSearchQuery] = useState('');
// Sort projects: favorites first, then by last opened
const sortedProjects = [...projects].sort((a, b) => {
@@ -91,8 +101,15 @@ export function DashboardView() {
return dateB - dateA;
});
const favoriteProjects = sortedProjects.filter((p) => p.isFavorite);
const recentProjects = sortedProjects.filter((p) => !p.isFavorite);
// Filter projects based on search query
const filteredProjects = sortedProjects.filter((project) => {
if (!searchQuery.trim()) return true;
const query = searchQuery.toLowerCase();
return project.name.toLowerCase().includes(query) || project.path.toLowerCase().includes(query);
});
const favoriteProjects = filteredProjects.filter((p) => p.isFavorite);
const recentProjects = filteredProjects.filter((p) => !p.isFavorite);
/**
* Initialize project and navigate to board
@@ -104,18 +121,27 @@ export function DashboardView() {
const initResult = await initializeProject(path);
if (!initResult.success) {
// If the project directory doesn't exist, automatically remove it from the project list
if (initResult.error?.includes('does not exist')) {
const projectToRemove = projects.find((p) => p.path === path);
if (projectToRemove) {
logger.warn(`[Dashboard] Removing project with non-existent path: ${path}`);
moveProjectToTrash(projectToRemove.id);
toast.error('Project directory not found', {
description: `Removed ${name} from your projects list since the directory no longer exists.`,
});
return;
}
}
toast.error('Failed to initialize project', {
description: initResult.error || 'Unknown error occurred',
});
return;
}
const trashedProject = trashedProjects.find((p) => p.path === path);
const effectiveTheme =
(trashedProject?.theme as ThemeMode | undefined) ||
(currentProject?.theme as ThemeMode | undefined) ||
globalTheme;
upsertAndSetCurrentProject(path, name, effectiveTheme);
// Theme handling (trashed project recovery or undefined for global) is done by the store
upsertAndSetCurrentProject(path, name);
toast.success('Project opened', {
description: `Opened ${name}`,
@@ -131,7 +157,7 @@ export function DashboardView() {
setIsOpening(false);
}
},
[trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject, navigate]
[projects, upsertAndSetCurrentProject, navigate, moveProjectToTrash]
);
const handleOpenProject = useCallback(async () => {
@@ -529,14 +555,35 @@ export function DashboardView() {
</span>
</div>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => navigate({ to: '/settings' })}
className="titlebar-no-drag"
>
<Settings className="w-5 h-5" />
</Button>
{/* Mobile action buttons in header */}
{hasProjects && (
<div className="flex sm:hidden gap-2 titlebar-no-drag">
<Button variant="outline" size="icon" onClick={handleOpenProject}>
<FolderOpen className="w-4 h-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
className="bg-linear-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-700 text-white"
>
<Plus className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem onClick={handleNewProject}>
<Plus className="w-4 h-4 mr-2" />
Quick Setup
</DropdownMenuItem>
<DropdownMenuItem onClick={handleInteractiveMode}>
<MessageSquare className="w-4 h-4 mr-2" />
Interactive Mode
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</div>
</header>
@@ -646,25 +693,42 @@ export function DashboardView() {
{/* Has projects - show project list */}
{hasProjects && (
<div className="space-y-6 sm:space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
{/* Quick actions header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<h2 className="text-2xl font-bold text-foreground">Your Projects</h2>
<div className="flex gap-2">
<Button
variant="outline"
onClick={handleOpenProject}
className="flex-1 sm:flex-none"
>
<FolderOpen className="w-4 h-4 sm:mr-2" />
<span className="hidden sm:inline">Open Folder</span>
{/* Search and actions header */}
<div className="flex items-center justify-between gap-4">
<h2 className="text-xl sm:text-2xl font-bold text-foreground">Your Projects</h2>
<div className="flex items-center gap-2">
{/* Search input */}
<div className="relative flex-1 sm:flex-none">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
<Input
type="text"
placeholder="Search projects..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 pr-8 w-full sm:w-64"
data-testid="project-search-input"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-2 top-1/2 -translate-y-1/2 p-0.5 rounded hover:bg-muted transition-colors"
title="Clear search"
>
<X className="w-4 h-4 text-muted-foreground" />
</button>
)}
</div>
{/* Desktop only buttons */}
<Button variant="outline" onClick={handleOpenProject} className="hidden sm:flex">
<FolderOpen className="w-4 h-4 mr-2" />
Open Folder
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="flex-1 sm:flex-none bg-linear-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-700 text-white">
<Plus className="w-4 h-4 sm:mr-2" />
<span className="hidden sm:inline">New Project</span>
<span className="sm:hidden">New</span>
<ChevronDown className="w-4 h-4 ml-1 sm:ml-2" />
<Button className="hidden sm:flex bg-linear-to-r from-brand-500 to-brand-600 hover:from-brand-600 hover:to-brand-700 text-white">
<Plus className="w-4 h-4 mr-2" />
New Project
<ChevronDown className="w-4 h-4 ml-2" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
@@ -703,8 +767,24 @@ export function DashboardView() {
<div className="absolute inset-0 rounded-xl bg-linear-to-br from-yellow-500/5 to-amber-600/5 opacity-0 group-hover:opacity-100 transition-all duration-300" />
<div className="relative p-3 sm:p-4">
<div className="flex items-start gap-2.5 sm:gap-3">
<div className="w-9 h-9 sm:w-10 sm:h-10 rounded-lg bg-yellow-500/10 border border-yellow-500/30 flex items-center justify-center group-hover:bg-yellow-500/20 transition-all duration-300 shrink-0">
<Folder className="w-4 h-4 sm:w-5 sm:h-5 text-yellow-500" />
<div className="w-9 h-9 sm:w-10 sm:h-10 rounded-lg bg-yellow-500/10 border border-yellow-500/30 flex items-center justify-center group-hover:bg-yellow-500/20 transition-all duration-300 shrink-0 overflow-hidden">
{project.customIconPath ? (
<img
src={getAuthenticatedImageUrl(
project.customIconPath,
project.path
)}
alt={project.name}
className="w-full h-full object-cover"
/>
) : (
(() => {
const IconComponent = getIconComponent(project.icon);
return (
<IconComponent className="w-4 h-4 sm:w-5 sm:h-5 text-yellow-500" />
);
})()
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm sm:text-base font-medium text-foreground truncate group-hover:text-yellow-500 transition-colors duration-300">
@@ -778,8 +858,24 @@ export function DashboardView() {
<div className="absolute inset-0 rounded-xl bg-linear-to-br from-brand-500/0 to-purple-600/0 group-hover:from-brand-500/5 group-hover:to-purple-600/5 transition-all duration-300" />
<div className="relative p-3 sm:p-4">
<div className="flex items-start gap-2.5 sm:gap-3">
<div className="w-9 h-9 sm:w-10 sm:h-10 rounded-lg bg-muted/80 border border-border flex items-center justify-center group-hover:bg-brand-500/10 group-hover:border-brand-500/30 transition-all duration-300 shrink-0">
<Folder className="w-4 h-4 sm:w-5 sm:h-5 text-muted-foreground group-hover:text-brand-500 transition-colors duration-300" />
<div className="w-9 h-9 sm:w-10 sm:h-10 rounded-lg bg-muted/80 border border-border flex items-center justify-center group-hover:bg-brand-500/10 group-hover:border-brand-500/30 transition-all duration-300 shrink-0 overflow-hidden">
{project.customIconPath ? (
<img
src={getAuthenticatedImageUrl(
project.customIconPath,
project.path
)}
alt={project.name}
className="w-full h-full object-cover"
/>
) : (
(() => {
const IconComponent = getIconComponent(project.icon);
return (
<IconComponent className="w-4 h-4 sm:w-5 sm:h-5 text-muted-foreground group-hover:text-brand-500 transition-colors duration-300" />
);
})()
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm sm:text-base font-medium text-foreground truncate group-hover:text-brand-500 transition-colors duration-300">
@@ -797,10 +893,10 @@ export function DashboardView() {
<div className="flex items-center gap-0.5 sm:gap-1">
<button
onClick={(e) => handleToggleFavorite(e, project.id)}
className="p-1 sm:p-1.5 rounded-lg hover:bg-muted transition-colors opacity-0 group-hover:opacity-100"
className="p-1 sm:p-1.5 rounded-lg hover:bg-muted transition-colors"
title="Add to favorites"
>
<Star className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-muted-foreground hover:text-yellow-500" />
<Star className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-muted-foreground/50 hover:text-yellow-500 transition-colors" />
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -830,6 +926,22 @@ export function DashboardView() {
</div>
</div>
)}
{/* No search results */}
{searchQuery && favoriteProjects.length === 0 && recentProjects.length === 0 && (
<div className="text-center py-12">
<div className="w-16 h-16 rounded-full bg-muted/50 flex items-center justify-center mx-auto mb-4">
<Search className="w-8 h-8 text-muted-foreground" />
</div>
<h3 className="text-lg font-medium text-foreground mb-2">No projects found</h3>
<p className="text-sm text-muted-foreground mb-4">
No projects match "{searchQuery}"
</p>
<Button variant="outline" size="sm" onClick={() => setSearchQuery('')}>
Clear search
</Button>
</div>
)}
</div>
)}
</div>
@@ -886,7 +998,7 @@ export function DashboardView() {
data-testid="project-opening-overlay"
>
<div className="flex flex-col items-center gap-4 p-8 rounded-2xl bg-card border border-border shadow-2xl">
<Loader2 className="w-10 h-10 text-brand-500 animate-spin" />
<Spinner size="xl" />
<p className="text-foreground font-medium">Opening project...</p>
</div>
</div>

View File

@@ -1,22 +1,28 @@
// @ts-nocheck
import { useState, useCallback, useMemo } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { CircleDot, RefreshCw } from 'lucide-react';
import { CircleDot, RefreshCw, SearchX } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query';
import { getElectronAPI, GitHubIssue, IssueValidationResult } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
import { Button } from '@/components/ui/button';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { LoadingState } from '@/components/ui/loading-state';
import { ErrorState } from '@/components/ui/error-state';
import { cn, pathsEqual } from '@/lib/utils';
import { cn, pathsEqual, generateUUID } from '@/lib/utils';
import { toast } from 'sonner';
import { queryKeys } from '@/lib/query-keys';
import { useGithubIssues, useIssueValidation } from './github-issues-view/hooks';
import { useGithubIssues, useIssueValidation, useIssuesFilter } from './github-issues-view/hooks';
import { IssueRow, IssueDetailPanel, IssuesListHeader } from './github-issues-view/components';
import { ValidationDialog } from './github-issues-view/dialogs';
import { formatDate, getFeaturePriority } from './github-issues-view/utils';
import { useModelOverride } from '@/components/shared';
import type { ValidateIssueOptions } from './github-issues-view/types';
import type {
ValidateIssueOptions,
IssuesFilterState,
IssuesStateFilter,
} from './github-issues-view/types';
import { DEFAULT_ISSUES_FILTER_STATE } from './github-issues-view/types';
const logger = createLogger('GitHubIssuesView');
@@ -28,6 +34,9 @@ export function GitHubIssuesView() {
const [pendingRevalidateOptions, setPendingRevalidateOptions] =
useState<ValidateIssueOptions | null>(null);
// Filter state
const [filterState, setFilterState] = useState<IssuesFilterState>(DEFAULT_ISSUES_FILTER_STATE);
const { currentProject, getCurrentWorktree, worktreesByProject } = useAppStore();
const queryClient = useQueryClient();
@@ -47,6 +56,41 @@ export function GitHubIssuesView() {
onShowValidationDialogChange: setShowValidationDialog,
});
// Combine all issues for filtering
const allIssues = useMemo(() => [...openIssues, ...closedIssues], [openIssues, closedIssues]);
// Apply filter to issues - now returns matched issues directly for better performance
const filterResult = useIssuesFilter(allIssues, filterState, cachedValidations);
// Separate filtered issues by state - this is O(n) but now only done once
// since filterResult.matchedIssues already contains the filtered issues
const { filteredOpenIssues, filteredClosedIssues } = useMemo(() => {
const open: typeof openIssues = [];
const closed: typeof closedIssues = [];
for (const issue of filterResult.matchedIssues) {
if (issue.state.toLowerCase() === 'open') {
open.push(issue);
} else {
closed.push(issue);
}
}
return { filteredOpenIssues: open, filteredClosedIssues: closed };
}, [filterResult.matchedIssues]);
// Filter state change handlers
const handleStateFilterChange = useCallback((stateFilter: IssuesStateFilter) => {
setFilterState((prev) => ({ ...prev, stateFilter }));
}, []);
const handleLabelsChange = useCallback((selectedLabels: string[]) => {
setFilterState((prev) => ({ ...prev, selectedLabels }));
}, []);
// Clear all filters to default state
const handleClearFilters = useCallback(() => {
setFilterState(DEFAULT_ISSUES_FILTER_STATE);
}, []);
// Get current branch from selected worktree
const currentBranch = useMemo(() => {
if (!currentProject?.path) return '';
@@ -96,7 +140,7 @@ export function GitHubIssuesView() {
.join('\n');
const feature = {
id: `issue-${issue.number}-${crypto.randomUUID()}`,
id: `issue-${issue.number}-${generateUUID()}`,
title: issue.title,
description,
category: 'From GitHub',
@@ -137,7 +181,10 @@ export function GitHubIssuesView() {
return <ErrorState error={error} title="Failed to Load Issues" onRetry={refresh} />;
}
const totalIssues = openIssues.length + closedIssues.length;
const totalIssues = filteredOpenIssues.length + filteredClosedIssues.length;
const totalUnfilteredIssues = openIssues.length + closedIssues.length;
const isFilteredEmpty =
totalIssues === 0 && totalUnfilteredIssues > 0 && filterResult.hasActiveFilter;
return (
<div className="flex-1 flex overflow-hidden">
@@ -150,10 +197,21 @@ export function GitHubIssuesView() {
>
{/* Header */}
<IssuesListHeader
openCount={openIssues.length}
closedCount={closedIssues.length}
openCount={filteredOpenIssues.length}
closedCount={filteredClosedIssues.length}
totalOpenCount={openIssues.length}
totalClosedCount={closedIssues.length}
hasActiveFilter={filterResult.hasActiveFilter}
refreshing={refreshing}
onRefresh={refresh}
compact={!!selectedIssue}
filterProps={{
stateFilter: filterState.stateFilter,
selectedLabels: filterState.selectedLabels,
availableLabels: filterResult.availableLabels,
onStateFilterChange: handleStateFilterChange,
onLabelsChange: handleLabelsChange,
}}
/>
{/* Issues List */}
@@ -161,15 +219,35 @@ export function GitHubIssuesView() {
{totalIssues === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center p-6">
<div className="p-4 rounded-full bg-muted/50 mb-4">
<CircleDot className="h-8 w-8 text-muted-foreground" />
{isFilteredEmpty ? (
<SearchX className="h-8 w-8 text-muted-foreground" />
) : (
<CircleDot className="h-8 w-8 text-muted-foreground" />
)}
</div>
<h2 className="text-base font-medium mb-2">No Issues</h2>
<p className="text-sm text-muted-foreground">This repository has no issues yet.</p>
<h2 className="text-base font-medium mb-2">
{isFilteredEmpty ? 'No Matching Issues' : 'No Issues'}
</h2>
<p className="text-sm text-muted-foreground mb-4">
{isFilteredEmpty
? 'No issues match your current filters.'
: 'This repository has no issues yet.'}
</p>
{isFilteredEmpty && (
<Button
variant="outline"
size="sm"
onClick={handleClearFilters}
className="text-xs"
>
Clear Filters
</Button>
)}
</div>
) : (
<div className="divide-y divide-border">
{/* Open Issues */}
{openIssues.map((issue) => (
{filteredOpenIssues.map((issue) => (
<IssueRow
key={issue.number}
issue={issue}
@@ -183,12 +261,12 @@ export function GitHubIssuesView() {
))}
{/* Closed Issues Section */}
{closedIssues.length > 0 && (
{filteredClosedIssues.length > 0 && (
<>
<div className="px-4 py-2 bg-muted/30 text-xs font-medium text-muted-foreground">
Closed Issues ({closedIssues.length})
Closed Issues ({filteredClosedIssues.length})
</div>
{closedIssues.map((issue) => (
{filteredClosedIssues.map((issue) => (
<IssueRow
key={issue.number}
issue={issue}

View File

@@ -1,4 +1,5 @@
export { IssueRow } from './issue-row';
export { IssueDetailPanel } from './issue-detail-panel';
export { IssuesListHeader } from './issues-list-header';
export { IssuesFilterControls } from './issues-filter-controls';
export { CommentItem } from './comment-item';

View File

@@ -4,7 +4,6 @@ import {
X,
Wand2,
ExternalLink,
Loader2,
CheckCircle,
Clock,
GitPullRequest,
@@ -14,6 +13,7 @@ import {
ChevronDown,
ChevronUp,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
@@ -87,7 +87,7 @@ export function IssueDetailPanel({
if (isValidating) {
return (
<Button variant="default" size="sm" disabled>
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
<Spinner size="sm" className="mr-1" />
Validating...
</Button>
);
@@ -297,9 +297,7 @@ export function IssueDetailPanel({
<span className="text-sm font-medium">
Comments {totalCount > 0 && `(${totalCount})`}
</span>
{commentsLoading && (
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
)}
{commentsLoading && <Spinner size="xs" />}
{commentsExpanded ? (
<ChevronUp className="h-4 w-4 text-muted-foreground" />
) : (
@@ -340,7 +338,7 @@ export function IssueDetailPanel({
>
{loadingMore ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
<Spinner size="sm" className="mr-2" />
Loading...
</>
) : (

View File

@@ -2,12 +2,12 @@ import {
Circle,
CheckCircle2,
ExternalLink,
Loader2,
CheckCircle,
Sparkles,
GitPullRequest,
User,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import type { IssueRowProps } from '../types';
@@ -97,7 +97,7 @@ export function IssueRow({
{/* Validating indicator */}
{isValidating && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full bg-primary/10 text-primary border border-primary/20 animate-in fade-in duration-200">
<Loader2 className="h-3 w-3 animate-spin" />
<Spinner size="xs" />
Analyzing...
</span>
)}

View File

@@ -0,0 +1,191 @@
import { ChevronDown, Tag, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import type { IssuesStateFilter } from '../types';
import { ISSUES_STATE_FILTER_OPTIONS } from '../types';
/** Maximum number of labels to display before showing "+N more" in normal layout */
const VISIBLE_LABELS_LIMIT = 3;
/** Maximum number of labels to display before showing "+N more" in compact layout */
const VISIBLE_LABELS_LIMIT_COMPACT = 2;
interface IssuesFilterControlsProps {
/** Current state filter value */
stateFilter: IssuesStateFilter;
/** Currently selected labels */
selectedLabels: string[];
/** Available labels to choose from (typically from useIssuesFilter result) */
availableLabels: string[];
/** Callback when state filter changes */
onStateFilterChange: (filter: IssuesStateFilter) => void;
/** Callback when labels selection changes */
onLabelsChange: (labels: string[]) => void;
/** Whether the controls are disabled (e.g., during loading) */
disabled?: boolean;
/** Whether to use compact layout (stacked vertically) */
compact?: boolean;
/** Additional class name for the container */
className?: string;
}
/** Human-readable labels for state filter options */
const STATE_FILTER_LABELS: Record<IssuesStateFilter, string> = {
open: 'Open',
closed: 'Closed',
all: 'All',
};
export function IssuesFilterControls({
stateFilter,
selectedLabels,
availableLabels,
onStateFilterChange,
onLabelsChange,
disabled = false,
compact = false,
className,
}: IssuesFilterControlsProps) {
/**
* Handles toggling a label in the selection.
* If the label is already selected, it removes it; otherwise, it adds it.
*/
const handleLabelToggle = (label: string) => {
const isSelected = selectedLabels.includes(label);
if (isSelected) {
onLabelsChange(selectedLabels.filter((l) => l !== label));
} else {
onLabelsChange([...selectedLabels, label]);
}
};
/**
* Clears all selected labels.
*/
const handleClearLabels = () => {
onLabelsChange([]);
};
const hasSelectedLabels = selectedLabels.length > 0;
const hasAvailableLabels = availableLabels.length > 0;
return (
<div className={cn('flex flex-col gap-2', className)}>
{/* Filter Controls Row */}
<div className="flex items-center gap-2">
{/* State Filter Select */}
<Select
value={stateFilter}
onValueChange={(value) => onStateFilterChange(value as IssuesStateFilter)}
disabled={disabled}
>
<SelectTrigger className={cn('h-8 text-sm', compact ? 'w-[90px]' : 'w-[110px]')}>
<SelectValue placeholder="State" />
</SelectTrigger>
<SelectContent>
{ISSUES_STATE_FILTER_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{STATE_FILTER_LABELS[option]}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Labels Filter Dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild disabled={disabled || !hasAvailableLabels}>
<Button
variant="outline"
size="sm"
className={cn('h-8 gap-1.5', hasSelectedLabels && 'border-primary/50 bg-primary/5')}
disabled={disabled || !hasAvailableLabels}
>
<Tag className="h-3.5 w-3.5" />
<span>Labels</span>
{hasSelectedLabels && (
<Badge variant="secondary" size="sm" className="ml-1 px-1.5 py-0">
{selectedLabels.length}
</Badge>
)}
<ChevronDown className="h-3.5 w-3.5 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56 max-h-64 overflow-y-auto">
<DropdownMenuLabel className="flex items-center justify-between">
<span>Filter by label</span>
{hasSelectedLabels && (
<Button
variant="ghost"
size="sm"
className="h-5 px-1.5 text-xs text-muted-foreground hover:text-foreground"
onClick={handleClearLabels}
>
<X className="h-3 w-3 mr-0.5" />
Clear
</Button>
)}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{availableLabels.map((label) => (
<DropdownMenuCheckboxItem
key={label}
checked={selectedLabels.includes(label)}
onCheckedChange={() => handleLabelToggle(label)}
onSelect={(e) => e.preventDefault()} // Prevent dropdown from closing
>
{label}
</DropdownMenuCheckboxItem>
))}
{!hasAvailableLabels && (
<div className="px-2 py-1.5 text-sm text-muted-foreground">No labels available</div>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Selected Labels Display - shown on separate row */}
{hasSelectedLabels && (
<div className="flex items-center gap-1 flex-wrap">
{selectedLabels
.slice(0, compact ? VISIBLE_LABELS_LIMIT_COMPACT : VISIBLE_LABELS_LIMIT)
.map((label) => (
<Badge
key={label}
variant="outline"
size="sm"
className="gap-1 cursor-pointer hover:bg-destructive/10 hover:border-destructive/50"
onClick={() => handleLabelToggle(label)}
>
{label}
<X className="h-2.5 w-2.5" />
</Badge>
))}
{selectedLabels.length >
(compact ? VISIBLE_LABELS_LIMIT_COMPACT : VISIBLE_LABELS_LIMIT) && (
<Badge variant="muted" size="sm">
+
{selectedLabels.length -
(compact ? VISIBLE_LABELS_LIMIT_COMPACT : VISIBLE_LABELS_LIMIT)}{' '}
more
</Badge>
)}
</div>
)}
</div>
);
}

View File

@@ -1,38 +1,101 @@
import { CircleDot, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import type { IssuesStateFilter } from '../types';
import { IssuesFilterControls } from './issues-filter-controls';
interface IssuesListHeaderProps {
openCount: number;
closedCount: number;
/** Total open issues count (unfiltered) - used to show "X of Y" when filtered */
totalOpenCount?: number;
/** Total closed issues count (unfiltered) - used to show "X of Y" when filtered */
totalClosedCount?: number;
/** Whether any filter is currently active */
hasActiveFilter?: boolean;
refreshing: boolean;
onRefresh: () => void;
/** Whether the list is in compact mode (e.g., when detail panel is open) */
compact?: boolean;
/** Optional filter state and handlers - when provided, filter controls are rendered */
filterProps?: {
stateFilter: IssuesStateFilter;
selectedLabels: string[];
availableLabels: string[];
onStateFilterChange: (filter: IssuesStateFilter) => void;
onLabelsChange: (labels: string[]) => void;
};
}
export function IssuesListHeader({
openCount,
closedCount,
totalOpenCount,
totalClosedCount,
hasActiveFilter = false,
refreshing,
onRefresh,
compact = false,
filterProps,
}: IssuesListHeaderProps) {
const totalIssues = openCount + closedCount;
// Format the counts subtitle based on filter state
const getCountsSubtitle = () => {
if (totalIssues === 0) {
return hasActiveFilter ? 'No matching issues' : 'No issues found';
}
// When filters are active and we have total counts, show "X of Y" format
if (hasActiveFilter && totalOpenCount !== undefined && totalClosedCount !== undefined) {
const openText =
openCount === totalOpenCount
? `${openCount} open`
: `${openCount} of ${totalOpenCount} open`;
const closedText =
closedCount === totalClosedCount
? `${closedCount} closed`
: `${closedCount} of ${totalClosedCount} closed`;
return `${openText}, ${closedText}`;
}
// Default format when no filters active
return `${openCount} open, ${closedCount} closed`;
};
return (
<div className="flex items-center justify-between p-4 border-b border-border">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-green-500/10">
<CircleDot className="h-5 w-5 text-green-500" />
</div>
<div>
<h1 className="text-lg font-bold">Issues</h1>
<p className="text-xs text-muted-foreground">
{totalIssues === 0 ? 'No issues found' : `${openCount} open, ${closedCount} closed`}
</p>
<div className="border-b border-border">
{/* Top row: Title and refresh button */}
<div className="flex items-center justify-between p-4 pb-2">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-green-500/10">
<CircleDot className="h-5 w-5 text-green-500" />
</div>
<div>
<h1 className="text-lg font-bold">Issues</h1>
<p className="text-xs text-muted-foreground">{getCountsSubtitle()}</p>
</div>
</div>
<Button variant="outline" size="sm" onClick={onRefresh} disabled={refreshing}>
{refreshing ? <Spinner size="sm" /> : <RefreshCw className="h-4 w-4" />}
</Button>
</div>
<Button variant="outline" size="sm" onClick={onRefresh} disabled={refreshing}>
<RefreshCw className={cn('h-4 w-4', refreshing && 'animate-spin')} />
</Button>
{/* Filter controls row (optional) */}
{filterProps && (
<div className="px-4 pb-3 pt-1">
<IssuesFilterControls
stateFilter={filterProps.stateFilter}
selectedLabels={filterProps.selectedLabels}
availableLabels={filterProps.availableLabels}
onStateFilterChange={filterProps.onStateFilterChange}
onLabelsChange={filterProps.onLabelsChange}
disabled={refreshing}
compact={compact}
/>
</div>
)}
</div>
);
}

View File

@@ -1,3 +1,4 @@
export { useGithubIssues } from './use-github-issues';
export { useIssueValidation } from './use-issue-validation';
export { useIssueComments } from './use-issue-comments';
export { useIssuesFilter } from './use-issues-filter';

View File

@@ -0,0 +1,240 @@
import { useMemo } from 'react';
import type { GitHubIssue, StoredValidation } from '@/lib/electron';
import type { IssuesFilterState, IssuesFilterResult, IssuesValidationStatus } from '../types';
import { isValidationStale } from '../utils';
/**
* Determines the validation status of an issue based on its cached validation.
*/
function getValidationStatus(
issueNumber: number,
cachedValidations: Map<number, StoredValidation>
): IssuesValidationStatus | null {
const validation = cachedValidations.get(issueNumber);
if (!validation) {
return 'not_validated';
}
if (isValidationStale(validation.validatedAt)) {
return 'stale';
}
return 'validated';
}
/**
* Checks if a search query matches an issue's searchable content.
* Searches through title and body (case-insensitive).
*/
function matchesSearchQuery(issue: GitHubIssue, normalizedQuery: string): boolean {
if (!normalizedQuery) return true;
const titleMatch = issue.title?.toLowerCase().includes(normalizedQuery);
const bodyMatch = issue.body?.toLowerCase().includes(normalizedQuery);
return titleMatch || bodyMatch;
}
/**
* Checks if an issue matches the state filter (open/closed/all).
* Note: GitHub CLI returns state in uppercase (OPEN/CLOSED), so we compare case-insensitively.
*/
function matchesStateFilter(
issue: GitHubIssue,
stateFilter: IssuesFilterState['stateFilter']
): boolean {
if (stateFilter === 'all') return true;
return issue.state.toLowerCase() === stateFilter;
}
/**
* Checks if an issue matches any of the selected labels.
* Returns true if no labels are selected (no filter) or if any selected label matches.
*/
function matchesLabels(issue: GitHubIssue, selectedLabels: string[]): boolean {
if (selectedLabels.length === 0) return true;
const issueLabels = issue.labels.map((l) => l.name);
return selectedLabels.some((label) => issueLabels.includes(label));
}
/**
* Checks if an issue matches any of the selected assignees.
* Returns true if no assignees are selected (no filter) or if any selected assignee matches.
*/
function matchesAssignees(issue: GitHubIssue, selectedAssignees: string[]): boolean {
if (selectedAssignees.length === 0) return true;
const issueAssignees = issue.assignees?.map((a) => a.login) ?? [];
return selectedAssignees.some((assignee) => issueAssignees.includes(assignee));
}
/**
* Checks if an issue matches any of the selected milestones.
* Returns true if no milestones are selected (no filter) or if any selected milestone matches.
* Note: GitHub issues may not have milestone data in the current schema, this is a placeholder.
*/
function matchesMilestones(issue: GitHubIssue, selectedMilestones: string[]): boolean {
if (selectedMilestones.length === 0) return true;
// GitHub issues in the current schema don't have milestone field
// This is a placeholder for future milestone support
// For now, issues with no milestone won't match if a milestone filter is active
return false;
}
/**
* Checks if an issue matches the validation status filter.
*/
function matchesValidationStatus(
issue: GitHubIssue,
validationStatusFilter: IssuesValidationStatus | null,
cachedValidations: Map<number, StoredValidation>
): boolean {
if (!validationStatusFilter) return true;
const status = getValidationStatus(issue.number, cachedValidations);
return status === validationStatusFilter;
}
/**
* Extracts all unique labels from a list of issues.
*/
function extractAvailableLabels(issues: GitHubIssue[]): string[] {
const labelsSet = new Set<string>();
for (const issue of issues) {
for (const label of issue.labels) {
labelsSet.add(label.name);
}
}
return Array.from(labelsSet).sort();
}
/**
* Extracts all unique assignees from a list of issues.
*/
function extractAvailableAssignees(issues: GitHubIssue[]): string[] {
const assigneesSet = new Set<string>();
for (const issue of issues) {
for (const assignee of issue.assignees ?? []) {
assigneesSet.add(assignee.login);
}
}
return Array.from(assigneesSet).sort();
}
/**
* Extracts all unique milestones from a list of issues.
* Note: Currently returns empty array as milestone is not in the GitHubIssue schema.
*/
function extractAvailableMilestones(_issues: GitHubIssue[]): string[] {
// GitHub issues in the current schema don't have milestone field
// This is a placeholder for future milestone support
return [];
}
/**
* Determines if any filter is currently active.
*/
function hasActiveFilterCheck(filterState: IssuesFilterState): boolean {
const {
searchQuery,
stateFilter,
selectedLabels,
selectedAssignees,
selectedMilestones,
validationStatusFilter,
} = filterState;
// Note: stateFilter 'open' is the default, so we consider it "not active" for UI purposes
// Only 'closed' or 'all' are considered active filters
const hasStateFilter = stateFilter !== 'open';
const hasSearchQuery = searchQuery.trim().length > 0;
const hasLabelFilter = selectedLabels.length > 0;
const hasAssigneeFilter = selectedAssignees.length > 0;
const hasMilestoneFilter = selectedMilestones.length > 0;
const hasValidationFilter = validationStatusFilter !== null;
return (
hasSearchQuery ||
hasStateFilter ||
hasLabelFilter ||
hasAssigneeFilter ||
hasMilestoneFilter ||
hasValidationFilter
);
}
/**
* Hook to filter GitHub issues based on the current filter state.
*
* This hook follows the same pattern as useGraphFilter but is tailored for GitHub issues.
* It computes matched issues and extracts available filter options from all issues.
*
* @param issues - Combined array of all issues (open + closed) to filter
* @param filterState - Current filter state including search, labels, assignees, etc.
* @param cachedValidations - Map of issue numbers to their cached validation results
* @returns Filter result containing matched issue numbers and available filter options
*/
export function useIssuesFilter(
issues: GitHubIssue[],
filterState: IssuesFilterState,
cachedValidations: Map<number, StoredValidation> = new Map()
): IssuesFilterResult {
const {
searchQuery,
stateFilter,
selectedLabels,
selectedAssignees,
selectedMilestones,
validationStatusFilter,
} = filterState;
return useMemo(() => {
// Extract available options from all issues (for filter dropdown population)
const availableLabels = extractAvailableLabels(issues);
const availableAssignees = extractAvailableAssignees(issues);
const availableMilestones = extractAvailableMilestones(issues);
// Check if any filter is active
const hasActiveFilter = hasActiveFilterCheck(filterState);
// Normalize search query for case-insensitive matching
const normalizedQuery = searchQuery.toLowerCase().trim();
// Filter issues based on all criteria - return matched issues directly
// This eliminates the redundant O(n) filtering operation in the consuming component
const matchedIssues: GitHubIssue[] = [];
for (const issue of issues) {
// All conditions must be true for a match
const matchesAllFilters =
matchesSearchQuery(issue, normalizedQuery) &&
matchesStateFilter(issue, stateFilter) &&
matchesLabels(issue, selectedLabels) &&
matchesAssignees(issue, selectedAssignees) &&
matchesMilestones(issue, selectedMilestones) &&
matchesValidationStatus(issue, validationStatusFilter, cachedValidations);
if (matchesAllFilters) {
matchedIssues.push(issue);
}
}
return {
matchedIssues,
availableLabels,
availableAssignees,
availableMilestones,
hasActiveFilter,
matchedCount: matchedIssues.length,
};
}, [
issues,
searchQuery,
stateFilter,
selectedLabels,
selectedAssignees,
selectedMilestones,
validationStatusFilter,
cachedValidations,
]);
}

View File

@@ -1,6 +1,111 @@
import type { GitHubIssue, StoredValidation, GitHubComment } from '@/lib/electron';
import type { ModelId, LinkedPRInfo, PhaseModelEntry } from '@automaker/types';
// ============================================================================
// Issues Filter State Types
// ============================================================================
/**
* Available sort columns for issues list
*/
export const ISSUES_SORT_COLUMNS = [
'title',
'created_at',
'updated_at',
'comments',
'number',
] as const;
export type IssuesSortColumn = (typeof ISSUES_SORT_COLUMNS)[number];
/**
* Sort direction options
*/
export type IssuesSortDirection = 'asc' | 'desc';
/**
* Available issue state filter values
*/
export const ISSUES_STATE_FILTER_OPTIONS = ['open', 'closed', 'all'] as const;
export type IssuesStateFilter = (typeof ISSUES_STATE_FILTER_OPTIONS)[number];
/**
* Validation status filter values for filtering issues by validation state
*/
export const ISSUES_VALIDATION_STATUS_OPTIONS = ['validated', 'not_validated', 'stale'] as const;
export type IssuesValidationStatus = (typeof ISSUES_VALIDATION_STATUS_OPTIONS)[number];
/**
* Sort configuration for issues list
*/
export interface IssuesSortConfig {
column: IssuesSortColumn;
direction: IssuesSortDirection;
}
/**
* Main filter state interface for the GitHub Issues view
*
* This interface defines all filterable/sortable state for the issues list.
* It follows the same pattern as GraphFilterState but is tailored for GitHub issues.
*/
export interface IssuesFilterState {
/** Search query for filtering by issue title or body */
searchQuery: string;
/** Filter by issue state (open/closed/all) */
stateFilter: IssuesStateFilter;
/** Filter by selected labels (matches any) */
selectedLabels: string[];
/** Filter by selected assignees (matches any) */
selectedAssignees: string[];
/** Filter by selected milestones (matches any) */
selectedMilestones: string[];
/** Filter by validation status */
validationStatusFilter: IssuesValidationStatus | null;
/** Current sort configuration */
sortConfig: IssuesSortConfig;
}
/**
* Result of applying filters to the issues list
*/
export interface IssuesFilterResult {
/** Array of GitHubIssue objects that match the current filters */
matchedIssues: GitHubIssue[];
/** Available labels from all issues (for filter dropdown population) */
availableLabels: string[];
/** Available assignees from all issues (for filter dropdown population) */
availableAssignees: string[];
/** Available milestones from all issues (for filter dropdown population) */
availableMilestones: string[];
/** Whether any filter is currently active */
hasActiveFilter: boolean;
/** Total count of matched issues */
matchedCount: number;
}
/**
* Default values for IssuesFilterState
*/
export const DEFAULT_ISSUES_FILTER_STATE: IssuesFilterState = {
searchQuery: '',
stateFilter: 'open',
selectedLabels: [],
selectedAssignees: [],
selectedMilestones: [],
validationStatusFilter: null,
sortConfig: {
column: 'updated_at',
direction: 'desc',
},
};
// ============================================================================
// Component Props Types
// ============================================================================
export interface IssueRowProps {
issue: GitHubIssue;
isSelected: boolean;

Some files were not shown because too many files have changed in this diff Show More