mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 20:03:37 +00:00
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:
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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" />
|
||||
)}
|
||||
|
||||
@@ -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...'}
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
213
apps/ui/src/components/icons/terminal-icons.tsx
Normal file
213
apps/ui/src/components/icons/terminal-icons.tsx
Normal 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;
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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.`}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { PanelLeft, PanelLeftClose } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatShortcut } from '@/store/app-store';
|
||||
import { useIsCompact } from '@/hooks/use-media-query';
|
||||
|
||||
interface CollapseToggleButtonProps {
|
||||
sidebarOpen: boolean;
|
||||
@@ -13,6 +14,13 @@ export function CollapseToggleButton({
|
||||
toggleSidebar,
|
||||
shortcut,
|
||||
}: CollapseToggleButtonProps) {
|
||||
const isCompact = useIsCompact();
|
||||
|
||||
// Hide when in compact mode (mobile menu is shown in board header)
|
||||
if (isCompact) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
|
||||
@@ -8,3 +8,4 @@ export { ProjectActions } from './project-actions';
|
||||
export { SidebarNavigation } from './sidebar-navigation';
|
||||
export { ProjectSelectorWithOptions } from './project-selector-with-options';
|
||||
export { SidebarFooter } from './sidebar-footer';
|
||||
export { MobileSidebarToggle } from './mobile-sidebar-toggle';
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { PanelLeft } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useIsCompact } from '@/hooks/use-media-query';
|
||||
|
||||
/**
|
||||
* Floating toggle button for mobile that completely hides/shows the sidebar.
|
||||
* Positioned at the left-center of the screen.
|
||||
* Only visible on compact/mobile screens when the sidebar is hidden.
|
||||
*/
|
||||
export function MobileSidebarToggle() {
|
||||
const isCompact = useIsCompact();
|
||||
const { mobileSidebarHidden, toggleMobileSidebarHidden } = useAppStore();
|
||||
|
||||
// Only show on compact screens when sidebar is hidden
|
||||
if (!isCompact || !mobileSidebarHidden) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggleMobileSidebarHidden}
|
||||
className={cn(
|
||||
'fixed left-0 top-1/2 -translate-y-1/2 z-50',
|
||||
'flex items-center justify-center',
|
||||
'w-8 h-12 rounded-r-lg',
|
||||
// Glass morphism background
|
||||
'bg-card/95 backdrop-blur-sm border border-l-0 border-border/80',
|
||||
// Shadow and hover effects
|
||||
'shadow-lg shadow-black/10 hover:shadow-xl hover:shadow-brand-500/10',
|
||||
'text-muted-foreground hover:text-brand-500 hover:bg-accent/80',
|
||||
'hover:border-brand-500/30',
|
||||
'transition-all duration-200 ease-out',
|
||||
'hover:w-10 active:scale-95'
|
||||
)}
|
||||
aria-label="Show sidebar"
|
||||
data-testid="mobile-sidebar-toggle"
|
||||
>
|
||||
<PanelLeft className="w-4 h-4 pointer-events-none" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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'
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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" />
|
||||
)}
|
||||
|
||||
47
apps/ui/src/components/shared/font-selector.tsx
Normal file
47
apps/ui/src/components/shared/font-selector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -5,3 +5,6 @@ export {
|
||||
type UseModelOverrideOptions,
|
||||
type UseModelOverrideResult,
|
||||
} from './use-model-override';
|
||||
|
||||
// Font Components
|
||||
export { FontSelector } from './font-selector';
|
||||
|
||||
@@ -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({
|
||||
|
||||
245
apps/ui/src/components/ui/dependency-selector.tsx
Normal file
245
apps/ui/src/components/ui/dependency-selector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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" />
|
||||
)}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
105
apps/ui/src/components/ui/header-actions-panel.tsx
Normal file
105
apps/ui/src/components/ui/header-actions-panel.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { createPortal } from 'react-dom';
|
||||
import { X, Menu } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface HeaderActionsPanelProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* A slide-out panel for header actions on tablet and below.
|
||||
* Shows as a right-side panel that slides in from the right edge.
|
||||
* On desktop (lg+), this component is hidden and children should be rendered inline.
|
||||
*/
|
||||
export function HeaderActionsPanel({
|
||||
isOpen,
|
||||
onClose,
|
||||
title = 'Actions',
|
||||
children,
|
||||
}: HeaderActionsPanelProps) {
|
||||
// Use portal to render outside parent stacking contexts (backdrop-blur creates stacking context)
|
||||
const panelContent = (
|
||||
<>
|
||||
{/* Mobile backdrop overlay - only shown when isOpen is true on tablet/mobile */}
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-[60] lg:hidden"
|
||||
onClick={onClose}
|
||||
data-testid="header-actions-backdrop"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Actions panel */}
|
||||
<div
|
||||
className={cn(
|
||||
// Mobile: fixed position overlay with slide transition from right
|
||||
'fixed inset-y-0 right-0 w-72 z-[70]',
|
||||
'transition-transform duration-200 ease-out',
|
||||
// Hide on mobile when closed, show when open
|
||||
isOpen ? 'translate-x-0' : 'translate-x-full',
|
||||
// Desktop: hidden entirely (actions shown inline in header)
|
||||
'lg:hidden',
|
||||
'flex flex-col',
|
||||
'border-l border-border/50',
|
||||
'bg-gradient-to-b from-card/95 via-card/90 to-card/85 backdrop-blur-xl'
|
||||
)}
|
||||
>
|
||||
{/* Panel header with close button */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border/50">
|
||||
<span className="text-sm font-semibold text-foreground">{title}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Close actions panel"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Panel content */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3">{children}</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
// Render to document.body to escape stacking context
|
||||
if (typeof document !== 'undefined') {
|
||||
return createPortal(panelContent, document.body);
|
||||
}
|
||||
|
||||
return panelContent;
|
||||
}
|
||||
|
||||
interface HeaderActionsPanelTriggerProps {
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle button for the HeaderActionsPanel.
|
||||
* Only visible on tablet and below (lg:hidden).
|
||||
*/
|
||||
export function HeaderActionsPanelTrigger({
|
||||
isOpen,
|
||||
onToggle,
|
||||
className,
|
||||
}: HeaderActionsPanelTriggerProps) {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onToggle}
|
||||
className={cn('h-8 w-8 p-0 text-muted-foreground hover:text-foreground lg:hidden', className)}
|
||||
aria-label={isOpen ? 'Close actions menu' : 'Open actions menu'}
|
||||
>
|
||||
{isOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -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" />
|
||||
)}
|
||||
|
||||
140
apps/ui/src/components/ui/json-syntax-editor.tsx
Normal file
140
apps/ui/src/components/ui/json-syntax-editor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
32
apps/ui/src/components/ui/spinner.tsx
Normal file
32
apps/ui/src/components/ui/spinner.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -27,18 +27,6 @@ export function AgentHeader({
|
||||
return (
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border bg-card/50 backdrop-blur-sm">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onToggleSessionManager}
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showSessionManager ? (
|
||||
<PanelLeftClose className="w-4 h-4" />
|
||||
) : (
|
||||
<PanelLeft className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
<div className="w-9 h-9 rounded-xl bg-primary/10 flex items-center justify-center">
|
||||
<Bot className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
@@ -71,6 +59,19 @@ export function AgentHeader({
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onToggleSessionManager}
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label={showSessionManager ? 'Hide sessions panel' : 'Show sessions panel'}
|
||||
>
|
||||
{showSessionManager ? (
|
||||
<PanelLeftClose className="w-4 h-4" />
|
||||
) : (
|
||||
<PanelLeft className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,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>
|
||||
|
||||
@@ -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...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -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'}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
)}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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?.()}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -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...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -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...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -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...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -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...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -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" />
|
||||
)}
|
||||
|
||||
@@ -2,18 +2,17 @@ import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Menu, Bot, Wand2, Settings2, GitBranch, Zap } from 'lucide-react';
|
||||
HeaderActionsPanel,
|
||||
HeaderActionsPanelTrigger,
|
||||
} from '@/components/ui/header-actions-panel';
|
||||
import { Bot, Wand2, Settings2, GitBranch, Zap } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { MobileUsageBar } from './mobile-usage-bar';
|
||||
|
||||
interface HeaderMobileMenuProps {
|
||||
// Panel visibility
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
// Worktree panel visibility
|
||||
isWorktreePanelVisible: boolean;
|
||||
onWorktreePanelToggle: (visible: boolean) => void;
|
||||
@@ -33,6 +32,8 @@ interface HeaderMobileMenuProps {
|
||||
}
|
||||
|
||||
export function HeaderMobileMenu({
|
||||
isOpen,
|
||||
onToggle,
|
||||
isWorktreePanelVisible,
|
||||
onWorktreePanelToggle,
|
||||
maxConcurrency,
|
||||
@@ -46,129 +47,122 @@ export function HeaderMobileMenu({
|
||||
showCodexUsage,
|
||||
}: HeaderMobileMenuProps) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
data-testid="header-mobile-menu-trigger"
|
||||
>
|
||||
<Menu className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-64">
|
||||
<>
|
||||
<HeaderActionsPanelTrigger isOpen={isOpen} onToggle={onToggle} />
|
||||
<HeaderActionsPanel isOpen={isOpen} onClose={onToggle} title="Board Controls">
|
||||
{/* Usage Bar - show if either provider is authenticated */}
|
||||
{(showClaudeUsage || showCodexUsage) && (
|
||||
<>
|
||||
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Usage
|
||||
</DropdownMenuLabel>
|
||||
</span>
|
||||
<MobileUsageBar showClaudeUsage={showClaudeUsage} showCodexUsage={showCodexUsage} />
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
|
||||
Controls
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{/* Controls Section */}
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Controls
|
||||
</span>
|
||||
|
||||
{/* Auto Mode Toggle */}
|
||||
<div
|
||||
className="flex items-center justify-between px-2 py-2 cursor-pointer hover:bg-accent rounded-sm"
|
||||
onClick={() => onAutoModeToggle(!isAutoModeRunning)}
|
||||
data-testid="mobile-auto-mode-toggle-container"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap
|
||||
className={cn(
|
||||
'w-4 h-4',
|
||||
isAutoModeRunning ? 'text-yellow-500' : 'text-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
<span className="text-sm font-medium">Auto Mode</span>
|
||||
{/* Auto Mode Toggle */}
|
||||
<div
|
||||
className="flex items-center justify-between p-3 cursor-pointer hover:bg-accent/50 rounded-lg border border-border/50 transition-colors"
|
||||
onClick={() => onAutoModeToggle(!isAutoModeRunning)}
|
||||
data-testid="mobile-auto-mode-toggle-container"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap
|
||||
className={cn(
|
||||
'w-4 h-4',
|
||||
isAutoModeRunning ? 'text-yellow-500' : 'text-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
<span className="text-sm font-medium">Auto Mode</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="mobile-auto-mode-toggle"
|
||||
checked={isAutoModeRunning}
|
||||
onCheckedChange={onAutoModeToggle}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
data-testid="mobile-auto-mode-toggle"
|
||||
/>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenAutoModeSettings();
|
||||
}}
|
||||
className="p-1 rounded hover:bg-accent/50 transition-colors"
|
||||
title="Auto Mode Settings"
|
||||
data-testid="mobile-auto-mode-settings-button"
|
||||
>
|
||||
<Settings2 className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
{/* Worktrees Toggle */}
|
||||
<div
|
||||
className="flex items-center justify-between p-3 cursor-pointer hover:bg-accent/50 rounded-lg border border-border/50 transition-colors"
|
||||
onClick={() => onWorktreePanelToggle(!isWorktreePanelVisible)}
|
||||
data-testid="mobile-worktrees-toggle-container"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<GitBranch className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Worktree Bar</span>
|
||||
</div>
|
||||
<Switch
|
||||
id="mobile-auto-mode-toggle"
|
||||
checked={isAutoModeRunning}
|
||||
onCheckedChange={onAutoModeToggle}
|
||||
id="mobile-worktrees-toggle"
|
||||
checked={isWorktreePanelVisible}
|
||||
onCheckedChange={onWorktreePanelToggle}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
data-testid="mobile-auto-mode-toggle"
|
||||
data-testid="mobile-worktrees-toggle"
|
||||
/>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenAutoModeSettings();
|
||||
}}
|
||||
className="p-1 rounded hover:bg-accent/50 transition-colors"
|
||||
title="Auto Mode Settings"
|
||||
data-testid="mobile-auto-mode-settings-button"
|
||||
>
|
||||
<Settings2 className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* Worktrees Toggle */}
|
||||
<div
|
||||
className="flex items-center justify-between px-2 py-2 cursor-pointer hover:bg-accent rounded-sm"
|
||||
onClick={() => onWorktreePanelToggle(!isWorktreePanelVisible)}
|
||||
data-testid="mobile-worktrees-toggle-container"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<GitBranch className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Worktrees</span>
|
||||
{/* Concurrency Control */}
|
||||
<div
|
||||
className="p-3 rounded-lg border border-border/50"
|
||||
data-testid="mobile-concurrency-control"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Bot className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Max Agents</span>
|
||||
<span
|
||||
className="text-sm text-muted-foreground ml-auto"
|
||||
data-testid="mobile-concurrency-value"
|
||||
>
|
||||
{runningAgentsCount}/{maxConcurrency}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[maxConcurrency]}
|
||||
onValueChange={(value) => onConcurrencyChange(value[0])}
|
||||
min={1}
|
||||
max={10}
|
||||
step={1}
|
||||
className="w-full"
|
||||
data-testid="mobile-concurrency-slider"
|
||||
/>
|
||||
</div>
|
||||
<Switch
|
||||
id="mobile-worktrees-toggle"
|
||||
checked={isWorktreePanelVisible}
|
||||
onCheckedChange={onWorktreePanelToggle}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
data-testid="mobile-worktrees-toggle"
|
||||
/>
|
||||
|
||||
{/* Plan Button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start"
|
||||
onClick={() => {
|
||||
onOpenPlanDialog();
|
||||
onToggle();
|
||||
}}
|
||||
data-testid="mobile-plan-button"
|
||||
>
|
||||
<Wand2 className="w-4 h-4 mr-2" />
|
||||
Plan
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* Concurrency Control */}
|
||||
<div className="px-2 py-2" data-testid="mobile-concurrency-control">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Bot className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Max Agents</span>
|
||||
<span
|
||||
className="text-sm text-muted-foreground ml-auto"
|
||||
data-testid="mobile-concurrency-value"
|
||||
>
|
||||
{runningAgentsCount}/{maxConcurrency}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[maxConcurrency]}
|
||||
onValueChange={(value) => onConcurrencyChange(value[0])}
|
||||
min={1}
|
||||
max={10}
|
||||
step={1}
|
||||
className="w-full"
|
||||
data-testid="mobile-concurrency-slider"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* Plan Button */}
|
||||
<DropdownMenuItem
|
||||
onClick={onOpenPlanDialog}
|
||||
className="flex items-center gap-2"
|
||||
data-testid="mobile-plan-button"
|
||||
>
|
||||
<Wand2 className="w-4 h-4" />
|
||||
<span>Plan</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</HeaderActionsPanel>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,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';
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user