feat: enhance auto mode service and UI components for branch handling and verification

- Added a new function to retrieve the current branch name in the auto mode service, improving branch management.
- Updated the `getRunningCountForWorktree` method to utilize the current branch name for accurate feature counting.
- Modified UI components to include a toggle for skipping verification in auto mode, enhancing user control.
- Refactored various hooks and components to ensure consistent handling of branch names across the application.
- Introduced a new utility file for string operations, providing common functions for text manipulation.
This commit is contained in:
webdevcody
2026-01-20 13:39:38 -05:00
parent 2ab78dd590
commit 8facdc66a9
10 changed files with 337 additions and 94 deletions

View File

@@ -74,6 +74,21 @@ import { getNotificationService } from './notification-service.js';
const execAsync = promisify(exec);
/**
* Get the current branch name for a git repository
* @param projectPath - Path to the git repository
* @returns The current branch name, or null if not in a git repo or on detached HEAD
*/
async function getCurrentBranch(projectPath: string): Promise<string | null> {
try {
const { stdout } = await execAsync('git branch --show-current', { cwd: projectPath });
const branch = stdout.trim();
return branch || null;
} catch {
return null;
}
}
// PlanningMode type is imported from @automaker/types
interface ParsedTask {
@@ -635,7 +650,7 @@ export class AutoModeService {
iterationCount++;
try {
// Count running features for THIS project/worktree only
const projectRunningCount = this.getRunningCountForWorktree(projectPath, branchName);
const projectRunningCount = await this.getRunningCountForWorktree(projectPath, branchName);
// Check if we have capacity for this project/worktree
if (projectRunningCount >= projectState.config.maxConcurrency) {
@@ -728,20 +743,24 @@ export class AutoModeService {
/**
* Get count of running features for a specific worktree
* @param projectPath - The project path
* @param branchName - The branch name, or null for main worktree (features without branchName or with "main")
* @param branchName - The branch name, or null for main worktree (features without branchName or matching primary branch)
*/
private getRunningCountForWorktree(projectPath: string, branchName: string | null): number {
const normalizedBranch = branchName === 'main' ? null : branchName;
private async getRunningCountForWorktree(
projectPath: string,
branchName: string | null
): Promise<number> {
// Get the actual primary branch name for the project
const primaryBranch = await getCurrentBranch(projectPath);
let count = 0;
for (const [, feature] of this.runningFeatures) {
// Filter by project path AND branchName to get accurate worktree-specific count
const featureBranch = feature.branchName ?? null;
if (normalizedBranch === null) {
// Main worktree: match features with branchName === null OR branchName === "main"
if (
feature.projectPath === projectPath &&
(featureBranch === null || featureBranch === 'main')
) {
if (branchName === null) {
// Main worktree: match features with branchName === null OR branchName matching primary branch
const isPrimaryBranch =
featureBranch === null || (primaryBranch && featureBranch === primaryBranch);
if (feature.projectPath === projectPath && isPrimaryBranch) {
count++;
}
} else {
@@ -790,7 +809,7 @@ export class AutoModeService {
// Remove from map
this.autoLoopsByProject.delete(worktreeKey);
return this.getRunningCountForWorktree(projectPath, branchName);
return await this.getRunningCountForWorktree(projectPath, branchName);
}
/**
@@ -1025,7 +1044,7 @@ export class AutoModeService {
const maxAgents = await this.resolveMaxConcurrency(projectPath, branchName);
// Get current running count for this worktree
const currentAgents = this.getRunningCountForWorktree(projectPath, branchName);
const currentAgents = await this.getRunningCountForWorktree(projectPath, branchName);
return {
hasCapacity: currentAgents < maxAgents,
@@ -2952,6 +2971,10 @@ Format your response as a structured markdown document.`;
// Features are stored in .automaker directory
const featuresDir = getFeaturesDir(projectPath);
// Get the actual primary branch name for the project (e.g., "main", "master", "develop")
// This is needed to correctly match features when branchName is null (main worktree)
const primaryBranch = await getCurrentBranch(projectPath);
try {
const entries = await secureFs.readdir(featuresDir, {
withFileTypes: true,
@@ -2991,17 +3014,21 @@ Format your response as a structured markdown document.`;
(feature.planSpec.tasksCompleted ?? 0) < (feature.planSpec.tasksTotal ?? 0))
) {
// Filter by branchName:
// - If branchName is null (main worktree), include features with branchName === null OR branchName === "main"
// - If branchName is null (main worktree), include features with:
// - branchName === null, OR
// - branchName === primaryBranch (e.g., "main", "master", "develop")
// - If branchName is set, only include features with matching branchName
const featureBranch = feature.branchName ?? null;
if (branchName === null) {
// Main worktree: include features without branchName OR with branchName === "main"
// This handles both correct (null) and legacy ("main") cases
if (featureBranch === null || featureBranch === 'main') {
// Main worktree: include features without branchName OR with branchName matching primary branch
// This handles repos where the primary branch is named something other than "main"
const isPrimaryBranch =
featureBranch === null || (primaryBranch && featureBranch === primaryBranch);
if (isPrimaryBranch) {
pendingFeatures.push(feature);
} else {
logger.debug(
`[loadPendingFeatures] Filtering out feature ${feature.id} (branchName: ${featureBranch}) for main worktree`
`[loadPendingFeatures] Filtering out feature ${feature.id} (branchName: ${featureBranch}, primaryBranch: ${primaryBranch}) for main worktree`
);
}
} else {

View File

@@ -142,7 +142,8 @@ export function BoardHeader({
onConcurrencyChange={onConcurrencyChange}
isAutoModeRunning={isAutoModeRunning}
onAutoModeToggle={onAutoModeToggle}
onOpenAutoModeSettings={() => {}}
skipVerificationInAutoMode={skipVerificationInAutoMode}
onSkipVerificationChange={setSkipVerificationInAutoMode}
onOpenPlanDialog={onOpenPlanDialog}
showClaudeUsage={showClaudeUsage}
showCodexUsage={showCodexUsage}

View File

@@ -180,8 +180,10 @@ export const KanbanCard = memo(function KanbanCard({
'kanban-card-content h-full relative',
reduceEffects ? 'shadow-none' : 'shadow-sm',
'transition-all duration-200 ease-out',
// Disable hover translate for in-progress cards to prevent gap showing gradient
isInteractive &&
!reduceEffects &&
!isCurrentAutoTask &&
'hover:-translate-y-0.5 hover:shadow-md hover:shadow-black/10 bg-transparent',
!glassmorphism && 'backdrop-blur-[0px]!',
!isCurrentAutoTask &&

View File

@@ -5,7 +5,7 @@ import {
HeaderActionsPanel,
HeaderActionsPanelTrigger,
} from '@/components/ui/header-actions-panel';
import { Bot, Wand2, Settings2, GitBranch, Zap } from 'lucide-react';
import { Bot, Wand2, GitBranch, Zap, FastForward } from 'lucide-react';
import { cn } from '@/lib/utils';
import { MobileUsageBar } from './mobile-usage-bar';
@@ -23,7 +23,8 @@ interface HeaderMobileMenuProps {
// Auto mode
isAutoModeRunning: boolean;
onAutoModeToggle: (enabled: boolean) => void;
onOpenAutoModeSettings: () => void;
skipVerificationInAutoMode: boolean;
onSkipVerificationChange: (value: boolean) => void;
// Plan button
onOpenPlanDialog: () => void;
// Usage bar visibility
@@ -41,7 +42,8 @@ export function HeaderMobileMenu({
onConcurrencyChange,
isAutoModeRunning,
onAutoModeToggle,
onOpenAutoModeSettings,
skipVerificationInAutoMode,
onSkipVerificationChange,
onOpenPlanDialog,
showClaudeUsage,
showCodexUsage,
@@ -66,29 +68,23 @@ export function HeaderMobileMenu({
Controls
</span>
{/* Auto Mode Toggle */}
<div
className="flex items-center justify-between p-3 cursor-pointer hover:bg-accent/50 rounded-lg border border-border/50 transition-colors"
onClick={() => onAutoModeToggle(!isAutoModeRunning)}
data-testid="mobile-auto-mode-toggle-container"
>
<div className="flex items-center gap-2">
<Zap
className={cn(
'w-4 h-4',
isAutoModeRunning ? 'text-yellow-500' : 'text-muted-foreground'
)}
/>
<span className="text-sm font-medium">Auto Mode</span>
<span
className="text-[10px] font-medium text-muted-foreground bg-muted/60 px-1.5 py-0.5 rounded"
data-testid="mobile-auto-mode-max-concurrency"
title="Max concurrent agents"
>
{maxConcurrency}
</span>
</div>
<div className="flex items-center gap-2">
{/* Auto Mode Section */}
<div className="rounded-lg border border-border/50 overflow-hidden">
{/* Auto Mode Toggle */}
<div
className="flex items-center justify-between p-3 cursor-pointer hover:bg-accent/50 transition-colors"
onClick={() => onAutoModeToggle(!isAutoModeRunning)}
data-testid="mobile-auto-mode-toggle-container"
>
<div className="flex items-center gap-2">
<Zap
className={cn(
'w-4 h-4',
isAutoModeRunning ? 'text-yellow-500' : 'text-muted-foreground'
)}
/>
<span className="text-sm font-medium">Auto Mode</span>
</div>
<Switch
id="mobile-auto-mode-toggle"
checked={isAutoModeRunning}
@@ -96,17 +92,51 @@ export function HeaderMobileMenu({
onClick={(e) => e.stopPropagation()}
data-testid="mobile-auto-mode-toggle"
/>
<button
onClick={(e) => {
e.stopPropagation();
onOpenAutoModeSettings();
}}
className="p-1 rounded hover:bg-accent/50 transition-colors"
title="Auto Mode Settings"
data-testid="mobile-auto-mode-settings-button"
>
<Settings2 className="w-4 h-4 text-muted-foreground" />
</button>
</div>
{/* Skip Verification Toggle */}
<div
className="flex items-center justify-between p-3 pl-9 cursor-pointer hover:bg-accent/50 border-t border-border/30 transition-colors"
onClick={() => onSkipVerificationChange(!skipVerificationInAutoMode)}
data-testid="mobile-skip-verification-toggle-container"
>
<div className="flex items-center gap-2">
<FastForward className="w-4 h-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Skip Verification</span>
</div>
<Switch
id="mobile-skip-verification-toggle"
checked={skipVerificationInAutoMode}
onCheckedChange={onSkipVerificationChange}
onClick={(e) => e.stopPropagation()}
data-testid="mobile-skip-verification-toggle"
/>
</div>
{/* Concurrency Control */}
<div
className="p-3 pl-9 border-t border-border/30"
data-testid="mobile-concurrency-control"
>
<div className="flex items-center gap-2 mb-3">
<Bot className="w-4 h-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Max Agents</span>
<span
className="text-sm text-muted-foreground ml-auto"
data-testid="mobile-concurrency-value"
>
{runningAgentsCount}/{maxConcurrency}
</span>
</div>
<Slider
value={[maxConcurrency]}
onValueChange={(value) => onConcurrencyChange(value[0])}
min={1}
max={10}
step={1}
className="w-full"
data-testid="mobile-concurrency-slider"
/>
</div>
</div>
@@ -129,32 +159,6 @@ export function HeaderMobileMenu({
/>
</div>
{/* Concurrency Control */}
<div
className="p-3 rounded-lg border border-border/50"
data-testid="mobile-concurrency-control"
>
<div className="flex items-center gap-2 mb-3">
<Bot className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium">Max Agents</span>
<span
className="text-sm text-muted-foreground ml-auto"
data-testid="mobile-concurrency-value"
>
{runningAgentsCount}/{maxConcurrency}
</span>
</div>
<Slider
value={[maxConcurrency]}
onValueChange={(value) => onConcurrencyChange(value[0])}
min={1}
max={10}
step={1}
className="w-full"
data-testid="mobile-concurrency-slider"
/>
</div>
{/* Plan Button */}
<Button
variant="outline"

View File

@@ -487,7 +487,15 @@ export function useBoardActions({
const handleStartImplementation = useCallback(
async (feature: Feature) => {
// Check capacity for the feature's specific worktree, not the current view
const featureBranchName = feature.branchName ?? null;
// Normalize the branch name: if the feature's branch is the primary worktree branch,
// treat it as null (main worktree) to match how running tasks are stored
const rawBranchName = feature.branchName ?? null;
const featureBranchName =
currentProject?.path &&
rawBranchName &&
isPrimaryWorktreeBranch(currentProject.path, rawBranchName)
? null
: rawBranchName;
const featureWorktreeState = currentProject
? getAutoModeState(currentProject.id, featureBranchName)
: null;
@@ -567,6 +575,7 @@ export function useBoardActions({
handleRunFeature,
currentProject,
getAutoModeState,
isPrimaryWorktreeBranch,
]
);

View File

@@ -128,15 +128,22 @@ export function useBoardDragDrop({
const targetBranch = worktreeData.branch;
const currentBranch = draggedFeature.branchName;
// For main worktree, set branchName to null to indicate it should use main
// (must use null not undefined so it serializes to JSON for the API call)
// For other worktrees, set branchName to the target branch
const newBranchName = worktreeData.isMain ? null : targetBranch;
// If already on the same branch, nothing to do
if (currentBranch === targetBranch) {
// For main worktree: feature with null/undefined branchName is already on main
// For other worktrees: compare branch names directly
const isAlreadyOnTarget = worktreeData.isMain
? !currentBranch // null or undefined means already on main
: currentBranch === targetBranch;
if (isAlreadyOnTarget) {
return;
}
// For main worktree, set branchName to undefined/null to indicate it should use main
// For other worktrees, set branchName to the target branch
const newBranchName = worktreeData.isMain ? undefined : targetBranch;
// Update feature's branchName
updateFeature(featureId, { branchName: newBranchName });
await persistFeatureUpdate(featureId, { branchName: newBranchName });

View File

@@ -17,11 +17,8 @@ export function useRunningFeatures({ runningFeatureIds, features }: UseRunningFe
// Match by branchName only (worktreePath is no longer stored)
if (feature.branchName) {
// Special case: if feature is on 'main' branch, it belongs to main worktree
// irrespective of whether the branch name matches exactly (it should, but strict equality might fail if refs differ)
if (worktree.isMain && feature.branchName === 'main') {
return true;
}
// Check if branch names match - this handles both main worktree (any primary branch name)
// and feature worktrees
return worktree.branch === feature.branchName;
}

View File

@@ -77,6 +77,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
getWorktreeKey,
getMaxConcurrencyForWorktree,
setMaxConcurrencyForWorktree,
isPrimaryWorktreeBranch,
} = useAppStore(
useShallow((state) => ({
autoModeByWorktree: state.autoModeByWorktree,
@@ -90,6 +91,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
getWorktreeKey: state.getWorktreeKey,
getMaxConcurrencyForWorktree: state.getMaxConcurrencyForWorktree,
setMaxConcurrencyForWorktree: state.setMaxConcurrencyForWorktree,
isPrimaryWorktreeBranch: state.isPrimaryWorktreeBranch,
}))
);
@@ -197,9 +199,21 @@ export function useAutoMode(worktree?: WorktreeInfo) {
}
// Extract branchName from event, defaulting to null (main worktree)
const eventBranchName: string | null =
const rawEventBranchName: string | null =
'branchName' in event && event.branchName !== undefined ? event.branchName : null;
// Get projectPath for worktree lookup
const eventProjectPath = 'projectPath' in event ? event.projectPath : currentProject?.path;
// Normalize branchName: convert primary worktree branch to null for consistent key lookup
// This handles cases where the main branch is named something other than 'main' (e.g., 'master', 'develop')
const eventBranchName: string | null =
eventProjectPath &&
rawEventBranchName &&
isPrimaryWorktreeBranch(eventProjectPath, rawEventBranchName)
? null
: rawEventBranchName;
// Skip event if we couldn't determine the project
if (!eventProjectId) {
logger.warn('Could not determine project for event:', event);
@@ -493,6 +507,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
currentProject?.path,
getMaxConcurrencyForWorktree,
setMaxConcurrencyForWorktree,
isPrimaryWorktreeBranch,
]);
// Start auto mode - calls backend to start the auto loop for this worktree

View File

@@ -895,12 +895,15 @@ function RootLayoutContent() {
}
function RootLayout() {
// Hide devtools on compact screens (mobile/tablet) to avoid overlap with sidebar settings
const isCompact = useIsCompact();
return (
<QueryClientProvider client={queryClient}>
<FileBrowserProvider>
<RootLayoutContent />
</FileBrowserProvider>
{SHOW_QUERY_DEVTOOLS ? (
{SHOW_QUERY_DEVTOOLS && !isCompact ? (
<ReactQueryDevtools initialIsOpen={false} buttonPosition="bottom-left" />
) : null}
</QueryClientProvider>

View File

@@ -0,0 +1,178 @@
/**
* String utility functions for common text operations
*/
/**
* Truncate a string to a maximum length, adding an ellipsis if truncated
* @param str - The string to truncate
* @param maxLength - Maximum length of the result (including ellipsis)
* @param ellipsis - The ellipsis string to use (default: '...')
* @returns The truncated string
*/
export function truncate(str: string, maxLength: number, ellipsis: string = '...'): string {
if (maxLength < ellipsis.length) {
throw new Error(
`maxLength (${maxLength}) must be at least the length of ellipsis (${ellipsis.length})`
);
}
if (str.length <= maxLength) {
return str;
}
return str.slice(0, maxLength - ellipsis.length) + ellipsis;
}
/**
* Convert a string to kebab-case (e.g., "Hello World" -> "hello-world")
* @param str - The string to convert
* @returns The kebab-case string
*/
export function toKebabCase(str: string): string {
return str
.replace(/([a-z])([A-Z])/g, '$1-$2') // camelCase -> camel-Case
.replace(/[\s_]+/g, '-') // spaces and underscores -> hyphens
.replace(/[^a-zA-Z0-9-]/g, '') // remove non-alphanumeric (except hyphens)
.replace(/-+/g, '-') // collapse multiple hyphens
.replace(/^-|-$/g, '') // remove leading/trailing hyphens
.toLowerCase();
}
/**
* Convert a string to camelCase (e.g., "hello-world" -> "helloWorld")
* @param str - The string to convert
* @returns The camelCase string
*/
export function toCamelCase(str: string): string {
return str
.replace(/[^a-zA-Z0-9\s_-]/g, '') // remove special characters
.replace(/[-_\s]+(.)?/g, (_, char) => (char ? char.toUpperCase() : ''))
.replace(/^[A-Z]/, (char) => char.toLowerCase());
}
/**
* Convert a string to PascalCase (e.g., "hello-world" -> "HelloWorld")
* @param str - The string to convert
* @returns The PascalCase string
*/
export function toPascalCase(str: string): string {
const camel = toCamelCase(str);
return camel.charAt(0).toUpperCase() + camel.slice(1);
}
/**
* Capitalize the first letter of a string
* @param str - The string to capitalize
* @returns The string with first letter capitalized
*/
export function capitalize(str: string): string {
if (str.length === 0) {
return str;
}
return str.charAt(0).toUpperCase() + str.slice(1);
}
/**
* Remove duplicate whitespace from a string, preserving single spaces
* @param str - The string to clean
* @returns The string with duplicate whitespace removed
*/
export function collapseWhitespace(str: string): string {
return str.replace(/\s+/g, ' ').trim();
}
/**
* Check if a string is empty or contains only whitespace
* @param str - The string to check
* @returns True if the string is blank
*/
export function isBlank(str: string | null | undefined): boolean {
return str === null || str === undefined || str.trim().length === 0;
}
/**
* Check if a string is not empty and contains non-whitespace characters
* @param str - The string to check
* @returns True if the string is not blank
*/
export function isNotBlank(str: string | null | undefined): boolean {
return !isBlank(str);
}
/**
* Safely parse a string to an integer, returning a default value on failure
* @param str - The string to parse
* @param defaultValue - The default value if parsing fails (default: 0)
* @returns The parsed integer or the default value
*/
export function safeParseInt(str: string | null | undefined, defaultValue: number = 0): number {
if (isBlank(str)) {
return defaultValue;
}
const parsed = parseInt(str!, 10);
return isNaN(parsed) ? defaultValue : parsed;
}
/**
* Generate a slug from a string (URL-friendly identifier)
* @param str - The string to convert to a slug
* @param maxLength - Optional maximum length for the slug
* @returns The slugified string
*/
export function slugify(str: string, maxLength?: number): string {
let slug = str
.toLowerCase()
.normalize('NFD') // Normalize unicode characters
.replace(/[\u0300-\u036f]/g, '') // Remove diacritics
.replace(/[^a-z0-9\s-]/g, '') // Remove non-alphanumeric
.replace(/\s+/g, '-') // Replace spaces with hyphens
.replace(/-+/g, '-') // Collapse multiple hyphens
.replace(/^-|-$/g, ''); // Remove leading/trailing hyphens
if (maxLength !== undefined && slug.length > maxLength) {
// Truncate at word boundary if possible
slug = slug.slice(0, maxLength);
const lastHyphen = slug.lastIndexOf('-');
if (lastHyphen > maxLength * 0.5) {
slug = slug.slice(0, lastHyphen);
}
slug = slug.replace(/-$/g, ''); // Remove trailing hyphen after truncation
}
return slug;
}
/**
* Escape special regex characters in a string
* @param str - The string to escape
* @returns The escaped string safe for use in a RegExp
*/
export function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Pluralize a word based on count
* @param word - The singular form of the word
* @param count - The count to base pluralization on
* @param pluralForm - Optional custom plural form (default: word + 's')
* @returns The word in singular or plural form
*/
export function pluralize(word: string, count: number, pluralForm?: string): string {
if (count === 1) {
return word;
}
return pluralForm || `${word}s`;
}
/**
* Format a count with its associated word (e.g., "1 item", "3 items")
* @param count - The count
* @param singular - The singular form of the word
* @param plural - Optional custom plural form
* @returns Formatted string with count and word
*/
export function formatCount(count: number, singular: string, plural?: string): string {
return `${count} ${pluralize(singular, count, plural)}`;
}