diff --git a/apps/server/src/services/settings-service.ts b/apps/server/src/services/settings-service.ts index 15154655..7acd2ed1 100644 --- a/apps/server/src/services/settings-service.ts +++ b/apps/server/src/services/settings-service.ts @@ -615,7 +615,8 @@ export class SettingsService { appState.skipVerificationInAutoMode !== undefined ? (appState.skipVerificationInAutoMode as boolean) : false, - useWorktrees: (appState.useWorktrees as boolean) || false, + useWorktrees: + appState.useWorktrees !== undefined ? (appState.useWorktrees as boolean) : true, showProfilesOnly: (appState.showProfilesOnly as boolean) || false, defaultPlanningMode: (appState.defaultPlanningMode as GlobalSettings['defaultPlanningMode']) || 'skip', diff --git a/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx index 92934722..bae7ce50 100644 --- a/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx @@ -1,5 +1,5 @@ // @ts-nocheck -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { createLogger } from '@automaker/utils/logger'; import { Dialog, @@ -46,11 +46,12 @@ import { import { TestingTabContent, PrioritySelector, - BranchSelector, + WorkModeSelector, PlanningModeSelect, AncestorContextSection, ProfileTypeahead, } from '../shared'; +import type { WorkMode } from '../shared'; import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector'; import { ModelOverrideTrigger, useModelOverride } from '@/components/shared'; import { @@ -84,6 +85,7 @@ type FeatureData = { planningMode: PlanningMode; requirePlanApproval: boolean; dependencies?: string[]; + workMode: WorkMode; }; interface AddFeatureDialogProps { @@ -123,7 +125,7 @@ export function AddFeatureDialog({ }: AddFeatureDialogProps) { const isSpawnMode = !!parentFeature; const navigate = useNavigate(); - const [useCurrentBranch, setUseCurrentBranch] = useState(true); + const [workMode, setWorkMode] = useState('current'); // Form state const [title, setTitle] = useState(''); @@ -161,22 +163,27 @@ export function AddFeatureDialog({ const [selectedAncestorIds, setSelectedAncestorIds] = useState>(new Set()); // Get defaults from store - const { defaultPlanningMode, defaultRequirePlanApproval, defaultAIProfileId, useWorktrees } = - useAppStore(); + const { defaultPlanningMode, defaultRequirePlanApproval, defaultAIProfileId } = useAppStore(); // Enhancement model override const enhancementOverride = useModelOverride({ phase: 'enhancementModel' }); - // Sync defaults when dialog opens + // Track previous open state to detect when dialog opens + const wasOpenRef = useRef(false); + + // Sync defaults only when dialog opens (transitions from closed to open) useEffect(() => { - if (open) { + const justOpened = open && !wasOpenRef.current; + wasOpenRef.current = open; + + if (justOpened) { const defaultProfile = defaultAIProfileId ? aiProfiles.find((p) => p.id === defaultAIProfileId) : null; setSkipTests(defaultSkipTests); setBranchName(defaultBranch || ''); - setUseCurrentBranch(true); + setWorkMode('current'); setPlanningMode(defaultPlanningMode); setRequirePlanApproval(defaultRequirePlanApproval); @@ -248,7 +255,7 @@ export function AddFeatureDialog({ return null; } - if (useWorktrees && !useCurrentBranch && !branchName.trim()) { + if (workMode === 'custom' && !branchName.trim()) { toast.error('Please select a branch name'); return null; } @@ -262,7 +269,10 @@ export function AddFeatureDialog({ ? modelEntry.reasoningEffort || 'none' : 'none'; - const finalBranchName = useCurrentBranch ? currentBranch || '' : branchName || ''; + // For 'current' mode, use empty string (work on current branch) + // For 'auto' mode, use empty string (will be auto-generated in use-board-actions) + // For 'custom' mode, use the specified branch name + const finalBranchName = workMode === 'custom' ? branchName || '' : ''; // Build final description with ancestor context in spawn mode let finalDescription = description; @@ -303,6 +313,7 @@ export function AddFeatureDialog({ planningMode, requirePlanApproval, dependencies: isSpawnMode && parentFeature ? [parentFeature.id] : undefined, + workMode, }; }; @@ -318,7 +329,7 @@ export function AddFeatureDialog({ setPriority(2); setSelectedProfileId(undefined); setModelEntry({ model: 'opus' }); - setUseCurrentBranch(true); + setWorkMode('current'); setPlanningMode(defaultPlanningMode); setRequirePlanApproval(defaultRequirePlanApproval); setPreviewMap(new Map()); @@ -643,21 +654,19 @@ export function AddFeatureDialog({ - {/* Branch Selector */} - {useWorktrees && ( -
- -
- )} + {/* Work Mode Selector */} +
+ +
@@ -670,7 +679,7 @@ export function AddFeatureDialog({ onClick={handleAddAndStart} variant="secondary" data-testid="confirm-add-and-start-feature" - disabled={useWorktrees && !useCurrentBranch && !branchName.trim()} + disabled={workMode === 'custom' && !branchName.trim()} > Make @@ -681,7 +690,7 @@ export function AddFeatureDialog({ hotkey={{ key: 'Enter', cmdCtrl: true }} hotkeyActive={open} data-testid="confirm-add-feature" - disabled={useWorktrees && !useCurrentBranch && !branchName.trim()} + disabled={workMode === 'custom' && !branchName.trim()} > {isSpawnMode ? 'Spawn Task' : 'Add Feature'} diff --git a/apps/ui/src/components/views/board-view/dialogs/edit-feature-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/edit-feature-dialog.tsx index 3a21436c..19b051f5 100644 --- a/apps/ui/src/components/views/board-view/dialogs/edit-feature-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/edit-feature-dialog.tsx @@ -46,10 +46,11 @@ import type { ReasoningEffort, PhaseModelEntry, DescriptionHistoryEntry } from ' import { TestingTabContent, PrioritySelector, - BranchSelector, + WorkModeSelector, PlanningModeSelect, ProfileTypeahead, } from '../shared'; +import type { WorkMode } from '../shared'; import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector'; import { ModelOverrideTrigger, useModelOverride } from '@/components/shared'; import { @@ -118,9 +119,11 @@ export function EditFeatureDialog({ }: EditFeatureDialogProps) { const navigate = useNavigate(); const [editingFeature, setEditingFeature] = useState(feature); - const [useCurrentBranch, setUseCurrentBranch] = useState(() => { - // If feature has no branchName, default to using current branch - return !feature?.branchName; + // Derive initial workMode from feature's branchName + const [workMode, setWorkMode] = useState(() => { + // If feature has a branchName, it's using 'custom' mode + // Otherwise, it's on 'current' branch (no worktree isolation) + return feature?.branchName ? 'custom' : 'current'; }); const [editFeaturePreviewMap, setEditFeaturePreviewMap] = useState( () => new Map() @@ -156,9 +159,6 @@ export function EditFeatureDialog({ // Track if history dropdown is open const [showHistory, setShowHistory] = useState(false); - // Get worktrees setting from store - const { useWorktrees } = useAppStore(); - // Enhancement model override const enhancementOverride = useModelOverride({ phase: 'enhancementModel' }); @@ -167,8 +167,8 @@ export function EditFeatureDialog({ if (feature) { setPlanningMode(feature.planningMode ?? 'skip'); setRequirePlanApproval(feature.requirePlanApproval ?? false); - // If feature has no branchName, default to using current branch - setUseCurrentBranch(!feature.branchName); + // Derive workMode from feature's branchName + setWorkMode(feature.branchName ? 'custom' : 'current'); // Reset history tracking state setOriginalDescription(feature.description ?? ''); setDescriptionChangeSource(null); @@ -222,14 +222,9 @@ export function EditFeatureDialog({ const handleUpdate = () => { if (!editingFeature) return; - // Validate branch selection when "other branch" is selected and branch selector is enabled + // Validate branch selection for custom mode const isBranchSelectorEnabled = editingFeature.status === 'backlog'; - if ( - useWorktrees && - isBranchSelectorEnabled && - !useCurrentBranch && - !editingFeature.branchName?.trim() - ) { + if (isBranchSelectorEnabled && workMode === 'custom' && !editingFeature.branchName?.trim()) { toast.error('Please select a branch name'); return; } @@ -242,12 +237,10 @@ export function EditFeatureDialog({ ? (modelEntry.reasoningEffort ?? 'none') : 'none'; - // Use current branch if toggle is on - // If currentBranch is provided (non-primary worktree), use it - // Otherwise (primary worktree), use empty string which means "unassigned" (show only on primary) - const finalBranchName = useCurrentBranch - ? currentBranch || '' - : editingFeature.branchName || ''; + // For 'current' mode, use empty string (work on current branch) + // For 'auto' mode, use empty string (will be auto-generated in use-board-actions) + // For 'custom' mode, use the specified branch name + const finalBranchName = workMode === 'custom' ? editingFeature.branchName || '' : ''; const updates = { title: editingFeature.title ?? '', @@ -263,6 +256,7 @@ export function EditFeatureDialog({ priority: editingFeature.priority ?? 2, planningMode, requirePlanApproval, + workMode, }; // Determine if description changed and what source to use @@ -688,27 +682,25 @@ export function EditFeatureDialog({ - {/* Branch Selector */} - {useWorktrees && ( -
- - setEditingFeature({ - ...editingFeature, - branchName: value, - }) - } - branchSuggestions={branchSuggestions} - branchCardCounts={branchCardCounts} - currentBranch={currentBranch} - disabled={editingFeature.status !== 'backlog'} - testIdPrefix="edit-feature" - /> -
- )} + {/* Work Mode Selector */} +
+ + setEditingFeature({ + ...editingFeature, + branchName: value, + }) + } + branchSuggestions={branchSuggestions} + branchCardCounts={branchCardCounts} + currentBranch={currentBranch} + disabled={editingFeature.status !== 'backlog'} + testIdPrefix="edit-feature-work-mode" + /> +
@@ -731,9 +723,8 @@ export function EditFeatureDialog({ hotkeyActive={!!editingFeature} data-testid="confirm-edit-feature" disabled={ - useWorktrees && editingFeature.status === 'backlog' && - !useCurrentBranch && + workMode === 'custom' && !editingFeature.branchName?.trim() } > diff --git a/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts b/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts index 6857bde9..074e900d 100644 --- a/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts +++ b/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts @@ -111,14 +111,32 @@ export function useBoardActions({ planningMode: PlanningMode; requirePlanApproval: boolean; dependencies?: string[]; + workMode?: 'current' | 'auto' | 'custom'; }) => { - // Empty string means "unassigned" (show only on primary worktree) - convert to undefined - // Non-empty string is the actual branch name (for non-primary worktrees) - const finalBranchName = featureData.branchName || undefined; + const workMode = featureData.workMode || 'current'; - // If worktrees enabled and a branch is specified, create the worktree now - // This ensures the worktree exists before the feature starts - if (useWorktrees && finalBranchName && currentProject) { + // Determine final branch name based on work mode: + // - 'current': No branch name, work on current branch (no worktree) + // - 'auto': Auto-generate branch name based on current branch + // - 'custom': Use the provided branch name + let finalBranchName: string | undefined; + + if (workMode === 'current') { + // 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'; + const timestamp = Date.now(); + const randomSuffix = Math.random().toString(36).substring(2, 6); + finalBranchName = `feature/${baseBranch}-${timestamp}-${randomSuffix}`; + } else { + // Custom mode - use provided branch name + finalBranchName = featureData.branchName || undefined; + } + + // Create worktree for 'auto' or 'custom' modes when we have a branch name + if ((workMode === 'auto' || workMode === 'custom') && finalBranchName && currentProject) { try { const api = getElectronAPI(); if (api?.worktree?.create) { @@ -207,10 +225,10 @@ export function useBoardActions({ persistFeatureUpdate, updateFeature, saveCategory, - useWorktrees, currentProject, onWorktreeCreated, onWorktreeAutoSelect, + currentWorktreeBranch, ] ); @@ -230,15 +248,29 @@ export function useBoardActions({ priority: number; planningMode?: PlanningMode; requirePlanApproval?: boolean; + workMode?: 'current' | 'auto' | 'custom'; }, descriptionHistorySource?: 'enhance' | 'edit', enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' ) => { - const finalBranchName = updates.branchName || undefined; + const workMode = updates.workMode || 'current'; - // If worktrees enabled and a branch is specified, create the worktree now - // This ensures the worktree exists before the feature starts - if (useWorktrees && finalBranchName && currentProject) { + // Determine final branch name based on work mode + let finalBranchName: string | undefined; + + if (workMode === 'current') { + finalBranchName = undefined; + } else if (workMode === 'auto') { + const baseBranch = currentWorktreeBranch || 'main'; + const timestamp = Date.now(); + const randomSuffix = Math.random().toString(36).substring(2, 6); + finalBranchName = `feature/${baseBranch}-${timestamp}-${randomSuffix}`; + } else { + finalBranchName = updates.branchName || undefined; + } + + // Create worktree for 'auto' or 'custom' modes when we have a branch name + if ((workMode === 'auto' || workMode === 'custom') && finalBranchName && currentProject) { try { const api = getElectronAPI(); if (api?.worktree?.create) { @@ -287,9 +319,9 @@ export function useBoardActions({ persistFeatureUpdate, saveCategory, setEditingFeature, - useWorktrees, currentProject, onWorktreeCreated, + currentWorktreeBranch, ] ); diff --git a/apps/ui/src/components/views/board-view/shared/index.ts b/apps/ui/src/components/views/board-view/shared/index.ts index 5b16449e..6abe1855 100644 --- a/apps/ui/src/components/views/board-view/shared/index.ts +++ b/apps/ui/src/components/views/board-view/shared/index.ts @@ -12,3 +12,4 @@ export * from './branch-selector'; export * from './planning-mode-selector'; export * from './planning-mode-select'; export * from './ancestor-context-section'; +export * from './work-mode-selector'; diff --git a/apps/ui/src/components/views/board-view/shared/work-mode-selector.tsx b/apps/ui/src/components/views/board-view/shared/work-mode-selector.tsx new file mode 100644 index 00000000..b2e7c274 --- /dev/null +++ b/apps/ui/src/components/views/board-view/shared/work-mode-selector.tsx @@ -0,0 +1,163 @@ +'use client'; + +import { Label } from '@/components/ui/label'; +import { BranchAutocomplete } from '@/components/ui/branch-autocomplete'; +import { GitBranch, GitFork, Pencil } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +export type WorkMode = 'current' | 'auto' | 'custom'; + +interface WorkModeSelectorProps { + workMode: WorkMode; + onWorkModeChange: (mode: WorkMode) => void; + branchName: string; + onBranchNameChange: (branchName: string) => void; + branchSuggestions: string[]; + branchCardCounts?: Record; + currentBranch?: string; + disabled?: boolean; + testIdPrefix?: string; +} + +const WORK_MODES = [ + { + value: 'current' as const, + label: 'Current Branch', + description: 'Work directly on the selected branch', + icon: GitBranch, + }, + { + value: 'auto' as const, + label: 'Auto Worktree', + description: 'Create isolated worktree automatically', + icon: GitFork, + }, + { + value: 'custom' as const, + label: 'Custom Branch', + description: 'Specify a branch name', + icon: Pencil, + }, +]; + +export function WorkModeSelector({ + workMode, + onWorkModeChange, + branchName, + onBranchNameChange, + branchSuggestions, + branchCardCounts, + currentBranch, + disabled = false, + testIdPrefix = 'work-mode', +}: WorkModeSelectorProps) { + const hasError = workMode === 'custom' && !branchName.trim(); + + return ( +
+ + +
+ {WORK_MODES.map((mode) => { + const isSelected = workMode === mode.value; + const Icon = mode.icon; + return ( + + ); + })} +
+ + {/* Description text based on selected mode */} +

+ {workMode === 'current' && ( + <> + Work will be done directly on{' '} + {currentBranch ? ( + {currentBranch} + ) : ( + 'the current branch' + )} + . No isolation. + + )} + {workMode === 'auto' && ( + <> + A new worktree will be created automatically based on{' '} + {currentBranch ? ( + {currentBranch} + ) : ( + 'the current branch' + )}{' '} + when this card is created. + + )} + {workMode === 'custom' && ( + <>Specify a branch name below. A worktree will be created if it doesn't exist. + )} +

+ + {/* Branch input for custom mode */} + {workMode === 'custom' && ( +
+ + {hasError && ( +

+ Branch name is required for custom branch mode. +

+ )} +
+ )} + + {disabled && ( +

+ Work mode cannot be changed after work has started. +

+ )} +
+ ); +} diff --git a/apps/ui/src/components/views/settings-view/feature-defaults/feature-defaults-section.tsx b/apps/ui/src/components/views/settings-view/feature-defaults/feature-defaults-section.tsx index d55522bf..3089f02f 100644 --- a/apps/ui/src/components/views/settings-view/feature-defaults/feature-defaults-section.tsx +++ b/apps/ui/src/components/views/settings-view/feature-defaults/feature-defaults-section.tsx @@ -358,9 +358,6 @@ export function FeatureDefaultsSection({ > Enable Git Worktree Isolation - - experimental -

Creates isolated git branches for each feature. When disabled, agents work directly in diff --git a/apps/ui/src/hooks/use-settings-migration.ts b/apps/ui/src/hooks/use-settings-migration.ts index 5939f645..29ddeb5d 100644 --- a/apps/ui/src/hooks/use-settings-migration.ts +++ b/apps/ui/src/hooks/use-settings-migration.ts @@ -560,7 +560,7 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void { defaultSkipTests: settings.defaultSkipTests ?? true, enableDependencyBlocking: settings.enableDependencyBlocking ?? true, skipVerificationInAutoMode: settings.skipVerificationInAutoMode ?? false, - useWorktrees: settings.useWorktrees ?? false, + useWorktrees: settings.useWorktrees ?? true, showProfilesOnly: settings.showProfilesOnly ?? false, defaultPlanningMode: settings.defaultPlanningMode ?? 'skip', defaultRequirePlanApproval: settings.defaultRequirePlanApproval ?? false, diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index 2f55ab96..78d6e65c 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -523,7 +523,7 @@ export interface AppState { skipVerificationInAutoMode: boolean; // When true, auto-mode grabs features even if dependencies are not verified (only checks they're not running) // Worktree Settings - useWorktrees: boolean; // Whether to use git worktree isolation for features (default: false) + useWorktrees: boolean; // Whether to use git worktree isolation for features (default: true) // User-managed Worktrees (per-project) // projectPath -> { path: worktreePath or null for main, branch: branch name } @@ -1172,7 +1172,7 @@ const initialState: AppState = { defaultSkipTests: true, // Default to manual verification (tests disabled) enableDependencyBlocking: true, // Default to enabled (show dependency blocking UI) skipVerificationInAutoMode: false, // Default to disabled (require dependencies to be verified) - useWorktrees: false, // Default to disabled (worktree feature is experimental) + useWorktrees: true, // Default to enabled (git worktree isolation) currentWorktreeByProject: {}, worktreesByProject: {}, showProfilesOnly: false, // Default to showing all options (not profiles only) diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts index 50854ca7..6f13c8a3 100644 --- a/libs/types/src/settings.ts +++ b/libs/types/src/settings.ts @@ -785,7 +785,7 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = { defaultSkipTests: true, enableDependencyBlocking: true, skipVerificationInAutoMode: false, - useWorktrees: false, + useWorktrees: true, showProfilesOnly: false, defaultPlanningMode: 'skip', defaultRequirePlanApproval: false,