Merge remote-tracking branch 'origin/v0.13.0rc' into feature/claude-code-max-glm-api-keys

This commit is contained in:
Stefan de Vogelaere
2026-01-19 14:42:15 +01:00
200 changed files with 6671 additions and 1311 deletions

View File

@@ -2,6 +2,7 @@ import { useState, useEffect, useMemo, useCallback } 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 { getElectronAPI } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
@@ -279,7 +280,7 @@ export function ClaudeUsagePopover() {
) : !claudeUsage ? (
// 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>
) : (

View File

@@ -2,6 +2,7 @@ import { useState, useEffect, useMemo, useCallback } 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 { getElectronAPI } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
@@ -333,7 +334,7 @@ export function CodexUsagePopover() {
) : !codexUsage ? (
// Loading state
<div className="flex flex-col items-center justify-center py-8 space-y-2">
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground/50" />
<Spinner size="lg" />
<p className="text-xs text-muted-foreground">Loading usage data...</p>
</div>
) : codexUsage.rateLimits ? (

View File

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

View File

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

View File

@@ -8,7 +8,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 { getHttpApiClient } from '@/lib/http-api-client';
interface WorkspaceDirectory {
@@ -74,7 +75,7 @@ export function WorkspacePickerModal({ open, onOpenChange, onSelect }: Workspace
<div className="flex-1 overflow-y-auto py-4 min-h-[200px]">
{isLoading && (
<div className="flex flex-col items-center justify-center h-full gap-3">
<Loader2 className="w-8 h-8 text-brand-500 animate-spin" />
<Spinner size="xl" />
<p className="text-sm text-muted-foreground">Loading projects...</p>
</div>
)}

View File

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

View File

@@ -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';
@@ -37,10 +37,15 @@ export function ProjectSwitcherItem({
const IconComponent = getIconComponent();
const hasCustomIcon = !!project.customIconPath;
// Combine project.id with sanitized name for uniqueness and readability
// Format: project-switcher-{id}-{sanitizedName}
const testId = `project-switcher-${project.id}-${sanitizeForTestId(project.name)}`;
return (
<button
onClick={onClick}
onContextMenu={onContextMenu}
data-testid={testId}
className={cn(
'group w-full aspect-square rounded-xl flex items-center justify-center relative overflow-hidden',
'transition-all duration-200 ease-out',
@@ -60,7 +65,6 @@ export function ProjectSwitcherItem({
'hover:scale-105 active:scale-95'
)}
title={project.name}
data-testid={`project-switcher-${project.id}`}
>
{hasCustomIcon ? (
<img

View File

@@ -2,7 +2,7 @@ import { useState, useCallback, useEffect } from 'react';
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';
@@ -10,7 +10,7 @@ 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';
@@ -41,7 +41,6 @@ export function ProjectSwitcher() {
projects,
currentProject,
setCurrentProject,
trashedProjects,
upsertAndSetCurrentProject,
specCreatingForProject,
setSpecCreatingForProject,
@@ -69,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,
@@ -84,9 +80,6 @@ export function ProjectSwitcher() {
handleCreateFromTemplate,
handleCreateFromCustomUrl,
} = useProjectCreation({
trashedProjects,
currentProject,
globalTheme,
upsertAndSetCurrentProject,
});
@@ -161,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);
@@ -198,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 () => {

View File

@@ -4,7 +4,7 @@ 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';
@@ -34,7 +34,6 @@ import {
useProjectCreation,
useSetupDialog,
useTrashOperations,
useProjectTheme,
useUnviewedValidations,
} from './sidebar/hooks';
@@ -79,9 +78,6 @@ export function Sidebar() {
// 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,
@@ -97,9 +93,6 @@ export function Sidebar() {
handleCreateFromTemplate,
handleCreateFromCustomUrl,
} = useProjectCreation({
trashedProjects,
currentProject,
globalTheme,
upsertAndSetCurrentProject,
});
@@ -198,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);
@@ -232,7 +220,7 @@ export function Sidebar() {
});
}
}
}, [trashedProjects, upsertAndSetCurrentProject, currentProject, globalTheme]);
}, [upsertAndSetCurrentProject]);
// Navigation sections and keyboard shortcuts (defined after handlers)
const { navSections, navigationShortcuts } = useNavigation({

View File

@@ -1,9 +1,9 @@
import type { NavigateOptions } from '@tanstack/react-router';
import { Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { formatShortcut } from '@/store/app-store';
import type { NavSection } from '../types';
import type { Project } from '@/lib/electron';
import { Spinner } from '@/components/ui/spinner';
interface SidebarNavigationProps {
currentProject: Project | null;
@@ -93,9 +93,10 @@ export function SidebarNavigation({
>
<div className="relative">
{item.isLoading ? (
<Loader2
<Spinner
size="md"
className={cn(
'w-[18px] h-[18px] shrink-0 animate-spin',
'shrink-0',
isActive ? 'text-brand-500' : 'text-muted-foreground'
)}
/>

View File

@@ -6,20 +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) {
// Modal state
@@ -67,14 +60,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 +79,7 @@ export function useProjectCreation({
throw error;
}
},
[trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject]
[upsertAndSetCurrentProject]
);
/**
@@ -169,14 +156,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 +175,7 @@ export function useProjectCreation({
setIsCreatingProject(false);
}
},
[trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject]
[upsertAndSetCurrentProject]
);
/**
@@ -244,14 +225,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 +244,7 @@ export function useProjectCreation({
setIsCreatingProject(false);
}
},
[trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject]
[upsertAndSetCurrentProject]
);
return {

View File

@@ -16,8 +16,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';
@@ -466,7 +466,7 @@ export function SessionManager({
{/* Show loading indicator if this session is running (either current session thinking or any session in runningSessions) */}
{(currentSessionId === session.id && isCurrentSessionThinking) ||
runningSessions.has(session.id) ? (
<Loader2 className="w-4 h-4 text-primary animate-spin shrink-0" />
<Spinner size="sm" className="shrink-0" />
) : (
<MessageSquare className="w-4 h-4 text-muted-foreground shrink-0" />
)}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -22,8 +22,8 @@ import {
Filter,
Circle,
Play,
Loader2,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import {
parseLogOutput,
@@ -148,7 +148,7 @@ 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-muted-foreground/70" />;
default:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock, ExternalLink } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import { getElectronAPI } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
@@ -449,7 +450,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>
) : (
@@ -568,7 +569,7 @@ export function UsagePopover() {
</div>
) : !codexUsage ? (
<div className="flex flex-col items-center justify-center py-8 space-y-2">
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground/50" />
<Spinner size="lg" />
<p className="text-xs text-muted-foreground">Loading usage data...</p>
</div>
) : codexUsage.rateLimits ? (

View File

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

View File

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

View File

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

View File

@@ -14,12 +14,12 @@ import {
RefreshCw,
BarChart3,
FileCode,
Loader2,
FileText,
CheckCircle,
AlertCircle,
ListChecks,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn, generateUUID } from '@/lib/utils';
const logger = createLogger('AnalysisView');
@@ -742,7 +742,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...
</>
) : (
@@ -771,7 +771,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 ? (
@@ -850,7 +850,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
>
{isGeneratingSpec ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
<Spinner size="sm" className="mr-2" />
Generating...
</>
) : (
@@ -903,7 +903,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
>
{isGeneratingFeatureList ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
<Spinner size="sm" className="mr-2" />
Generating...
</>
) : (

View File

@@ -34,7 +34,7 @@ import { pathsEqual } from '@/lib/utils';
import { toast } from 'sonner';
import { getBlockingDependencies } from '@automaker/dependency-resolver';
import { BoardBackgroundModal } from '@/components/dialogs/board-background-modal';
import { RefreshCw } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { useAutoMode } from '@/hooks/use-auto-mode';
import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts';
import { useWindowState } from '@/hooks/use-window-state';
@@ -856,68 +856,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(() => {
@@ -976,219 +917,6 @@ export function BoardView() {
};
}, [currentProject, pendingBacklogPlan]);
useEffect(() => {
logger.info(
'[AutoMode] Effect triggered - isRunning:',
autoMode.isRunning,
'hasProject:',
!!currentProject
);
if (!autoMode.isRunning || !currentProject) {
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
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;
}
};
// Check immediately, then every 3 seconds
checkAndStartFeatures();
const interval = setInterval(checkAndStartFeatures, 3000);
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,
]);
// Use keyboard shortcuts hook (after actions hook)
useBoardKeyboardShortcuts({
features: hookFeatures,
@@ -1384,7 +1112,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>
);
}
@@ -1403,9 +1131,13 @@ 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)}

View File

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

View File

@@ -11,16 +11,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';
@@ -338,7 +330,7 @@ export function AgentInfoPanel({
{todo.status === 'completed' ? (
<CheckCircle2 className="w-2.5 h-2.5 text-[var(--status-success)] shrink-0" />
) : todo.status === 'in_progress' ? (
<Loader2 className="w-2.5 h-2.5 text-[var(--status-warning)] animate-spin shrink-0" />
<Spinner size="xs" className="w-2.5 h-2.5 shrink-0" />
) : (
<Circle className="w-2.5 h-2.5 text-muted-foreground/50 shrink-0" />
)}

View File

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

View File

@@ -170,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);

View File

@@ -6,7 +6,8 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Loader2, List, FileText, GitBranch, ClipboardList } from 'lucide-react';
import { List, FileText, GitBranch, ClipboardList } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI } from '@/lib/electron';
import { LogViewer } from '@/components/ui/log-viewer';
import { GitDiffPanel } from '@/components/ui/git-diff-panel';
@@ -353,7 +354,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>
@@ -439,7 +440,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>
)}
@@ -457,7 +458,7 @@ export function AgentOutputModal({
>
{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 ? (

View File

@@ -11,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';
@@ -287,8 +279,7 @@ 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>
@@ -405,7 +396,7 @@ export function BacklogPlanDialog({
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>
);
@@ -452,7 +443,7 @@ 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...
</>
) : (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -28,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,
@@ -107,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',
}));
@@ -157,9 +158,9 @@ 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',
});

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import { useEffect, useCallback, useState, type ComponentType, type ReactNode } from 'react';
import { RefreshCw } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
import { AnthropicIcon, OpenAIIcon } from '@/components/ui/provider-icon';
@@ -90,9 +91,11 @@ function UsageItem({
className="p-1 rounded hover:bg-accent/50 transition-colors"
title="Refresh usage"
>
<RefreshCw
className={cn('w-3.5 h-3.5 text-muted-foreground', isLoading && 'animate-spin')}
/>
{isLoading ? (
<Spinner size="xs" />
) : (
<RefreshCw className="w-3.5 h-3.5 text-muted-foreground" />
)}
</button>
</div>
<div className="pl-6 space-y-2">{children}</div>

View File

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

View File

@@ -11,7 +11,7 @@ import { getModelProvider, PROVIDER_PREFIXES, stripProviderPrefix } from '@autom
import type { ModelProvider } from '@automaker/types';
import { CLAUDE_MODELS, CURSOR_MODELS, ModelOption } from './model-constants';
import { useEffect } from 'react';
import { RefreshCw } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
interface ModelSelectorProps {
selectedModel: string; // Can be ModelAlias or "cursor-{id}"
@@ -70,22 +70,30 @@ export function ModelSelector({
// Filter Cursor models based on enabled models from global settings
const filteredCursorModels = CURSOR_MODELS.filter((model) => {
// Compare model.id directly since both model.id and enabledCursorModels use full IDs with prefix
return enabledCursorModels.includes(model.id 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');
}
};
@@ -294,7 +302,7 @@ export function ModelSelector({
{/* Loading state */}
{codexModelsLoading && dynamicCodexModels.length === 0 && (
<div className="flex items-center justify-center gap-2 p-6 text-sm text-muted-foreground">
<RefreshCw className="w-4 h-4 animate-spin" />
<Spinner size="sm" />
Loading models...
</div>
)}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import type { JSX } from 'react';
import { Button } from '@/components/ui/button';
import { RefreshCw, Globe, Loader2, CircleDot, GitPullRequest } from 'lucide-react';
import { Globe, CircleDot, GitPullRequest } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import type { WorktreeInfo, BranchInfo, DevServerInfo, PRInfo, GitRepoStatus } from '../types';
@@ -37,6 +38,8 @@ interface WorktreeTabProps {
onPull: (worktree: WorktreeInfo) => void;
onPush: (worktree: WorktreeInfo) => void;
onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void;
onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void;
onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void;
onCommit: (worktree: WorktreeInfo) => void;
onCreatePR: (worktree: WorktreeInfo) => void;
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
@@ -81,6 +84,8 @@ export function WorktreeTab({
onPull,
onPush,
onOpenInEditor,
onOpenInIntegratedTerminal,
onOpenInExternalTerminal,
onCommit,
onCreatePR,
onAddressPRComments,
@@ -197,8 +202,8 @@ export function WorktreeTab({
aria-label={worktree.branch}
data-testid={`worktree-branch-${worktree.branch}`}
>
{isRunning && <Loader2 className="w-3 h-3 animate-spin" />}
{isActivating && !isRunning && <RefreshCw className="w-3 h-3 animate-spin" />}
{isRunning && <Spinner size="xs" />}
{isActivating && !isRunning && <Spinner size="xs" />}
{worktree.branch}
{cardCount !== undefined && cardCount > 0 && (
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
@@ -264,8 +269,8 @@ export function WorktreeTab({
: 'Click to switch to this branch'
}
>
{isRunning && <Loader2 className="w-3 h-3 animate-spin" />}
{isActivating && !isRunning && <RefreshCw className="w-3 h-3 animate-spin" />}
{isRunning && <Spinner size="xs" />}
{isActivating && !isRunning && <Spinner size="xs" />}
{worktree.branch}
{cardCount !== undefined && cardCount > 0 && (
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
@@ -342,6 +347,8 @@ export function WorktreeTab({
onPull={onPull}
onPush={onPush}
onOpenInEditor={onOpenInEditor}
onOpenInIntegratedTerminal={onOpenInIntegratedTerminal}
onOpenInExternalTerminal={onOpenInExternalTerminal}
onCommit={onCommit}
onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments}

View File

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

View File

@@ -1,4 +1,5 @@
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';
@@ -35,6 +36,7 @@ interface UseWorktreeActionsOptions {
}
export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktreeActionsOptions) {
const navigate = useNavigate();
const [isPulling, setIsPulling] = useState(false);
const [isPushing, setIsPushing] = useState(false);
const [isSwitching, setIsSwitching] = useState(false);
@@ -125,6 +127,19 @@ export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktre
[isPushing, fetchBranches, fetchWorktrees]
);
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) => {
try {
const api = getElectronAPI();
@@ -143,6 +158,27 @@ export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktre
}
}, []);
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,
isPushing,
@@ -152,6 +188,8 @@ export function useWorktreeActions({ fetchWorktrees, fetchBranches }: UseWorktre
handleSwitchBranch,
handlePull,
handlePush,
handleOpenInIntegratedTerminal,
handleOpenInEditor,
handleOpenInExternalTerminal,
};
}

View File

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

View File

@@ -1,6 +1,7 @@
import { useEffect, useRef, useCallback, useState } from 'react';
import { Button } from '@/components/ui/button';
import { GitBranch, Plus, RefreshCw } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn, pathsEqual } from '@/lib/utils';
import { toast } from 'sonner';
import { getHttpApiClient } from '@/lib/http-api-client';
@@ -78,7 +79,9 @@ export function WorktreePanel({
handleSwitchBranch,
handlePull,
handlePush,
handleOpenInIntegratedTerminal,
handleOpenInEditor,
handleOpenInExternalTerminal,
} = useWorktreeActions({
fetchWorktrees,
fetchBranches,
@@ -245,6 +248,8 @@ export function WorktreePanel({
onPull={handlePull}
onPush={handlePush}
onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
onOpenInExternalTerminal={handleOpenInExternalTerminal}
onCommit={onCommit}
onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments}
@@ -285,7 +290,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>
</>
)}
@@ -332,6 +337,8 @@ export function WorktreePanel({
onPull={handlePull}
onPush={handlePush}
onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
onOpenInExternalTerminal={handleOpenInExternalTerminal}
onCommit={onCommit}
onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments}
@@ -390,6 +397,8 @@ export function WorktreePanel({
onPull={handlePull}
onPush={handlePush}
onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
onOpenInExternalTerminal={handleOpenInExternalTerminal}
onCommit={onCommit}
onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments}
@@ -429,7 +438,7 @@ export function WorktreePanel({
disabled={isLoading}
title="Refresh worktrees"
>
<RefreshCw className={cn('w-3.5 h-3.5', isLoading && 'animate-spin')} />
{isLoading ? <Spinner size="xs" /> : <RefreshCw className="w-3.5 h-3.5" />}
</Button>
</div>
</>

View File

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

View File

@@ -12,7 +12,6 @@ import {
HeaderActionsPanelTrigger,
} from '@/components/ui/header-actions-panel';
import {
RefreshCw,
FileText,
Image as ImageIcon,
Trash2,
@@ -24,9 +23,9 @@ import {
Pencil,
FilePlus,
FileUp,
Loader2,
MoreVertical,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import {
useKeyboardShortcuts,
useKeyboardShortcutsConfig,
@@ -670,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>
);
}
@@ -790,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>
@@ -838,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 ? (
@@ -955,7 +954,7 @@ export function ContextView() {
</span>
{generatingDescriptions.has(selectedFile.name) ? (
<div className="flex items-center gap-2 mt-1 text-sm text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" />
<Spinner size="sm" />
<span>Generating description with AI...</span>
</div>
) : selectedFile.description ? (

View File

@@ -1,7 +1,7 @@
import { useState, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { useNavigate } from '@tanstack/react-router';
import { useAppStore, type ThemeMode } from '@/store/app-store';
import { useAppStore } from '@/store/app-store';
import { useOSDetection } from '@/hooks/use-os-detection';
import { getElectronAPI, isElectron } from '@/lib/electron';
import { initializeProject } from '@/lib/project-init';
@@ -18,7 +18,6 @@ import {
Folder,
Star,
Clock,
Loader2,
ChevronDown,
MessageSquare,
MoreVertical,
@@ -28,6 +27,7 @@ import {
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 {
@@ -76,14 +76,11 @@ export function DashboardView() {
const {
projects,
trashedProjects,
currentProject,
upsertAndSetCurrentProject,
addProject,
setCurrentProject,
toggleProjectFavorite,
moveProjectToTrash,
theme: globalTheme,
} = useAppStore();
const [showNewProjectModal, setShowNewProjectModal] = useState(false);
@@ -124,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}`,
@@ -151,7 +157,7 @@ export function DashboardView() {
setIsOpening(false);
}
},
[trashedProjects, currentProject, globalTheme, upsertAndSetCurrentProject, navigate]
[projects, upsertAndSetCurrentProject, navigate, moveProjectToTrash]
);
const handleOpenProject = useCallback(async () => {
@@ -992,7 +998,7 @@ export function DashboardView() {
data-testid="project-opening-overlay"
>
<div className="flex flex-col items-center gap-4 p-8 rounded-2xl bg-card border border-border shadow-2xl">
<Loader2 className="w-10 h-10 text-brand-500 animate-spin" />
<Spinner size="xl" />
<p className="text-foreground font-medium">Opening project...</p>
</div>
</div>

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
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';
@@ -77,7 +78,7 @@ export function IssuesListHeader({
</div>
</div>
<Button variant="outline" size="sm" onClick={onRefresh} disabled={refreshing}>
<RefreshCw className={cn('h-4 w-4', refreshing && 'animate-spin')} />
{refreshing ? <Spinner size="sm" /> : <RefreshCw className="h-4 w-4" />}
</Button>
</div>

View File

@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { GitPullRequest, Loader2, RefreshCw, ExternalLink, GitMerge, X } from 'lucide-react';
import { GitPullRequest, RefreshCw, ExternalLink, GitMerge, X } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI, GitHubPR } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
import { Button } from '@/components/ui/button';
@@ -86,7 +87,7 @@ export function GitHubPRsView() {
if (loading) {
return (
<div className="flex-1 flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<Spinner size="xl" />
</div>
);
}
@@ -134,7 +135,7 @@ export function GitHubPRsView() {
</div>
</div>
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={refreshing}>
<RefreshCw className={cn('h-4 w-4', refreshing && 'animate-spin')} />
{refreshing ? <Spinner size="sm" /> : <RefreshCw className="h-4 w-4" />}
</Button>
</div>

View File

@@ -17,7 +17,7 @@ import {
import { useWorktrees } from './board-view/worktree-panel/hooks';
import { useAutoMode } from '@/hooks/use-auto-mode';
import { pathsEqual } from '@/lib/utils';
import { RefreshCw } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI } from '@/lib/electron';
import { createLogger } from '@automaker/utils/logger';
import { toast } from 'sonner';
@@ -330,7 +330,7 @@ export function GraphViewPage() {
if (isLoading) {
return (
<div className="flex-1 flex items-center justify-center" data-testid="graph-view-loading">
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground" />
<Spinner size="lg" />
</div>
);
}

View File

@@ -4,7 +4,8 @@
*/
import { useState, useMemo, useEffect, useCallback } from 'react';
import { Loader2, AlertCircle, Plus, X, Sparkles, Lightbulb, Trash2 } from 'lucide-react';
import { AlertCircle, Plus, X, Sparkles, Lightbulb, Trash2 } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
@@ -109,7 +110,7 @@ function SuggestionCard({
)}
>
{isAdding ? (
<Loader2 className="w-4 h-4 animate-spin" />
<Spinner size="sm" />
) : (
<>
<Plus className="w-4 h-4" />
@@ -153,11 +154,7 @@ function GeneratingCard({ job }: { job: GenerationJob }) {
isError ? 'bg-destructive/10 text-destructive' : 'bg-blue-500/10 text-blue-500'
)}
>
{isError ? (
<AlertCircle className="w-5 h-5" />
) : (
<Loader2 className="w-5 h-5 animate-spin" />
)}
{isError ? <AlertCircle className="w-5 h-5" /> : <Spinner size="md" />}
</div>
<div>
<p className="font-medium">{job.prompt.title}</p>

View File

@@ -13,8 +13,8 @@ import {
Gauge,
Accessibility,
BarChart3,
Loader2,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { Card, CardContent } from '@/components/ui/card';
import { useGuidedPrompts } from '@/hooks/use-guided-prompts';
import type { IdeaCategory } from '@automaker/types';
@@ -53,7 +53,7 @@ export function PromptCategoryGrid({ onSelect, onBack }: PromptCategoryGridProps
{isLoading && (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
<Spinner size="lg" />
<span className="ml-2 text-muted-foreground">Loading categories...</span>
</div>
)}

View File

@@ -3,7 +3,8 @@
*/
import { useState, useMemo } from 'react';
import { ArrowLeft, Lightbulb, Loader2, CheckCircle2 } from 'lucide-react';
import { ArrowLeft, Lightbulb, CheckCircle2 } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { Card, CardContent } from '@/components/ui/card';
import { useGuidedPrompts } from '@/hooks/use-guided-prompts';
import { useIdeationStore } from '@/store/ideation-store';
@@ -121,7 +122,7 @@ export function PromptList({ category, onBack }: PromptListProps) {
<div className="space-y-3">
{isLoadingPrompts && (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
<Spinner size="lg" />
<span className="ml-2 text-muted-foreground">Loading prompts...</span>
</div>
)}
@@ -162,7 +163,7 @@ export function PromptList({ category, onBack }: PromptListProps) {
}`}
>
{isLoading || isGenerating ? (
<Loader2 className="w-5 h-5 animate-spin" />
<Spinner size="md" />
) : isStarted ? (
<CheckCircle2 className="w-5 h-5" />
) : (

View File

@@ -11,7 +11,8 @@ import { PromptList } from './components/prompt-list';
import { IdeationDashboard } from './components/ideation-dashboard';
import { useGuidedPrompts } from '@/hooks/use-guided-prompts';
import { Button } from '@/components/ui/button';
import { ArrowLeft, ChevronRight, Lightbulb, CheckCheck, Loader2, Trash2 } from 'lucide-react';
import { ArrowLeft, ChevronRight, Lightbulb, CheckCheck, Trash2 } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import type { IdeaCategory } from '@automaker/types';
import type { IdeationMode } from '@/store/ideation-store';
@@ -152,11 +153,7 @@ function IdeationHeader({
className="gap-2"
disabled={isAcceptingAll}
>
{isAcceptingAll ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<CheckCheck className="w-4 h-4" />
)}
{isAcceptingAll ? <Spinner size="sm" /> : <CheckCheck className="w-4 h-4" />}
Accept All ({acceptAllCount})
</Button>
)}

View File

@@ -5,7 +5,8 @@ import { useAppStore, Feature } from '@/store/app-store';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Bot, Send, User, Loader2, Sparkles, FileText, ArrowLeft, CheckCircle } from 'lucide-react';
import { Bot, Send, User, Sparkles, FileText, ArrowLeft, CheckCircle } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn, generateUUID } from '@/lib/utils';
import { getElectronAPI } from '@/lib/electron';
import { Markdown } from '@/components/ui/markdown';
@@ -491,7 +492,7 @@ export function InterviewView() {
<Card className="border border-primary/30 bg-card">
<CardContent className="p-3">
<div className="flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin text-primary" />
<Spinner size="sm" />
<span className="text-sm text-primary">Generating specification...</span>
</div>
</CardContent>
@@ -571,7 +572,7 @@ export function InterviewView() {
>
{isGenerating ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
<Spinner size="sm" className="mr-2" />
Creating...
</>
) : (

View File

@@ -24,7 +24,8 @@ import {
} from '@/lib/http-api-client';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { KeyRound, AlertCircle, Loader2, RefreshCw, ServerCrash } from 'lucide-react';
import { KeyRound, AlertCircle, RefreshCw, ServerCrash } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { useAuthStore } from '@/store/auth-store';
import { useSetupStore } from '@/store/setup-store';
@@ -349,7 +350,7 @@ export function LoginView() {
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="text-center space-y-4">
<Loader2 className="h-8 w-8 animate-spin mx-auto text-primary" />
<Spinner size="xl" className="mx-auto" />
<p className="text-sm text-muted-foreground">
Connecting to server
{state.attempt > 1 ? ` (attempt ${state.attempt}/${MAX_RETRIES})` : '...'}
@@ -385,7 +386,7 @@ export function LoginView() {
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="text-center space-y-4">
<Loader2 className="h-8 w-8 animate-spin mx-auto text-primary" />
<Spinner size="xl" className="mx-auto" />
<p className="text-sm text-muted-foreground">
{state.phase === 'checking_setup' ? 'Loading settings...' : 'Redirecting...'}
</p>
@@ -447,7 +448,7 @@ export function LoginView() {
>
{isLoggingIn ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<Spinner size="sm" className="mr-2" />
Authenticating...
</>
) : (

View File

@@ -19,6 +19,7 @@ import {
FilePlus,
MoreVertical,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import {
Dialog,
DialogContent,
@@ -299,7 +300,7 @@ export function MemoryView() {
if (isLoading) {
return (
<div className="flex-1 flex items-center justify-center" data-testid="memory-view-loading">
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground" />
<Spinner size="lg" />
</div>
);
}

View File

@@ -9,7 +9,8 @@ import { useLoadNotifications, useNotificationEvents } from '@/hooks/use-notific
import { getHttpApiClient } from '@/lib/http-api-client';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Bell, Check, CheckCheck, Trash2, ExternalLink, Loader2 } from 'lucide-react';
import { Bell, Check, CheckCheck, Trash2, ExternalLink } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { useNavigate } from '@tanstack/react-router';
import type { Notification } from '@automaker/types';
@@ -146,7 +147,7 @@ export function NotificationsView() {
if (isLoading) {
return (
<div className="flex flex-1 flex-col items-center justify-center p-8">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<Spinner size="xl" />
<p className="text-muted-foreground mt-4">Loading notifications...</p>
</div>
);

View File

@@ -10,9 +10,9 @@ import {
Save,
RotateCcw,
Trash2,
Loader2,
PanelBottomClose,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import { apiGet, apiPut, apiDelete } from '@/lib/api-fetch';
import { toast } from 'sonner';
@@ -409,7 +409,7 @@ export function WorktreePreferencesSection({ project }: WorktreePreferencesSecti
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
<Spinner size="md" />
</div>
) : (
<>
@@ -448,11 +448,7 @@ npm install
disabled={!scriptExists || isSaving || isDeleting}
className="gap-1.5 text-destructive hover:text-destructive hover:bg-destructive/10"
>
{isDeleting ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Trash2 className="w-3.5 h-3.5" />
)}
{isDeleting ? <Spinner size="xs" /> : <Trash2 className="w-3.5 h-3.5" />}
Delete
</Button>
<Button
@@ -461,11 +457,7 @@ npm install
disabled={!hasChanges || isSaving || isDeleting}
className="gap-1.5"
>
{isSaving ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Save className="w-3.5 h-3.5" />
)}
{isSaving ? <Spinner size="xs" /> : <Save className="w-3.5 h-3.5" />}
Save
</Button>
</div>

View File

@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { Bot, Folder, Loader2, RefreshCw, Square, Activity, FileText } from 'lucide-react';
import { Bot, Folder, RefreshCw, Square, Activity, FileText } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI, RunningAgent } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
import { Button } from '@/components/ui/button';
@@ -146,7 +147,7 @@ export function RunningAgentsView() {
if (loading) {
return (
<div className="flex-1 flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<Spinner size="xl" />
</div>
);
}
@@ -169,7 +170,11 @@ export function RunningAgentsView() {
</div>
</div>
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={refreshing}>
<RefreshCw className={cn('h-4 w-4 mr-2', refreshing && 'animate-spin')} />
{refreshing ? (
<Spinner size="sm" className="mr-2" />
) : (
<RefreshCw className="h-4 w-4 mr-2" />
)}
Refresh
</Button>
</div>

View File

@@ -11,6 +11,7 @@ import {
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { toast } from 'sonner';
import { LogOut, User, Code2, RefreshCw } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import { logout } from '@/lib/http-api-client';
import { useAuthStore } from '@/store/auth-store';
@@ -143,7 +144,7 @@ export function AccountSection() {
disabled={isRefreshing || isLoadingEditors}
className="shrink-0 h-9 w-9"
>
<RefreshCw className={cn('w-4 h-4', isRefreshing && 'animate-spin')} />
{isRefreshing ? <Spinner size="sm" /> : <RefreshCw className="w-4 h-4" />}
</Button>
</TooltipTrigger>
<TooltipContent>

View File

@@ -1,7 +1,8 @@
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { AlertCircle, CheckCircle2, Eye, EyeOff, Loader2, Zap } from 'lucide-react';
import { AlertCircle, CheckCircle2, Eye, EyeOff, Zap } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import type { ProviderConfig } from '@/config/api-providers';
interface ApiKeyFieldProps {
@@ -70,7 +71,7 @@ export function ApiKeyField({ config }: ApiKeyFieldProps) {
>
{testButton.loading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
<Spinner size="sm" className="mr-2" />
Testing...
</>
) : (

View File

@@ -1,7 +1,8 @@
import { useAppStore } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
import { Button } from '@/components/ui/button';
import { Key, CheckCircle2, Trash2, Loader2 } from 'lucide-react';
import { Key, CheckCircle2, Trash2 } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { ApiKeyField } from './api-key-field';
import { buildProviderConfigs } from '@/config/api-providers';
import { SecurityNotice } from './security-notice';
@@ -142,7 +143,7 @@ export function ApiKeysSection() {
data-testid="delete-anthropic-key"
>
{isDeletingAnthropicKey ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
<Spinner size="sm" className="mr-2" />
) : (
<Trash2 className="w-4 h-4 mr-2" />
)}
@@ -159,7 +160,7 @@ export function ApiKeysSection() {
data-testid="delete-openai-key"
>
{isDeletingOpenaiKey ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
<Spinner size="sm" className="mr-2" />
) : (
<Trash2 className="w-4 h-4 mr-2" />
)}

View File

@@ -4,6 +4,7 @@ import { getElectronAPI } from '@/lib/electron';
import { useSetupStore } from '@/store/setup-store';
import { useAppStore } from '@/store/app-store';
import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner';
import { RefreshCw, AlertCircle } from 'lucide-react';
const ERROR_NO_API = 'Claude usage API not available';
@@ -178,7 +179,7 @@ export function ClaudeUsageSection() {
data-testid="refresh-claude-usage"
title={CLAUDE_REFRESH_LABEL}
>
<RefreshCw className={cn('w-4 h-4', isLoading && 'animate-spin')} />
{isLoading ? <Spinner size="sm" /> : <RefreshCw className="w-4 h-4" />}
</Button>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">{CLAUDE_USAGE_SUBTITLE}</p>

View File

@@ -1,5 +1,6 @@
import { useState, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner';
import { CheckCircle2, AlertCircle, RefreshCw, XCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { CliStatus } from '../shared/types';
@@ -172,7 +173,7 @@ export function ClaudeCliStatus({ status, authStatus, isChecking, onRefresh }: C
'transition-all duration-200'
)}
>
<RefreshCw className={cn('w-4 h-4', isChecking && 'animate-spin')} />
{isChecking ? <Spinner size="sm" /> : <RefreshCw className="w-4 h-4" />}
</Button>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">

View File

@@ -1,5 +1,6 @@
import { Button } from '@/components/ui/button';
import { CheckCircle2, AlertCircle, RefreshCw } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import type { CliStatus } from '../shared/types';
@@ -56,7 +57,7 @@ export function CliStatusCard({
'transition-all duration-200'
)}
>
<RefreshCw className={cn('w-4 h-4', isChecking && 'animate-spin')} />
{isChecking ? <Spinner size="sm" /> : <RefreshCw className="w-4 h-4" />}
</Button>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">{description}</p>

View File

@@ -1,5 +1,6 @@
import { useState, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner';
import { CheckCircle2, AlertCircle, RefreshCw, XCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { CliStatus } from '../shared/types';
@@ -165,7 +166,7 @@ export function CodexCliStatus({ status, authStatus, isChecking, onRefresh }: Cl
'transition-all duration-200'
)}
>
<RefreshCw className={cn('w-4 h-4', isChecking && 'animate-spin')} />
{isChecking ? <Spinner size="sm" /> : <RefreshCw className="w-4 h-4" />}
</Button>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">

View File

@@ -1,5 +1,6 @@
import { useState, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner';
import { CheckCircle2, AlertCircle, RefreshCw, XCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
import { CursorIcon } from '@/components/ui/provider-icon';
@@ -290,7 +291,7 @@ export function CursorCliStatus({ status, isChecking, onRefresh }: CursorCliStat
'transition-all duration-200'
)}
>
<RefreshCw className={cn('w-4 h-4', isChecking && 'animate-spin')} />
{isChecking ? <Spinner size="sm" /> : <RefreshCw className="w-4 h-4" />}
</Button>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">

View File

@@ -1,5 +1,6 @@
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Spinner } from '@/components/ui/spinner';
import { CheckCircle2, AlertCircle, RefreshCw, Bot, Cloud } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { CliStatus } from '../shared/types';
@@ -221,7 +222,7 @@ export function OpencodeCliStatus({
'transition-all duration-200'
)}
>
<RefreshCw className={cn('w-4 h-4', isChecking && 'animate-spin')} />
{isChecking ? <Spinner size="sm" /> : <RefreshCw className="w-4 h-4" />}
</Button>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">

View File

@@ -1,6 +1,7 @@
// @ts-nocheck
import { useCallback, useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner';
import { RefreshCw, AlertCircle } from 'lucide-react';
import { OpenAIIcon } from '@/components/ui/provider-icon';
import { cn } from '@/lib/utils';
@@ -168,7 +169,7 @@ export function CodexUsageSection() {
data-testid="refresh-codex-usage"
title={CODEX_REFRESH_LABEL}
>
<RefreshCw className={cn('w-4 h-4', isLoading && 'animate-spin')} />
{isLoading ? <Spinner size="sm" /> : <RefreshCw className="w-4 h-4" />}
</Button>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">{CODEX_USAGE_SUBTITLE}</p>

View File

@@ -1,5 +1,6 @@
import { useState, useEffect, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import {
History,
@@ -184,7 +185,11 @@ export function EventHistoryView() {
</p>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={loadEvents} disabled={loading}>
<RefreshCw className={cn('w-4 h-4 mr-2', loading && 'animate-spin')} />
{loading ? (
<Spinner size="sm" className="mr-2" />
) : (
<RefreshCw className="w-4 h-4 mr-2" />
)}
Refresh
</Button>
{events.length > 0 && (

View File

@@ -1,4 +1,5 @@
import { ChevronDown, ChevronRight, Code, Pencil, Trash2, PlayCircle, Loader2 } from 'lucide-react';
import { ChevronDown, ChevronRight, Code, Pencil, Trash2, PlayCircle } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
@@ -111,7 +112,7 @@ export function MCPServerCard({
className="h-8 px-2"
>
{testState?.status === 'testing' ? (
<Loader2 className="w-4 h-4 animate-spin" />
<Spinner size="sm" />
) : (
<PlayCircle className="w-4 h-4" />
)}

View File

@@ -1,5 +1,6 @@
import { Plug, RefreshCw, Download, Code, FileJson, Plus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
interface MCPServerHeaderProps {
@@ -43,7 +44,7 @@ export function MCPServerHeader({
disabled={isRefreshing}
data-testid="refresh-mcp-servers-button"
>
<RefreshCw className={cn('w-4 h-4', isRefreshing && 'animate-spin')} />
{isRefreshing ? <Spinner size="sm" /> : <RefreshCw className="w-4 h-4" />}
</Button>
{hasServers && (
<>

View File

@@ -1,4 +1,5 @@
import { Terminal, Globe, Loader2, CheckCircle2, XCircle } from 'lucide-react';
import { Terminal, Globe, CheckCircle2, XCircle } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import type { ServerType, ServerTestState } from './types';
import { SENSITIVE_PARAM_PATTERNS } from './constants';
@@ -40,7 +41,7 @@ export function getServerIcon(type: ServerType = 'stdio') {
export function getTestStatusIcon(status: ServerTestState['status']) {
switch (status) {
case 'testing':
return <Loader2 className="w-4 h-4 animate-spin text-brand-500" />;
return <Spinner size="sm" />;
case 'success':
return <CheckCircle2 className="w-4 h-4 text-green-500" />;
case 'error':

View File

@@ -279,8 +279,8 @@ export function PhaseModelSelector({
}, [codexModels]);
// Filter Cursor models to only show enabled ones
// With canonical IDs, both CURSOR_MODELS and enabledCursorModels use prefixed format
const availableCursorModels = CURSOR_MODELS.filter((model) => {
// Compare model.id directly since both model.id and enabledCursorModels use full IDs with prefix
return enabledCursorModels.includes(model.id as CursorModelId);
});
@@ -300,6 +300,7 @@ export function PhaseModelSelector({
};
}
// With canonical IDs, direct comparison works
const cursorModel = availableCursorModels.find((m) => m.id === selectedModel);
if (cursorModel) return { ...cursorModel, icon: CursorIcon };
@@ -352,7 +353,7 @@ export function PhaseModelSelector({
const seenGroups = new Set<string>();
availableCursorModels.forEach((model) => {
const cursorId = stripProviderPrefix(model.id) as CursorModelId;
const cursorId = model.id as CursorModelId;
// Check if this model is standalone
if (STANDALONE_CURSOR_MODELS.includes(cursorId)) {
@@ -908,8 +909,8 @@ export function PhaseModelSelector({
// Render Cursor model item (no thinking level needed)
const renderCursorModelItem = (model: (typeof CURSOR_MODELS)[0]) => {
const modelValue = stripProviderPrefix(model.id);
const isSelected = selectedModel === modelValue;
// With canonical IDs, store the full prefixed ID
const isSelected = selectedModel === model.id;
const isFavorite = favoriteModels.includes(model.id);
return (
@@ -917,7 +918,7 @@ export function PhaseModelSelector({
key={model.id}
value={model.label}
onSelect={() => {
onChange({ model: modelValue as CursorModelId });
onChange({ model: model.id as CursorModelId });
setOpen(false);
}}
className="group flex items-center justify-between py-2"
@@ -1458,7 +1459,7 @@ export function PhaseModelSelector({
return favorites.map((model) => {
// Check if this favorite is part of a grouped model
if (model.provider === 'cursor') {
const cursorId = stripProviderPrefix(model.id) as CursorModelId;
const cursorId = model.id as CursorModelId;
const group = getModelGroup(cursorId);
if (group) {
// Skip if we already rendered this group

View File

@@ -14,16 +14,8 @@ import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Checkbox } from '@/components/ui/checkbox';
import { cn } from '@/lib/utils';
import {
Bot,
RefreshCw,
Loader2,
Users,
ExternalLink,
Globe,
FolderOpen,
Sparkles,
} from 'lucide-react';
import { Bot, RefreshCw, Users, ExternalLink, Globe, FolderOpen, Sparkles } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { useSubagents } from './hooks/use-subagents';
import { useSubagentsSettings } from './hooks/use-subagents-settings';
import { SubagentCard } from './subagent-card';
@@ -178,11 +170,7 @@ export function SubagentsSection() {
title="Refresh agents from disk"
className="gap-1.5 h-7 px-2 text-xs"
>
{isLoadingAgents ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
{isLoadingAgents ? <Spinner size="xs" /> : <RefreshCw className="h-3.5 w-3.5" />}
Refresh
</Button>
</div>

View File

@@ -92,7 +92,8 @@ export function CursorModelConfiguration({
<div className="grid gap-3">
{availableModels.map((model) => {
const isEnabled = enabledCursorModels.includes(model.id);
const isAuto = model.id === 'auto';
// With canonical IDs, 'auto' becomes 'cursor-auto'
const isAuto = model.id === 'cursor-auto';
return (
<div

View File

@@ -4,6 +4,7 @@ import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { Shield, ShieldCheck, ShieldAlert, ChevronDown, Copy, Check } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import type { CursorStatus } from '../hooks/use-cursor-status';
import type { PermissionsData } from '../hooks/use-cursor-permissions';
@@ -118,7 +119,7 @@ export function CursorPermissionsSection({
{isLoadingPermissions ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin w-6 h-6 border-2 border-brand-500 border-t-transparent rounded-full" />
<Spinner size="lg" />
</div>
) : (
<>

View File

@@ -9,7 +9,8 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Terminal, Cloud, Cpu, Brain, Github, Loader2, KeyRound, ShieldCheck } from 'lucide-react';
import { Terminal, Cloud, Cpu, Brain, Github, KeyRound, ShieldCheck } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import { Input } from '@/components/ui/input';
import type {
@@ -500,7 +501,7 @@ export function OpencodeModelConfiguration({
</p>
{isLoadingDynamicModels && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Loader2 className="w-3 h-3 animate-spin" />
<Spinner size="xs" />
<span>Discovering...</span>
</div>
)}

View File

@@ -2,6 +2,7 @@ import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Slider } from '@/components/ui/slider';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
@@ -9,12 +10,20 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { SquareTerminal } from 'lucide-react';
import {
SquareTerminal,
RefreshCw,
Terminal,
SquarePlus,
SplitSquareHorizontal,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAppStore } from '@/store/app-store';
import { toast } from 'sonner';
import { TERMINAL_FONT_OPTIONS } from '@/config/terminal-themes';
import { DEFAULT_FONT_VALUE } from '@/config/ui-font-options';
import { useAvailableTerminals } from '@/components/views/board-view/worktree-panel/hooks/use-available-terminals';
import { getTerminalIcon } from '@/components/icons/terminal-icons';
export function TerminalSection() {
const {
@@ -25,6 +34,9 @@ export function TerminalSection() {
setTerminalScrollbackLines,
setTerminalLineHeight,
setTerminalDefaultFontSize,
defaultTerminalId,
setDefaultTerminalId,
setOpenTerminalMode,
} = useAppStore();
const {
@@ -34,8 +46,12 @@ export function TerminalSection() {
scrollbackLines,
lineHeight,
defaultFontSize,
openTerminalMode,
} = terminalState;
// Get available external terminals
const { terminals, isRefreshing, refresh } = useAvailableTerminals();
return (
<div
className={cn(
@@ -58,6 +74,103 @@ export function TerminalSection() {
</p>
</div>
<div className="p-6 space-y-6">
{/* Default External Terminal */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-foreground font-medium">Default External Terminal</Label>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={refresh}
disabled={isRefreshing}
title="Refresh available terminals"
aria-label="Refresh available terminals"
>
<RefreshCw className={cn('w-3.5 h-3.5', isRefreshing && 'animate-spin')} />
</Button>
</div>
<p className="text-xs text-muted-foreground">
Terminal to use when selecting "Open in Terminal" from the worktree menu
</p>
<Select
value={defaultTerminalId ?? 'integrated'}
onValueChange={(value) => {
setDefaultTerminalId(value === 'integrated' ? null : value);
toast.success(
value === 'integrated'
? 'Integrated terminal set as default'
: 'Default terminal changed'
);
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a terminal" />
</SelectTrigger>
<SelectContent>
<SelectItem value="integrated">
<span className="flex items-center gap-2">
<Terminal className="w-4 h-4" />
Integrated Terminal
</span>
</SelectItem>
{terminals.map((terminal) => {
const TerminalIcon = getTerminalIcon(terminal.id);
return (
<SelectItem key={terminal.id} value={terminal.id}>
<span className="flex items-center gap-2">
<TerminalIcon className="w-4 h-4" />
{terminal.name}
</span>
</SelectItem>
);
})}
</SelectContent>
</Select>
{terminals.length === 0 && !isRefreshing && (
<p className="text-xs text-muted-foreground italic">
No external terminals detected. Click refresh to re-scan.
</p>
)}
</div>
{/* Default Open Mode */}
<div className="space-y-3">
<Label className="text-foreground font-medium">Default Open Mode</Label>
<p className="text-xs text-muted-foreground">
How to open the integrated terminal when using "Open in Terminal" from the worktree menu
</p>
<Select
value={openTerminalMode}
onValueChange={(value: 'newTab' | 'split') => {
setOpenTerminalMode(value);
toast.success(
value === 'newTab'
? 'New terminals will open in new tabs'
: 'New terminals will split the current tab'
);
}}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="newTab">
<span className="flex items-center gap-2">
<SquarePlus className="w-4 h-4" />
New Tab
</span>
</SelectItem>
<SelectItem value="split">
<span className="flex items-center gap-2">
<SplitSquareHorizontal className="w-4 h-4" />
Split Current Tab
</span>
</SelectItem>
</SelectContent>
</Select>
</div>
{/* Font Family */}
<div className="space-y-3">
<Label className="text-foreground font-medium">Font Family</Label>

View File

@@ -1,6 +1,7 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Download, Loader2, AlertCircle } from 'lucide-react';
import { Download, AlertCircle } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { CopyableCommandField } from './copyable-command-field';
import { TerminalOutput } from './terminal-output';
@@ -59,7 +60,7 @@ export function CliInstallationCard({
>
{isInstalling ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
<Spinner size="sm" className="mr-2" />
Installing...
</>
) : (

View File

@@ -1,4 +1,5 @@
import { CheckCircle2, XCircle, Loader2, AlertCircle } from 'lucide-react';
import { CheckCircle2, XCircle, AlertCircle } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
interface StatusBadgeProps {
status:
@@ -34,7 +35,7 @@ export function StatusBadge({ status, label }: StatusBadgeProps) {
};
case 'checking':
return {
icon: <Loader2 className="w-4 h-4 animate-spin" />,
icon: <Spinner size="sm" />,
className: 'bg-yellow-500/10 text-yellow-500 border-yellow-500/20',
};
case 'unverified':

View File

@@ -14,7 +14,6 @@ import { useAppStore } from '@/store/app-store';
import { getElectronAPI } from '@/lib/electron';
import {
CheckCircle2,
Loader2,
Key,
ArrowRight,
ArrowLeft,
@@ -27,6 +26,7 @@ import {
XCircle,
Trash2,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { toast } from 'sonner';
import { StatusBadge, TerminalOutput } from '../components';
import { useCliStatus, useCliInstallation, useTokenSave } from '../hooks';
@@ -330,7 +330,7 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps
Authentication Methods
</CardTitle>
<Button variant="ghost" size="sm" onClick={checkStatus} disabled={isChecking}>
<RefreshCw className={`w-4 h-4 ${isChecking ? 'animate-spin' : ''}`} />
{isChecking ? <Spinner size="sm" /> : <RefreshCw className="w-4 h-4" />}
</Button>
</div>
<CardDescription>
@@ -412,7 +412,7 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps
>
{isInstalling ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
<Spinner size="sm" className="mr-2" />
Installing...
</>
) : (
@@ -435,7 +435,7 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps
{/* CLI Verification Status */}
{cliVerificationStatus === 'verifying' && (
<div className="flex items-center gap-3 p-4 rounded-lg bg-blue-500/10 border border-blue-500/20">
<Loader2 className="w-5 h-5 text-blue-500 animate-spin" />
<Spinner size="md" />
<div>
<p className="font-medium text-foreground">Verifying CLI authentication...</p>
<p className="text-sm text-muted-foreground">Running a test query</p>
@@ -494,7 +494,7 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps
>
{cliVerificationStatus === 'verifying' ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
<Spinner size="sm" className="mr-2" />
Verifying...
</>
) : cliVerificationStatus === 'error' ? (
@@ -574,7 +574,7 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps
>
{isSavingApiKey ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
<Spinner size="sm" className="mr-2" />
Saving...
</>
) : (
@@ -589,11 +589,7 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps
className="border-red-500/50 text-red-500 hover:bg-red-500/10 hover:text-red-400"
data-testid="delete-anthropic-key-button"
>
{isDeletingApiKey ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Trash2 className="w-4 h-4" />
)}
{isDeletingApiKey ? <Spinner size="sm" /> : <Trash2 className="w-4 h-4" />}
</Button>
)}
</div>
@@ -602,7 +598,7 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps
{/* API Key Verification Status */}
{apiKeyVerificationStatus === 'verifying' && (
<div className="flex items-center gap-3 p-4 rounded-lg bg-blue-500/10 border border-blue-500/20">
<Loader2 className="w-5 h-5 text-blue-500 animate-spin" />
<Spinner size="md" />
<div>
<p className="font-medium text-foreground">Verifying API key...</p>
<p className="text-sm text-muted-foreground">Running a test query</p>
@@ -642,7 +638,7 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps
>
{apiKeyVerificationStatus === 'verifying' ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
<Spinner size="sm" className="mr-2" />
Verifying...
</>
) : apiKeyVerificationStatus === 'error' ? (

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