diff --git a/apps/server/src/lib/sdk-options.ts b/apps/server/src/lib/sdk-options.ts index ff3d6067..cc1df2f5 100644 --- a/apps/server/src/lib/sdk-options.ts +++ b/apps/server/src/lib/sdk-options.ts @@ -129,10 +129,30 @@ export const TOOL_PRESETS = { specGeneration: ['Read', 'Glob', 'Grep'] as const, /** 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 */ - 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; /** diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index 30cd4db3..8f43b677 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -422,6 +422,31 @@ export function BoardView() { const selectedWorktreeBranch = 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 const { handleAddFeature, @@ -467,43 +492,90 @@ export function BoardView() { outputFeature, projectPath: currentProject?.path || null, onWorktreeCreated: () => setWorktreeRefreshKey((k) => k + 1), - onWorktreeAutoSelect: (newWorktree) => { - 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); - }, + onWorktreeAutoSelect: addAndSelectWorktree, currentWorktreeBranch, }); // Handler for bulk updating multiple features const handleBulkUpdate = useCallback( - async (updates: Partial) => { + async (updates: Partial, workMode: 'current' | 'auto' | 'custom') => { if (!currentProject || selectedFeatureIds.size === 0) return; 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 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) { // Update local state featureIds.forEach((featureId) => { - updateFeature(featureId, updates); + updateFeature(featureId, finalUpdates); }); toast.success(`Updated ${result.updatedCount} features`); exitSelectionMode(); @@ -517,7 +589,16 @@ export function BoardView() { toast.error('Failed to update features'); } }, - [currentProject, selectedFeatureIds, updateFeature, exitSelectionMode] + [ + currentProject, + selectedFeatureIds, + updateFeature, + exitSelectionMode, + currentWorktreeBranch, + getPrimaryWorktreeBranch, + addAndSelectWorktree, + setWorktreeRefreshKey, + ] ); // Handler for bulk deleting multiple features @@ -1325,6 +1406,9 @@ export function BoardView() { onClose={() => setShowMassEditDialog(false)} selectedFeatures={selectedFeatures} onApply={handleBulkUpdate} + branchSuggestions={branchSuggestions} + branchCardCounts={branchCardCounts} + currentBranch={currentWorktreeBranch || undefined} /> {/* Board Background Modal */} diff --git a/apps/ui/src/components/views/board-view/dialogs/mass-edit-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/mass-edit-dialog.tsx index 30042a4c..2be7d32f 100644 --- a/apps/ui/src/components/views/board-view/dialogs/mass-edit-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/mass-edit-dialog.tsx @@ -13,7 +13,8 @@ import { Label } from '@/components/ui/label'; import { AlertCircle } from 'lucide-react'; import { modelSupportsThinking } from '@/lib/utils'; 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 { isCursorModel, isClaudeModel, type PhaseModelEntry } from '@automaker/types'; import { cn } from '@/lib/utils'; @@ -23,7 +24,10 @@ interface MassEditDialogProps { open: boolean; onClose: () => void; selectedFeatures: Feature[]; - onApply: (updates: Partial) => Promise; + onApply: (updates: Partial, workMode: WorkMode) => Promise; + branchSuggestions: string[]; + branchCardCounts?: Record; + currentBranch?: string; } interface ApplyState { @@ -33,6 +37,7 @@ interface ApplyState { requirePlanApproval: boolean; priority: boolean; skipTests: boolean; + branchName: boolean; } function getMixedValues(features: Feature[]): Record { @@ -47,6 +52,7 @@ function getMixedValues(features: Feature[]): Record { ), priority: !features.every((f) => f.priority === first.priority), 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); // Track which fields to apply @@ -108,6 +122,7 @@ export function MassEditDialog({ open, onClose, selectedFeatures, onApply }: Mas requirePlanApproval: false, priority: false, skipTests: false, + branchName: false, }); // Field values @@ -118,6 +133,18 @@ export function MassEditDialog({ open, onClose, selectedFeatures, onApply }: Mas const [priority, setPriority] = useState(2); const [skipTests, setSkipTests] = useState(false); + // Work mode and branch name state + const [workMode, setWorkMode] = useState(() => { + // 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 const mixedValues = useMemo(() => getMixedValues(selectedFeatures), [selectedFeatures]); @@ -131,6 +158,7 @@ export function MassEditDialog({ open, onClose, selectedFeatures, onApply }: Mas requirePlanApproval: false, priority: false, skipTests: false, + branchName: false, }); setModel(getInitialValue(selectedFeatures, 'model', 'sonnet') as ModelAlias); setThinkingLevel(getInitialValue(selectedFeatures, 'thinkingLevel', 'none') as ThinkingLevel); @@ -138,6 +166,10 @@ export function MassEditDialog({ open, onClose, selectedFeatures, onApply }: Mas setRequirePlanApproval(getInitialValue(selectedFeatures, 'requirePlanApproval', false)); setPriority(getInitialValue(selectedFeatures, 'priority', 2)); 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]); @@ -150,6 +182,12 @@ export function MassEditDialog({ open, onClose, selectedFeatures, onApply }: Mas if (applyState.requirePlanApproval) updates.requirePlanApproval = requirePlanApproval; if (applyState.priority) updates.priority = priority; 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) { onClose(); @@ -158,7 +196,7 @@ export function MassEditDialog({ open, onClose, selectedFeatures, onApply }: Mas setIsApplying(true); try { - await onApply(updates); + await onApply(updates, workMode); onClose(); } finally { setIsApplying(false); @@ -293,6 +331,25 @@ export function MassEditDialog({ open, onClose, selectedFeatures, onApply }: Mas testIdPrefix="mass-edit" /> + + {/* Branch / Work Mode */} + setApplyState((prev) => ({ ...prev, branchName: apply }))} + > + + diff --git a/docs/docker-isolation.md b/docs/docker-isolation.md index ad7c712a..379e8c0d 100644 --- a/docs/docker-isolation.md +++ b/docs/docker-isolation.md @@ -136,12 +136,11 @@ volumes: ## Troubleshooting -| Problem | Solution | -| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -| 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` | -| 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. | -| 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. | +| Problem | Solution | +| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 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` | +| 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. | +| 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). | -