Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions apps/server/src/lib/sdk-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down
132 changes: 108 additions & 24 deletions apps/ui/src/components/views/board-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<Feature>) => {
async (updates: Partial<Feature>, 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();
Expand All @@ -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
Expand Down Expand Up @@ -1325,6 +1406,9 @@ export function BoardView() {
onClose={() => setShowMassEditDialog(false)}
selectedFeatures={selectedFeatures}
onApply={handleBulkUpdate}
branchSuggestions={branchSuggestions}
branchCardCounts={branchCardCounts}
currentBranch={currentWorktreeBranch || undefined}
/>

{/* Board Background Modal */}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -23,7 +24,10 @@ interface MassEditDialogProps {
open: boolean;
onClose: () => void;
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 {
Expand All @@ -33,6 +37,7 @@ interface ApplyState {
requirePlanApproval: boolean;
priority: boolean;
skipTests: boolean;
branchName: boolean;
}

function getMixedValues(features: Feature[]): Record<string, boolean> {
Expand All @@ -47,6 +52,7 @@ function getMixedValues(features: Feature[]): Record<string, boolean> {
),
priority: !features.every((f) => f.priority === first.priority),
skipTests: !features.every((f) => f.skipTests === first.skipTests),
branchName: !features.every((f) => f.branchName === first.branchName),
};
}

Expand Down Expand Up @@ -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
Expand All @@ -108,6 +122,7 @@ export function MassEditDialog({ open, onClose, selectedFeatures, onApply }: Mas
requirePlanApproval: false,
priority: false,
skipTests: false,
branchName: false,
});

// Field values
Expand All @@ -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<WorkMode>(() => {
// Derive initial work mode from first selected feature's branchName
if (selectedFeatures.length > 0 && selectedFeatures[0].branchName) {
return 'custom';
}
return 'current';
});
Comment on lines +137 to +143
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The initial workMode derivation only considers the branchName of the first selected feature. If selectedFeatures contains features with mixed branch assignments (e.g., some with a branch, some without, or different branches), mixedValues.branchName will correctly be true, but the workMode will only reflect the first feature. This could lead to a slightly confusing initial UI state. Consider a more comprehensive initialization that accounts for truly mixed branch states, perhaps defaulting to 'current' if mixedValues.branchName is true.

const [branchName, setBranchName] = useState(() => {
return getInitialValue(selectedFeatures, 'branchName', '') as string;
});

// Calculate mixed values
const mixedValues = useMemo(() => getMixedValues(selectedFeatures), [selectedFeatures]);

Expand All @@ -131,13 +158,18 @@ 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);
setPlanningMode(getInitialValue(selectedFeatures, 'planningMode', 'skip') as PlanningMode);
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]);

Expand All @@ -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 : '';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Here, updates.branchName is set to an empty string ('') for 'current' and 'auto' modes. However, in board-view.tsx (line 507), finalBranchName is explicitly set to undefined for 'current' mode. For consistency, it would be better to use undefined here as well if the intent is to signify "no specific branch assigned" or "auto-generated branch". This avoids potential subtle differences in how '' and undefined are handled downstream.

Suggested change
updates.branchName = workMode === 'custom' ? branchName : '';
updates.branchName = workMode === 'custom' ? branchName : undefined;

}

if (Object.keys(updates).length === 0) {
onClose();
Expand All @@ -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);
Expand Down Expand Up @@ -293,6 +331,25 @@ export function MassEditDialog({ open, onClose, selectedFeatures, onApply }: Mas
testIdPrefix="mass-edit"
/>
</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>

<DialogFooter>
Expand Down
15 changes: 7 additions & 8 deletions docs/docker-isolation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). |