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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,5 +7,5 @@ export { useBoardEffects } from './use-board-effects';
export { useBoardBackground } from './use-board-background';
export { useBoardPersistence } from './use-board-persistence';
export { useFollowUpState } from './use-follow-up-state';
export { useSelectionMode } from './use-selection-mode';
export { useSelectionMode, type SelectionTarget } from './use-selection-mode';
export { useListViewState } from './use-list-view-state';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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