Merge pull request #468 from AutoMaker-Org/feat/add-branch-name-to-mass-edit

feat: add branch/worktree support to mass edit dialog
This commit is contained in:
Shirone
2026-01-13 17:43:25 +00:00
committed by GitHub
4 changed files with 198 additions and 38 deletions

View File

@@ -129,10 +129,30 @@ export const TOOL_PRESETS = {
specGeneration: ['Read', 'Glob', 'Grep'] as const, specGeneration: ['Read', 'Glob', 'Grep'] as const,
/** Full tool access for feature implementation */ /** Full tool access for feature implementation */
fullAccess: ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch', 'TodoWrite'] as const, fullAccess: [
'Read',
'Write',
'Edit',
'Glob',
'Grep',
'Bash',
'WebSearch',
'WebFetch',
'TodoWrite',
] as const,
/** Tools for chat/interactive mode */ /** Tools for chat/interactive mode */
chat: ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch', 'TodoWrite'] as const, chat: [
'Read',
'Write',
'Edit',
'Glob',
'Grep',
'Bash',
'WebSearch',
'WebFetch',
'TodoWrite',
] as const,
} as const; } as const;
/** /**

View File

@@ -422,6 +422,31 @@ export function BoardView() {
const selectedWorktreeBranch = const selectedWorktreeBranch =
currentWorktreeBranch || worktrees.find((w) => w.isMain)?.branch || 'main'; currentWorktreeBranch || worktrees.find((w) => w.isMain)?.branch || 'main';
// Helper function to add and select a worktree
const addAndSelectWorktree = useCallback(
(worktreeResult: { path: string; branch: string }) => {
if (!currentProject) return;
const currentWorktrees = getWorktrees(currentProject.path);
const existingWorktree = currentWorktrees.find((w) => w.branch === worktreeResult.branch);
// Only add if it doesn't already exist (to avoid duplicates)
if (!existingWorktree) {
const newWorktreeInfo = {
path: worktreeResult.path,
branch: worktreeResult.branch,
isMain: false,
isCurrent: false,
hasWorktree: true,
};
setWorktrees(currentProject.path, [...currentWorktrees, newWorktreeInfo]);
}
// Select the worktree (whether it existed or was just added)
setCurrentWorktree(currentProject.path, worktreeResult.path, worktreeResult.branch);
},
[currentProject, getWorktrees, setWorktrees, setCurrentWorktree]
);
// Extract all action handlers into a hook // Extract all action handlers into a hook
const { const {
handleAddFeature, handleAddFeature,
@@ -467,43 +492,90 @@ export function BoardView() {
outputFeature, outputFeature,
projectPath: currentProject?.path || null, projectPath: currentProject?.path || null,
onWorktreeCreated: () => setWorktreeRefreshKey((k) => k + 1), onWorktreeCreated: () => setWorktreeRefreshKey((k) => k + 1),
onWorktreeAutoSelect: (newWorktree) => { onWorktreeAutoSelect: addAndSelectWorktree,
if (!currentProject) return;
// Check if worktree already exists in the store (by branch name)
const currentWorktrees = getWorktrees(currentProject.path);
const existingWorktree = currentWorktrees.find((w) => w.branch === newWorktree.branch);
// Only add if it doesn't already exist (to avoid duplicates)
if (!existingWorktree) {
const newWorktreeInfo = {
path: newWorktree.path,
branch: newWorktree.branch,
isMain: false,
isCurrent: false,
hasWorktree: true,
};
setWorktrees(currentProject.path, [...currentWorktrees, newWorktreeInfo]);
}
// Select the worktree (whether it existed or was just added)
setCurrentWorktree(currentProject.path, newWorktree.path, newWorktree.branch);
},
currentWorktreeBranch, currentWorktreeBranch,
}); });
// Handler for bulk updating multiple features // Handler for bulk updating multiple features
const handleBulkUpdate = useCallback( const handleBulkUpdate = useCallback(
async (updates: Partial<Feature>) => { async (updates: Partial<Feature>, workMode: 'current' | 'auto' | 'custom') => {
if (!currentProject || selectedFeatureIds.size === 0) return; if (!currentProject || selectedFeatureIds.size === 0) return;
try { try {
// 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 || getPrimaryWorktreeBranch(currentProject.path) || '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 = updates.branchName || undefined;
}
// Create worktree for 'auto' or 'custom' modes when we have a branch name
if ((workMode === 'auto' || workMode === 'custom') && finalBranchName) {
try {
const electronApi = getElectronAPI();
if (electronApi?.worktree?.create) {
const result = await electronApi.worktree.create(
currentProject.path,
finalBranchName
);
if (result.success && result.worktree) {
logger.info(
`Worktree for branch "${finalBranchName}" ${
result.worktree?.isNew ? 'created' : 'already exists'
}`
);
// Auto-select the worktree when creating/using it for bulk update
addAndSelectWorktree(result.worktree);
// Refresh worktree list in UI
setWorktreeRefreshKey((k) => k + 1);
} else if (!result.success) {
logger.error(
`Failed to create worktree for branch "${finalBranchName}":`,
result.error
);
toast.error('Failed to create worktree', {
description: result.error || 'An error occurred',
});
return; // Don't proceed with update if worktree creation failed
}
}
} catch (error) {
logger.error('Error creating worktree:', error);
toast.error('Failed to create worktree', {
description: error instanceof Error ? error.message : 'An error occurred',
});
return; // Don't proceed with update if worktree creation failed
}
}
// Use the final branch name in updates
const finalUpdates = {
...updates,
branchName: finalBranchName,
};
const api = getHttpApiClient(); const api = getHttpApiClient();
const featureIds = Array.from(selectedFeatureIds); const featureIds = Array.from(selectedFeatureIds);
const result = await api.features.bulkUpdate(currentProject.path, featureIds, updates); const result = await api.features.bulkUpdate(currentProject.path, featureIds, finalUpdates);
if (result.success) { if (result.success) {
// Update local state // Update local state
featureIds.forEach((featureId) => { featureIds.forEach((featureId) => {
updateFeature(featureId, updates); updateFeature(featureId, finalUpdates);
}); });
toast.success(`Updated ${result.updatedCount} features`); toast.success(`Updated ${result.updatedCount} features`);
exitSelectionMode(); exitSelectionMode();
@@ -517,7 +589,16 @@ export function BoardView() {
toast.error('Failed to update features'); toast.error('Failed to update features');
} }
}, },
[currentProject, selectedFeatureIds, updateFeature, exitSelectionMode] [
currentProject,
selectedFeatureIds,
updateFeature,
exitSelectionMode,
currentWorktreeBranch,
getPrimaryWorktreeBranch,
addAndSelectWorktree,
setWorktreeRefreshKey,
]
); );
// Handler for bulk deleting multiple features // Handler for bulk deleting multiple features
@@ -1325,6 +1406,9 @@ export function BoardView() {
onClose={() => setShowMassEditDialog(false)} onClose={() => setShowMassEditDialog(false)}
selectedFeatures={selectedFeatures} selectedFeatures={selectedFeatures}
onApply={handleBulkUpdate} onApply={handleBulkUpdate}
branchSuggestions={branchSuggestions}
branchCardCounts={branchCardCounts}
currentBranch={currentWorktreeBranch || undefined}
/> />
{/* Board Background Modal */} {/* Board Background Modal */}

View File

@@ -13,7 +13,8 @@ import { Label } from '@/components/ui/label';
import { AlertCircle } from 'lucide-react'; import { AlertCircle } from 'lucide-react';
import { modelSupportsThinking } from '@/lib/utils'; import { modelSupportsThinking } from '@/lib/utils';
import { Feature, ModelAlias, ThinkingLevel, PlanningMode } from '@/store/app-store'; import { Feature, ModelAlias, ThinkingLevel, PlanningMode } from '@/store/app-store';
import { TestingTabContent, PrioritySelect, PlanningModeSelect } from '../shared'; import { TestingTabContent, PrioritySelect, PlanningModeSelect, WorkModeSelector } from '../shared';
import type { WorkMode } from '../shared';
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector'; import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
import { isCursorModel, isClaudeModel, type PhaseModelEntry } from '@automaker/types'; import { isCursorModel, isClaudeModel, type PhaseModelEntry } from '@automaker/types';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -23,7 +24,10 @@ interface MassEditDialogProps {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
selectedFeatures: Feature[]; selectedFeatures: Feature[];
onApply: (updates: Partial<Feature>) => Promise<void>; onApply: (updates: Partial<Feature>, workMode: WorkMode) => Promise<void>;
branchSuggestions: string[];
branchCardCounts?: Record<string, number>;
currentBranch?: string;
} }
interface ApplyState { interface ApplyState {
@@ -33,6 +37,7 @@ interface ApplyState {
requirePlanApproval: boolean; requirePlanApproval: boolean;
priority: boolean; priority: boolean;
skipTests: boolean; skipTests: boolean;
branchName: boolean;
} }
function getMixedValues(features: Feature[]): Record<string, boolean> { function getMixedValues(features: Feature[]): Record<string, boolean> {
@@ -47,6 +52,7 @@ function getMixedValues(features: Feature[]): Record<string, boolean> {
), ),
priority: !features.every((f) => f.priority === first.priority), priority: !features.every((f) => f.priority === first.priority),
skipTests: !features.every((f) => f.skipTests === first.skipTests), skipTests: !features.every((f) => f.skipTests === first.skipTests),
branchName: !features.every((f) => f.branchName === first.branchName),
}; };
} }
@@ -97,7 +103,15 @@ function FieldWrapper({ label, isMixed, willApply, onApplyChange, children }: Fi
); );
} }
export function MassEditDialog({ open, onClose, selectedFeatures, onApply }: MassEditDialogProps) { export function MassEditDialog({
open,
onClose,
selectedFeatures,
onApply,
branchSuggestions,
branchCardCounts,
currentBranch,
}: MassEditDialogProps) {
const [isApplying, setIsApplying] = useState(false); const [isApplying, setIsApplying] = useState(false);
// Track which fields to apply // Track which fields to apply
@@ -108,6 +122,7 @@ export function MassEditDialog({ open, onClose, selectedFeatures, onApply }: Mas
requirePlanApproval: false, requirePlanApproval: false,
priority: false, priority: false,
skipTests: false, skipTests: false,
branchName: false,
}); });
// Field values // Field values
@@ -118,6 +133,18 @@ export function MassEditDialog({ open, onClose, selectedFeatures, onApply }: Mas
const [priority, setPriority] = useState(2); const [priority, setPriority] = useState(2);
const [skipTests, setSkipTests] = useState(false); const [skipTests, setSkipTests] = useState(false);
// Work mode and branch name state
const [workMode, setWorkMode] = useState<WorkMode>(() => {
// Derive initial work mode from first selected feature's branchName
if (selectedFeatures.length > 0 && selectedFeatures[0].branchName) {
return 'custom';
}
return 'current';
});
const [branchName, setBranchName] = useState(() => {
return getInitialValue(selectedFeatures, 'branchName', '') as string;
});
// Calculate mixed values // Calculate mixed values
const mixedValues = useMemo(() => getMixedValues(selectedFeatures), [selectedFeatures]); const mixedValues = useMemo(() => getMixedValues(selectedFeatures), [selectedFeatures]);
@@ -131,6 +158,7 @@ export function MassEditDialog({ open, onClose, selectedFeatures, onApply }: Mas
requirePlanApproval: false, requirePlanApproval: false,
priority: false, priority: false,
skipTests: false, skipTests: false,
branchName: false,
}); });
setModel(getInitialValue(selectedFeatures, 'model', 'sonnet') as ModelAlias); setModel(getInitialValue(selectedFeatures, 'model', 'sonnet') as ModelAlias);
setThinkingLevel(getInitialValue(selectedFeatures, 'thinkingLevel', 'none') as ThinkingLevel); setThinkingLevel(getInitialValue(selectedFeatures, 'thinkingLevel', 'none') as ThinkingLevel);
@@ -138,6 +166,10 @@ export function MassEditDialog({ open, onClose, selectedFeatures, onApply }: Mas
setRequirePlanApproval(getInitialValue(selectedFeatures, 'requirePlanApproval', false)); setRequirePlanApproval(getInitialValue(selectedFeatures, 'requirePlanApproval', false));
setPriority(getInitialValue(selectedFeatures, 'priority', 2)); setPriority(getInitialValue(selectedFeatures, 'priority', 2));
setSkipTests(getInitialValue(selectedFeatures, 'skipTests', false)); setSkipTests(getInitialValue(selectedFeatures, 'skipTests', false));
// Reset work mode and branch name
const initialBranchName = getInitialValue(selectedFeatures, 'branchName', '') as string;
setBranchName(initialBranchName);
setWorkMode(initialBranchName ? 'custom' : 'current');
} }
}, [open, selectedFeatures]); }, [open, selectedFeatures]);
@@ -150,6 +182,12 @@ export function MassEditDialog({ open, onClose, selectedFeatures, onApply }: Mas
if (applyState.requirePlanApproval) updates.requirePlanApproval = requirePlanApproval; if (applyState.requirePlanApproval) updates.requirePlanApproval = requirePlanApproval;
if (applyState.priority) updates.priority = priority; if (applyState.priority) updates.priority = priority;
if (applyState.skipTests) updates.skipTests = skipTests; if (applyState.skipTests) updates.skipTests = skipTests;
if (applyState.branchName) {
// For 'current' mode, use empty string (work on current branch)
// For 'auto' mode, use empty string (will be auto-generated)
// For 'custom' mode, use the specified branch name
updates.branchName = workMode === 'custom' ? branchName : '';
}
if (Object.keys(updates).length === 0) { if (Object.keys(updates).length === 0) {
onClose(); onClose();
@@ -158,7 +196,7 @@ export function MassEditDialog({ open, onClose, selectedFeatures, onApply }: Mas
setIsApplying(true); setIsApplying(true);
try { try {
await onApply(updates); await onApply(updates, workMode);
onClose(); onClose();
} finally { } finally {
setIsApplying(false); setIsApplying(false);
@@ -293,6 +331,25 @@ export function MassEditDialog({ open, onClose, selectedFeatures, onApply }: Mas
testIdPrefix="mass-edit" testIdPrefix="mass-edit"
/> />
</FieldWrapper> </FieldWrapper>
{/* Branch / Work Mode */}
<FieldWrapper
label="Branch / Work Mode"
isMixed={mixedValues.branchName}
willApply={applyState.branchName}
onApplyChange={(apply) => setApplyState((prev) => ({ ...prev, branchName: apply }))}
>
<WorkModeSelector
workMode={workMode}
onWorkModeChange={setWorkMode}
branchName={branchName}
onBranchNameChange={setBranchName}
branchSuggestions={branchSuggestions}
branchCardCounts={branchCardCounts}
currentBranch={currentBranch}
testIdPrefix="mass-edit-work-mode"
/>
</FieldWrapper>
</div> </div>
<DialogFooter> <DialogFooter>

View File

@@ -136,12 +136,11 @@ volumes:
## Troubleshooting ## Troubleshooting
| Problem | Solution | | Problem | Solution |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Container won't start | Check `.env` has `ANTHROPIC_API_KEY` set. Run `docker-compose logs` for errors. | | Container won't start | Check `.env` has `ANTHROPIC_API_KEY` set. Run `docker-compose logs` for errors. |
| Can't access web UI | Verify container is running with `docker ps \| grep automaker` | | Can't access web UI | Verify container is running with `docker ps \| grep automaker` |
| Need a fresh start | Run `docker-compose down && docker volume rm automaker-data && docker-compose up -d --build` | | Need a fresh start | Run `docker-compose down && docker volume rm automaker-data && docker-compose up -d --build` |
| Cursor auth fails | Re-extract token with `./scripts/get-cursor-token.sh` - tokens expire periodically. Make sure you've run `cursor-agent login` on your host first. | | Cursor auth fails | Re-extract token with `./scripts/get-cursor-token.sh` - tokens expire periodically. Make sure you've run `cursor-agent login` on your host first. |
| OpenCode not detected | Mount `~/.local/share/opencode` to `/home/automaker/.local/share/opencode`. Make sure you've run `opencode auth login` on your host first. | | OpenCode not detected | Mount `~/.local/share/opencode` to `/home/automaker/.local/share/opencode`. Make sure you've run `opencode auth login` on your host first. |
| File permission errors | Rebuild with `UID=$(id -u) GID=$(id -g) docker-compose build` to match container user to your host user. See [Fixing File Permission Issues](#fixing-file-permission-issues). | | File permission errors | Rebuild with `UID=$(id -u) GID=$(id -g) docker-compose build` to match container user to your host user. See [Fixing File Permission Issues](#fixing-file-permission-issues). |