feat: enhance auto mode functionality with worktree support

- Updated auto mode handlers to support branch-specific operations, allowing for better management of features across different worktrees.
- Introduced normalization of branch names to handle undefined values gracefully.
- Enhanced status and response messages to reflect the current worktree context.
- Updated the auto mode service to manage state and concurrency settings per worktree, improving user experience and flexibility.
- Added UI elements to display current max concurrency for auto mode in both board and mobile views.

This update aims to streamline the auto mode experience, making it more intuitive for users working with multiple branches and worktrees.
This commit is contained in:
webdevcody
2026-01-19 17:17:40 -05:00
parent 63b8eb0991
commit 82e22b4362
25 changed files with 2693 additions and 268 deletions

View File

@@ -88,8 +88,8 @@ const logger = createLogger('Board');
export function BoardView() {
const {
currentProject,
maxConcurrency,
setMaxConcurrency,
maxConcurrency: legacyMaxConcurrency,
setMaxConcurrency: legacySetMaxConcurrency,
defaultSkipTests,
specCreatingForProject,
setSpecCreatingForProject,
@@ -261,11 +261,6 @@ export function BoardView() {
loadPipelineConfig();
}, [currentProject?.path, setPipelineConfig]);
// Auto mode hook
const autoMode = useAutoMode();
// Get runningTasks from the hook (scoped to current project)
const runningAutoTasks = autoMode.runningTasks;
// Window state hook for compact dialog mode
const { isMaximized } = useWindowState();
@@ -374,14 +369,6 @@ export function BoardView() {
[hookFeatures, updateFeature, persistFeatureUpdate]
);
// Get in-progress features for keyboard shortcuts (needed before actions hook)
const inProgressFeaturesForShortcuts = useMemo(() => {
return hookFeatures.filter((f) => {
const isRunning = runningAutoTasks.includes(f.id);
return isRunning || f.status === 'in_progress';
});
}, [hookFeatures, runningAutoTasks]);
// Get current worktree info (path) for filtering features
// This needs to be before useBoardActions so we can pass currentWorktreeBranch
const currentWorktreeInfo = currentProject ? getCurrentWorktree(currentProject.path) : null;
@@ -407,6 +394,16 @@ export function BoardView() {
}
}, [worktrees, currentWorktreePath]);
// Auto mode hook - pass current worktree to get worktree-specific state
// Must be after selectedWorktree is defined
const autoMode = useAutoMode(selectedWorktree ?? undefined);
// Get runningTasks from the hook (scoped to current project/worktree)
const runningAutoTasks = autoMode.runningTasks;
// Get worktree-specific maxConcurrency from the hook
const maxConcurrency = autoMode.maxConcurrency;
// Get worktree-specific setter
const setMaxConcurrencyForWorktree = useAppStore((state) => state.setMaxConcurrencyForWorktree);
// Get the current branch from the selected worktree (not from store which may be stale)
const currentWorktreeBranch = selectedWorktree?.branch ?? null;
@@ -415,6 +412,15 @@ export function BoardView() {
const selectedWorktreeBranch =
currentWorktreeBranch || worktrees.find((w) => w.isMain)?.branch || 'main';
// Get in-progress features for keyboard shortcuts (needed before actions hook)
// Must be after runningAutoTasks is defined
const inProgressFeaturesForShortcuts = useMemo(() => {
return hookFeatures.filter((f) => {
const isRunning = runningAutoTasks.includes(f.id);
return isRunning || f.status === 'in_progress';
});
}, [hookFeatures, runningAutoTasks]);
// Calculate unarchived card counts per branch
const branchCardCounts = useMemo(() => {
// Use primary worktree branch as default for features without branchName
@@ -512,14 +518,14 @@ export function BoardView() {
try {
// Determine final branch name based on work mode:
// - 'current': Empty string to clear branch assignment (work on main/current branch)
// - 'current': Use selected worktree branch if available, otherwise undefined (work on main)
// - 'auto': Auto-generate branch name based on current branch
// - 'custom': Use the provided branch name
let finalBranchName: string | undefined;
if (workMode === 'current') {
// Empty string clears the branch assignment, moving features to main/current branch
finalBranchName = '';
// If a worktree is selected, use its branch; otherwise work on main (undefined = no branch assignment)
finalBranchName = currentWorktreeBranch || undefined;
} else if (workMode === 'auto') {
// Auto-generate a branch name based on primary branch (main/master) and timestamp
// Always use primary branch to avoid nested feature/feature/... paths
@@ -605,6 +611,7 @@ export function BoardView() {
exitSelectionMode,
getPrimaryWorktreeBranch,
addAndSelectWorktree,
currentWorktreeBranch,
setWorktreeRefreshKey,
]
);
@@ -1127,7 +1134,21 @@ export function BoardView() {
projectPath={currentProject.path}
maxConcurrency={maxConcurrency}
runningAgentsCount={runningAutoTasks.length}
onConcurrencyChange={setMaxConcurrency}
onConcurrencyChange={(newMaxConcurrency) => {
if (currentProject && selectedWorktree) {
const branchName = selectedWorktree.isMain ? null : selectedWorktree.branch;
setMaxConcurrencyForWorktree(currentProject.id, branchName, newMaxConcurrency);
// Also update backend if auto mode is running
if (autoMode.isRunning) {
// Restart auto mode with new concurrency (backend will handle this)
autoMode.stop().then(() => {
autoMode.start().catch((error) => {
logger.error('[AutoMode] Failed to restart with new concurrency:', error);
});
});
}
}
}}
isAutoModeRunning={autoMode.isRunning}
onAutoModeToggle={(enabled) => {
if (enabled) {

View File

@@ -182,6 +182,13 @@ export function BoardHeader({
>
Auto Mode
</Label>
<span
className="text-[10px] font-medium text-muted-foreground bg-muted/60 px-1.5 py-0.5 rounded"
data-testid="auto-mode-max-concurrency"
title="Max concurrent agents"
>
{maxConcurrency}
</span>
<Switch
id="auto-mode-toggle"
checked={isAutoModeRunning}

View File

@@ -80,6 +80,13 @@ export function HeaderMobileMenu({
)}
/>
<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">
<Switch

View File

@@ -118,14 +118,14 @@ export function useBoardActions({
const workMode = featureData.workMode || 'current';
// Determine final branch name based on work mode:
// - 'current': No branch name, work on current branch (no worktree)
// - 'current': Use selected worktree branch if available, otherwise undefined (work on main)
// - '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;
// If a worktree is selected, use its branch; otherwise work on main (undefined = no branch assignment)
finalBranchName = currentWorktreeBranch || undefined;
} else if (workMode === 'auto') {
// Auto-generate a branch name based on primary branch (main/master) and timestamp
// Always use primary branch to avoid nested feature/feature/... paths
@@ -249,6 +249,7 @@ export function useBoardActions({
onWorktreeAutoSelect,
getPrimaryWorktreeBranch,
features,
currentWorktreeBranch,
]
);
@@ -282,7 +283,8 @@ export function useBoardActions({
let finalBranchName: string | undefined;
if (workMode === 'current') {
finalBranchName = undefined;
// If a worktree is selected, use its branch; otherwise work on main (undefined = no branch assignment)
finalBranchName = currentWorktreeBranch || undefined;
} else if (workMode === 'auto') {
// Auto-generate a branch name based on primary branch (main/master) and timestamp
// Always use primary branch to avoid nested feature/feature/... paths
@@ -397,6 +399,7 @@ export function useBoardActions({
onWorktreeCreated,
getPrimaryWorktreeBranch,
features,
currentWorktreeBranch,
]
);

View File

@@ -97,8 +97,25 @@ export function useBoardColumnFeatures({
// Historically, we forced "running" features into in_progress so they never disappeared
// during stale reload windows. With pipelines, a feature can legitimately be running while
// its status is `pipeline_*`, so we must respect that status to render it in the right column.
// NOTE: runningAutoTasks is already worktree-scoped, so if a feature is in runningAutoTasks,
// it's already running for the current worktree. However, we still need to check matchesWorktree
// to ensure the feature's branchName matches the current worktree's branch.
if (isRunning) {
if (!matchesWorktree) return;
// If feature is running but doesn't match worktree, it might be a timing issue where
// the feature was started for a different worktree. Still show it if it's running to
// prevent disappearing features, but log a warning.
if (!matchesWorktree) {
// This can happen if:
// 1. Feature was started for a different worktree (bug)
// 2. Timing issue where branchName hasn't been set yet
// 3. User switched worktrees while feature was starting
// Still show it in in_progress to prevent it from disappearing
console.debug(
`Feature ${f.id} is running but branchName (${featureBranch}) doesn't match current worktree branch (${effectiveBranch}) - showing anyway to prevent disappearing`
);
map.in_progress.push(f);
return;
}
if (status.startsWith('pipeline_')) {
if (!map[status]) map[status] = [];

View File

@@ -196,13 +196,30 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
const { removeRunningTask } = useAppStore.getState();
const projectId = currentProject.id;
const projectPath = currentProject.path;
const unsubscribe = api.autoMode.onEvent((event) => {
// Check if event is for the current project by matching projectPath
const eventProjectPath = ('projectPath' in event && event.projectPath) as string | undefined;
if (eventProjectPath && eventProjectPath !== projectPath) {
// Event is for a different project, ignore it
logger.debug(
`Ignoring auto mode event for different project: ${eventProjectPath} (current: ${projectPath})`
);
return;
}
// Use event's projectPath or projectId if available, otherwise use current project
// Board view only reacts to events for the currently selected project
const eventProjectId = ('projectId' in event && event.projectId) || projectId;
if (event.type === 'auto_mode_feature_complete') {
if (event.type === 'auto_mode_feature_start') {
// Reload features when a feature starts to ensure status update (backlog -> in_progress) is reflected
logger.info(
`[BoardFeatures] Feature ${event.featureId} started for project ${projectPath}, reloading features to update status...`
);
loadFeatures();
} else if (event.type === 'auto_mode_feature_complete') {
// Reload features when a feature is completed
logger.info('Feature completed, reloading features...');
loadFeatures();

View File

@@ -29,6 +29,7 @@ import {
Terminal,
SquarePlus,
SplitSquareHorizontal,
Zap,
} from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
@@ -56,6 +57,8 @@ interface WorktreeActionsDropdownProps {
gitRepoStatus: GitRepoStatus;
/** When true, renders as a standalone button (not attached to another element) */
standalone?: boolean;
/** Whether auto mode is running for this worktree */
isAutoModeRunning?: boolean;
onOpenChange: (open: boolean) => void;
onPull: (worktree: WorktreeInfo) => void;
onPush: (worktree: WorktreeInfo) => void;
@@ -73,6 +76,7 @@ interface WorktreeActionsDropdownProps {
onOpenDevServerUrl: (worktree: WorktreeInfo) => void;
onViewDevServerLogs: (worktree: WorktreeInfo) => void;
onRunInitScript: (worktree: WorktreeInfo) => void;
onToggleAutoMode?: (worktree: WorktreeInfo) => void;
hasInitScript: boolean;
}
@@ -88,6 +92,7 @@ export function WorktreeActionsDropdown({
devServerInfo,
gitRepoStatus,
standalone = false,
isAutoModeRunning = false,
onOpenChange,
onPull,
onPush,
@@ -105,6 +110,7 @@ export function WorktreeActionsDropdown({
onOpenDevServerUrl,
onViewDevServerLogs,
onRunInitScript,
onToggleAutoMode,
hasInitScript,
}: WorktreeActionsDropdownProps) {
// Get available editors for the "Open In" submenu
@@ -214,6 +220,26 @@ export function WorktreeActionsDropdown({
<DropdownMenuSeparator />
</>
)}
{/* Auto Mode toggle */}
{onToggleAutoMode && (
<>
{isAutoModeRunning ? (
<DropdownMenuItem onClick={() => onToggleAutoMode(worktree)} className="text-xs">
<span className="flex items-center mr-2">
<Zap className="w-3.5 h-3.5 text-yellow-500" />
<span className="ml-0.5 h-2 w-2 rounded-full bg-green-500 animate-pulse" />
</span>
Stop Auto Mode
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={() => onToggleAutoMode(worktree)} className="text-xs">
<Zap className="w-3.5 h-3.5 mr-2" />
Start Auto Mode
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
</>
)}
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
<DropdownMenuItem
onClick={() => canPerformGitOps && onPull(worktree)}

View File

@@ -29,6 +29,8 @@ interface WorktreeTabProps {
aheadCount: number;
behindCount: number;
gitRepoStatus: GitRepoStatus;
/** Whether auto mode is running for this worktree */
isAutoModeRunning?: boolean;
onSelectWorktree: (worktree: WorktreeInfo) => void;
onBranchDropdownOpenChange: (open: boolean) => void;
onActionsDropdownOpenChange: (open: boolean) => void;
@@ -51,6 +53,7 @@ interface WorktreeTabProps {
onOpenDevServerUrl: (worktree: WorktreeInfo) => void;
onViewDevServerLogs: (worktree: WorktreeInfo) => void;
onRunInitScript: (worktree: WorktreeInfo) => void;
onToggleAutoMode?: (worktree: WorktreeInfo) => void;
hasInitScript: boolean;
}
@@ -75,6 +78,7 @@ export function WorktreeTab({
aheadCount,
behindCount,
gitRepoStatus,
isAutoModeRunning = false,
onSelectWorktree,
onBranchDropdownOpenChange,
onActionsDropdownOpenChange,
@@ -97,6 +101,7 @@ export function WorktreeTab({
onOpenDevServerUrl,
onViewDevServerLogs,
onRunInitScript,
onToggleAutoMode,
hasInitScript,
}: WorktreeTabProps) {
let prBadge: JSX.Element | null = null;
@@ -332,6 +337,26 @@ export function WorktreeTab({
</TooltipProvider>
)}
{isAutoModeRunning && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn(
'flex items-center justify-center h-7 px-1.5 rounded-none border-r-0',
isSelected ? 'bg-primary text-primary-foreground' : 'bg-secondary/50'
)}
>
<span className="h-2 w-2 rounded-full bg-green-500 animate-pulse" />
</span>
</TooltipTrigger>
<TooltipContent>
<p>Auto Mode Running</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<WorktreeActionsDropdown
worktree={worktree}
isSelected={isSelected}
@@ -343,6 +368,7 @@ export function WorktreeTab({
isDevServerRunning={isDevServerRunning}
devServerInfo={devServerInfo}
gitRepoStatus={gitRepoStatus}
isAutoModeRunning={isAutoModeRunning}
onOpenChange={onActionsDropdownOpenChange}
onPull={onPull}
onPush={onPush}
@@ -360,6 +386,7 @@ export function WorktreeTab({
onOpenDevServerUrl={onOpenDevServerUrl}
onViewDevServerLogs={onViewDevServerLogs}
onRunInitScript={onRunInitScript}
onToggleAutoMode={onToggleAutoMode}
hasInitScript={hasInitScript}
/>
</div>

View File

@@ -2,7 +2,7 @@ import { useEffect, useRef, useCallback, useState } from 'react';
import { Button } from '@/components/ui/button';
import { GitBranch, Plus, RefreshCw } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn, pathsEqual } from '@/lib/utils';
import { pathsEqual } from '@/lib/utils';
import { toast } from 'sonner';
import { getHttpApiClient } from '@/lib/http-api-client';
import { useIsMobile } from '@/hooks/use-media-query';
@@ -21,6 +21,7 @@ import {
WorktreeActionsDropdown,
BranchSwitchDropdown,
} from './components';
import { useAppStore } from '@/store/app-store';
export function WorktreePanel({
projectPath,
@@ -50,7 +51,6 @@ export function WorktreePanel({
const {
isStartingDevServer,
getWorktreeKey,
isDevServerRunning,
getDevServerInfo,
handleStartDevServer,
@@ -92,6 +92,67 @@ export function WorktreePanel({
features,
});
// Auto-mode state management using the store
// Use separate selectors to avoid creating new object references on each render
const autoModeByWorktree = useAppStore((state) => state.autoModeByWorktree);
const currentProject = useAppStore((state) => state.currentProject);
// Helper to generate worktree key for auto-mode (inlined to avoid selector issues)
const getAutoModeWorktreeKey = useCallback(
(projectId: string, branchName: string | null): string => {
return `${projectId}::${branchName ?? '__main__'}`;
},
[]
);
// Helper to check if auto-mode is running for a specific worktree
const isAutoModeRunningForWorktree = useCallback(
(worktree: WorktreeInfo): boolean => {
if (!currentProject) return false;
const branchName = worktree.isMain ? null : worktree.branch;
const key = getAutoModeWorktreeKey(currentProject.id, branchName);
return autoModeByWorktree[key]?.isRunning ?? false;
},
[currentProject, autoModeByWorktree, getAutoModeWorktreeKey]
);
// Handler to toggle auto-mode for a worktree
const handleToggleAutoMode = useCallback(
async (worktree: WorktreeInfo) => {
if (!currentProject) return;
// Import the useAutoMode to get start/stop functions
// Since useAutoMode is a hook, we'll use the API client directly
const api = getHttpApiClient();
const branchName = worktree.isMain ? null : worktree.branch;
const isRunning = isAutoModeRunningForWorktree(worktree);
try {
if (isRunning) {
const result = await api.autoMode.stop(projectPath, branchName);
if (result.success) {
const desc = branchName ? `worktree ${branchName}` : 'main branch';
toast.success(`Auto Mode stopped for ${desc}`);
} else {
toast.error(result.error || 'Failed to stop Auto Mode');
}
} else {
const result = await api.autoMode.start(projectPath, branchName);
if (result.success) {
const desc = branchName ? `worktree ${branchName}` : 'main branch';
toast.success(`Auto Mode started for ${desc}`);
} else {
toast.error(result.error || 'Failed to start Auto Mode');
}
}
} catch (error) {
toast.error('Error toggling Auto Mode');
console.error('Auto mode toggle error:', error);
}
},
[currentProject, projectPath, isAutoModeRunningForWorktree]
);
// Track whether init script exists for the project
const [hasInitScript, setHasInitScript] = useState(false);
@@ -244,6 +305,7 @@ export function WorktreePanel({
isDevServerRunning={isDevServerRunning(selectedWorktree)}
devServerInfo={getDevServerInfo(selectedWorktree)}
gitRepoStatus={gitRepoStatus}
isAutoModeRunning={isAutoModeRunningForWorktree(selectedWorktree)}
onOpenChange={handleActionsDropdownOpenChange(selectedWorktree)}
onPull={handlePull}
onPush={handlePush}
@@ -261,6 +323,7 @@ export function WorktreePanel({
onOpenDevServerUrl={handleOpenDevServerUrl}
onViewDevServerLogs={handleViewDevServerLogs}
onRunInitScript={handleRunInitScript}
onToggleAutoMode={handleToggleAutoMode}
hasInitScript={hasInitScript}
/>
)}
@@ -328,6 +391,7 @@ export function WorktreePanel({
aheadCount={aheadCount}
behindCount={behindCount}
gitRepoStatus={gitRepoStatus}
isAutoModeRunning={isAutoModeRunningForWorktree(mainWorktree)}
onSelectWorktree={handleSelectWorktree}
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(mainWorktree)}
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(mainWorktree)}
@@ -350,6 +414,7 @@ export function WorktreePanel({
onOpenDevServerUrl={handleOpenDevServerUrl}
onViewDevServerLogs={handleViewDevServerLogs}
onRunInitScript={handleRunInitScript}
onToggleAutoMode={handleToggleAutoMode}
hasInitScript={hasInitScript}
/>
)}
@@ -388,6 +453,7 @@ export function WorktreePanel({
aheadCount={aheadCount}
behindCount={behindCount}
gitRepoStatus={gitRepoStatus}
isAutoModeRunning={isAutoModeRunningForWorktree(worktree)}
onSelectWorktree={handleSelectWorktree}
onBranchDropdownOpenChange={handleBranchDropdownOpenChange(worktree)}
onActionsDropdownOpenChange={handleActionsDropdownOpenChange(worktree)}
@@ -410,6 +476,7 @@ export function WorktreePanel({
onOpenDevServerUrl={handleOpenDevServerUrl}
onViewDevServerLogs={handleViewDevServerLogs}
onRunInitScript={handleRunInitScript}
onToggleAutoMode={handleToggleAutoMode}
hasInitScript={hasInitScript}
/>
);