mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-02 08:33:36 +00:00
Merge branch 'v0.13.0rc' into feat/react-query
Merged latest changes from v0.13.0rc into feat/react-query while preserving React Query migration. Key merge decisions: - Kept React Query hooks for data fetching (useRunningAgents, useStopFeature, etc.) - Added backlog plan handling to running-agents-view stop functionality - Imported both SkeletonPulse and Spinner for CLI status components - Used Spinner for refresh buttons across all settings sections - Preserved isBacklogPlan check in agent-output-modal TaskProgressPanel - Added handleOpenInIntegratedTerminal to worktree actions while keeping React Query mutations
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Wand2, GitBranch } from 'lucide-react';
|
||||
import { Wand2, GitBranch, ClipboardCheck } from 'lucide-react';
|
||||
import { UsagePopover } from '@/components/usage-popover';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useSetupStore } from '@/store/setup-store';
|
||||
import { useIsMobile } from '@/hooks/use-media-query';
|
||||
import { useIsTablet } from '@/hooks/use-media-query';
|
||||
import { AutoModeSettingsPopover } from './dialogs/auto-mode-settings-popover';
|
||||
import { WorktreeSettingsPopover } from './dialogs/worktree-settings-popover';
|
||||
import { PlanSettingsPopover } from './dialogs/plan-settings-popover';
|
||||
@@ -25,6 +25,8 @@ interface BoardHeaderProps {
|
||||
isAutoModeRunning: boolean;
|
||||
onAutoModeToggle: (enabled: boolean) => void;
|
||||
onOpenPlanDialog: () => void;
|
||||
hasPendingPlan?: boolean;
|
||||
onOpenPendingPlan?: () => void;
|
||||
isMounted: boolean;
|
||||
// Search bar props
|
||||
searchQuery: string;
|
||||
@@ -50,6 +52,8 @@ export function BoardHeader({
|
||||
isAutoModeRunning,
|
||||
onAutoModeToggle,
|
||||
onOpenPlanDialog,
|
||||
hasPendingPlan,
|
||||
onOpenPendingPlan,
|
||||
isMounted,
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
@@ -104,7 +108,10 @@ export function BoardHeader({
|
||||
// Show if Codex is authenticated (CLI or API key)
|
||||
const showCodexUsage = !!codexAuthStatus?.authenticated;
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
// State for mobile actions panel
|
||||
const [showActionsPanel, setShowActionsPanel] = useState(false);
|
||||
|
||||
const isTablet = useIsTablet();
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-5 p-4 border-b border-border bg-glass backdrop-blur-md">
|
||||
@@ -121,11 +128,13 @@ export function BoardHeader({
|
||||
</div>
|
||||
<div className="flex gap-4 items-center">
|
||||
{/* Usage Popover - show if either provider is authenticated, only on desktop */}
|
||||
{isMounted && !isMobile && (showClaudeUsage || showCodexUsage) && <UsagePopover />}
|
||||
{isMounted && !isTablet && (showClaudeUsage || showCodexUsage) && <UsagePopover />}
|
||||
|
||||
{/* Mobile view: show hamburger menu with all controls */}
|
||||
{isMounted && isMobile && (
|
||||
{/* Tablet/Mobile view: show hamburger menu with all controls */}
|
||||
{isMounted && isTablet && (
|
||||
<HeaderMobileMenu
|
||||
isOpen={showActionsPanel}
|
||||
onToggle={() => setShowActionsPanel(!showActionsPanel)}
|
||||
isWorktreePanelVisible={isWorktreePanelVisible}
|
||||
onWorktreePanelToggle={handleWorktreePanelToggle}
|
||||
maxConcurrency={maxConcurrency}
|
||||
@@ -142,7 +151,7 @@ export function BoardHeader({
|
||||
|
||||
{/* Desktop view: show full controls */}
|
||||
{/* Worktrees Toggle - only show after mount to prevent hydration issues */}
|
||||
{isMounted && !isMobile && (
|
||||
{isMounted && !isTablet && (
|
||||
<div className={controlContainerClass} data-testid="worktrees-toggle-container">
|
||||
<GitBranch className="w-4 h-4 text-muted-foreground" />
|
||||
<Label
|
||||
@@ -165,7 +174,7 @@ export function BoardHeader({
|
||||
)}
|
||||
|
||||
{/* Auto Mode Toggle - only show after mount to prevent hydration issues */}
|
||||
{isMounted && !isMobile && (
|
||||
{isMounted && !isTablet && (
|
||||
<div className={controlContainerClass} data-testid="auto-mode-toggle-container">
|
||||
<Label
|
||||
htmlFor="auto-mode-toggle"
|
||||
@@ -189,9 +198,18 @@ export function BoardHeader({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Plan Button with Settings - only show on desktop, mobile has it in the menu */}
|
||||
{isMounted && !isMobile && (
|
||||
{/* Plan Button with Settings - only show on desktop, tablet/mobile has it in the panel */}
|
||||
{isMounted && !isTablet && (
|
||||
<div className={controlContainerClass} data-testid="plan-button-container">
|
||||
{hasPendingPlan && (
|
||||
<button
|
||||
onClick={onOpenPendingPlan || onOpenPlanDialog}
|
||||
className="flex items-center gap-1.5 text-emerald-500 hover:text-emerald-400 transition-colors"
|
||||
data-testid="plan-review-button"
|
||||
>
|
||||
<ClipboardCheck className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onOpenPlanDialog}
|
||||
className="flex items-center gap-1.5 hover:text-foreground transition-colors"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useRef, useEffect } from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Search, X, Loader2 } from 'lucide-react';
|
||||
import { Search, X } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
|
||||
interface BoardSearchBarProps {
|
||||
searchQuery: string;
|
||||
@@ -75,7 +76,7 @@ export function BoardSearchBar({
|
||||
title="Creating App Specification"
|
||||
data-testid="spec-creation-badge"
|
||||
>
|
||||
<Loader2 className="w-3 h-3 animate-spin text-brand-500 shrink-0" />
|
||||
<Spinner size="xs" className="shrink-0" />
|
||||
<span className="text-xs font-medium text-brand-500 whitespace-nowrap">
|
||||
Creating spec
|
||||
</span>
|
||||
|
||||
@@ -10,16 +10,8 @@ import {
|
||||
} from '@/lib/agent-context-parser';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { AutoModeEvent } from '@/types/electron';
|
||||
import {
|
||||
Brain,
|
||||
ListTodo,
|
||||
Sparkles,
|
||||
Expand,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
Loader2,
|
||||
Wrench,
|
||||
} from 'lucide-react';
|
||||
import { Brain, ListTodo, Sparkles, Expand, CheckCircle2, Circle, Wrench } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { SummaryDialog } from './summary-dialog';
|
||||
import { getProviderIconForModel } from '@/components/ui/provider-icon';
|
||||
@@ -303,7 +295,7 @@ export function AgentInfoPanel({
|
||||
{todo.status === 'completed' ? (
|
||||
<CheckCircle2 className="w-2.5 h-2.5 text-[var(--status-success)] shrink-0" />
|
||||
) : todo.status === 'in_progress' ? (
|
||||
<Loader2 className="w-2.5 h-2.5 text-[var(--status-warning)] animate-spin shrink-0" />
|
||||
<Spinner size="xs" className="w-2.5 h-2.5 shrink-0" />
|
||||
) : (
|
||||
<Circle className="w-2.5 h-2.5 text-muted-foreground/50 shrink-0" />
|
||||
)}
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
import {
|
||||
GripVertical,
|
||||
Edit,
|
||||
Loader2,
|
||||
Trash2,
|
||||
FileText,
|
||||
MoreVertical,
|
||||
@@ -21,6 +20,7 @@ import {
|
||||
ChevronUp,
|
||||
GitFork,
|
||||
} from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { CountUpTimer } from '@/components/ui/count-up-timer';
|
||||
import { formatModelName, DEFAULT_MODEL } from '@/lib/agent-context-parser';
|
||||
import { DeleteConfirmDialog } from '@/components/ui/delete-confirm-dialog';
|
||||
@@ -65,7 +65,7 @@ export function CardHeaderSection({
|
||||
{isCurrentAutoTask && !isSelectionMode && (
|
||||
<div className="absolute top-2 right-2 flex items-center gap-1">
|
||||
<div className="flex items-center justify-center gap-2 bg-[var(--status-in-progress)]/15 border border-[var(--status-in-progress)]/50 rounded-md px-2 py-0.5">
|
||||
<Loader2 className="w-3.5 h-3.5 text-[var(--status-in-progress)] animate-spin" />
|
||||
<Spinner size="xs" />
|
||||
{feature.startedAt && (
|
||||
<CountUpTimer
|
||||
startedAt={feature.startedAt}
|
||||
@@ -324,7 +324,7 @@ export function CardHeaderSection({
|
||||
<div className="flex-1 min-w-0 overflow-hidden">
|
||||
{feature.titleGenerating ? (
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<Loader2 className="w-3 h-3 animate-spin text-muted-foreground" />
|
||||
<Spinner size="xs" />
|
||||
<span className="text-xs text-muted-foreground italic">Generating title...</span>
|
||||
</div>
|
||||
) : feature.title ? (
|
||||
|
||||
@@ -65,6 +65,7 @@ interface KanbanCardProps {
|
||||
isSelectionMode?: boolean;
|
||||
isSelected?: boolean;
|
||||
onToggleSelect?: () => void;
|
||||
selectionTarget?: 'backlog' | 'waiting_approval' | null;
|
||||
}
|
||||
|
||||
export const KanbanCard = memo(function KanbanCard({
|
||||
@@ -96,6 +97,7 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
isSelectionMode = false,
|
||||
isSelected = false,
|
||||
onToggleSelect,
|
||||
selectionTarget = null,
|
||||
}: KanbanCardProps) {
|
||||
const { useWorktrees, currentProject } = useAppStore();
|
||||
const [isLifted, setIsLifted] = useState(false);
|
||||
@@ -125,8 +127,8 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
|
||||
const cardStyle = getCardBorderStyle(cardBorderEnabled, cardBorderOpacity);
|
||||
|
||||
// Only allow selection for backlog features
|
||||
const isSelectable = isSelectionMode && feature.status === 'backlog';
|
||||
// Only allow selection for features matching the selection target
|
||||
const isSelectable = isSelectionMode && feature.status === selectionTarget;
|
||||
|
||||
const wrapperClasses = cn(
|
||||
'relative select-none outline-none touch-none transition-transform duration-200 ease-out',
|
||||
@@ -180,7 +182,7 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
|
||||
{/* Category row with selection checkbox */}
|
||||
<div className="px-3 pt-3 flex items-center gap-2">
|
||||
{isSelectionMode && !isOverlay && feature.status === 'backlog' && (
|
||||
{isSelectable && !isOverlay && (
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => onToggleSelect?.()}
|
||||
|
||||
@@ -411,7 +411,6 @@ export const ListView = memo(function ListView({
|
||||
feature={feature}
|
||||
handlers={createHandlers(feature)}
|
||||
isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
|
||||
pipelineConfig={pipelineConfig}
|
||||
isSelected={selectedFeatureIds.has(feature.id)}
|
||||
showCheckbox={isSelectionMode}
|
||||
onToggleSelect={() => onToggleFeatureSelection?.(feature.id)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Pencil, X, CheckSquare, Trash2 } from 'lucide-react';
|
||||
import { Pencil, X, CheckSquare, Trash2, CheckCircle2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -11,13 +11,17 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
export type SelectionActionMode = 'backlog' | 'waiting_approval';
|
||||
|
||||
interface SelectionActionBarProps {
|
||||
selectedCount: number;
|
||||
totalCount: number;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onEdit?: () => void;
|
||||
onDelete?: () => void;
|
||||
onVerify?: () => void;
|
||||
onClear: () => void;
|
||||
onSelectAll: () => void;
|
||||
mode?: SelectionActionMode;
|
||||
}
|
||||
|
||||
export function SelectionActionBar({
|
||||
@@ -25,10 +29,13 @@ export function SelectionActionBar({
|
||||
totalCount,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onVerify,
|
||||
onClear,
|
||||
onSelectAll,
|
||||
mode = 'backlog',
|
||||
}: SelectionActionBarProps) {
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [showVerifyDialog, setShowVerifyDialog] = useState(false);
|
||||
|
||||
const allSelected = selectedCount === totalCount && totalCount > 0;
|
||||
|
||||
@@ -38,7 +45,16 @@ export function SelectionActionBar({
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
setShowDeleteDialog(false);
|
||||
onDelete();
|
||||
onDelete?.();
|
||||
};
|
||||
|
||||
const handleVerifyClick = () => {
|
||||
setShowVerifyDialog(true);
|
||||
};
|
||||
|
||||
const handleConfirmVerify = () => {
|
||||
setShowVerifyDialog(false);
|
||||
onVerify?.();
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -54,36 +70,56 @@ export function SelectionActionBar({
|
||||
>
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{selectedCount === 0
|
||||
? 'Select features to edit'
|
||||
? mode === 'waiting_approval'
|
||||
? 'Select features to verify'
|
||||
: 'Select features to edit'
|
||||
: `${selectedCount} feature${selectedCount !== 1 ? 's' : ''} selected`}
|
||||
</span>
|
||||
|
||||
<div className="h-4 w-px bg-border" />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={onEdit}
|
||||
disabled={selectedCount === 0}
|
||||
className="h-8 bg-brand-500 hover:bg-brand-600 disabled:opacity-50"
|
||||
data-testid="selection-edit-button"
|
||||
>
|
||||
<Pencil className="w-4 h-4 mr-1.5" />
|
||||
Edit Selected
|
||||
</Button>
|
||||
{mode === 'backlog' && (
|
||||
<>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={onEdit}
|
||||
disabled={selectedCount === 0}
|
||||
className="h-8 bg-brand-500 hover:bg-brand-600 disabled:opacity-50"
|
||||
data-testid="selection-edit-button"
|
||||
>
|
||||
<Pencil className="w-4 h-4 mr-1.5" />
|
||||
Edit Selected
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDeleteClick}
|
||||
disabled={selectedCount === 0}
|
||||
className="h-8 text-destructive hover:text-destructive hover:bg-destructive/10 disabled:opacity-50"
|
||||
data-testid="selection-delete-button"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-1.5" />
|
||||
Delete
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDeleteClick}
|
||||
disabled={selectedCount === 0}
|
||||
className="h-8 text-destructive hover:text-destructive hover:bg-destructive/10 disabled:opacity-50"
|
||||
data-testid="selection-delete-button"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-1.5" />
|
||||
Delete
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{mode === 'waiting_approval' && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleVerifyClick}
|
||||
disabled={selectedCount === 0}
|
||||
className="h-8 bg-green-600 hover:bg-green-700 disabled:opacity-50"
|
||||
data-testid="selection-verify-button"
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4 mr-1.5" />
|
||||
Verify Selected
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!allSelected && (
|
||||
<Button
|
||||
@@ -146,6 +182,42 @@ export function SelectionActionBar({
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Verify Confirmation Dialog */}
|
||||
<Dialog open={showVerifyDialog} onOpenChange={setShowVerifyDialog}>
|
||||
<DialogContent data-testid="bulk-verify-confirmation-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-green-600">
|
||||
<CheckCircle2 className="w-5 h-5" />
|
||||
Verify Selected Features?
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to mark {selectedCount} feature
|
||||
{selectedCount !== 1 ? 's' : ''} as verified?
|
||||
<span className="block mt-2 text-muted-foreground">
|
||||
This will move them to the Verified column.
|
||||
</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setShowVerifyDialog(false)}
|
||||
data-testid="cancel-bulk-verify-button"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
onClick={handleConfirmVerify}
|
||||
data-testid="confirm-bulk-verify-button"
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4 mr-2" />
|
||||
Verify
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { CategoryAutocomplete } from '@/components/ui/category-autocomplete';
|
||||
import { DependencySelector } from '@/components/ui/dependency-selector';
|
||||
import {
|
||||
DescriptionImageDropZone,
|
||||
FeatureImagePath as DescriptionImagePath,
|
||||
@@ -99,6 +100,7 @@ type FeatureData = {
|
||||
planningMode: PlanningMode;
|
||||
requirePlanApproval: boolean;
|
||||
dependencies?: string[];
|
||||
childDependencies?: string[]; // Feature IDs that should depend on this feature
|
||||
workMode: WorkMode;
|
||||
};
|
||||
|
||||
@@ -168,7 +170,7 @@ export function AddFeatureDialog({
|
||||
const [priority, setPriority] = useState(2);
|
||||
|
||||
// Model selection state
|
||||
const [modelEntry, setModelEntry] = useState<PhaseModelEntry>({ model: 'opus' });
|
||||
const [modelEntry, setModelEntry] = useState<PhaseModelEntry>({ model: 'claude-opus' });
|
||||
|
||||
// Check if current model supports planning mode (Claude/Anthropic only)
|
||||
const modelSupportsPlanningMode = isClaudeModel(modelEntry.model);
|
||||
@@ -188,6 +190,10 @@ export function AddFeatureDialog({
|
||||
const [ancestors, setAncestors] = useState<AncestorContext[]>([]);
|
||||
const [selectedAncestorIds, setSelectedAncestorIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// Dependency selection state (not in spawn mode)
|
||||
const [parentDependencies, setParentDependencies] = useState<string[]>([]);
|
||||
const [childDependencies, setChildDependencies] = useState<string[]>([]);
|
||||
|
||||
// Get defaults from store
|
||||
const { defaultPlanningMode, defaultRequirePlanApproval, useWorktrees, defaultFeatureModel } =
|
||||
useAppStore();
|
||||
@@ -224,6 +230,10 @@ export function AddFeatureDialog({
|
||||
setAncestors([]);
|
||||
setSelectedAncestorIds(new Set());
|
||||
}
|
||||
|
||||
// Reset dependency selections
|
||||
setParentDependencies([]);
|
||||
setChildDependencies([]);
|
||||
}
|
||||
}, [
|
||||
open,
|
||||
@@ -291,6 +301,16 @@ export function AddFeatureDialog({
|
||||
}
|
||||
}
|
||||
|
||||
// Determine final dependencies
|
||||
// In spawn mode, use parent feature as dependency
|
||||
// Otherwise, use manually selected parent dependencies
|
||||
const finalDependencies =
|
||||
isSpawnMode && parentFeature
|
||||
? [parentFeature.id]
|
||||
: parentDependencies.length > 0
|
||||
? parentDependencies
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
title,
|
||||
category: finalCategory,
|
||||
@@ -306,7 +326,8 @@ export function AddFeatureDialog({
|
||||
priority,
|
||||
planningMode,
|
||||
requirePlanApproval,
|
||||
dependencies: isSpawnMode && parentFeature ? [parentFeature.id] : undefined,
|
||||
dependencies: finalDependencies,
|
||||
childDependencies: childDependencies.length > 0 ? childDependencies : undefined,
|
||||
workMode,
|
||||
};
|
||||
};
|
||||
@@ -331,6 +352,8 @@ export function AddFeatureDialog({
|
||||
setPreviewMap(new Map());
|
||||
setDescriptionError(false);
|
||||
setDescriptionHistory([]);
|
||||
setParentDependencies([]);
|
||||
setChildDependencies([]);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
@@ -641,6 +664,38 @@ export function AddFeatureDialog({
|
||||
testIdPrefix="feature-work-mode"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Dependencies - only show when not in spawn mode */}
|
||||
{!isSpawnMode && allFeatures.length > 0 && (
|
||||
<div className="pt-2 space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Parent Dependencies (this feature depends on)
|
||||
</Label>
|
||||
<DependencySelector
|
||||
value={parentDependencies}
|
||||
onChange={setParentDependencies}
|
||||
features={allFeatures}
|
||||
type="parent"
|
||||
placeholder="Select features this depends on..."
|
||||
data-testid="add-feature-parent-deps"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Child Dependencies (features that depend on this)
|
||||
</Label>
|
||||
<DependencySelector
|
||||
value={childDependencies}
|
||||
onChange={setChildDependencies}
|
||||
features={allFeatures}
|
||||
type="child"
|
||||
placeholder="Select features that will depend on this..."
|
||||
data-testid="add-feature-child-deps"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Loader2, List, FileText, GitBranch, ClipboardList } from 'lucide-react';
|
||||
import { List, FileText, GitBranch, ClipboardList } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { LogViewer } from '@/components/ui/log-viewer';
|
||||
import { GitDiffPanel } from '@/components/ui/git-diff-panel';
|
||||
@@ -41,6 +42,8 @@ export function AgentOutputModal({
|
||||
onNumberKeyPress,
|
||||
projectPath: projectPathProp,
|
||||
}: AgentOutputModalProps) {
|
||||
const isBacklogPlan = featureId.startsWith('backlog-plan:');
|
||||
|
||||
// Resolve project path - prefer prop, fallback to window.__currentProject
|
||||
const resolvedProjectPath = projectPathProp || (window as any).__currentProject?.path || '';
|
||||
|
||||
@@ -86,7 +89,7 @@ export function AgentOutputModal({
|
||||
if (!open) return;
|
||||
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode) return;
|
||||
if (!api?.autoMode || isBacklogPlan) return;
|
||||
|
||||
console.log('[AgentOutputModal] Subscribing to events for featureId:', featureId);
|
||||
|
||||
@@ -247,7 +250,43 @@ export function AgentOutputModal({
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [open, featureId]);
|
||||
}, [open, featureId, isBacklogPlan]);
|
||||
|
||||
// Listen to backlog plan events and update output
|
||||
useEffect(() => {
|
||||
if (!open || !isBacklogPlan) return;
|
||||
|
||||
const api = getElectronAPI();
|
||||
if (!api?.backlogPlan) return;
|
||||
|
||||
const unsubscribe = api.backlogPlan.onEvent((event: any) => {
|
||||
if (!event?.type) return;
|
||||
|
||||
let newContent = '';
|
||||
switch (event.type) {
|
||||
case 'backlog_plan_progress':
|
||||
newContent = `\n🧭 ${event.content || 'Backlog plan progress update'}\n`;
|
||||
break;
|
||||
case 'backlog_plan_error':
|
||||
newContent = `\n❌ Backlog plan error: ${event.error || 'Unknown error'}\n`;
|
||||
break;
|
||||
case 'backlog_plan_complete':
|
||||
newContent = `\n✅ Backlog plan completed\n`;
|
||||
break;
|
||||
default:
|
||||
newContent = `\nℹ️ ${event.type}\n`;
|
||||
break;
|
||||
}
|
||||
|
||||
if (newContent) {
|
||||
setOutput((prev) => `${prev}${newContent}`);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [open, isBacklogPlan]);
|
||||
|
||||
// Handle scroll to detect if user scrolled up
|
||||
const handleScroll = () => {
|
||||
@@ -286,7 +325,7 @@ export function AgentOutputModal({
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 pr-8">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{featureStatus !== 'verified' && featureStatus !== 'waiting_approval' && (
|
||||
<Loader2 className="w-5 h-5 text-primary animate-spin" />
|
||||
<Spinner size="md" />
|
||||
)}
|
||||
Agent Output
|
||||
</DialogTitle>
|
||||
@@ -344,7 +383,7 @@ export function AgentOutputModal({
|
||||
</div>
|
||||
</div>
|
||||
<DialogDescription
|
||||
className="mt-1 max-h-24 overflow-y-auto break-words"
|
||||
className="mt-1 max-h-24 overflow-y-auto wrap-break-word"
|
||||
data-testid="agent-output-description"
|
||||
>
|
||||
{featureDescription}
|
||||
@@ -352,11 +391,13 @@ export function AgentOutputModal({
|
||||
</DialogHeader>
|
||||
|
||||
{/* Task Progress Panel - shows when tasks are being executed */}
|
||||
<TaskProgressPanel
|
||||
featureId={featureId}
|
||||
projectPath={resolvedProjectPath}
|
||||
className="flex-shrink-0 mx-3 my-2"
|
||||
/>
|
||||
{!isBacklogPlan && (
|
||||
<TaskProgressPanel
|
||||
featureId={featureId}
|
||||
projectPath={resolvedProjectPath}
|
||||
className="shrink-0 mx-3 my-2"
|
||||
/>
|
||||
)}
|
||||
|
||||
{effectiveViewMode === 'changes' ? (
|
||||
<div className="flex-1 min-h-0 sm:min-h-[200px] sm:max-h-[60vh] overflow-y-auto scrollbar-visible">
|
||||
@@ -370,7 +411,7 @@ export function AgentOutputModal({
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
<Loader2 className="w-6 h-6 animate-spin mr-2" />
|
||||
<Spinner size="lg" className="mr-2" />
|
||||
Loading...
|
||||
</div>
|
||||
)}
|
||||
@@ -384,11 +425,11 @@ export function AgentOutputModal({
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
className="flex-1 min-h-0 sm:min-h-[200px] sm:max-h-[60vh] overflow-y-auto bg-zinc-950 rounded-lg p-4 font-mono text-xs scrollbar-visible"
|
||||
className="flex-1 min-h-0 sm:min-h-[200px] sm:max-h-[60vh] overflow-y-auto bg-popover border border-border/50 rounded-lg p-4 font-mono text-xs scrollbar-visible"
|
||||
>
|
||||
{isLoading && !output ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
<Loader2 className="w-6 h-6 animate-spin mr-2" />
|
||||
<Spinner size="lg" className="mr-2" />
|
||||
Loading output...
|
||||
</div>
|
||||
) : !output ? (
|
||||
@@ -398,11 +439,13 @@ export function AgentOutputModal({
|
||||
) : effectiveViewMode === 'parsed' ? (
|
||||
<LogViewer output={output} />
|
||||
) : (
|
||||
<div className="whitespace-pre-wrap break-words text-zinc-300">{output}</div>
|
||||
<div className="whitespace-pre-wrap wrap-break-word text-foreground/80">
|
||||
{output}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground text-center flex-shrink-0">
|
||||
<div className="text-xs text-muted-foreground text-center shrink-0">
|
||||
{autoScrollRef.current
|
||||
? 'Auto-scrolling enabled'
|
||||
: 'Scroll to bottom to enable auto-scroll'}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -10,16 +11,8 @@ import {
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Loader2,
|
||||
Wand2,
|
||||
Check,
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
} from 'lucide-react';
|
||||
import { Wand2, Check, Plus, Pencil, Trash2, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -43,16 +36,6 @@ function normalizeEntry(entry: PhaseModelEntry | string): PhaseModelEntry {
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract model string from PhaseModelEntry or string
|
||||
*/
|
||||
function extractModel(entry: PhaseModelEntry | string): ModelAlias | CursorModelId {
|
||||
if (typeof entry === 'string') {
|
||||
return entry as ModelAlias | CursorModelId;
|
||||
}
|
||||
return entry.model;
|
||||
}
|
||||
|
||||
interface BacklogPlanDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
@@ -80,6 +63,7 @@ export function BacklogPlanDialog({
|
||||
setIsGeneratingPlan,
|
||||
currentBranch,
|
||||
}: BacklogPlanDialogProps) {
|
||||
const logger = createLogger('BacklogPlanDialog');
|
||||
const [mode, setMode] = useState<DialogMode>('input');
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [expandedChanges, setExpandedChanges] = useState<Set<number>>(new Set());
|
||||
@@ -110,11 +94,17 @@ export function BacklogPlanDialog({
|
||||
|
||||
const api = getElectronAPI();
|
||||
if (!api?.backlogPlan) {
|
||||
logger.warn('Backlog plan API not available');
|
||||
toast.error('API not available');
|
||||
return;
|
||||
}
|
||||
|
||||
// Start generation in background
|
||||
logger.debug('Starting backlog plan generation', {
|
||||
projectPath,
|
||||
promptLength: prompt.length,
|
||||
hasModelOverride: Boolean(modelOverride),
|
||||
});
|
||||
setIsGeneratingPlan(true);
|
||||
|
||||
// Use model override if set, otherwise use global default (extract model string from PhaseModelEntry)
|
||||
@@ -122,12 +112,20 @@ export function BacklogPlanDialog({
|
||||
const effectiveModel = effectiveModelEntry.model;
|
||||
const result = await api.backlogPlan.generate(projectPath, prompt, effectiveModel);
|
||||
if (!result.success) {
|
||||
logger.error('Backlog plan generation failed to start', {
|
||||
error: result.error,
|
||||
projectPath,
|
||||
});
|
||||
setIsGeneratingPlan(false);
|
||||
toast.error(result.error || 'Failed to start plan generation');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show toast and close dialog - generation runs in background
|
||||
logger.debug('Backlog plan generation started', {
|
||||
projectPath,
|
||||
model: effectiveModel,
|
||||
});
|
||||
toast.info('Generating plan... This will be ready soon!', {
|
||||
duration: 3000,
|
||||
});
|
||||
@@ -194,10 +192,15 @@ export function BacklogPlanDialog({
|
||||
currentBranch,
|
||||
]);
|
||||
|
||||
const handleDiscard = useCallback(() => {
|
||||
const handleDiscard = useCallback(async () => {
|
||||
setPendingPlanResult(null);
|
||||
setMode('input');
|
||||
}, [setPendingPlanResult]);
|
||||
|
||||
const api = getElectronAPI();
|
||||
if (api?.backlogPlan) {
|
||||
await api.backlogPlan.clear(projectPath);
|
||||
}
|
||||
}, [setPendingPlanResult, projectPath]);
|
||||
|
||||
const toggleChangeExpanded = (index: number) => {
|
||||
setExpandedChanges((prev) => {
|
||||
@@ -260,11 +263,11 @@ export function BacklogPlanDialog({
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Describe the changes you want to make to your backlog. The AI will analyze your
|
||||
current features and propose additions, updates, or deletions.
|
||||
Describe the changes you want to make across your features. The AI will analyze your
|
||||
current feature list and propose additions, updates, deletions, or restructuring.
|
||||
</div>
|
||||
<Textarea
|
||||
placeholder="e.g., Add authentication features with login, signup, and password reset. Also add a dashboard feature that depends on authentication."
|
||||
placeholder="e.g., Refactor onboarding into smaller features, add a dashboard feature that depends on authentication, and remove the legacy tour task."
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
className="min-h-[150px] resize-none"
|
||||
@@ -276,14 +279,13 @@ export function BacklogPlanDialog({
|
||||
</div>
|
||||
{isGeneratingPlan && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground bg-muted/50 rounded-lg p-3">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />A plan is currently being generated in
|
||||
the background...
|
||||
<Spinner size="sm" />A plan is currently being generated in the background...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'review':
|
||||
case 'review': {
|
||||
if (!pendingPlanResult) return null;
|
||||
|
||||
const additions = pendingPlanResult.changes.filter((c) => c.type === 'add');
|
||||
@@ -389,11 +391,12 @@ export function BacklogPlanDialog({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case 'applying':
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary mb-4" />
|
||||
<Spinner size="xl" className="mb-4" />
|
||||
<p className="text-muted-foreground">Applying changes...</p>
|
||||
</div>
|
||||
);
|
||||
@@ -402,7 +405,6 @@ export function BacklogPlanDialog({
|
||||
|
||||
// Get effective model entry (override or global default)
|
||||
const effectiveModelEntry = modelOverride || normalizeEntry(phaseModels.backlogPlanningModel);
|
||||
const effectiveModel = effectiveModelEntry.model;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
@@ -410,12 +412,12 @@ export function BacklogPlanDialog({
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Wand2 className="w-5 h-5 text-primary" />
|
||||
{mode === 'review' ? 'Review Plan' : 'Plan Backlog Changes'}
|
||||
{mode === 'review' ? 'Review Plan' : 'Plan Feature Changes'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{mode === 'review'
|
||||
? 'Select which changes to apply to your backlog'
|
||||
: 'Use AI to add, update, or remove features from your backlog'}
|
||||
? 'Select which changes to apply to your features'
|
||||
: 'Use AI to add, update, remove, or restructure your features'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -441,13 +443,13 @@ export function BacklogPlanDialog({
|
||||
<Button onClick={handleGenerate} disabled={!prompt.trim() || isGeneratingPlan}>
|
||||
{isGeneratingPlan ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
Generating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Wand2 className="w-4 h-4 mr-2" />
|
||||
Generate Plan
|
||||
Apply Changes
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { GitCommit, Loader2, Sparkles } from 'lucide-react';
|
||||
import { GitCommit, Sparkles } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { toast } from 'sonner';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
@@ -209,7 +210,7 @@ export function CommitWorktreeDialog({
|
||||
<Button onClick={handleCommit} disabled={isLoading || isGenerating || !message.trim()}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
Committing...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -13,7 +13,8 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { toast } from 'sonner';
|
||||
import { GitBranchPlus, Loader2 } from 'lucide-react';
|
||||
import { GitBranchPlus } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
|
||||
interface WorktreeInfo {
|
||||
path: string;
|
||||
@@ -133,7 +134,7 @@ export function CreateBranchDialog({
|
||||
<Button onClick={handleCreate} disabled={!branchName.trim() || isCreating}>
|
||||
{isCreating ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -13,7 +13,8 @@ import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { BranchAutocomplete } from '@/components/ui/branch-autocomplete';
|
||||
import { GitPullRequest, Loader2, ExternalLink } from 'lucide-react';
|
||||
import { GitPullRequest, ExternalLink } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { toast } from 'sonner';
|
||||
import { useWorktreeBranches } from '@/hooks/queries';
|
||||
@@ -384,7 +385,7 @@ export function CreatePRDialog({
|
||||
<Button onClick={handleCreate} disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { GitBranch, Loader2, AlertCircle } from 'lucide-react';
|
||||
import { GitBranch, AlertCircle } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
@@ -216,7 +217,7 @@ export function CreateWorktreeDialog({
|
||||
<Button onClick={handleCreate} disabled={isLoading || !branchName.trim()}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Loader2, Trash2, AlertTriangle, FileWarning } from 'lucide-react';
|
||||
import { Trash2, AlertTriangle, FileWarning } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
@@ -147,7 +148,7 @@ export function DeleteWorktreeDialog({
|
||||
<Button variant="destructive" onClick={handleDelete} disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
Deleting...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { CategoryAutocomplete } from '@/components/ui/category-autocomplete';
|
||||
import { DependencySelector } from '@/components/ui/dependency-selector';
|
||||
import {
|
||||
DescriptionImageDropZone,
|
||||
FeatureImagePath as DescriptionImagePath,
|
||||
@@ -27,6 +28,7 @@ import { toast } from 'sonner';
|
||||
import { cn, modelSupportsThinking } from '@/lib/utils';
|
||||
import { Feature, ModelAlias, ThinkingLevel, useAppStore, PlanningMode } from '@/store/app-store';
|
||||
import type { ReasoningEffort, PhaseModelEntry, DescriptionHistoryEntry } from '@automaker/types';
|
||||
import { migrateModelId } from '@automaker/types';
|
||||
import {
|
||||
TestingTabContent,
|
||||
PrioritySelector,
|
||||
@@ -63,6 +65,8 @@ interface EditFeatureDialogProps {
|
||||
priority: number;
|
||||
planningMode: PlanningMode;
|
||||
requirePlanApproval: boolean;
|
||||
dependencies?: string[];
|
||||
childDependencies?: string[]; // Feature IDs that should depend on this feature
|
||||
},
|
||||
descriptionHistorySource?: 'enhance' | 'edit',
|
||||
enhancementMode?: EnhancementMode,
|
||||
@@ -104,9 +108,9 @@ export function EditFeatureDialog({
|
||||
feature?.requirePlanApproval ?? false
|
||||
);
|
||||
|
||||
// Model selection state
|
||||
// Model selection state - migrate legacy model IDs to canonical format
|
||||
const [modelEntry, setModelEntry] = useState<PhaseModelEntry>(() => ({
|
||||
model: (feature?.model as ModelAlias) || 'opus',
|
||||
model: migrateModelId(feature?.model) || 'claude-opus',
|
||||
thinkingLevel: feature?.thinkingLevel || 'none',
|
||||
reasoningEffort: feature?.reasoningEffort || 'none',
|
||||
}));
|
||||
@@ -127,6 +131,21 @@ export function EditFeatureDialog({
|
||||
feature?.descriptionHistory ?? []
|
||||
);
|
||||
|
||||
// Dependency state
|
||||
const [parentDependencies, setParentDependencies] = useState<string[]>(
|
||||
feature?.dependencies ?? []
|
||||
);
|
||||
// Child dependencies are features that have this feature in their dependencies
|
||||
const [childDependencies, setChildDependencies] = useState<string[]>(() => {
|
||||
if (!feature) return [];
|
||||
return allFeatures.filter((f) => f.dependencies?.includes(feature.id)).map((f) => f.id);
|
||||
});
|
||||
// Track original child dependencies to detect changes
|
||||
const [originalChildDependencies, setOriginalChildDependencies] = useState<string[]>(() => {
|
||||
if (!feature) return [];
|
||||
return allFeatures.filter((f) => f.dependencies?.includes(feature.id)).map((f) => f.id);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setEditingFeature(feature);
|
||||
if (feature) {
|
||||
@@ -139,19 +158,29 @@ export function EditFeatureDialog({
|
||||
setDescriptionChangeSource(null);
|
||||
setPreEnhancementDescription(null);
|
||||
setLocalHistory(feature.descriptionHistory ?? []);
|
||||
// Reset model entry
|
||||
// Reset model entry - migrate legacy model IDs
|
||||
setModelEntry({
|
||||
model: (feature.model as ModelAlias) || 'opus',
|
||||
model: migrateModelId(feature.model) || 'claude-opus',
|
||||
thinkingLevel: feature.thinkingLevel || 'none',
|
||||
reasoningEffort: feature.reasoningEffort || 'none',
|
||||
});
|
||||
// Reset dependency state
|
||||
setParentDependencies(feature.dependencies ?? []);
|
||||
const childDeps = allFeatures
|
||||
.filter((f) => f.dependencies?.includes(feature.id))
|
||||
.map((f) => f.id);
|
||||
setChildDependencies(childDeps);
|
||||
setOriginalChildDependencies(childDeps);
|
||||
} else {
|
||||
setEditFeaturePreviewMap(new Map());
|
||||
setDescriptionChangeSource(null);
|
||||
setPreEnhancementDescription(null);
|
||||
setLocalHistory([]);
|
||||
setParentDependencies([]);
|
||||
setChildDependencies([]);
|
||||
setOriginalChildDependencies([]);
|
||||
}
|
||||
}, [feature]);
|
||||
}, [feature, allFeatures]);
|
||||
|
||||
const handleModelChange = (entry: PhaseModelEntry) => {
|
||||
setModelEntry(entry);
|
||||
@@ -180,6 +209,12 @@ export function EditFeatureDialog({
|
||||
// For 'custom' mode, use the specified branch name
|
||||
const finalBranchName = workMode === 'custom' ? editingFeature.branchName || '' : '';
|
||||
|
||||
// Check if child dependencies changed
|
||||
const childDepsChanged =
|
||||
childDependencies.length !== originalChildDependencies.length ||
|
||||
childDependencies.some((id) => !originalChildDependencies.includes(id)) ||
|
||||
originalChildDependencies.some((id) => !childDependencies.includes(id));
|
||||
|
||||
const updates = {
|
||||
title: editingFeature.title ?? '',
|
||||
category: editingFeature.category,
|
||||
@@ -195,6 +230,8 @@ export function EditFeatureDialog({
|
||||
planningMode,
|
||||
requirePlanApproval,
|
||||
workMode,
|
||||
dependencies: parentDependencies,
|
||||
childDependencies: childDepsChanged ? childDependencies : undefined,
|
||||
};
|
||||
|
||||
// Determine if description changed and what source to use
|
||||
@@ -547,6 +584,40 @@ export function EditFeatureDialog({
|
||||
testIdPrefix="edit-feature-work-mode"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Dependencies */}
|
||||
{allFeatures.length > 1 && (
|
||||
<div className="pt-2 space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Parent Dependencies (this feature depends on)
|
||||
</Label>
|
||||
<DependencySelector
|
||||
currentFeatureId={editingFeature.id}
|
||||
value={parentDependencies}
|
||||
onChange={setParentDependencies}
|
||||
features={allFeatures}
|
||||
type="parent"
|
||||
placeholder="Select features this depends on..."
|
||||
data-testid="edit-feature-parent-deps"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Child Dependencies (features that depend on this)
|
||||
</Label>
|
||||
<DependencySelector
|
||||
currentFeatureId={editingFeature.id}
|
||||
value={childDependencies}
|
||||
onChange={setChildDependencies}
|
||||
features={allFeatures}
|
||||
type="child"
|
||||
placeholder="Select features that depend on this..."
|
||||
data-testid="edit-feature-child-deps"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -126,7 +126,7 @@ export function MassEditDialog({
|
||||
});
|
||||
|
||||
// Field values
|
||||
const [model, setModel] = useState<ModelAlias>('sonnet');
|
||||
const [model, setModel] = useState<ModelAlias>('claude-sonnet');
|
||||
const [thinkingLevel, setThinkingLevel] = useState<ThinkingLevel>('none');
|
||||
const [planningMode, setPlanningMode] = useState<PlanningMode>('skip');
|
||||
const [requirePlanApproval, setRequirePlanApproval] = useState(false);
|
||||
@@ -160,7 +160,7 @@ export function MassEditDialog({
|
||||
skipTests: false,
|
||||
branchName: false,
|
||||
});
|
||||
setModel(getInitialValue(selectedFeatures, 'model', 'sonnet') as ModelAlias);
|
||||
setModel(getInitialValue(selectedFeatures, 'model', 'claude-sonnet') as ModelAlias);
|
||||
setThinkingLevel(getInitialValue(selectedFeatures, 'thinkingLevel', 'none') as ThinkingLevel);
|
||||
setPlanningMode(getInitialValue(selectedFeatures, 'planningMode', 'skip') as PlanningMode);
|
||||
setRequirePlanApproval(getInitialValue(selectedFeatures, 'requirePlanApproval', false));
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Loader2, GitMerge, AlertTriangle, CheckCircle2 } from 'lucide-react';
|
||||
import { GitMerge, AlertTriangle, CheckCircle2 } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
@@ -217,7 +218,7 @@ export function MergeWorktreeDialog({
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
Merging...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -14,7 +14,8 @@ import { Textarea } from '@/components/ui/textarea';
|
||||
import { Markdown } from '@/components/ui/markdown';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Feature } from '@/store/app-store';
|
||||
import { Check, RefreshCw, Edit2, Eye, Loader2 } from 'lucide-react';
|
||||
import { Check, RefreshCw, Edit2, Eye } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
|
||||
interface PlanApprovalDialogProps {
|
||||
open: boolean;
|
||||
@@ -171,7 +172,7 @@ export function PlanApprovalDialog({
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={handleReject} disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
) : (
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
@@ -190,7 +191,7 @@ export function PlanApprovalDialog({
|
||||
className="bg-green-600 hover:bg-green-700 text-white"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
) : (
|
||||
<Check className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
|
||||
@@ -2,18 +2,17 @@ import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Menu, Bot, Wand2, Settings2, GitBranch, Zap } from 'lucide-react';
|
||||
HeaderActionsPanel,
|
||||
HeaderActionsPanelTrigger,
|
||||
} from '@/components/ui/header-actions-panel';
|
||||
import { Bot, Wand2, Settings2, GitBranch, Zap } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { MobileUsageBar } from './mobile-usage-bar';
|
||||
|
||||
interface HeaderMobileMenuProps {
|
||||
// Panel visibility
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
// Worktree panel visibility
|
||||
isWorktreePanelVisible: boolean;
|
||||
onWorktreePanelToggle: (visible: boolean) => void;
|
||||
@@ -33,6 +32,8 @@ interface HeaderMobileMenuProps {
|
||||
}
|
||||
|
||||
export function HeaderMobileMenu({
|
||||
isOpen,
|
||||
onToggle,
|
||||
isWorktreePanelVisible,
|
||||
onWorktreePanelToggle,
|
||||
maxConcurrency,
|
||||
@@ -46,129 +47,122 @@ export function HeaderMobileMenu({
|
||||
showCodexUsage,
|
||||
}: HeaderMobileMenuProps) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
data-testid="header-mobile-menu-trigger"
|
||||
>
|
||||
<Menu className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-64">
|
||||
<>
|
||||
<HeaderActionsPanelTrigger isOpen={isOpen} onToggle={onToggle} />
|
||||
<HeaderActionsPanel isOpen={isOpen} onClose={onToggle} title="Board Controls">
|
||||
{/* Usage Bar - show if either provider is authenticated */}
|
||||
{(showClaudeUsage || showCodexUsage) && (
|
||||
<>
|
||||
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Usage
|
||||
</DropdownMenuLabel>
|
||||
</span>
|
||||
<MobileUsageBar showClaudeUsage={showClaudeUsage} showCodexUsage={showCodexUsage} />
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
|
||||
Controls
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{/* Controls Section */}
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Controls
|
||||
</span>
|
||||
|
||||
{/* Auto Mode Toggle */}
|
||||
<div
|
||||
className="flex items-center justify-between px-2 py-2 cursor-pointer hover:bg-accent rounded-sm"
|
||||
onClick={() => onAutoModeToggle(!isAutoModeRunning)}
|
||||
data-testid="mobile-auto-mode-toggle-container"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap
|
||||
className={cn(
|
||||
'w-4 h-4',
|
||||
isAutoModeRunning ? 'text-yellow-500' : 'text-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
<span className="text-sm font-medium">Auto Mode</span>
|
||||
{/* Auto Mode Toggle */}
|
||||
<div
|
||||
className="flex items-center justify-between p-3 cursor-pointer hover:bg-accent/50 rounded-lg border border-border/50 transition-colors"
|
||||
onClick={() => onAutoModeToggle(!isAutoModeRunning)}
|
||||
data-testid="mobile-auto-mode-toggle-container"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap
|
||||
className={cn(
|
||||
'w-4 h-4',
|
||||
isAutoModeRunning ? 'text-yellow-500' : 'text-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
<span className="text-sm font-medium">Auto Mode</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="mobile-auto-mode-toggle"
|
||||
checked={isAutoModeRunning}
|
||||
onCheckedChange={onAutoModeToggle}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
data-testid="mobile-auto-mode-toggle"
|
||||
/>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenAutoModeSettings();
|
||||
}}
|
||||
className="p-1 rounded hover:bg-accent/50 transition-colors"
|
||||
title="Auto Mode Settings"
|
||||
data-testid="mobile-auto-mode-settings-button"
|
||||
>
|
||||
<Settings2 className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
{/* Worktrees Toggle */}
|
||||
<div
|
||||
className="flex items-center justify-between p-3 cursor-pointer hover:bg-accent/50 rounded-lg border border-border/50 transition-colors"
|
||||
onClick={() => onWorktreePanelToggle(!isWorktreePanelVisible)}
|
||||
data-testid="mobile-worktrees-toggle-container"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<GitBranch className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Worktree Bar</span>
|
||||
</div>
|
||||
<Switch
|
||||
id="mobile-auto-mode-toggle"
|
||||
checked={isAutoModeRunning}
|
||||
onCheckedChange={onAutoModeToggle}
|
||||
id="mobile-worktrees-toggle"
|
||||
checked={isWorktreePanelVisible}
|
||||
onCheckedChange={onWorktreePanelToggle}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
data-testid="mobile-auto-mode-toggle"
|
||||
data-testid="mobile-worktrees-toggle"
|
||||
/>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenAutoModeSettings();
|
||||
}}
|
||||
className="p-1 rounded hover:bg-accent/50 transition-colors"
|
||||
title="Auto Mode Settings"
|
||||
data-testid="mobile-auto-mode-settings-button"
|
||||
>
|
||||
<Settings2 className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* Worktrees Toggle */}
|
||||
<div
|
||||
className="flex items-center justify-between px-2 py-2 cursor-pointer hover:bg-accent rounded-sm"
|
||||
onClick={() => onWorktreePanelToggle(!isWorktreePanelVisible)}
|
||||
data-testid="mobile-worktrees-toggle-container"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<GitBranch className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Worktrees</span>
|
||||
{/* Concurrency Control */}
|
||||
<div
|
||||
className="p-3 rounded-lg border border-border/50"
|
||||
data-testid="mobile-concurrency-control"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Bot className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Max Agents</span>
|
||||
<span
|
||||
className="text-sm text-muted-foreground ml-auto"
|
||||
data-testid="mobile-concurrency-value"
|
||||
>
|
||||
{runningAgentsCount}/{maxConcurrency}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[maxConcurrency]}
|
||||
onValueChange={(value) => onConcurrencyChange(value[0])}
|
||||
min={1}
|
||||
max={10}
|
||||
step={1}
|
||||
className="w-full"
|
||||
data-testid="mobile-concurrency-slider"
|
||||
/>
|
||||
</div>
|
||||
<Switch
|
||||
id="mobile-worktrees-toggle"
|
||||
checked={isWorktreePanelVisible}
|
||||
onCheckedChange={onWorktreePanelToggle}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
data-testid="mobile-worktrees-toggle"
|
||||
/>
|
||||
|
||||
{/* Plan Button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start"
|
||||
onClick={() => {
|
||||
onOpenPlanDialog();
|
||||
onToggle();
|
||||
}}
|
||||
data-testid="mobile-plan-button"
|
||||
>
|
||||
<Wand2 className="w-4 h-4 mr-2" />
|
||||
Plan
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* Concurrency Control */}
|
||||
<div className="px-2 py-2" data-testid="mobile-concurrency-control">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Bot className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Max Agents</span>
|
||||
<span
|
||||
className="text-sm text-muted-foreground ml-auto"
|
||||
data-testid="mobile-concurrency-value"
|
||||
>
|
||||
{runningAgentsCount}/{maxConcurrency}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[maxConcurrency]}
|
||||
onValueChange={(value) => onConcurrencyChange(value[0])}
|
||||
min={1}
|
||||
max={10}
|
||||
step={1}
|
||||
className="w-full"
|
||||
data-testid="mobile-concurrency-slider"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* Plan Button */}
|
||||
<DropdownMenuItem
|
||||
onClick={onOpenPlanDialog}
|
||||
className="flex items-center gap-2"
|
||||
data-testid="mobile-plan-button"
|
||||
>
|
||||
<Wand2 className="w-4 h-4" />
|
||||
<span>Plan</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</HeaderActionsPanel>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@ export { useBoardEffects } from './use-board-effects';
|
||||
export { useBoardBackground } from './use-board-background';
|
||||
export { useBoardPersistence } from './use-board-persistence';
|
||||
export { useFollowUpState } from './use-follow-up-state';
|
||||
export { useSelectionMode } from './use-selection-mode';
|
||||
export { useSelectionMode, type SelectionTarget } from './use-selection-mode';
|
||||
export { useListViewState } from './use-list-view-state';
|
||||
|
||||
@@ -117,6 +117,7 @@ export function useBoardActions({
|
||||
planningMode: PlanningMode;
|
||||
requirePlanApproval: boolean;
|
||||
dependencies?: string[];
|
||||
childDependencies?: string[]; // Feature IDs that should depend on this feature
|
||||
workMode?: 'current' | 'auto' | 'custom';
|
||||
}) => {
|
||||
const workMode = featureData.workMode || 'current';
|
||||
@@ -131,8 +132,10 @@ export function useBoardActions({
|
||||
// No worktree isolation - work directly on current branch
|
||||
finalBranchName = undefined;
|
||||
} else if (workMode === 'auto') {
|
||||
// Auto-generate a branch name based on current branch and timestamp
|
||||
const baseBranch = currentWorktreeBranch || 'main';
|
||||
// Auto-generate a branch name based on primary branch (main/master) and timestamp
|
||||
// Always use primary branch to avoid nested feature/feature/... paths
|
||||
const baseBranch =
|
||||
(currentProject?.path ? getPrimaryWorktreeBranch(currentProject.path) : null) || 'main';
|
||||
const timestamp = Date.now();
|
||||
const randomSuffix = Math.random().toString(36).substring(2, 6);
|
||||
finalBranchName = `feature/${baseBranch}-${timestamp}-${randomSuffix}`;
|
||||
@@ -194,6 +197,21 @@ export function useBoardActions({
|
||||
await persistFeatureCreate(createdFeature);
|
||||
saveCategory(featureData.category);
|
||||
|
||||
// Handle child dependencies - update other features to depend on this new feature
|
||||
if (featureData.childDependencies && featureData.childDependencies.length > 0) {
|
||||
for (const childId of featureData.childDependencies) {
|
||||
const childFeature = features.find((f) => f.id === childId);
|
||||
if (childFeature) {
|
||||
const childDeps = childFeature.dependencies || [];
|
||||
if (!childDeps.includes(createdFeature.id)) {
|
||||
const newDeps = [...childDeps, createdFeature.id];
|
||||
updateFeature(childId, { dependencies: newDeps });
|
||||
persistFeatureUpdate(childId, { dependencies: newDeps });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate title in the background if needed (non-blocking)
|
||||
if (needsTitleGeneration) {
|
||||
const api = getElectronAPI();
|
||||
@@ -234,7 +252,8 @@ export function useBoardActions({
|
||||
currentProject,
|
||||
onWorktreeCreated,
|
||||
onWorktreeAutoSelect,
|
||||
currentWorktreeBranch,
|
||||
getPrimaryWorktreeBranch,
|
||||
features,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -255,6 +274,8 @@ export function useBoardActions({
|
||||
planningMode?: PlanningMode;
|
||||
requirePlanApproval?: boolean;
|
||||
workMode?: 'current' | 'auto' | 'custom';
|
||||
dependencies?: string[];
|
||||
childDependencies?: string[]; // Feature IDs that should depend on this feature
|
||||
},
|
||||
descriptionHistorySource?: 'enhance' | 'edit',
|
||||
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer',
|
||||
@@ -268,7 +289,10 @@ export function useBoardActions({
|
||||
if (workMode === 'current') {
|
||||
finalBranchName = undefined;
|
||||
} else if (workMode === 'auto') {
|
||||
const baseBranch = currentWorktreeBranch || 'main';
|
||||
// Auto-generate a branch name based on primary branch (main/master) and timestamp
|
||||
// Always use primary branch to avoid nested feature/feature/... paths
|
||||
const baseBranch =
|
||||
(currentProject?.path ? getPrimaryWorktreeBranch(currentProject.path) : null) || 'main';
|
||||
const timestamp = Date.now();
|
||||
const randomSuffix = Math.random().toString(36).substring(2, 6);
|
||||
finalBranchName = `feature/${baseBranch}-${timestamp}-${randomSuffix}`;
|
||||
@@ -308,8 +332,11 @@ export function useBoardActions({
|
||||
}
|
||||
}
|
||||
|
||||
// Separate child dependencies from the main updates (they affect other features)
|
||||
const { childDependencies, ...restUpdates } = updates;
|
||||
|
||||
const finalUpdates = {
|
||||
...updates,
|
||||
...restUpdates,
|
||||
title: updates.title,
|
||||
branchName: finalBranchName,
|
||||
};
|
||||
@@ -322,6 +349,45 @@ export function useBoardActions({
|
||||
enhancementMode,
|
||||
preEnhancementDescription
|
||||
);
|
||||
|
||||
// Handle child dependency changes
|
||||
// This updates other features' dependencies arrays
|
||||
if (childDependencies !== undefined) {
|
||||
// Find current child dependencies (features that have this feature in their dependencies)
|
||||
const currentChildDeps = features
|
||||
.filter((f) => f.dependencies?.includes(featureId))
|
||||
.map((f) => f.id);
|
||||
|
||||
// Find features to add this feature as a dependency (new child deps)
|
||||
const toAdd = childDependencies.filter((id) => !currentChildDeps.includes(id));
|
||||
// Find features to remove this feature as a dependency (removed child deps)
|
||||
const toRemove = currentChildDeps.filter((id) => !childDependencies.includes(id));
|
||||
|
||||
// Add this feature as a dependency to new child features
|
||||
for (const childId of toAdd) {
|
||||
const childFeature = features.find((f) => f.id === childId);
|
||||
if (childFeature) {
|
||||
const childDeps = childFeature.dependencies || [];
|
||||
if (!childDeps.includes(featureId)) {
|
||||
const newDeps = [...childDeps, featureId];
|
||||
updateFeature(childId, { dependencies: newDeps });
|
||||
persistFeatureUpdate(childId, { dependencies: newDeps });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove this feature as a dependency from removed child features
|
||||
for (const childId of toRemove) {
|
||||
const childFeature = features.find((f) => f.id === childId);
|
||||
if (childFeature) {
|
||||
const childDeps = childFeature.dependencies || [];
|
||||
const newDeps = childDeps.filter((depId) => depId !== featureId);
|
||||
updateFeature(childId, { dependencies: newDeps });
|
||||
persistFeatureUpdate(childId, { dependencies: newDeps });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (updates.category) {
|
||||
saveCategory(updates.category);
|
||||
}
|
||||
@@ -334,7 +400,8 @@ export function useBoardActions({
|
||||
setEditingFeature,
|
||||
currentProject,
|
||||
onWorktreeCreated,
|
||||
currentWorktreeBranch,
|
||||
getPrimaryWorktreeBranch,
|
||||
features,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('Calling API features.update', { featureId, updates });
|
||||
const result = await api.features.update(
|
||||
currentProject.path,
|
||||
featureId,
|
||||
@@ -42,12 +43,18 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
|
||||
enhancementMode,
|
||||
preEnhancementDescription
|
||||
);
|
||||
logger.info('API features.update result', {
|
||||
success: result.success,
|
||||
feature: result.feature,
|
||||
});
|
||||
if (result.success && result.feature) {
|
||||
updateFeature(result.feature.id, result.feature);
|
||||
// Invalidate React Query cache to sync UI
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.features.all(currentProject.path),
|
||||
});
|
||||
} else if (!result.success) {
|
||||
logger.error('API features.update failed', result);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to persist feature update:', error);
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
|
||||
export type SelectionTarget = 'backlog' | 'waiting_approval' | null;
|
||||
|
||||
interface UseSelectionModeReturn {
|
||||
isSelectionMode: boolean;
|
||||
selectionTarget: SelectionTarget;
|
||||
selectedFeatureIds: Set<string>;
|
||||
selectedCount: number;
|
||||
toggleSelectionMode: () => void;
|
||||
toggleSelectionMode: (target?: SelectionTarget) => void;
|
||||
toggleFeatureSelection: (featureId: string) => void;
|
||||
selectAll: (featureIds: string[]) => void;
|
||||
clearSelection: () => void;
|
||||
@@ -13,21 +16,26 @@ interface UseSelectionModeReturn {
|
||||
}
|
||||
|
||||
export function useSelectionMode(): UseSelectionModeReturn {
|
||||
const [isSelectionMode, setIsSelectionMode] = useState(false);
|
||||
const [selectionTarget, setSelectionTarget] = useState<SelectionTarget>(null);
|
||||
const [selectedFeatureIds, setSelectedFeatureIds] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggleSelectionMode = useCallback(() => {
|
||||
setIsSelectionMode((prev) => {
|
||||
if (prev) {
|
||||
const isSelectionMode = selectionTarget !== null;
|
||||
|
||||
const toggleSelectionMode = useCallback((target: SelectionTarget = 'backlog') => {
|
||||
setSelectionTarget((prev) => {
|
||||
if (prev === target) {
|
||||
// Exiting selection mode - clear selection
|
||||
setSelectedFeatureIds(new Set());
|
||||
return null;
|
||||
}
|
||||
return !prev;
|
||||
// Switching to a different target or entering selection mode
|
||||
setSelectedFeatureIds(new Set());
|
||||
return target;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const exitSelectionMode = useCallback(() => {
|
||||
setIsSelectionMode(false);
|
||||
setSelectionTarget(null);
|
||||
setSelectedFeatureIds(new Set());
|
||||
}, []);
|
||||
|
||||
@@ -70,6 +78,7 @@ export function useSelectionMode(): UseSelectionModeReturn {
|
||||
|
||||
return {
|
||||
isSelectionMode,
|
||||
selectionTarget,
|
||||
selectedFeatureIds,
|
||||
selectedCount: selectedFeatureIds.size,
|
||||
toggleSelectionMode,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { Terminal, Check, X, Loader2, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { Terminal, Check, X, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAppStore, type InitScriptState } from '@/store/app-store';
|
||||
import { AnsiOutput } from '@/components/ui/ansi-output';
|
||||
@@ -65,7 +66,7 @@ function SingleIndicator({
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-3 border-b border-border/50">
|
||||
<div className="flex items-center gap-2">
|
||||
{status === 'running' && <Loader2 className="w-4 h-4 animate-spin text-blue-500" />}
|
||||
{status === 'running' && <Spinner size="sm" />}
|
||||
{status === 'success' && <Check className="w-4 h-4 text-green-500" />}
|
||||
{status === 'failed' && <X className="w-4 h-4 text-red-500" />}
|
||||
<span className="font-medium text-sm">
|
||||
|
||||
@@ -50,9 +50,10 @@ interface KanbanBoardProps {
|
||||
onOpenPipelineSettings?: () => void;
|
||||
// Selection mode props
|
||||
isSelectionMode?: boolean;
|
||||
selectionTarget?: 'backlog' | 'waiting_approval' | null;
|
||||
selectedFeatureIds?: Set<string>;
|
||||
onToggleFeatureSelection?: (featureId: string) => void;
|
||||
onToggleSelectionMode?: () => void;
|
||||
onToggleSelectionMode?: (target?: 'backlog' | 'waiting_approval') => void;
|
||||
// Empty state action props
|
||||
onAiSuggest?: () => void;
|
||||
/** Whether currently dragging (hides empty states during drag) */
|
||||
@@ -95,6 +96,7 @@ export function KanbanBoard({
|
||||
pipelineConfig,
|
||||
onOpenPipelineSettings,
|
||||
isSelectionMode = false,
|
||||
selectionTarget = null,
|
||||
selectedFeatureIds = new Set(),
|
||||
onToggleFeatureSelection,
|
||||
onToggleSelectionMode,
|
||||
@@ -189,12 +191,14 @@ export function KanbanBoard({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`h-6 px-2 text-xs ${isSelectionMode ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground'}`}
|
||||
onClick={onToggleSelectionMode}
|
||||
title={isSelectionMode ? 'Switch to Drag Mode' : 'Select Multiple'}
|
||||
className={`h-6 px-2 text-xs ${selectionTarget === 'backlog' ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground'}`}
|
||||
onClick={() => onToggleSelectionMode?.('backlog')}
|
||||
title={
|
||||
selectionTarget === 'backlog' ? 'Switch to Drag Mode' : 'Select Multiple'
|
||||
}
|
||||
data-testid="selection-mode-button"
|
||||
>
|
||||
{isSelectionMode ? (
|
||||
{selectionTarget === 'backlog' ? (
|
||||
<>
|
||||
<GripVertical className="w-3.5 h-3.5 mr-1" />
|
||||
Drag
|
||||
@@ -207,6 +211,31 @@ export function KanbanBoard({
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : column.id === 'waiting_approval' ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`h-6 px-2 text-xs ${selectionTarget === 'waiting_approval' ? 'text-primary bg-primary/10' : 'text-muted-foreground hover:text-foreground'}`}
|
||||
onClick={() => onToggleSelectionMode?.('waiting_approval')}
|
||||
title={
|
||||
selectionTarget === 'waiting_approval'
|
||||
? 'Switch to Drag Mode'
|
||||
: 'Select Multiple'
|
||||
}
|
||||
data-testid="waiting-approval-selection-mode-button"
|
||||
>
|
||||
{selectionTarget === 'waiting_approval' ? (
|
||||
<>
|
||||
<GripVertical className="w-3.5 h-3.5 mr-1" />
|
||||
Drag
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckSquare className="w-3.5 h-3.5 mr-1" />
|
||||
Select
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
) : column.id === 'in_progress' ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -305,6 +334,7 @@ export function KanbanBoard({
|
||||
cardBorderEnabled={backgroundSettings.cardBorderEnabled}
|
||||
cardBorderOpacity={backgroundSettings.cardBorderOpacity}
|
||||
isSelectionMode={isSelectionMode}
|
||||
selectionTarget={selectionTarget}
|
||||
isSelected={selectedFeatureIds.has(feature.id)}
|
||||
onToggleSelect={() => onToggleFeatureSelection?.(feature.id)}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useCallback, useState, type ComponentType, type ReactNode } from 'react';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { AnthropicIcon, OpenAIIcon } from '@/components/ui/provider-icon';
|
||||
@@ -90,9 +91,11 @@ function UsageItem({
|
||||
className="p-1 rounded hover:bg-accent/50 transition-colors"
|
||||
title="Refresh usage"
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn('w-3.5 h-3.5 text-muted-foreground', isLoading && 'animate-spin')}
|
||||
/>
|
||||
{isLoading ? (
|
||||
<Spinner size="xs" />
|
||||
) : (
|
||||
<RefreshCw className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="pl-6 space-y-2">{children}</div>
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Brain, Zap, Scale, Cpu, Rocket, Sparkles } from 'lucide-react';
|
||||
import { AnthropicIcon, CursorIcon, OpenAIIcon, OpenCodeIcon } from '@/components/ui/provider-icon';
|
||||
|
||||
export type ModelOption = {
|
||||
id: string; // Claude models use ModelAlias, Cursor models use "cursor-{id}"
|
||||
id: string; // All model IDs use canonical prefixed format (e.g., "claude-sonnet", "cursor-auto")
|
||||
label: string;
|
||||
description: string;
|
||||
badge?: string;
|
||||
@@ -17,23 +17,27 @@ export type ModelOption = {
|
||||
hasThinking?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Claude models with canonical prefixed IDs
|
||||
* UI displays short labels but stores full canonical IDs
|
||||
*/
|
||||
export const CLAUDE_MODELS: ModelOption[] = [
|
||||
{
|
||||
id: 'haiku',
|
||||
id: 'claude-haiku', // Canonical prefixed ID
|
||||
label: 'Claude Haiku',
|
||||
description: 'Fast and efficient for simple tasks.',
|
||||
badge: 'Speed',
|
||||
provider: 'claude',
|
||||
},
|
||||
{
|
||||
id: 'sonnet',
|
||||
id: 'claude-sonnet', // Canonical prefixed ID
|
||||
label: 'Claude Sonnet',
|
||||
description: 'Balanced performance with strong reasoning.',
|
||||
badge: 'Balanced',
|
||||
provider: 'claude',
|
||||
},
|
||||
{
|
||||
id: 'opus',
|
||||
id: 'claude-opus', // Canonical prefixed ID
|
||||
label: 'Claude Opus',
|
||||
description: 'Most capable model for complex work.',
|
||||
badge: 'Premium',
|
||||
@@ -43,11 +47,11 @@ export const CLAUDE_MODELS: ModelOption[] = [
|
||||
|
||||
/**
|
||||
* Cursor models derived from CURSOR_MODEL_MAP
|
||||
* ID is prefixed with "cursor-" for ProviderFactory routing
|
||||
* IDs already have 'cursor-' prefix in the canonical format
|
||||
*/
|
||||
export const CURSOR_MODELS: ModelOption[] = Object.entries(CURSOR_MODEL_MAP).map(
|
||||
([id, config]) => ({
|
||||
id: `cursor-${id}`,
|
||||
id, // Already prefixed in canonical format
|
||||
label: config.label,
|
||||
description: config.description,
|
||||
provider: 'cursor' as ModelProvider,
|
||||
|
||||
@@ -11,7 +11,7 @@ import { getModelProvider, PROVIDER_PREFIXES, stripProviderPrefix } from '@autom
|
||||
import type { ModelProvider } from '@automaker/types';
|
||||
import { CLAUDE_MODELS, CURSOR_MODELS, ModelOption } from './model-constants';
|
||||
import { useEffect } from 'react';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
|
||||
interface ModelSelectorProps {
|
||||
selectedModel: string; // Can be ModelAlias or "cursor-{id}"
|
||||
@@ -31,6 +31,7 @@ export function ModelSelector({
|
||||
codexModelsLoading,
|
||||
codexModelsError,
|
||||
fetchCodexModels,
|
||||
disabledProviders,
|
||||
} = useAppStore();
|
||||
const { cursorCliStatus, codexCliStatus } = useSetupStore();
|
||||
|
||||
@@ -69,79 +70,106 @@ export function ModelSelector({
|
||||
|
||||
// Filter Cursor models based on enabled models from global settings
|
||||
const filteredCursorModels = CURSOR_MODELS.filter((model) => {
|
||||
// Extract the cursor model ID from the prefixed ID (e.g., "cursor-auto" -> "auto")
|
||||
const cursorModelId = stripProviderPrefix(model.id);
|
||||
return enabledCursorModels.includes(cursorModelId as any);
|
||||
// enabledCursorModels stores CursorModelIds which may or may not have "cursor-" prefix
|
||||
// (e.g., 'auto', 'sonnet-4.5' without prefix, but 'cursor-gpt-5.2' with prefix)
|
||||
// CURSOR_MODELS always has the "cursor-" prefix added in model-constants.ts
|
||||
// Check both the full ID (for GPT models) and the unprefixed version (for non-GPT models)
|
||||
const unprefixedId = model.id.startsWith('cursor-') ? model.id.slice(7) : model.id;
|
||||
return (
|
||||
enabledCursorModels.includes(model.id as any) ||
|
||||
enabledCursorModels.includes(unprefixedId as any)
|
||||
);
|
||||
});
|
||||
|
||||
const handleProviderChange = (provider: ModelProvider) => {
|
||||
if (provider === 'cursor' && selectedProvider !== 'cursor') {
|
||||
// Switch to Cursor's default model (from global settings)
|
||||
onModelSelect(`${PROVIDER_PREFIXES.cursor}${cursorDefaultModel}`);
|
||||
// cursorDefaultModel is now canonical (e.g., 'cursor-auto'), so use directly
|
||||
onModelSelect(cursorDefaultModel);
|
||||
} else if (provider === 'codex' && selectedProvider !== 'codex') {
|
||||
// Switch to Codex's default model (use isDefault flag from dynamic models)
|
||||
const defaultModel = codexModels.find((m) => m.isDefault);
|
||||
const defaultModelId = defaultModel?.id || codexModels[0]?.id || 'codex-gpt-5.2-codex';
|
||||
onModelSelect(defaultModelId);
|
||||
} else if (provider === 'claude' && selectedProvider !== 'claude') {
|
||||
// Switch to Claude's default model
|
||||
onModelSelect('sonnet');
|
||||
// Switch to Claude's default model (canonical format)
|
||||
onModelSelect('claude-sonnet');
|
||||
}
|
||||
};
|
||||
|
||||
// Check which providers are disabled
|
||||
const isClaudeDisabled = disabledProviders.includes('claude');
|
||||
const isCursorDisabled = disabledProviders.includes('cursor');
|
||||
const isCodexDisabled = disabledProviders.includes('codex');
|
||||
|
||||
// Count available providers
|
||||
const availableProviders = [
|
||||
!isClaudeDisabled && 'claude',
|
||||
!isCursorDisabled && 'cursor',
|
||||
!isCodexDisabled && 'codex',
|
||||
].filter(Boolean) as ModelProvider[];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Provider Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label>AI Provider</Label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleProviderChange('claude')}
|
||||
className={cn(
|
||||
'flex-1 px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-center gap-2',
|
||||
selectedProvider === 'claude'
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-background hover:bg-accent border-border'
|
||||
{availableProviders.length > 1 && (
|
||||
<div className="space-y-2">
|
||||
<Label>AI Provider</Label>
|
||||
<div className="flex gap-2">
|
||||
{!isClaudeDisabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleProviderChange('claude')}
|
||||
className={cn(
|
||||
'flex-1 px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-center gap-2',
|
||||
selectedProvider === 'claude'
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-background hover:bg-accent border-border'
|
||||
)}
|
||||
data-testid={`${testIdPrefix}-provider-claude`}
|
||||
>
|
||||
<AnthropicIcon className="w-4 h-4" />
|
||||
Claude
|
||||
</button>
|
||||
)}
|
||||
data-testid={`${testIdPrefix}-provider-claude`}
|
||||
>
|
||||
<AnthropicIcon className="w-4 h-4" />
|
||||
Claude
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleProviderChange('cursor')}
|
||||
className={cn(
|
||||
'flex-1 px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-center gap-2',
|
||||
selectedProvider === 'cursor'
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-background hover:bg-accent border-border'
|
||||
{!isCursorDisabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleProviderChange('cursor')}
|
||||
className={cn(
|
||||
'flex-1 px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-center gap-2',
|
||||
selectedProvider === 'cursor'
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-background hover:bg-accent border-border'
|
||||
)}
|
||||
data-testid={`${testIdPrefix}-provider-cursor`}
|
||||
>
|
||||
<CursorIcon className="w-4 h-4" />
|
||||
Cursor CLI
|
||||
</button>
|
||||
)}
|
||||
data-testid={`${testIdPrefix}-provider-cursor`}
|
||||
>
|
||||
<CursorIcon className="w-4 h-4" />
|
||||
Cursor CLI
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleProviderChange('codex')}
|
||||
className={cn(
|
||||
'flex-1 px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-center gap-2',
|
||||
selectedProvider === 'codex'
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-background hover:bg-accent border-border'
|
||||
{!isCodexDisabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleProviderChange('codex')}
|
||||
className={cn(
|
||||
'flex-1 px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-center gap-2',
|
||||
selectedProvider === 'codex'
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-background hover:bg-accent border-border'
|
||||
)}
|
||||
data-testid={`${testIdPrefix}-provider-codex`}
|
||||
>
|
||||
<OpenAIIcon className="w-4 h-4" />
|
||||
Codex CLI
|
||||
</button>
|
||||
)}
|
||||
data-testid={`${testIdPrefix}-provider-codex`}
|
||||
>
|
||||
<OpenAIIcon className="w-4 h-4" />
|
||||
Codex CLI
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Claude Models */}
|
||||
{selectedProvider === 'claude' && (
|
||||
{selectedProvider === 'claude' && !isClaudeDisabled && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="flex items-center gap-2">
|
||||
@@ -179,7 +207,7 @@ export function ModelSelector({
|
||||
)}
|
||||
|
||||
{/* Cursor Models */}
|
||||
{selectedProvider === 'cursor' && (
|
||||
{selectedProvider === 'cursor' && !isCursorDisabled && (
|
||||
<div className="space-y-3">
|
||||
{/* Warning when Cursor CLI is not available */}
|
||||
{!isCursorAvailable && (
|
||||
@@ -248,7 +276,7 @@ export function ModelSelector({
|
||||
)}
|
||||
|
||||
{/* Codex Models */}
|
||||
{selectedProvider === 'codex' && (
|
||||
{selectedProvider === 'codex' && !isCodexDisabled && (
|
||||
<div className="space-y-3">
|
||||
{/* Warning when Codex CLI is not available */}
|
||||
{!isCodexAvailable && (
|
||||
@@ -274,7 +302,7 @@ export function ModelSelector({
|
||||
{/* Loading state */}
|
||||
{codexModelsLoading && dynamicCodexModels.length === 0 && (
|
||||
<div className="flex items-center justify-center gap-2 p-6 text-sm text-muted-foreground">
|
||||
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||
<Spinner size="sm" />
|
||||
Loading models...
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -6,12 +6,12 @@ import {
|
||||
ClipboardList,
|
||||
FileText,
|
||||
ScrollText,
|
||||
Loader2,
|
||||
Check,
|
||||
Eye,
|
||||
RefreshCw,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
@@ -236,7 +236,7 @@ export function PlanningModeSelector({
|
||||
<div className="flex items-center gap-2">
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
||||
<Spinner size="sm" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Generating {mode === 'full' ? 'comprehensive spec' : 'spec'}...
|
||||
</span>
|
||||
|
||||
@@ -8,7 +8,8 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuLabel,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { GitBranch, RefreshCw, GitBranchPlus, Check, Search } from 'lucide-react';
|
||||
import { GitBranch, GitBranchPlus, Check, Search } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { WorktreeInfo, BranchInfo } from '../types';
|
||||
|
||||
@@ -81,7 +82,7 @@ export function BranchSwitchDropdown({
|
||||
<div className="max-h-[250px] overflow-y-auto">
|
||||
{isLoadingBranches ? (
|
||||
<DropdownMenuItem disabled className="text-xs">
|
||||
<RefreshCw className="w-3.5 h-3.5 mr-2 animate-spin" />
|
||||
<Spinner size="xs" className="mr-2" />
|
||||
Loading branches...
|
||||
</DropdownMenuItem>
|
||||
) : filteredBranches.length === 0 ? (
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Loader2,
|
||||
Terminal,
|
||||
ArrowDown,
|
||||
ExternalLink,
|
||||
@@ -12,6 +11,7 @@ import {
|
||||
Clock,
|
||||
GitBranch,
|
||||
} from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { XtermLogViewer, type XtermLogViewerRef } from '@/components/ui/xterm-log-viewer';
|
||||
import { useDevServerLogs } from '../hooks/use-dev-server-logs';
|
||||
@@ -183,7 +183,7 @@ export function DevServerLogsPanel({
|
||||
onClick={() => fetchLogs()}
|
||||
title="Refresh logs"
|
||||
>
|
||||
<RefreshCw className={cn('w-3.5 h-3.5', isLoading && 'animate-spin')} />
|
||||
{isLoading ? <Spinner size="xs" /> : <RefreshCw className="w-3.5 h-3.5" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -234,7 +234,7 @@ export function DevServerLogsPanel({
|
||||
>
|
||||
{isLoading && !logs ? (
|
||||
<div className="flex items-center justify-center h-full min-h-[300px] text-muted-foreground">
|
||||
<Loader2 className="w-5 h-5 animate-spin mr-2" />
|
||||
<Spinner size="md" className="mr-2" />
|
||||
<span className="text-sm">Loading logs...</span>
|
||||
</div>
|
||||
) : !logs && !isRunning ? (
|
||||
@@ -245,7 +245,7 @@ export function DevServerLogsPanel({
|
||||
</div>
|
||||
) : !logs ? (
|
||||
<div className="flex flex-col items-center justify-center h-full min-h-[300px] text-muted-foreground p-8">
|
||||
<div className="w-8 h-8 mb-3 rounded-full border-2 border-muted-foreground/20 border-t-muted-foreground/60 animate-spin" />
|
||||
<Spinner size="xl" className="mb-3" />
|
||||
<p className="text-sm">Waiting for output...</p>
|
||||
<p className="text-xs mt-1 opacity-60">
|
||||
Logs will appear as the server generates output
|
||||
@@ -256,7 +256,6 @@ export function DevServerLogsPanel({
|
||||
ref={xtermRef}
|
||||
className="h-full"
|
||||
minHeight={280}
|
||||
fontSize={13}
|
||||
autoScroll={autoScrollEnabled}
|
||||
onScrollAwayFromBottom={() => setAutoScrollEnabled(false)}
|
||||
onScrollToBottom={() => setAutoScrollEnabled(true)}
|
||||
|
||||
@@ -26,13 +26,22 @@ import {
|
||||
RefreshCw,
|
||||
Copy,
|
||||
ScrollText,
|
||||
Terminal,
|
||||
SquarePlus,
|
||||
SplitSquareHorizontal,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { WorktreeInfo, DevServerInfo, PRInfo, GitRepoStatus } from '../types';
|
||||
import { TooltipWrapper } from './tooltip-wrapper';
|
||||
import { useAvailableEditors, useEffectiveDefaultEditor } from '../hooks/use-available-editors';
|
||||
import {
|
||||
useAvailableTerminals,
|
||||
useEffectiveDefaultTerminal,
|
||||
} from '../hooks/use-available-terminals';
|
||||
import { getEditorIcon } from '@/components/icons/editor-icons';
|
||||
import { getTerminalIcon } from '@/components/icons/terminal-icons';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
|
||||
interface WorktreeActionsDropdownProps {
|
||||
worktree: WorktreeInfo;
|
||||
@@ -51,6 +60,8 @@ interface WorktreeActionsDropdownProps {
|
||||
onPull: (worktree: WorktreeInfo) => void;
|
||||
onPush: (worktree: WorktreeInfo) => void;
|
||||
onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void;
|
||||
onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void;
|
||||
onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void;
|
||||
onCommit: (worktree: WorktreeInfo) => void;
|
||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||
@@ -81,6 +92,8 @@ export function WorktreeActionsDropdown({
|
||||
onPull,
|
||||
onPush,
|
||||
onOpenInEditor,
|
||||
onOpenInIntegratedTerminal,
|
||||
onOpenInExternalTerminal,
|
||||
onCommit,
|
||||
onCreatePR,
|
||||
onAddressPRComments,
|
||||
@@ -108,6 +121,20 @@ export function WorktreeActionsDropdown({
|
||||
? getEditorIcon(effectiveDefaultEditor.command)
|
||||
: null;
|
||||
|
||||
// Get available terminals for the "Open In Terminal" submenu
|
||||
const { terminals, hasExternalTerminals } = useAvailableTerminals();
|
||||
|
||||
// Use shared hook for effective default terminal (null = integrated terminal)
|
||||
const effectiveDefaultTerminal = useEffectiveDefaultTerminal(terminals);
|
||||
|
||||
// Get the user's preferred mode for opening terminals (new tab vs split)
|
||||
const openTerminalMode = useAppStore((s) => s.terminalState.openTerminalMode);
|
||||
|
||||
// Get icon component for the effective terminal
|
||||
const DefaultTerminalIcon = effectiveDefaultTerminal
|
||||
? getTerminalIcon(effectiveDefaultTerminal.id)
|
||||
: Terminal;
|
||||
|
||||
// Check if there's a PR associated with this worktree from stored metadata
|
||||
const hasPR = !!worktree.pr;
|
||||
|
||||
@@ -303,6 +330,77 @@ export function WorktreeActionsDropdown({
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
{/* Open in terminal - always show with integrated + external options */}
|
||||
<DropdownMenuSub>
|
||||
<div className="flex items-center">
|
||||
{/* Main clickable area - opens in default terminal (integrated or external) */}
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (effectiveDefaultTerminal) {
|
||||
// External terminal is the default
|
||||
onOpenInExternalTerminal(worktree, effectiveDefaultTerminal.id);
|
||||
} else {
|
||||
// Integrated terminal is the default - use user's preferred mode
|
||||
const mode = openTerminalMode === 'newTab' ? 'tab' : 'split';
|
||||
onOpenInIntegratedTerminal(worktree, mode);
|
||||
}
|
||||
}}
|
||||
className="text-xs flex-1 pr-0 rounded-r-none"
|
||||
>
|
||||
<DefaultTerminalIcon className="w-3.5 h-3.5 mr-2" />
|
||||
Open in {effectiveDefaultTerminal?.name ?? 'Terminal'}
|
||||
</DropdownMenuItem>
|
||||
{/* Chevron trigger for submenu with all terminals */}
|
||||
<DropdownMenuSubTrigger className="text-xs px-1 rounded-l-none border-l border-border/30 h-8" />
|
||||
</div>
|
||||
<DropdownMenuSubContent>
|
||||
{/* Automaker Terminal - with submenu for new tab vs split */}
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger className="text-xs">
|
||||
<Terminal className="w-3.5 h-3.5 mr-2" />
|
||||
Terminal
|
||||
{!effectiveDefaultTerminal && (
|
||||
<span className="ml-auto mr-2 text-[10px] text-muted-foreground">(default)</span>
|
||||
)}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onOpenInIntegratedTerminal(worktree, 'tab')}
|
||||
className="text-xs"
|
||||
>
|
||||
<SquarePlus className="w-3.5 h-3.5 mr-2" />
|
||||
New Tab
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onOpenInIntegratedTerminal(worktree, 'split')}
|
||||
className="text-xs"
|
||||
>
|
||||
<SplitSquareHorizontal className="w-3.5 h-3.5 mr-2" />
|
||||
Split
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
{/* External terminals */}
|
||||
{terminals.length > 0 && <DropdownMenuSeparator />}
|
||||
{terminals.map((terminal) => {
|
||||
const TerminalIcon = getTerminalIcon(terminal.id);
|
||||
const isDefault = terminal.id === effectiveDefaultTerminal?.id;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={terminal.id}
|
||||
onClick={() => onOpenInExternalTerminal(worktree, terminal.id)}
|
||||
className="text-xs"
|
||||
>
|
||||
<TerminalIcon className="w-3.5 h-3.5 mr-2" />
|
||||
{terminal.name}
|
||||
{isDefault && (
|
||||
<span className="ml-auto text-[10px] text-muted-foreground">(default)</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
{!worktree.isMain && hasInitScript && (
|
||||
<DropdownMenuItem onClick={() => onRunInitScript(worktree)} className="text-xs">
|
||||
<RefreshCw className="w-3.5 h-3.5 mr-2" />
|
||||
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { GitBranch, ChevronDown, Loader2, CircleDot, Check } from 'lucide-react';
|
||||
import { GitBranch, ChevronDown, CircleDot, Check } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { WorktreeInfo } from '../types';
|
||||
|
||||
@@ -44,7 +45,7 @@ export function WorktreeMobileDropdown({
|
||||
<GitBranch className="w-3.5 h-3.5 shrink-0" />
|
||||
<span className="truncate">{displayBranch}</span>
|
||||
{isActivating ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin shrink-0" />
|
||||
<Spinner size="xs" className="shrink-0" />
|
||||
) : (
|
||||
<ChevronDown className="w-3 h-3 shrink-0 ml-auto" />
|
||||
)}
|
||||
@@ -74,7 +75,7 @@ export function WorktreeMobileDropdown({
|
||||
) : (
|
||||
<div className="w-3.5 h-3.5 shrink-0" />
|
||||
)}
|
||||
{isRunning && <Loader2 className="w-3 h-3 animate-spin shrink-0" />}
|
||||
{isRunning && <Spinner size="xs" className="shrink-0" />}
|
||||
<span className={cn('font-mono text-xs truncate', isSelected && 'font-medium')}>
|
||||
{worktree.branch}
|
||||
</span>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { JSX } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RefreshCw, Globe, Loader2, CircleDot, GitPullRequest } from 'lucide-react';
|
||||
import { Globe, CircleDot, GitPullRequest } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import type { WorktreeInfo, BranchInfo, DevServerInfo, PRInfo, GitRepoStatus } from '../types';
|
||||
@@ -37,6 +38,8 @@ interface WorktreeTabProps {
|
||||
onPull: (worktree: WorktreeInfo) => void;
|
||||
onPush: (worktree: WorktreeInfo) => void;
|
||||
onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void;
|
||||
onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void;
|
||||
onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void;
|
||||
onCommit: (worktree: WorktreeInfo) => void;
|
||||
onCreatePR: (worktree: WorktreeInfo) => void;
|
||||
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||
@@ -81,6 +84,8 @@ export function WorktreeTab({
|
||||
onPull,
|
||||
onPush,
|
||||
onOpenInEditor,
|
||||
onOpenInIntegratedTerminal,
|
||||
onOpenInExternalTerminal,
|
||||
onCommit,
|
||||
onCreatePR,
|
||||
onAddressPRComments,
|
||||
@@ -197,8 +202,8 @@ export function WorktreeTab({
|
||||
aria-label={worktree.branch}
|
||||
data-testid={`worktree-branch-${worktree.branch}`}
|
||||
>
|
||||
{isRunning && <Loader2 className="w-3 h-3 animate-spin" />}
|
||||
{isActivating && !isRunning && <RefreshCw className="w-3 h-3 animate-spin" />}
|
||||
{isRunning && <Spinner size="xs" />}
|
||||
{isActivating && !isRunning && <Spinner size="xs" />}
|
||||
{worktree.branch}
|
||||
{cardCount !== undefined && cardCount > 0 && (
|
||||
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
|
||||
@@ -264,8 +269,8 @@ export function WorktreeTab({
|
||||
: 'Click to switch to this branch'
|
||||
}
|
||||
>
|
||||
{isRunning && <Loader2 className="w-3 h-3 animate-spin" />}
|
||||
{isActivating && !isRunning && <RefreshCw className="w-3 h-3 animate-spin" />}
|
||||
{isRunning && <Spinner size="xs" />}
|
||||
{isActivating && !isRunning && <Spinner size="xs" />}
|
||||
{worktree.branch}
|
||||
{cardCount !== undefined && cardCount > 0 && (
|
||||
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
|
||||
@@ -342,6 +347,8 @@ export function WorktreeTab({
|
||||
onPull={onPull}
|
||||
onPush={onPush}
|
||||
onOpenInEditor={onOpenInEditor}
|
||||
onOpenInIntegratedTerminal={onOpenInIntegratedTerminal}
|
||||
onOpenInExternalTerminal={onOpenInExternalTerminal}
|
||||
onCommit={onCommit}
|
||||
onCreatePR={onCreatePR}
|
||||
onAddressPRComments={onAddressPRComments}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import type { TerminalInfo } from '@automaker/types';
|
||||
|
||||
const logger = createLogger('AvailableTerminals');
|
||||
|
||||
// Re-export TerminalInfo for convenience
|
||||
export type { TerminalInfo };
|
||||
|
||||
export function useAvailableTerminals() {
|
||||
const [terminals, setTerminals] = useState<TerminalInfo[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
const fetchAvailableTerminals = useCallback(async () => {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.getAvailableTerminals) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
const result = await api.worktree.getAvailableTerminals();
|
||||
if (result.success && result.result?.terminals) {
|
||||
setTerminals(result.result.terminals);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch available terminals:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Refresh terminals by clearing the server cache and re-detecting
|
||||
* Use this when the user has installed/uninstalled terminals
|
||||
*/
|
||||
const refresh = useCallback(async () => {
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.refreshTerminals) {
|
||||
// Fallback to regular fetch if refresh not available
|
||||
await fetchAvailableTerminals();
|
||||
return;
|
||||
}
|
||||
const result = await api.worktree.refreshTerminals();
|
||||
if (result.success && result.result?.terminals) {
|
||||
setTerminals(result.result.terminals);
|
||||
logger.info(`Terminal cache refreshed, found ${result.result.terminals.length} terminals`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to refresh terminals:', error);
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
}, [fetchAvailableTerminals]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAvailableTerminals();
|
||||
}, [fetchAvailableTerminals]);
|
||||
|
||||
return {
|
||||
terminals,
|
||||
isLoading,
|
||||
isRefreshing,
|
||||
refresh,
|
||||
// Convenience property: has external terminals available
|
||||
hasExternalTerminals: terminals.length > 0,
|
||||
// The first terminal is the "default" one (highest priority)
|
||||
defaultTerminal: terminals[0] ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get the effective default terminal based on user settings
|
||||
* Returns null if user prefers integrated terminal (defaultTerminalId is null)
|
||||
* Falls back to: user preference > first available external terminal
|
||||
*/
|
||||
export function useEffectiveDefaultTerminal(terminals: TerminalInfo[]): TerminalInfo | null {
|
||||
const defaultTerminalId = useAppStore((s) => s.defaultTerminalId);
|
||||
|
||||
return useMemo(() => {
|
||||
// If user hasn't set a preference (null/undefined), they prefer integrated terminal
|
||||
if (defaultTerminalId == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If user has set a preference, find it in available terminals
|
||||
if (defaultTerminalId) {
|
||||
const found = terminals.find((t) => t.id === defaultTerminalId);
|
||||
if (found) return found;
|
||||
}
|
||||
|
||||
// If the saved preference doesn't exist anymore, fall back to first available
|
||||
return terminals[0] ?? null;
|
||||
}, [terminals, defaultTerminalId]);
|
||||
}
|
||||
@@ -93,7 +93,7 @@ export function useDevServerLogs({ worktreePath, autoSubscribe = true }: UseDevS
|
||||
isRunning: true,
|
||||
isLoading: false,
|
||||
port: result.result!.port,
|
||||
url: `http://localhost:${result.result!.port}`,
|
||||
url: result.result!.url,
|
||||
startedAt: result.result!.startedAt,
|
||||
error: null,
|
||||
}));
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
useSwitchBranch,
|
||||
usePullWorktree,
|
||||
@@ -7,7 +11,10 @@ import {
|
||||
} from '@/hooks/mutations';
|
||||
import type { WorktreeInfo } from '../types';
|
||||
|
||||
const logger = createLogger('WorktreeActions');
|
||||
|
||||
export function useWorktreeActions() {
|
||||
const navigate = useNavigate();
|
||||
const [isActivating, setIsActivating] = useState(false);
|
||||
|
||||
// Use React Query mutations
|
||||
@@ -45,6 +52,19 @@ export function useWorktreeActions() {
|
||||
[pushMutation]
|
||||
);
|
||||
|
||||
const handleOpenInIntegratedTerminal = useCallback(
|
||||
(worktree: WorktreeInfo, mode?: 'tab' | 'split') => {
|
||||
// Navigate to the terminal view with the worktree path and branch name
|
||||
// The terminal view will handle creating the terminal with the specified cwd
|
||||
// Include nonce to allow opening the same worktree multiple times
|
||||
navigate({
|
||||
to: '/terminal',
|
||||
search: { cwd: worktree.path, branch: worktree.branch, mode, nonce: Date.now() },
|
||||
});
|
||||
},
|
||||
[navigate]
|
||||
);
|
||||
|
||||
const handleOpenInEditor = useCallback(
|
||||
async (worktree: WorktreeInfo, editorCommand?: string) => {
|
||||
openInEditorMutation.mutate({
|
||||
@@ -55,6 +75,27 @@ export function useWorktreeActions() {
|
||||
[openInEditorMutation]
|
||||
);
|
||||
|
||||
const handleOpenInExternalTerminal = useCallback(
|
||||
async (worktree: WorktreeInfo, terminalId?: string) => {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.openInExternalTerminal) {
|
||||
logger.warn('Open in external terminal API not available');
|
||||
return;
|
||||
}
|
||||
const result = await api.worktree.openInExternalTerminal(worktree.path, terminalId);
|
||||
if (result.success && result.result) {
|
||||
toast.success(result.result.message);
|
||||
} else if (result.error) {
|
||||
toast.error(result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Open in external terminal failed:', error);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return {
|
||||
isPulling: pullMutation.isPending,
|
||||
isPushing: pushMutation.isPending,
|
||||
@@ -64,6 +105,8 @@ export function useWorktreeActions() {
|
||||
handleSwitchBranch,
|
||||
handlePull,
|
||||
handlePush,
|
||||
handleOpenInIntegratedTerminal,
|
||||
handleOpenInEditor,
|
||||
handleOpenInExternalTerminal,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
export interface WorktreePRInfo {
|
||||
number: number;
|
||||
url: string;
|
||||
title: string;
|
||||
state: string;
|
||||
createdAt: string;
|
||||
}
|
||||
// Re-export shared types from @automaker/types
|
||||
export type { PRState, WorktreePRInfo } from '@automaker/types';
|
||||
import type { PRState, WorktreePRInfo } from '@automaker/types';
|
||||
|
||||
export interface WorktreeInfo {
|
||||
path: string;
|
||||
@@ -43,7 +39,8 @@ export interface PRInfo {
|
||||
number: number;
|
||||
title: string;
|
||||
url: string;
|
||||
state: string;
|
||||
/** PR state: OPEN, MERGED, or CLOSED */
|
||||
state: PRState;
|
||||
author: string;
|
||||
body: string;
|
||||
comments: Array<{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { GitBranch, Plus, RefreshCw } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { cn, pathsEqual } from '@/lib/utils';
|
||||
import { toast } from 'sonner';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
@@ -79,7 +80,9 @@ export function WorktreePanel({
|
||||
handleSwitchBranch,
|
||||
handlePull,
|
||||
handlePush,
|
||||
handleOpenInIntegratedTerminal,
|
||||
handleOpenInEditor,
|
||||
handleOpenInExternalTerminal,
|
||||
} = useWorktreeActions();
|
||||
|
||||
const { hasRunningFeatures } = useRunningFeatures({
|
||||
@@ -225,6 +228,8 @@ export function WorktreePanel({
|
||||
onPull={handlePull}
|
||||
onPush={handlePush}
|
||||
onOpenInEditor={handleOpenInEditor}
|
||||
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
|
||||
onOpenInExternalTerminal={handleOpenInExternalTerminal}
|
||||
onCommit={onCommit}
|
||||
onCreatePR={onCreatePR}
|
||||
onAddressPRComments={onAddressPRComments}
|
||||
@@ -265,7 +270,7 @@ export function WorktreePanel({
|
||||
disabled={isLoading}
|
||||
title="Refresh worktrees"
|
||||
>
|
||||
<RefreshCw className={cn('w-3.5 h-3.5', isLoading && 'animate-spin')} />
|
||||
{isLoading ? <Spinner size="xs" /> : <RefreshCw className="w-3.5 h-3.5" />}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
@@ -312,6 +317,8 @@ export function WorktreePanel({
|
||||
onPull={handlePull}
|
||||
onPush={handlePush}
|
||||
onOpenInEditor={handleOpenInEditor}
|
||||
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
|
||||
onOpenInExternalTerminal={handleOpenInExternalTerminal}
|
||||
onCommit={onCommit}
|
||||
onCreatePR={onCreatePR}
|
||||
onAddressPRComments={onAddressPRComments}
|
||||
@@ -370,6 +377,8 @@ export function WorktreePanel({
|
||||
onPull={handlePull}
|
||||
onPush={handlePush}
|
||||
onOpenInEditor={handleOpenInEditor}
|
||||
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
|
||||
onOpenInExternalTerminal={handleOpenInExternalTerminal}
|
||||
onCommit={onCommit}
|
||||
onCreatePR={onCreatePR}
|
||||
onAddressPRComments={onAddressPRComments}
|
||||
@@ -409,7 +418,7 @@ export function WorktreePanel({
|
||||
disabled={isLoading}
|
||||
title="Refresh worktrees"
|
||||
>
|
||||
<RefreshCw className={cn('w-3.5 h-3.5', isLoading && 'animate-spin')} />
|
||||
{isLoading ? <Spinner size="xs" /> : <RefreshCw className="w-3.5 h-3.5" />}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user