feat: implement work mode selection in feature dialogs

- Added WorkModeSelector component to allow users to choose between 'current', 'auto', and 'custom' work modes for feature management.
- Updated AddFeatureDialog and EditFeatureDialog to utilize the new work mode functionality, replacing the previous branch selector logic.
- Enhanced useBoardActions hook to handle branch name generation based on the selected work mode.
- Adjusted settings to default to using worktrees, improving the overall feature creation experience.

These changes streamline the feature management process by providing clearer options for branch handling and worktree isolation.
This commit is contained in:
webdevcody
2026-01-09 16:15:09 -05:00
parent 93807c22c1
commit 7ea64b32f3
10 changed files with 287 additions and 93 deletions

View File

@@ -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<WorkMode>('current');
// Form state
const [title, setTitle] = useState('');
@@ -161,22 +163,27 @@ export function AddFeatureDialog({
const [selectedAncestorIds, setSelectedAncestorIds] = useState<Set<string>>(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({
</div>
</div>
{/* Branch Selector */}
{useWorktrees && (
<div className="pt-2">
<BranchSelector
useCurrentBranch={useCurrentBranch}
onUseCurrentBranchChange={setUseCurrentBranch}
branchName={branchName}
onBranchNameChange={setBranchName}
branchSuggestions={branchSuggestions}
branchCardCounts={branchCardCounts}
currentBranch={currentBranch}
testIdPrefix="feature"
/>
</div>
)}
{/* Work Mode Selector */}
<div className="pt-2">
<WorkModeSelector
workMode={workMode}
onWorkModeChange={setWorkMode}
branchName={branchName}
onBranchNameChange={setBranchName}
branchSuggestions={branchSuggestions}
branchCardCounts={branchCardCounts}
currentBranch={currentBranch}
testIdPrefix="feature-work-mode"
/>
</div>
</div>
</div>
@@ -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()}
>
<Play className="w-4 h-4 mr-2" />
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'}
</HotkeyButton>

View File

@@ -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 | null>(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<WorkMode>(() => {
// 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<ImagePreviewMap>(
() => 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({
</div>
</div>
{/* Branch Selector */}
{useWorktrees && (
<div className="pt-2">
<BranchSelector
useCurrentBranch={useCurrentBranch}
onUseCurrentBranchChange={setUseCurrentBranch}
branchName={editingFeature.branchName ?? ''}
onBranchNameChange={(value) =>
setEditingFeature({
...editingFeature,
branchName: value,
})
}
branchSuggestions={branchSuggestions}
branchCardCounts={branchCardCounts}
currentBranch={currentBranch}
disabled={editingFeature.status !== 'backlog'}
testIdPrefix="edit-feature"
/>
</div>
)}
{/* Work Mode Selector */}
<div className="pt-2">
<WorkModeSelector
workMode={workMode}
onWorkModeChange={setWorkMode}
branchName={editingFeature.branchName ?? ''}
onBranchNameChange={(value) =>
setEditingFeature({
...editingFeature,
branchName: value,
})
}
branchSuggestions={branchSuggestions}
branchCardCounts={branchCardCounts}
currentBranch={currentBranch}
disabled={editingFeature.status !== 'backlog'}
testIdPrefix="edit-feature-work-mode"
/>
</div>
</div>
</div>
@@ -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()
}
>

View File

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

View File

@@ -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';

View File

@@ -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<string, number>;
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 (
<div className="space-y-3">
<Label id={`${testIdPrefix}-label`}>Work Mode</Label>
<div className="grid grid-cols-3 gap-2">
{WORK_MODES.map((mode) => {
const isSelected = workMode === mode.value;
const Icon = mode.icon;
return (
<button
key={mode.value}
type="button"
onClick={() => !disabled && onWorkModeChange(mode.value)}
disabled={disabled}
data-testid={`${testIdPrefix}-${mode.value}`}
className={cn(
'flex flex-col items-center gap-1.5 p-3 rounded-lg cursor-pointer transition-all duration-200',
'border-2 hover:border-primary/50',
isSelected
? 'border-primary bg-primary/10'
: 'border-border/50 bg-card/50 hover:bg-accent/30',
disabled && 'opacity-50 cursor-not-allowed'
)}
>
<div
className={cn(
'w-8 h-8 rounded-full flex items-center justify-center transition-colors',
isSelected ? 'bg-primary/20' : 'bg-muted'
)}
>
<Icon
className={cn(
'h-4 w-4 transition-colors',
isSelected ? 'text-primary' : 'text-muted-foreground'
)}
/>
</div>
<span
className={cn(
'font-medium text-xs text-center',
isSelected ? 'text-foreground' : 'text-muted-foreground'
)}
>
{mode.label}
</span>
</button>
);
})}
</div>
{/* Description text based on selected mode */}
<p className="text-xs text-muted-foreground">
{workMode === 'current' && (
<>
Work will be done directly on{' '}
{currentBranch ? (
<span className="font-medium">{currentBranch}</span>
) : (
'the current branch'
)}
. No isolation.
</>
)}
{workMode === 'auto' && (
<>
A new worktree will be created automatically based on{' '}
{currentBranch ? (
<span className="font-medium">{currentBranch}</span>
) : (
'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.</>
)}
</p>
{/* Branch input for custom mode */}
{workMode === 'custom' && (
<div className="space-y-1">
<BranchAutocomplete
value={branchName}
onChange={onBranchNameChange}
branches={branchSuggestions}
branchCardCounts={branchCardCounts}
placeholder="Select or create branch..."
data-testid={`${testIdPrefix}-branch-input`}
disabled={disabled}
error={hasError}
/>
{hasError && (
<p className="text-xs text-destructive">
Branch name is required for custom branch mode.
</p>
)}
</div>
)}
{disabled && (
<p className="text-xs text-muted-foreground italic">
Work mode cannot be changed after work has started.
</p>
)}
</div>
);
}

View File

@@ -358,9 +358,6 @@ export function FeatureDefaultsSection({
>
<GitBranch className="w-4 h-4 text-brand-500" />
Enable Git Worktree Isolation
<span className="text-[10px] px-1.5 py-0.5 rounded-md bg-amber-500/15 text-amber-500 border border-amber-500/20 font-medium">
experimental
</span>
</Label>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
Creates isolated git branches for each feature. When disabled, agents work directly in