apply the patches

This commit is contained in:
webdevcody
2026-01-20 10:24:38 -05:00
parent 179c5ae9c2
commit 76eb3a2ac2
42 changed files with 2679 additions and 757 deletions

View File

@@ -249,7 +249,7 @@ notificationService.setEventEmitter(events);
const eventHistoryService = getEventHistoryService(); const eventHistoryService = getEventHistoryService();
// Initialize Event Hook Service for custom event triggers (with history storage) // Initialize Event Hook Service for custom event triggers (with history storage)
eventHookService.initialize(events, settingsService, eventHistoryService); eventHookService.initialize(events, settingsService, eventHistoryService, featureLoader);
// Initialize services // Initialize services
(async () => { (async () => {

View File

@@ -26,6 +26,24 @@ export function createRunFeatureHandler(autoModeService: AutoModeService) {
return; return;
} }
// Check per-worktree capacity before starting
const capacity = await autoModeService.checkWorktreeCapacity(projectPath, featureId);
if (!capacity.hasCapacity) {
const worktreeDesc = capacity.branchName
? `worktree "${capacity.branchName}"`
: 'main worktree';
res.status(429).json({
success: false,
error: `Agent limit reached for ${worktreeDesc} (${capacity.currentAgents}/${capacity.maxAgents}). Wait for running tasks to complete or increase the limit.`,
details: {
currentAgents: capacity.currentAgents,
maxAgents: capacity.maxAgents,
branchName: capacity.branchName,
},
});
return;
}
// Start execution in background // Start execution in background
// executeFeature derives workDir from feature.branchName // executeFeature derives workDir from feature.branchName
autoModeService autoModeService

View File

@@ -85,8 +85,9 @@ export function createApplyHandler() {
if (!change.feature) continue; if (!change.feature) continue;
try { try {
// Create the new feature // Create the new feature - use the AI-generated ID if provided
const newFeature = await featureLoader.create(projectPath, { const newFeature = await featureLoader.create(projectPath, {
id: change.feature.id, // Use descriptive ID from AI if provided
title: change.feature.title, title: change.feature.title,
description: change.feature.description || '', description: change.feature.description || '',
category: change.feature.category || 'Uncategorized', category: change.feature.category || 'Uncategorized',

View File

@@ -49,6 +49,7 @@ import {
createRunInitScriptHandler, createRunInitScriptHandler,
} from './routes/init-script.js'; } from './routes/init-script.js';
import { createDiscardChangesHandler } from './routes/discard-changes.js'; import { createDiscardChangesHandler } from './routes/discard-changes.js';
import { createListRemotesHandler } from './routes/list-remotes.js';
import type { SettingsService } from '../../services/settings-service.js'; import type { SettingsService } from '../../services/settings-service.js';
export function createWorktreeRoutes( export function createWorktreeRoutes(
@@ -157,5 +158,13 @@ export function createWorktreeRoutes(
createDiscardChangesHandler() createDiscardChangesHandler()
); );
// List remotes route
router.post(
'/list-remotes',
validatePathParams('worktreePath'),
requireValidWorktree,
createListRemotesHandler()
);
return router; return router;
} }

View File

@@ -110,9 +110,10 @@ export function createListBranchesHandler() {
} }
} }
// Get ahead/behind count for current branch // Get ahead/behind count for current branch and check if remote branch exists
let aheadCount = 0; let aheadCount = 0;
let behindCount = 0; let behindCount = 0;
let hasRemoteBranch = false;
try { try {
// First check if there's a remote tracking branch // First check if there's a remote tracking branch
const { stdout: upstreamOutput } = await execAsync( const { stdout: upstreamOutput } = await execAsync(
@@ -121,6 +122,7 @@ export function createListBranchesHandler() {
); );
if (upstreamOutput.trim()) { if (upstreamOutput.trim()) {
hasRemoteBranch = true;
const { stdout: aheadBehindOutput } = await execAsync( const { stdout: aheadBehindOutput } = await execAsync(
`git rev-list --left-right --count ${currentBranch}@{upstream}...HEAD`, `git rev-list --left-right --count ${currentBranch}@{upstream}...HEAD`,
{ cwd: worktreePath } { cwd: worktreePath }
@@ -130,7 +132,18 @@ export function createListBranchesHandler() {
behindCount = behind || 0; behindCount = behind || 0;
} }
} catch { } catch {
// No upstream branch set, that's okay // No upstream branch set - check if the branch exists on any remote
try {
// Check if there's a matching branch on origin (most common remote)
const { stdout: remoteBranchOutput } = await execAsync(
`git ls-remote --heads origin ${currentBranch}`,
{ cwd: worktreePath, timeout: 5000 }
);
hasRemoteBranch = remoteBranchOutput.trim().length > 0;
} catch {
// No remote branch found or origin doesn't exist
hasRemoteBranch = false;
}
} }
res.json({ res.json({
@@ -140,6 +153,7 @@ export function createListBranchesHandler() {
branches, branches,
aheadCount, aheadCount,
behindCount, behindCount,
hasRemoteBranch,
}, },
}); });
} catch (error) { } catch (error) {

View File

@@ -0,0 +1,127 @@
/**
* POST /list-remotes endpoint - List all remotes and their branches
*
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
* the requireValidWorktree middleware in index.ts
*/
import type { Request, Response } from 'express';
import { exec } from 'child_process';
import { promisify } from 'util';
import { getErrorMessage, logWorktreeError } from '../common.js';
const execAsync = promisify(exec);
interface RemoteBranch {
name: string;
fullRef: string;
}
interface RemoteInfo {
name: string;
url: string;
branches: RemoteBranch[];
}
export function createListRemotesHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath } = req.body as {
worktreePath: string;
};
if (!worktreePath) {
res.status(400).json({
success: false,
error: 'worktreePath required',
});
return;
}
// Get list of remotes
const { stdout: remotesOutput } = await execAsync('git remote -v', {
cwd: worktreePath,
});
// Parse remotes (each remote appears twice - once for fetch, once for push)
const remotesSet = new Map<string, string>();
remotesOutput
.trim()
.split('\n')
.filter((line) => line.trim())
.forEach((line) => {
const match = line.match(/^(\S+)\s+(\S+)\s+\(fetch\)$/);
if (match) {
remotesSet.set(match[1], match[2]);
}
});
// Fetch latest from all remotes (silently, don't fail if offline)
try {
await execAsync('git fetch --all --quiet', {
cwd: worktreePath,
timeout: 15000, // 15 second timeout
});
} catch {
// Ignore fetch errors - we'll use cached remote refs
}
// Get all remote branches
const { stdout: remoteBranchesOutput } = await execAsync(
'git branch -r --format="%(refname:short)"',
{ cwd: worktreePath }
);
// Group branches by remote
const remotesBranches = new Map<string, RemoteBranch[]>();
remotesSet.forEach((_, remoteName) => {
remotesBranches.set(remoteName, []);
});
remoteBranchesOutput
.trim()
.split('\n')
.filter((line) => line.trim())
.forEach((line) => {
const cleanLine = line.trim().replace(/^['"]|['"]$/g, '');
// Skip HEAD pointers like "origin/HEAD"
if (cleanLine.includes('/HEAD')) return;
// Parse remote name from branch ref (e.g., "origin/main" -> "origin")
const slashIndex = cleanLine.indexOf('/');
if (slashIndex === -1) return;
const remoteName = cleanLine.substring(0, slashIndex);
const branchName = cleanLine.substring(slashIndex + 1);
if (remotesBranches.has(remoteName)) {
remotesBranches.get(remoteName)!.push({
name: branchName,
fullRef: cleanLine,
});
}
});
// Build final result
const remotes: RemoteInfo[] = [];
remotesSet.forEach((url, name) => {
remotes.push({
name,
url,
branches: remotesBranches.get(name) || [],
});
});
res.json({
success: true,
result: {
remotes,
},
});
} catch (error) {
const worktreePath = req.body?.worktreePath;
logWorktreeError(error, 'List remotes failed', worktreePath);
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -1,5 +1,7 @@
/** /**
* POST /merge endpoint - Merge feature (merge worktree branch into main) * POST /merge endpoint - Merge feature (merge worktree branch into a target branch)
*
* Allows merging a worktree branch into any target branch (defaults to 'main').
* *
* Note: Git repository validation (isGitRepo, hasCommits) is handled by * Note: Git repository validation (isGitRepo, hasCommits) is handled by
* the requireValidProject middleware in index.ts * the requireValidProject middleware in index.ts
@@ -8,18 +10,21 @@
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { exec } from 'child_process'; import { exec } from 'child_process';
import { promisify } from 'util'; import { promisify } from 'util';
import { getErrorMessage, logError } from '../common.js'; import { getErrorMessage, logError, isValidBranchName, execGitCommand } from '../common.js';
import { createLogger } from '@automaker/utils';
const execAsync = promisify(exec); const execAsync = promisify(exec);
const logger = createLogger('Worktree');
export function createMergeHandler() { export function createMergeHandler() {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
try { try {
const { projectPath, branchName, worktreePath, options } = req.body as { const { projectPath, branchName, worktreePath, targetBranch, options } = req.body as {
projectPath: string; projectPath: string;
branchName: string; branchName: string;
worktreePath: string; worktreePath: string;
options?: { squash?: boolean; message?: string }; targetBranch?: string; // Branch to merge into (defaults to 'main')
options?: { squash?: boolean; message?: string; deleteWorktreeAndBranch?: boolean };
}; };
if (!projectPath || !branchName || !worktreePath) { if (!projectPath || !branchName || !worktreePath) {
@@ -30,7 +35,10 @@ export function createMergeHandler() {
return; return;
} }
// Validate branch exists // Determine the target branch (default to 'main')
const mergeTo = targetBranch || 'main';
// Validate source branch exists
try { try {
await execAsync(`git rev-parse --verify ${branchName}`, { cwd: projectPath }); await execAsync(`git rev-parse --verify ${branchName}`, { cwd: projectPath });
} catch { } catch {
@@ -41,12 +49,44 @@ export function createMergeHandler() {
return; return;
} }
// Merge the feature branch // Validate target branch exists
try {
await execAsync(`git rev-parse --verify ${mergeTo}`, { cwd: projectPath });
} catch {
res.status(400).json({
success: false,
error: `Target branch "${mergeTo}" does not exist`,
});
return;
}
// Merge the feature branch into the target branch
const mergeCmd = options?.squash const mergeCmd = options?.squash
? `git merge --squash ${branchName}` ? `git merge --squash ${branchName}`
: `git merge ${branchName} -m "${options?.message || `Merge ${branchName}`}"`; : `git merge ${branchName} -m "${options?.message || `Merge ${branchName} into ${mergeTo}`}"`;
try {
await execAsync(mergeCmd, { cwd: projectPath }); await execAsync(mergeCmd, { cwd: projectPath });
} catch (mergeError: unknown) {
// Check if this is a merge conflict
const err = mergeError as { stdout?: string; stderr?: string; message?: string };
const output = `${err.stdout || ''} ${err.stderr || ''} ${err.message || ''}`;
const hasConflicts =
output.includes('CONFLICT') || output.includes('Automatic merge failed');
if (hasConflicts) {
// Return conflict-specific error message that frontend can detect
res.status(409).json({
success: false,
error: `Merge CONFLICT: Automatic merge of "${branchName}" into "${mergeTo}" failed. Please resolve conflicts manually.`,
hasConflicts: true,
});
return;
}
// Re-throw non-conflict errors to be handled by outer catch
throw mergeError;
}
// If squash merge, need to commit // If squash merge, need to commit
if (options?.squash) { if (options?.squash) {
@@ -55,17 +95,46 @@ export function createMergeHandler() {
}); });
} }
// Clean up worktree and branch // Optionally delete the worktree and branch after merging
let worktreeDeleted = false;
let branchDeleted = false;
if (options?.deleteWorktreeAndBranch) {
// Remove the worktree
try { try {
await execAsync(`git worktree remove "${worktreePath}" --force`, { await execGitCommand(['worktree', 'remove', worktreePath, '--force'], projectPath);
cwd: projectPath, worktreeDeleted = true;
});
await execAsync(`git branch -D ${branchName}`, { cwd: projectPath });
} catch { } catch {
// Cleanup errors are non-fatal // Try with prune if remove fails
try {
await execGitCommand(['worktree', 'prune'], projectPath);
worktreeDeleted = true;
} catch {
logger.warn(`Failed to remove worktree: ${worktreePath}`);
}
} }
res.json({ success: true, mergedBranch: branchName }); // Delete the branch (but not main/master)
if (branchName !== 'main' && branchName !== 'master') {
if (!isValidBranchName(branchName)) {
logger.warn(`Invalid branch name detected, skipping deletion: ${branchName}`);
} else {
try {
await execGitCommand(['branch', '-D', branchName], projectPath);
branchDeleted = true;
} catch {
logger.warn(`Failed to delete branch: ${branchName}`);
}
}
}
}
res.json({
success: true,
mergedBranch: branchName,
targetBranch: mergeTo,
deleted: options?.deleteWorktreeAndBranch ? { worktreeDeleted, branchDeleted } : undefined,
});
} catch (error) { } catch (error) {
logError(error, 'Merge worktree failed'); logError(error, 'Merge worktree failed');
res.status(500).json({ success: false, error: getErrorMessage(error) }); res.status(500).json({ success: false, error: getErrorMessage(error) });

View File

@@ -15,9 +15,10 @@ const execAsync = promisify(exec);
export function createPushHandler() { export function createPushHandler() {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
try { try {
const { worktreePath, force } = req.body as { const { worktreePath, force, remote } = req.body as {
worktreePath: string; worktreePath: string;
force?: boolean; force?: boolean;
remote?: string;
}; };
if (!worktreePath) { if (!worktreePath) {
@@ -34,15 +35,18 @@ export function createPushHandler() {
}); });
const branchName = branchOutput.trim(); const branchName = branchOutput.trim();
// Use specified remote or default to 'origin'
const targetRemote = remote || 'origin';
// Push the branch // Push the branch
const forceFlag = force ? '--force' : ''; const forceFlag = force ? '--force' : '';
try { try {
await execAsync(`git push -u origin ${branchName} ${forceFlag}`, { await execAsync(`git push -u ${targetRemote} ${branchName} ${forceFlag}`, {
cwd: worktreePath, cwd: worktreePath,
}); });
} catch { } catch {
// Try setting upstream // Try setting upstream
await execAsync(`git push --set-upstream origin ${branchName} ${forceFlag}`, { await execAsync(`git push --set-upstream ${targetRemote} ${branchName} ${forceFlag}`, {
cwd: worktreePath, cwd: worktreePath,
}); });
} }
@@ -52,7 +56,7 @@ export function createPushHandler() {
result: { result: {
branch: branchName, branch: branchName,
pushed: true, pushed: true,
message: `Successfully pushed ${branchName} to origin`, message: `Successfully pushed ${branchName} to ${targetRemote}`,
}, },
}); });
} catch (error) { } catch (error) {

View File

@@ -248,7 +248,8 @@ interface AutoModeConfig {
* @param branchName - The branch name, or null for main worktree * @param branchName - The branch name, or null for main worktree
*/ */
function getWorktreeAutoLoopKey(projectPath: string, branchName: string | null): string { function getWorktreeAutoLoopKey(projectPath: string, branchName: string | null): string {
return `${projectPath}::${branchName ?? '__main__'}`; const normalizedBranch = branchName === 'main' ? null : branchName;
return `${projectPath}::${normalizedBranch ?? '__main__'}`;
} }
/** /**
@@ -514,14 +515,11 @@ export class AutoModeService {
? settings.maxConcurrency ? settings.maxConcurrency
: DEFAULT_MAX_CONCURRENCY; : DEFAULT_MAX_CONCURRENCY;
const projectId = settings.projects?.find((project) => project.path === projectPath)?.id; const projectId = settings.projects?.find((project) => project.path === projectPath)?.id;
const autoModeByWorktree = (settings as unknown as Record<string, unknown>) const autoModeByWorktree = settings.autoModeByWorktree;
.autoModeByWorktree;
if (projectId && autoModeByWorktree && typeof autoModeByWorktree === 'object') { if (projectId && autoModeByWorktree && typeof autoModeByWorktree === 'object') {
const key = `${projectId}::${branchName ?? '__main__'}`; const key = `${projectId}::${branchName ?? '__main__'}`;
const entry = (autoModeByWorktree as Record<string, unknown>)[key] as const entry = autoModeByWorktree[key];
| { maxConcurrency?: number }
| undefined;
if (entry && typeof entry.maxConcurrency === 'number') { if (entry && typeof entry.maxConcurrency === 'number') {
return entry.maxConcurrency; return entry.maxConcurrency;
} }
@@ -592,6 +590,7 @@ export class AutoModeService {
message: `Auto mode started with max ${resolvedMaxConcurrency} concurrent features`, message: `Auto mode started with max ${resolvedMaxConcurrency} concurrent features`,
projectPath, projectPath,
branchName, branchName,
maxConcurrency: resolvedMaxConcurrency,
}); });
// Save execution state for recovery after restart // Save execution state for recovery after restart
@@ -677,8 +676,10 @@ export class AutoModeService {
continue; continue;
} }
// Find a feature not currently running // Find a feature not currently running and not yet finished
const nextFeature = pendingFeatures.find((f) => !this.runningFeatures.has(f.id)); const nextFeature = pendingFeatures.find(
(f) => !this.runningFeatures.has(f.id) && !this.isFeatureFinished(f)
);
if (nextFeature) { if (nextFeature) {
logger.info(`[AutoLoop] Starting feature ${nextFeature.id}: ${nextFeature.title}`); logger.info(`[AutoLoop] Starting feature ${nextFeature.id}: ${nextFeature.title}`);
@@ -730,11 +731,12 @@ export class AutoModeService {
* @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 with "main")
*/ */
private getRunningCountForWorktree(projectPath: string, branchName: string | null): number { private getRunningCountForWorktree(projectPath: string, branchName: string | null): number {
const normalizedBranch = branchName === 'main' ? null : branchName;
let count = 0; let count = 0;
for (const [, feature] of this.runningFeatures) { for (const [, feature] of this.runningFeatures) {
// Filter by project path AND branchName to get accurate worktree-specific count // Filter by project path AND branchName to get accurate worktree-specific count
const featureBranch = feature.branchName ?? null; const featureBranch = feature.branchName ?? null;
if (branchName === null) { if (normalizedBranch === null) {
// Main worktree: match features with branchName === null OR branchName === "main" // Main worktree: match features with branchName === null OR branchName === "main"
if ( if (
feature.projectPath === projectPath && feature.projectPath === projectPath &&
@@ -998,6 +1000,41 @@ export class AutoModeService {
return this.runningFeatures.size; return this.runningFeatures.size;
} }
/**
* Check if there's capacity to start a feature on a worktree.
* This respects per-worktree agent limits from autoModeByWorktree settings.
*
* @param projectPath - The main project path
* @param featureId - The feature ID to check capacity for
* @returns Object with hasCapacity boolean and details about current/max agents
*/
async checkWorktreeCapacity(
projectPath: string,
featureId: string
): Promise<{
hasCapacity: boolean;
currentAgents: number;
maxAgents: number;
branchName: string | null;
}> {
// Load feature to get branchName
const feature = await this.loadFeature(projectPath, featureId);
const branchName = feature?.branchName ?? null;
// Get per-worktree limit
const maxAgents = await this.resolveMaxConcurrency(projectPath, branchName);
// Get current running count for this worktree
const currentAgents = this.getRunningCountForWorktree(projectPath, branchName);
return {
hasCapacity: currentAgents < maxAgents,
currentAgents,
maxAgents,
branchName,
};
}
/** /**
* Execute a single feature * Execute a single feature
* @param projectPath - The main project path * @param projectPath - The main project path
@@ -1036,7 +1073,6 @@ export class AutoModeService {
if (isAutoMode) { if (isAutoMode) {
await this.saveExecutionState(projectPath); await this.saveExecutionState(projectPath);
} }
// Declare feature outside try block so it's available in catch for error reporting // Declare feature outside try block so it's available in catch for error reporting
let feature: Awaited<ReturnType<typeof this.loadFeature>> | null = null; let feature: Awaited<ReturnType<typeof this.loadFeature>> | null = null;
@@ -1044,9 +1080,44 @@ export class AutoModeService {
// Validate that project path is allowed using centralized validation // Validate that project path is allowed using centralized validation
validateWorkingDirectory(projectPath); validateWorkingDirectory(projectPath);
// Load feature details FIRST to get status and plan info
feature = await this.loadFeature(projectPath, featureId);
if (!feature) {
throw new Error(`Feature ${featureId} not found`);
}
// Check if feature has existing context - if so, resume instead of starting fresh // Check if feature has existing context - if so, resume instead of starting fresh
// Skip this check if we're already being called with a continuation prompt (from resumeFeature) // Skip this check if we're already being called with a continuation prompt (from resumeFeature)
if (!options?.continuationPrompt) { if (!options?.continuationPrompt) {
// If feature has an approved plan but we don't have a continuation prompt yet,
// we should build one to ensure it proceeds with multi-agent execution
if (feature.planSpec?.status === 'approved') {
logger.info(`Feature ${featureId} has approved plan, building continuation prompt`);
// Get customized prompts from settings
const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]');
const planContent = feature.planSpec.content || '';
// Build continuation prompt using centralized template
let continuationPrompt = prompts.taskExecution.continuationAfterApprovalTemplate;
continuationPrompt = continuationPrompt.replace(/\{\{userFeedback\}\}/g, '');
continuationPrompt = continuationPrompt.replace(/\{\{approvedPlan\}\}/g, planContent);
// Recursively call executeFeature with the continuation prompt
// Remove from running features temporarily, it will be added back
this.runningFeatures.delete(featureId);
return this.executeFeature(
projectPath,
featureId,
useWorktrees,
isAutoMode,
providedWorktreePath,
{
continuationPrompt,
}
);
}
const hasExistingContext = await this.contextExists(projectPath, featureId); const hasExistingContext = await this.contextExists(projectPath, featureId);
if (hasExistingContext) { if (hasExistingContext) {
logger.info( logger.info(
@@ -1058,12 +1129,6 @@ export class AutoModeService {
} }
} }
// Load feature details FIRST to get branchName
feature = await this.loadFeature(projectPath, featureId);
if (!feature) {
throw new Error(`Feature ${featureId} not found`);
}
// Derive workDir from feature.branchName // Derive workDir from feature.branchName
// Worktrees should already be created when the feature is added/edited // Worktrees should already be created when the feature is added/edited
let worktreePath: string | null = null; let worktreePath: string | null = null;
@@ -1190,6 +1255,7 @@ export class AutoModeService {
systemPrompt: combinedSystemPrompt || undefined, systemPrompt: combinedSystemPrompt || undefined,
autoLoadClaudeMd, autoLoadClaudeMd,
thinkingLevel: feature.thinkingLevel, thinkingLevel: feature.thinkingLevel,
branchName: feature.branchName ?? null,
} }
); );
@@ -1361,6 +1427,7 @@ export class AutoModeService {
this.emitAutoModeEvent('auto_mode_progress', { this.emitAutoModeEvent('auto_mode_progress', {
featureId, featureId,
branchName: feature.branchName ?? null,
content: `Starting pipeline step ${i + 1}/${steps.length}: ${step.name}`, content: `Starting pipeline step ${i + 1}/${steps.length}: ${step.name}`,
projectPath, projectPath,
}); });
@@ -2805,6 +2872,21 @@ Format your response as a structured markdown document.`;
} }
} }
private isFeatureFinished(feature: Feature): boolean {
const isCompleted = feature.status === 'completed' || feature.status === 'verified';
// Even if marked as completed, if it has an approved plan with pending tasks, it's not finished
if (feature.planSpec?.status === 'approved') {
const tasksCompleted = feature.planSpec.tasksCompleted ?? 0;
const tasksTotal = feature.planSpec.tasksTotal ?? 0;
if (tasksCompleted < tasksTotal) {
return false;
}
}
return isCompleted;
}
/** /**
* Update the planSpec of a feature * Update the planSpec of a feature
*/ */
@@ -2899,10 +2981,14 @@ Format your response as a structured markdown document.`;
allFeatures.push(feature); allFeatures.push(feature);
// Track pending features separately, filtered by worktree/branch // Track pending features separately, filtered by worktree/branch
// Note: waiting_approval is NOT included - those features have completed execution
// and are waiting for user review, they should not be picked up again
if ( if (
feature.status === 'pending' || feature.status === 'pending' ||
feature.status === 'ready' || feature.status === 'ready' ||
feature.status === 'backlog' feature.status === 'backlog' ||
(feature.planSpec?.status === 'approved' &&
(feature.planSpec.tasksCompleted ?? 0) < (feature.planSpec.tasksTotal ?? 0))
) { ) {
// Filter by branchName: // 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 === "main"
@@ -2934,7 +3020,7 @@ Format your response as a structured markdown document.`;
const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree'; const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
logger.info( logger.info(
`[loadPendingFeatures] Found ${allFeatures.length} total features, ${pendingFeatures.length} with backlog/pending/ready status for ${worktreeDesc}` `[loadPendingFeatures] Found ${allFeatures.length} total features, ${pendingFeatures.length} candidates (pending/ready/backlog/approved_with_pending_tasks) for ${worktreeDesc}`
); );
if (pendingFeatures.length === 0) { if (pendingFeatures.length === 0) {
@@ -2943,7 +3029,12 @@ Format your response as a structured markdown document.`;
); );
// Log all backlog features to help debug branchName matching // Log all backlog features to help debug branchName matching
const allBacklogFeatures = allFeatures.filter( const allBacklogFeatures = allFeatures.filter(
(f) => f.status === 'backlog' || f.status === 'pending' || f.status === 'ready' (f) =>
f.status === 'backlog' ||
f.status === 'pending' ||
f.status === 'ready' ||
(f.planSpec?.status === 'approved' &&
(f.planSpec.tasksCompleted ?? 0) < (f.planSpec.tasksTotal ?? 0))
); );
if (allBacklogFeatures.length > 0) { if (allBacklogFeatures.length > 0) {
logger.info( logger.info(
@@ -2953,7 +3044,43 @@ Format your response as a structured markdown document.`;
} }
// Apply dependency-aware ordering // Apply dependency-aware ordering
const { orderedFeatures } = resolveDependencies(pendingFeatures); const { orderedFeatures, missingDependencies } = resolveDependencies(pendingFeatures);
// Remove missing dependencies from features and save them
// This allows features to proceed when their dependencies have been deleted or don't exist
if (missingDependencies.size > 0) {
for (const [featureId, missingDepIds] of missingDependencies) {
const feature = pendingFeatures.find((f) => f.id === featureId);
if (feature && feature.dependencies) {
// Filter out the missing dependency IDs
const validDependencies = feature.dependencies.filter(
(depId) => !missingDepIds.includes(depId)
);
logger.warn(
`[loadPendingFeatures] Feature ${featureId} has missing dependencies: ${missingDepIds.join(', ')}. Removing them automatically.`
);
// Update the feature in memory
feature.dependencies = validDependencies.length > 0 ? validDependencies : undefined;
// Save the updated feature to disk
try {
await this.featureLoader.update(projectPath, featureId, {
dependencies: feature.dependencies,
});
logger.info(
`[loadPendingFeatures] Updated feature ${featureId} - removed missing dependencies`
);
} catch (error) {
logger.error(
`[loadPendingFeatures] Failed to save feature ${featureId} after removing missing dependencies:`,
error
);
}
}
}
}
// Get skipVerificationInAutoMode setting // Get skipVerificationInAutoMode setting
const settings = await this.settingsService?.getGlobalSettings(); const settings = await this.settingsService?.getGlobalSettings();
@@ -3129,9 +3256,11 @@ You can use the Read tool to view these images at any time during implementation
systemPrompt?: string; systemPrompt?: string;
autoLoadClaudeMd?: boolean; autoLoadClaudeMd?: boolean;
thinkingLevel?: ThinkingLevel; thinkingLevel?: ThinkingLevel;
branchName?: string | null;
} }
): Promise<void> { ): Promise<void> {
const finalProjectPath = options?.projectPath || projectPath; const finalProjectPath = options?.projectPath || projectPath;
const branchName = options?.branchName ?? null;
const planningMode = options?.planningMode || 'skip'; const planningMode = options?.planningMode || 'skip';
const previousContent = options?.previousContent; const previousContent = options?.previousContent;
@@ -3496,6 +3625,7 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
this.emitAutoModeEvent('plan_approval_required', { this.emitAutoModeEvent('plan_approval_required', {
featureId, featureId,
projectPath, projectPath,
branchName,
planContent: currentPlanContent, planContent: currentPlanContent,
planningMode, planningMode,
planVersion, planVersion,
@@ -3527,6 +3657,7 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
this.emitAutoModeEvent('plan_approved', { this.emitAutoModeEvent('plan_approved', {
featureId, featureId,
projectPath, projectPath,
branchName,
hasEdits: !!approvalResult.editedPlan, hasEdits: !!approvalResult.editedPlan,
planVersion, planVersion,
}); });
@@ -3555,6 +3686,7 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
this.emitAutoModeEvent('plan_revision_requested', { this.emitAutoModeEvent('plan_revision_requested', {
featureId, featureId,
projectPath, projectPath,
branchName,
feedback: approvalResult.feedback, feedback: approvalResult.feedback,
hasEdits: !!hasEdits, hasEdits: !!hasEdits,
planVersion, planVersion,
@@ -3658,6 +3790,7 @@ After generating the revised spec, output:
this.emitAutoModeEvent('plan_auto_approved', { this.emitAutoModeEvent('plan_auto_approved', {
featureId, featureId,
projectPath, projectPath,
branchName,
planContent, planContent,
planningMode, planningMode,
}); });
@@ -3708,6 +3841,7 @@ After generating the revised spec, output:
this.emitAutoModeEvent('auto_mode_task_started', { this.emitAutoModeEvent('auto_mode_task_started', {
featureId, featureId,
projectPath, projectPath,
branchName,
taskId: task.id, taskId: task.id,
taskDescription: task.description, taskDescription: task.description,
taskIndex, taskIndex,
@@ -3753,11 +3887,13 @@ After generating the revised spec, output:
responseText += block.text || ''; responseText += block.text || '';
this.emitAutoModeEvent('auto_mode_progress', { this.emitAutoModeEvent('auto_mode_progress', {
featureId, featureId,
branchName,
content: block.text, content: block.text,
}); });
} else if (block.type === 'tool_use') { } else if (block.type === 'tool_use') {
this.emitAutoModeEvent('auto_mode_tool', { this.emitAutoModeEvent('auto_mode_tool', {
featureId, featureId,
branchName,
tool: block.name, tool: block.name,
input: block.input, input: block.input,
}); });
@@ -3776,6 +3912,7 @@ After generating the revised spec, output:
this.emitAutoModeEvent('auto_mode_task_complete', { this.emitAutoModeEvent('auto_mode_task_complete', {
featureId, featureId,
projectPath, projectPath,
branchName,
taskId: task.id, taskId: task.id,
tasksCompleted: taskIndex + 1, tasksCompleted: taskIndex + 1,
tasksTotal: parsedTasks.length, tasksTotal: parsedTasks.length,
@@ -3796,6 +3933,7 @@ After generating the revised spec, output:
this.emitAutoModeEvent('auto_mode_phase_complete', { this.emitAutoModeEvent('auto_mode_phase_complete', {
featureId, featureId,
projectPath, projectPath,
branchName,
phaseNumber: parseInt(phaseMatch[1], 10), phaseNumber: parseInt(phaseMatch[1], 10),
}); });
} }
@@ -3845,11 +3983,13 @@ After generating the revised spec, output:
responseText += block.text || ''; responseText += block.text || '';
this.emitAutoModeEvent('auto_mode_progress', { this.emitAutoModeEvent('auto_mode_progress', {
featureId, featureId,
branchName,
content: block.text, content: block.text,
}); });
} else if (block.type === 'tool_use') { } else if (block.type === 'tool_use') {
this.emitAutoModeEvent('auto_mode_tool', { this.emitAutoModeEvent('auto_mode_tool', {
featureId, featureId,
branchName,
tool: block.name, tool: block.name,
input: block.input, input: block.input,
}); });
@@ -3875,6 +4015,7 @@ After generating the revised spec, output:
); );
this.emitAutoModeEvent('auto_mode_progress', { this.emitAutoModeEvent('auto_mode_progress', {
featureId, featureId,
branchName,
content: block.text, content: block.text,
}); });
} }
@@ -3882,6 +4023,7 @@ After generating the revised spec, output:
// Emit event for real-time UI // Emit event for real-time UI
this.emitAutoModeEvent('auto_mode_tool', { this.emitAutoModeEvent('auto_mode_tool', {
featureId, featureId,
branchName,
tool: block.name, tool: block.name,
input: block.input, input: block.input,
}); });
@@ -4287,6 +4429,7 @@ After generating the revised spec, output:
id: f.id, id: f.id,
title: f.title, title: f.title,
status: f.status, status: f.status,
branchName: f.branchName ?? null,
})), })),
}); });

View File

@@ -21,6 +21,7 @@ import { createLogger } from '@automaker/utils';
import type { EventEmitter } from '../lib/events.js'; import type { EventEmitter } from '../lib/events.js';
import type { SettingsService } from './settings-service.js'; import type { SettingsService } from './settings-service.js';
import type { EventHistoryService } from './event-history-service.js'; import type { EventHistoryService } from './event-history-service.js';
import type { FeatureLoader } from './feature-loader.js';
import type { import type {
EventHook, EventHook,
EventHookTrigger, EventHookTrigger,
@@ -84,19 +85,22 @@ export class EventHookService {
private emitter: EventEmitter | null = null; private emitter: EventEmitter | null = null;
private settingsService: SettingsService | null = null; private settingsService: SettingsService | null = null;
private eventHistoryService: EventHistoryService | null = null; private eventHistoryService: EventHistoryService | null = null;
private featureLoader: FeatureLoader | null = null;
private unsubscribe: (() => void) | null = null; private unsubscribe: (() => void) | null = null;
/** /**
* Initialize the service with event emitter, settings service, and event history service * Initialize the service with event emitter, settings service, event history service, and feature loader
*/ */
initialize( initialize(
emitter: EventEmitter, emitter: EventEmitter,
settingsService: SettingsService, settingsService: SettingsService,
eventHistoryService?: EventHistoryService eventHistoryService?: EventHistoryService,
featureLoader?: FeatureLoader
): void { ): void {
this.emitter = emitter; this.emitter = emitter;
this.settingsService = settingsService; this.settingsService = settingsService;
this.eventHistoryService = eventHistoryService || null; this.eventHistoryService = eventHistoryService || null;
this.featureLoader = featureLoader || null;
// Subscribe to events // Subscribe to events
this.unsubscribe = emitter.subscribe((type, payload) => { this.unsubscribe = emitter.subscribe((type, payload) => {
@@ -121,6 +125,7 @@ export class EventHookService {
this.emitter = null; this.emitter = null;
this.settingsService = null; this.settingsService = null;
this.eventHistoryService = null; this.eventHistoryService = null;
this.featureLoader = null;
} }
/** /**
@@ -150,6 +155,19 @@ export class EventHookService {
if (!trigger) return; if (!trigger) return;
// Load feature name if we have featureId but no featureName
let featureName: string | undefined = undefined;
if (payload.featureId && payload.projectPath && this.featureLoader) {
try {
const feature = await this.featureLoader.get(payload.projectPath, payload.featureId);
if (feature?.title) {
featureName = feature.title;
}
} catch (error) {
logger.warn(`Failed to load feature ${payload.featureId} for event hook:`, error);
}
}
// Build context for variable substitution // Build context for variable substitution
const context: HookContext = { const context: HookContext = {
featureId: payload.featureId, featureId: payload.featureId,
@@ -315,6 +333,7 @@ export class EventHookService {
eventType: context.eventType, eventType: context.eventType,
timestamp: context.timestamp, timestamp: context.timestamp,
featureId: context.featureId, featureId: context.featureId,
featureName: context.featureName,
projectPath: context.projectPath, projectPath: context.projectPath,
projectName: context.projectName, projectName: context.projectName,
error: context.error, error: context.error,

View File

@@ -415,16 +415,25 @@ export class SettingsService {
ignoreEmptyArrayOverwrite('claudeApiProfiles'); ignoreEmptyArrayOverwrite('claudeApiProfiles');
// Empty object overwrite guard // Empty object overwrite guard
const ignoreEmptyObjectOverwrite = <K extends keyof GlobalSettings>(key: K): void => {
const nextVal = sanitizedUpdates[key] as unknown;
const curVal = current[key] as unknown;
if ( if (
sanitizedUpdates.lastSelectedSessionByProject && nextVal &&
typeof sanitizedUpdates.lastSelectedSessionByProject === 'object' && typeof nextVal === 'object' &&
!Array.isArray(sanitizedUpdates.lastSelectedSessionByProject) && !Array.isArray(nextVal) &&
Object.keys(sanitizedUpdates.lastSelectedSessionByProject).length === 0 && Object.keys(nextVal).length === 0 &&
current.lastSelectedSessionByProject && curVal &&
Object.keys(current.lastSelectedSessionByProject).length > 0 typeof curVal === 'object' &&
!Array.isArray(curVal) &&
Object.keys(curVal).length > 0
) { ) {
delete sanitizedUpdates.lastSelectedSessionByProject; delete sanitizedUpdates[key];
} }
};
ignoreEmptyObjectOverwrite('lastSelectedSessionByProject');
ignoreEmptyObjectOverwrite('autoModeByWorktree');
// If a request attempted to wipe projects, also ignore theme changes in that same request. // If a request attempted to wipe projects, also ignore theme changes in that same request.
if (attemptedProjectWipe) { if (attemptedProjectWipe) {

View File

@@ -2,6 +2,7 @@
import { useEffect, useState, useCallback, useMemo, useRef } from 'react'; import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
import { createLogger } from '@automaker/utils/logger'; import { createLogger } from '@automaker/utils/logger';
import { import {
DndContext,
PointerSensor, PointerSensor,
useSensor, useSensor,
useSensors, useSensors,
@@ -49,19 +50,21 @@ import {
CompletedFeaturesModal, CompletedFeaturesModal,
ArchiveAllVerifiedDialog, ArchiveAllVerifiedDialog,
DeleteCompletedFeatureDialog, DeleteCompletedFeatureDialog,
DependencyLinkDialog,
EditFeatureDialog, EditFeatureDialog,
FollowUpDialog, FollowUpDialog,
PlanApprovalDialog, PlanApprovalDialog,
PullResolveConflictsDialog,
} from './board-view/dialogs'; } from './board-view/dialogs';
import type { DependencyLinkType } from './board-view/dialogs';
import { PipelineSettingsDialog } from './board-view/dialogs/pipeline-settings-dialog'; import { PipelineSettingsDialog } from './board-view/dialogs/pipeline-settings-dialog';
import { CreateWorktreeDialog } from './board-view/dialogs/create-worktree-dialog'; import { CreateWorktreeDialog } from './board-view/dialogs/create-worktree-dialog';
import { DeleteWorktreeDialog } from './board-view/dialogs/delete-worktree-dialog'; import { DeleteWorktreeDialog } from './board-view/dialogs/delete-worktree-dialog';
import { CommitWorktreeDialog } from './board-view/dialogs/commit-worktree-dialog'; import { CommitWorktreeDialog } from './board-view/dialogs/commit-worktree-dialog';
import { CreatePRDialog } from './board-view/dialogs/create-pr-dialog'; import { CreatePRDialog } from './board-view/dialogs/create-pr-dialog';
import { CreateBranchDialog } from './board-view/dialogs/create-branch-dialog'; import { CreateBranchDialog } from './board-view/dialogs/create-branch-dialog';
import { MergeWorktreeDialog } from './board-view/dialogs/merge-worktree-dialog';
import { WorktreePanel } from './board-view/worktree-panel'; import { WorktreePanel } from './board-view/worktree-panel';
import type { PRInfo, WorktreeInfo } from './board-view/worktree-panel/types'; import type { PRInfo, WorktreeInfo, MergeConflictInfo } from './board-view/worktree-panel/types';
import { COLUMNS, getColumnsWithPipeline } from './board-view/constants'; import { COLUMNS, getColumnsWithPipeline } from './board-view/constants';
import { import {
useBoardFeatures, useBoardFeatures,
@@ -182,7 +185,7 @@ export function BoardView() {
const [showCommitWorktreeDialog, setShowCommitWorktreeDialog] = useState(false); const [showCommitWorktreeDialog, setShowCommitWorktreeDialog] = useState(false);
const [showCreatePRDialog, setShowCreatePRDialog] = useState(false); const [showCreatePRDialog, setShowCreatePRDialog] = useState(false);
const [showCreateBranchDialog, setShowCreateBranchDialog] = useState(false); const [showCreateBranchDialog, setShowCreateBranchDialog] = useState(false);
const [showMergeWorktreeDialog, setShowMergeWorktreeDialog] = useState(false); const [showPullResolveConflictsDialog, setShowPullResolveConflictsDialog] = useState(false);
const [selectedWorktreeForAction, setSelectedWorktreeForAction] = useState<{ const [selectedWorktreeForAction, setSelectedWorktreeForAction] = useState<{
path: string; path: string;
branch: string; branch: string;
@@ -359,10 +362,22 @@ export function BoardView() {
fetchBranches(); fetchBranches();
}, [currentProject, worktreeRefreshKey]); }, [currentProject, worktreeRefreshKey]);
// Custom collision detection that prioritizes columns over cards // Custom collision detection that prioritizes specific drop targets (cards, worktrees) over columns
const collisionDetectionStrategy = useCallback((args: any) => { const collisionDetectionStrategy = useCallback((args: any) => {
// First, check if pointer is within a column
const pointerCollisions = pointerWithin(args); const pointerCollisions = pointerWithin(args);
// Priority 1: Specific drop targets (cards for dependency links, worktrees)
// These need to be detected even if they are inside a column
const specificTargetCollisions = pointerCollisions.filter((collision: any) => {
const id = String(collision.id);
return id.startsWith('card-drop-') || id.startsWith('worktree-drop-');
});
if (specificTargetCollisions.length > 0) {
return specificTargetCollisions;
}
// Priority 2: Columns
const columnCollisions = pointerCollisions.filter((collision: any) => const columnCollisions = pointerCollisions.filter((collision: any) =>
COLUMNS.some((col) => col.id === collision.id) COLUMNS.some((col) => col.id === collision.id)
); );
@@ -372,7 +387,7 @@ export function BoardView() {
return columnCollisions; return columnCollisions;
} }
// Otherwise, use rectangle intersection for cards // Priority 3: Fallback to rectangle intersection
return rectIntersection(args); return rectIntersection(args);
}, []); }, []);
@@ -830,10 +845,15 @@ export function BoardView() {
[handleAddFeature, handleStartImplementation, defaultSkipTests] [handleAddFeature, handleStartImplementation, defaultSkipTests]
); );
// Handler for resolving conflicts - creates a feature to pull from the remote branch and resolve conflicts // Handler for resolving conflicts - opens dialog to select remote branch, then creates a feature
const handleResolveConflicts = useCallback( const handleResolveConflicts = useCallback((worktree: WorktreeInfo) => {
async (worktree: WorktreeInfo) => { setSelectedWorktreeForAction(worktree);
const remoteBranch = `origin/${worktree.branch}`; setShowPullResolveConflictsDialog(true);
}, []);
// Handler called when user confirms the pull & resolve conflicts dialog
const handleConfirmResolveConflicts = useCallback(
async (worktree: WorktreeInfo, remoteBranch: string) => {
const description = `Pull latest from ${remoteBranch} and resolve conflicts. Merge ${remoteBranch} into the current branch (${worktree.branch}), resolving any merge conflicts that arise. After resolving conflicts, ensure the code compiles and tests pass.`; const description = `Pull latest from ${remoteBranch} and resolve conflicts. Merge ${remoteBranch} into the current branch (${worktree.branch}), resolving any merge conflicts that arise. After resolving conflicts, ensure the code compiles and tests pass.`;
// Create the feature // Create the feature
@@ -873,6 +893,48 @@ export function BoardView() {
[handleAddFeature, handleStartImplementation, defaultSkipTests] [handleAddFeature, handleStartImplementation, defaultSkipTests]
); );
// Handler called when merge fails due to conflicts and user wants to create a feature to resolve them
const handleCreateMergeConflictResolutionFeature = useCallback(
async (conflictInfo: MergeConflictInfo) => {
const description = `Resolve merge conflicts when merging "${conflictInfo.sourceBranch}" into "${conflictInfo.targetBranch}". The merge was started but encountered conflicts that need to be resolved manually. After resolving all conflicts, ensure the code compiles and tests pass, then complete the merge by committing the resolved changes.`;
// Create the feature
const featureData = {
title: `Resolve Merge Conflicts: ${conflictInfo.sourceBranch}${conflictInfo.targetBranch}`,
category: 'Maintenance',
description,
images: [],
imagePaths: [],
skipTests: defaultSkipTests,
model: 'opus' as const,
thinkingLevel: 'none' as const,
branchName: conflictInfo.targetBranch,
workMode: 'custom' as const, // Use the target branch where conflicts need to be resolved
priority: 1, // High priority for conflict resolution
planningMode: 'skip' as const,
requirePlanApproval: false,
};
// Capture existing feature IDs before adding
const featuresBeforeIds = new Set(useAppStore.getState().features.map((f) => f.id));
await handleAddFeature(featureData);
// Find the newly created feature by looking for an ID that wasn't in the original set
const latestFeatures = useAppStore.getState().features;
const newFeature = latestFeatures.find((f) => !featuresBeforeIds.has(f.id));
if (newFeature) {
await handleStartImplementation(newFeature);
} else {
logger.error('Could not find newly created feature to start it automatically.');
toast.error('Failed to auto-start feature', {
description: 'The feature was created but could not be started automatically.',
});
}
},
[handleAddFeature, handleStartImplementation, defaultSkipTests]
);
// Handler for "Make" button - creates a feature and immediately starts it // Handler for "Make" button - creates a feature and immediately starts it
const handleAddAndStartFeature = useCallback( const handleAddAndStartFeature = useCallback(
async (featureData: Parameters<typeof handleAddFeature>[0]) => { async (featureData: Parameters<typeof handleAddFeature>[0]) => {
@@ -967,7 +1029,13 @@ export function BoardView() {
}); });
// Use drag and drop hook // Use drag and drop hook
const { activeFeature, handleDragStart, handleDragEnd } = useBoardDragDrop({ const {
activeFeature,
handleDragStart,
handleDragEnd,
pendingDependencyLink,
clearPendingDependencyLink,
} = useBoardDragDrop({
features: hookFeatures, features: hookFeatures,
currentProject, currentProject,
runningAutoTasks, runningAutoTasks,
@@ -975,6 +1043,50 @@ export function BoardView() {
handleStartImplementation, handleStartImplementation,
}); });
// Handle dependency link creation
const handleCreateDependencyLink = useCallback(
async (linkType: DependencyLinkType) => {
if (!pendingDependencyLink || !currentProject) return;
const { draggedFeature, targetFeature } = pendingDependencyLink;
if (linkType === 'parent') {
// Dragged feature depends on target (target is parent)
// Add targetFeature.id to draggedFeature.dependencies
const currentDeps = draggedFeature.dependencies || [];
if (!currentDeps.includes(targetFeature.id)) {
const newDeps = [...currentDeps, targetFeature.id];
updateFeature(draggedFeature.id, { dependencies: newDeps });
await persistFeatureUpdate(draggedFeature.id, { dependencies: newDeps });
toast.success('Dependency link created', {
description: `"${draggedFeature.description.slice(0, 30)}..." now depends on "${targetFeature.description.slice(0, 30)}..."`,
});
}
} else {
// Target feature depends on dragged (dragged is parent)
// Add draggedFeature.id to targetFeature.dependencies
const currentDeps = targetFeature.dependencies || [];
if (!currentDeps.includes(draggedFeature.id)) {
const newDeps = [...currentDeps, draggedFeature.id];
updateFeature(targetFeature.id, { dependencies: newDeps });
await persistFeatureUpdate(targetFeature.id, { dependencies: newDeps });
toast.success('Dependency link created', {
description: `"${targetFeature.description.slice(0, 30)}..." now depends on "${draggedFeature.description.slice(0, 30)}..."`,
});
}
}
clearPendingDependencyLink();
},
[
pendingDependencyLink,
currentProject,
updateFeature,
persistFeatureUpdate,
clearPendingDependencyLink,
]
);
// Use column features hook // Use column features hook
const { getColumnFeatures, completedFeatures } = useBoardColumnFeatures({ const { getColumnFeatures, completedFeatures } = useBoardColumnFeatures({
features: hookFeatures, features: hookFeatures,
@@ -1205,6 +1317,13 @@ export function BoardView() {
onViewModeChange={setViewMode} onViewModeChange={setViewMode}
/> />
{/* DndContext wraps both WorktreePanel and main content area to enable drag-to-worktree */}
<DndContext
sensors={sensors}
collisionDetection={collisionDetectionStrategy}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
{/* Worktree Panel - conditionally rendered based on visibility setting */} {/* Worktree Panel - conditionally rendered based on visibility setting */}
{(worktreePanelVisibleByProject[currentProject.path] ?? true) && ( {(worktreePanelVisibleByProject[currentProject.path] ?? true) && (
<WorktreePanel <WorktreePanel
@@ -1229,9 +1348,20 @@ export function BoardView() {
}} }}
onAddressPRComments={handleAddressPRComments} onAddressPRComments={handleAddressPRComments}
onResolveConflicts={handleResolveConflicts} onResolveConflicts={handleResolveConflicts}
onMerge={(worktree) => { onCreateMergeConflictResolutionFeature={handleCreateMergeConflictResolutionFeature}
setSelectedWorktreeForAction(worktree); onBranchDeletedDuringMerge={(branchName) => {
setShowMergeWorktreeDialog(true); // Reset features that were assigned to the deleted branch (same logic as onDeleted in DeleteWorktreeDialog)
hookFeatures.forEach((feature) => {
if (feature.branchName === branchName) {
// Reset the feature's branch assignment - update both local state and persist
const updates = {
branchName: null as unknown as string | undefined,
};
updateFeature(feature.id, updates);
persistFeatureUpdate(feature.id, updates);
}
});
setWorktreeRefreshKey((k) => k + 1);
}} }}
onRemovedWorktrees={handleRemovedWorktrees} onRemovedWorktrees={handleRemovedWorktrees}
runningFeatureIds={runningAutoTasks} runningFeatureIds={runningAutoTasks}
@@ -1287,10 +1417,6 @@ export function BoardView() {
/> />
) : ( ) : (
<KanbanBoard <KanbanBoard
sensors={sensors}
collisionDetectionStrategy={collisionDetectionStrategy}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
activeFeature={activeFeature} activeFeature={activeFeature}
getColumnFeatures={getColumnFeatures} getColumnFeatures={getColumnFeatures}
backgroundImageStyle={backgroundImageStyle} backgroundImageStyle={backgroundImageStyle}
@@ -1332,6 +1458,7 @@ export function BoardView() {
/> />
)} )}
</div> </div>
</DndContext>
{/* Selection Action Bar */} {/* Selection Action Bar */}
{isSelectionMode && ( {isSelectionMode && (
@@ -1425,6 +1552,15 @@ export function BoardView() {
forceCurrentBranchMode={!addFeatureUseSelectedWorktreeBranch} forceCurrentBranchMode={!addFeatureUseSelectedWorktreeBranch}
/> />
{/* Dependency Link Dialog */}
<DependencyLinkDialog
open={Boolean(pendingDependencyLink)}
onOpenChange={(open) => !open && clearPendingDependencyLink()}
draggedFeature={pendingDependencyLink?.draggedFeature || null}
targetFeature={pendingDependencyLink?.targetFeature || null}
onLink={handleCreateDependencyLink}
/>
{/* Edit Feature Dialog */} {/* Edit Feature Dialog */}
<EditFeatureDialog <EditFeatureDialog
feature={editingFeature} feature={editingFeature}
@@ -1596,33 +1732,12 @@ export function BoardView() {
}} }}
/> />
{/* Merge Worktree Dialog */} {/* Pull & Resolve Conflicts Dialog */}
<MergeWorktreeDialog <PullResolveConflictsDialog
open={showMergeWorktreeDialog} open={showPullResolveConflictsDialog}
onOpenChange={setShowMergeWorktreeDialog} onOpenChange={setShowPullResolveConflictsDialog}
projectPath={currentProject.path}
worktree={selectedWorktreeForAction} worktree={selectedWorktreeForAction}
affectedFeatureCount={ onConfirm={handleConfirmResolveConflicts}
selectedWorktreeForAction
? hookFeatures.filter((f) => f.branchName === selectedWorktreeForAction.branch).length
: 0
}
onMerged={(mergedWorktree) => {
// Reset features that were assigned to the merged worktree (by branch)
hookFeatures.forEach((feature) => {
if (feature.branchName === mergedWorktree.branch) {
// Reset the feature's branch assignment - update both local state and persist
const updates = {
branchName: null as unknown as string | undefined,
};
updateFeature(feature.id, updates);
persistFeatureUpdate(feature.id, updates);
}
});
setWorktreeRefreshKey((k) => k + 1);
setSelectedWorktreeForAction(null);
}}
/> />
{/* Commit Worktree Dialog */} {/* Commit Worktree Dialog */}

View File

@@ -1,6 +1,6 @@
// @ts-nocheck // @ts-nocheck
import React, { memo, useLayoutEffect, useState } from 'react'; import React, { memo, useLayoutEffect, useState, useCallback } from 'react';
import { useDraggable } from '@dnd-kit/core'; import { useDraggable, useDroppable } from '@dnd-kit/core';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
@@ -123,12 +123,39 @@ export const KanbanCard = memo(function KanbanCard({
(feature.status === 'backlog' || (feature.status === 'backlog' ||
feature.status === 'waiting_approval' || feature.status === 'waiting_approval' ||
feature.status === 'verified' || feature.status === 'verified' ||
feature.status.startsWith('pipeline_') ||
(feature.status === 'in_progress' && !isCurrentAutoTask)); (feature.status === 'in_progress' && !isCurrentAutoTask));
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ const {
attributes,
listeners,
setNodeRef: setDraggableRef,
isDragging,
} = useDraggable({
id: feature.id, id: feature.id,
disabled: !isDraggable || isOverlay || isSelectionMode, disabled: !isDraggable || isOverlay || isSelectionMode,
}); });
// Make the card a drop target for creating dependency links
// Only backlog cards can be link targets (to avoid complexity with running features)
const isDroppable = !isOverlay && feature.status === 'backlog' && !isSelectionMode;
const { setNodeRef: setDroppableRef, isOver } = useDroppable({
id: `card-drop-${feature.id}`,
disabled: !isDroppable,
data: {
type: 'card',
featureId: feature.id,
},
});
// Combine refs for both draggable and droppable
const setNodeRef = useCallback(
(node: HTMLElement | null) => {
setDraggableRef(node);
setDroppableRef(node);
},
[setDraggableRef, setDroppableRef]
);
const dndStyle = { const dndStyle = {
opacity: isDragging ? 0.5 : undefined, opacity: isDragging ? 0.5 : undefined,
}; };
@@ -141,7 +168,9 @@ export const KanbanCard = memo(function KanbanCard({
const wrapperClasses = cn( const wrapperClasses = cn(
'relative select-none outline-none touch-none transition-transform duration-200 ease-out', 'relative select-none outline-none touch-none transition-transform duration-200 ease-out',
getCursorClass(isOverlay, isDraggable, isSelectable), getCursorClass(isOverlay, isDraggable, isSelectable),
isOverlay && isLifted && 'scale-105 rotate-1 z-50' isOverlay && isLifted && 'scale-105 rotate-1 z-50',
// Visual feedback when another card is being dragged over this one
isOver && !isDragging && 'ring-2 ring-primary ring-offset-2 ring-offset-background scale-[1.02]'
); );
const isInteractive = !isDragging && !isOverlay; const isInteractive = !isDragging && !isOverlay;

View File

@@ -23,7 +23,6 @@ interface ColumnDef {
/** /**
* Default column definitions for the list view * Default column definitions for the list view
* Only showing title column with full width for a cleaner, more spacious layout
*/ */
export const LIST_COLUMNS: ColumnDef[] = [ export const LIST_COLUMNS: ColumnDef[] = [
{ {
@@ -34,6 +33,14 @@ export const LIST_COLUMNS: ColumnDef[] = [
minWidth: 'min-w-0', minWidth: 'min-w-0',
align: 'left', align: 'left',
}, },
{
id: 'priority',
label: '',
sortable: true,
width: 'w-18',
minWidth: 'min-w-[16px]',
align: 'center',
},
]; ];
export interface ListHeaderProps { export interface ListHeaderProps {
@@ -117,6 +124,7 @@ const SortableColumnHeader = memo(function SortableColumnHeader({
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1',
column.width, column.width,
column.minWidth, column.minWidth,
column.width !== 'flex-1' && 'shrink-0',
column.align === 'center' && 'justify-center', column.align === 'center' && 'justify-center',
column.align === 'right' && 'justify-end', column.align === 'right' && 'justify-end',
isSorted && 'text-foreground', isSorted && 'text-foreground',
@@ -141,6 +149,7 @@ const StaticColumnHeader = memo(function StaticColumnHeader({ column }: { column
'flex items-center px-3 py-2 text-xs font-medium text-muted-foreground', 'flex items-center px-3 py-2 text-xs font-medium text-muted-foreground',
column.width, column.width,
column.minWidth, column.minWidth,
column.width !== 'flex-1' && 'shrink-0',
column.align === 'center' && 'justify-center', column.align === 'center' && 'justify-center',
column.align === 'right' && 'justify-end', column.align === 'right' && 'justify-end',
column.className column.className

View File

@@ -281,7 +281,7 @@ export const ListRow = memo(function ListRow({
<div <div
role="cell" role="cell"
className={cn( className={cn(
'flex items-center px-3 py-3 gap-2', 'flex items-center pl-3 pr-0 py-3 gap-0',
getColumnWidth('title'), getColumnWidth('title'),
getColumnAlign('title') getColumnAlign('title')
)} )}
@@ -315,6 +315,42 @@ export const ListRow = memo(function ListRow({
</div> </div>
</div> </div>
{/* Priority column */}
<div
role="cell"
className={cn(
'flex items-center pl-0 pr-3 py-3 shrink-0',
getColumnWidth('priority'),
getColumnAlign('priority')
)}
data-testid={`list-row-priority-${feature.id}`}
>
{feature.priority ? (
<span
className={cn(
'inline-flex items-center justify-center w-6 h-6 rounded-md border-[1.5px] font-bold text-xs',
feature.priority === 1 &&
'bg-[var(--status-error-bg)] border-[var(--status-error)]/40 text-[var(--status-error)]',
feature.priority === 2 &&
'bg-[var(--status-warning-bg)] border-[var(--status-warning)]/40 text-[var(--status-warning)]',
feature.priority === 3 &&
'bg-[var(--status-info-bg)] border-[var(--status-info)]/40 text-[var(--status-info)]'
)}
title={
feature.priority === 1
? 'High Priority'
: feature.priority === 2
? 'Medium Priority'
: 'Low Priority'
}
>
{feature.priority === 1 ? 'H' : feature.priority === 2 ? 'M' : 'L'}
</span>
) : (
<span className="text-muted-foreground text-xs">-</span>
)}
</div>
{/* Actions column */} {/* Actions column */}
<div role="cell" className="flex items-center justify-end px-3 py-3 w-[80px] shrink-0"> <div role="cell" className="flex items-center justify-end px-3 py-3 w-[80px] shrink-0">
<RowActions feature={feature} handlers={handlers} isCurrentAutoTask={isCurrentAutoTask} /> <RowActions feature={feature} handlers={handlers} isCurrentAutoTask={isCurrentAutoTask} />

View File

@@ -0,0 +1,135 @@
'use client';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { ArrowDown, ArrowUp, Link2, X } from 'lucide-react';
import type { Feature } from '@/store/app-store';
import { cn } from '@/lib/utils';
export type DependencyLinkType = 'parent' | 'child';
interface DependencyLinkDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
draggedFeature: Feature | null;
targetFeature: Feature | null;
onLink: (linkType: DependencyLinkType) => void;
}
export function DependencyLinkDialog({
open,
onOpenChange,
draggedFeature,
targetFeature,
onLink,
}: DependencyLinkDialogProps) {
if (!draggedFeature || !targetFeature) return null;
// Check if a dependency relationship already exists
const draggedDependsOnTarget =
Array.isArray(draggedFeature.dependencies) &&
draggedFeature.dependencies.includes(targetFeature.id);
const targetDependsOnDragged =
Array.isArray(targetFeature.dependencies) &&
targetFeature.dependencies.includes(draggedFeature.id);
const existingLink = draggedDependsOnTarget || targetDependsOnDragged;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent data-testid="dependency-link-dialog" className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Link2 className="w-5 h-5" />
Link Features
</DialogTitle>
<DialogDescription>
Create a dependency relationship between these features.
</DialogDescription>
</DialogHeader>
<div className="py-4 space-y-4">
{/* Dragged feature */}
<div className="p-3 rounded-lg border bg-muted/30">
<div className="text-xs text-muted-foreground mb-1">Dragged Feature</div>
<div className="text-sm font-medium line-clamp-3 break-words">
{draggedFeature.description}
</div>
<div className="text-xs text-muted-foreground/70 mt-1">{draggedFeature.category}</div>
</div>
{/* Arrow indicating direction */}
<div className="flex justify-center">
<ArrowDown className="w-5 h-5 text-muted-foreground" />
</div>
{/* Target feature */}
<div className="p-3 rounded-lg border bg-muted/30">
<div className="text-xs text-muted-foreground mb-1">Target Feature</div>
<div className="text-sm font-medium line-clamp-3 break-words">
{targetFeature.description}
</div>
<div className="text-xs text-muted-foreground/70 mt-1">{targetFeature.category}</div>
</div>
{/* Existing link warning */}
{existingLink && (
<div className="p-3 rounded-lg border border-yellow-500/50 bg-yellow-500/10 text-sm text-yellow-600 dark:text-yellow-400">
{draggedDependsOnTarget
? 'The dragged feature already depends on the target feature.'
: 'The target feature already depends on the dragged feature.'}
</div>
)}
</div>
<DialogFooter className="flex flex-col gap-2 sm:flex-col sm:!justify-start">
{/* Set as Parent - top */}
<Button
variant="default"
onClick={() => onLink('child')}
disabled={draggedDependsOnTarget}
className={cn('w-full', draggedDependsOnTarget && 'opacity-50 cursor-not-allowed')}
title={
draggedDependsOnTarget
? 'This would create a circular dependency'
: 'Make target feature depend on dragged (dragged is parent)'
}
data-testid="link-as-parent"
>
<ArrowUp className="w-4 h-4 mr-2" />
Set as Parent
<span className="text-xs ml-1 opacity-70">(target depends on this)</span>
</Button>
{/* Set as Child - middle */}
<Button
variant="default"
onClick={() => onLink('parent')}
disabled={targetDependsOnDragged}
className={cn('w-full', targetDependsOnDragged && 'opacity-50 cursor-not-allowed')}
title={
targetDependsOnDragged
? 'This would create a circular dependency'
: 'Make dragged feature depend on target (target is parent)'
}
data-testid="link-as-child"
>
<ArrowDown className="w-4 h-4 mr-2" />
Set as Child
<span className="text-xs ml-1 opacity-70">(depends on target)</span>
</Button>
{/* Cancel - bottom */}
<Button variant="outline" onClick={() => onOpenChange(false)} className="w-full">
<X className="w-4 h-4 mr-2" />
Cancel
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -4,8 +4,12 @@ export { BacklogPlanDialog } from './backlog-plan-dialog';
export { CompletedFeaturesModal } from './completed-features-modal'; export { CompletedFeaturesModal } from './completed-features-modal';
export { ArchiveAllVerifiedDialog } from './archive-all-verified-dialog'; export { ArchiveAllVerifiedDialog } from './archive-all-verified-dialog';
export { DeleteCompletedFeatureDialog } from './delete-completed-feature-dialog'; export { DeleteCompletedFeatureDialog } from './delete-completed-feature-dialog';
export { DependencyLinkDialog, type DependencyLinkType } from './dependency-link-dialog';
export { EditFeatureDialog } from './edit-feature-dialog'; export { EditFeatureDialog } from './edit-feature-dialog';
export { FollowUpDialog, type FollowUpHistoryEntry } from './follow-up-dialog'; export { FollowUpDialog, type FollowUpHistoryEntry } from './follow-up-dialog';
export { MergeWorktreeDialog, type MergeConflictInfo } from './merge-worktree-dialog';
export { PlanApprovalDialog } from './plan-approval-dialog'; export { PlanApprovalDialog } from './plan-approval-dialog';
export { MassEditDialog } from './mass-edit-dialog'; export { MassEditDialog } from './mass-edit-dialog';
export { PullResolveConflictsDialog } from './pull-resolve-conflicts-dialog';
export { PushToRemoteDialog } from './push-to-remote-dialog';
export { ViewWorktreeChangesDialog } from './view-worktree-changes-dialog'; export { ViewWorktreeChangesDialog } from './view-worktree-changes-dialog';

View File

@@ -8,58 +8,81 @@ import {
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { GitMerge, AlertTriangle, CheckCircle2 } from 'lucide-react'; import { GitMerge, AlertTriangle, Trash2, Wrench } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner'; import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI } from '@/lib/electron'; import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { BranchAutocomplete } from '@/components/ui/branch-autocomplete';
import type { WorktreeInfo, BranchInfo, MergeConflictInfo } from '../worktree-panel/types';
interface WorktreeInfo { export type { MergeConflictInfo } from '../worktree-panel/types';
path: string;
branch: string;
isMain: boolean;
hasChanges?: boolean;
changedFilesCount?: number;
}
interface MergeWorktreeDialogProps { interface MergeWorktreeDialogProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
projectPath: string; projectPath: string;
worktree: WorktreeInfo | null; worktree: WorktreeInfo | null;
onMerged: (mergedWorktree: WorktreeInfo) => void; /** Called when merge is successful. deletedBranch indicates if the branch was also deleted. */
/** Number of features assigned to this worktree's branch */ onMerged: (mergedWorktree: WorktreeInfo, deletedBranch: boolean) => void;
affectedFeatureCount?: number; onCreateConflictResolutionFeature?: (conflictInfo: MergeConflictInfo) => void;
} }
type DialogStep = 'confirm' | 'verify';
export function MergeWorktreeDialog({ export function MergeWorktreeDialog({
open, open,
onOpenChange, onOpenChange,
projectPath, projectPath,
worktree, worktree,
onMerged, onMerged,
affectedFeatureCount = 0, onCreateConflictResolutionFeature,
}: MergeWorktreeDialogProps) { }: MergeWorktreeDialogProps) {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [step, setStep] = useState<DialogStep>('confirm'); const [targetBranch, setTargetBranch] = useState('main');
const [confirmText, setConfirmText] = useState(''); const [availableBranches, setAvailableBranches] = useState<string[]>([]);
const [loadingBranches, setLoadingBranches] = useState(false);
const [deleteWorktreeAndBranch, setDeleteWorktreeAndBranch] = useState(false);
const [mergeConflict, setMergeConflict] = useState<MergeConflictInfo | null>(null);
// Fetch available branches when dialog opens
useEffect(() => {
if (open && worktree && projectPath) {
setLoadingBranches(true);
const api = getElectronAPI();
if (api?.worktree?.listBranches) {
api.worktree
.listBranches(projectPath, false)
.then((result) => {
if (result.success && result.result?.branches) {
// Filter out the source branch (can't merge into itself) and remote branches
const branches = result.result.branches
.filter((b: BranchInfo) => !b.isRemote && b.name !== worktree.branch)
.map((b: BranchInfo) => b.name);
setAvailableBranches(branches);
}
})
.catch((err) => {
console.error('Failed to fetch branches:', err);
})
.finally(() => {
setLoadingBranches(false);
});
} else {
setLoadingBranches(false);
}
}
}, [open, worktree, projectPath]);
// Reset state when dialog opens // Reset state when dialog opens
useEffect(() => { useEffect(() => {
if (open) { if (open) {
setIsLoading(false); setIsLoading(false);
setStep('confirm'); setTargetBranch('main');
setConfirmText(''); setDeleteWorktreeAndBranch(false);
setMergeConflict(null);
} }
}, [open]); }, [open]);
const handleProceedToVerify = () => {
setStep('verify');
};
const handleMerge = async () => { const handleMerge = async () => {
if (!worktree) return; if (!worktree) return;
@@ -71,96 +94,151 @@ export function MergeWorktreeDialog({
return; return;
} }
// Pass branchName and worktreePath directly to the API // Pass branchName, worktreePath, targetBranch, and options to the API
const result = await api.worktree.mergeFeature(projectPath, worktree.branch, worktree.path); const result = await api.worktree.mergeFeature(
projectPath,
worktree.branch,
worktree.path,
targetBranch,
{ deleteWorktreeAndBranch }
);
if (result.success) { if (result.success) {
toast.success('Branch merged to main', { const description = deleteWorktreeAndBranch
description: `Branch "${worktree.branch}" has been merged and cleaned up`, ? `Branch "${worktree.branch}" has been merged into "${targetBranch}" and the worktree and branch were deleted`
}); : `Branch "${worktree.branch}" has been merged into "${targetBranch}"`;
onMerged(worktree); toast.success(`Branch merged to ${targetBranch}`, { description });
onMerged(worktree, deleteWorktreeAndBranch);
onOpenChange(false); onOpenChange(false);
} else {
// Check if the error indicates merge conflicts
const errorMessage = result.error || '';
const hasConflicts =
errorMessage.toLowerCase().includes('conflict') ||
errorMessage.toLowerCase().includes('merge failed') ||
errorMessage.includes('CONFLICT');
if (hasConflicts && onCreateConflictResolutionFeature) {
// Set merge conflict state to show the conflict resolution UI
setMergeConflict({
sourceBranch: worktree.branch,
targetBranch: targetBranch,
targetWorktreePath: projectPath, // The merge happens in the target branch's worktree
});
toast.error('Merge conflicts detected', {
description: 'The merge has conflicts that need to be resolved manually.',
});
} else { } else {
toast.error('Failed to merge branch', { toast.error('Failed to merge branch', {
description: result.error, description: result.error,
}); });
} }
}
} catch (err) { } catch (err) {
toast.error('Failed to merge branch', { const errorMessage = err instanceof Error ? err.message : 'Unknown error';
description: err instanceof Error ? err.message : 'Unknown error', // Check if the error indicates merge conflicts
const hasConflicts =
errorMessage.toLowerCase().includes('conflict') ||
errorMessage.toLowerCase().includes('merge failed') ||
errorMessage.includes('CONFLICT');
if (hasConflicts && onCreateConflictResolutionFeature) {
setMergeConflict({
sourceBranch: worktree.branch,
targetBranch: targetBranch,
targetWorktreePath: projectPath,
}); });
toast.error('Merge conflicts detected', {
description: 'The merge has conflicts that need to be resolved manually.',
});
} else {
toast.error('Failed to merge branch', {
description: errorMessage,
});
}
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; };
const handleCreateConflictResolutionFeature = () => {
if (mergeConflict && onCreateConflictResolutionFeature) {
onCreateConflictResolutionFeature(mergeConflict);
onOpenChange(false);
}
};
if (!worktree) return null; if (!worktree) return null;
const confirmationWord = 'merge'; // Show conflict resolution UI if there are merge conflicts
const isConfirmValid = confirmText.toLowerCase() === confirmationWord; if (mergeConflict) {
// First step: Show what will happen and ask for confirmation
if (step === 'confirm') {
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<GitMerge className="w-5 h-5 text-green-600" /> <AlertTriangle className="w-5 h-5 text-orange-500" />
Merge to Main Merge Conflicts Detected
</DialogTitle> </DialogTitle>
<DialogDescription asChild> <DialogDescription asChild>
<div className="space-y-3"> <div className="space-y-4">
<span className="block"> <span className="block">
Merge branch{' '} There are conflicts when merging{' '}
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code> into <code className="font-mono bg-muted px-1 rounded">
main? {mergeConflict.sourceBranch}
</code>{' '}
into{' '}
<code className="font-mono bg-muted px-1 rounded">
{mergeConflict.targetBranch}
</code>
.
</span> </span>
<div className="text-sm text-muted-foreground mt-2"> <div className="flex items-start gap-2 p-3 rounded-md bg-orange-500/10 border border-orange-500/20">
This will: <AlertTriangle className="w-4 h-4 text-orange-500 mt-0.5 flex-shrink-0" />
<ul className="list-disc list-inside mt-1 space-y-1"> <span className="text-orange-500 text-sm">
<li>Merge the branch into the main branch</li> The merge could not be completed automatically. You can create a feature task to
<li>Remove the worktree directory</li> resolve the conflicts in the{' '}
<li>Delete the branch</li> <code className="font-mono bg-muted px-0.5 rounded">
{mergeConflict.targetBranch}
</code>{' '}
branch.
</span>
</div>
<div className="mt-2 p-3 rounded-md bg-muted/50 border border-border">
<p className="text-sm text-muted-foreground">
This will create a high-priority feature task that will:
</p>
<ul className="text-sm text-muted-foreground mt-2 list-disc list-inside space-y-1">
<li>
Resolve merge conflicts in the{' '}
<code className="font-mono bg-muted px-0.5 rounded">
{mergeConflict.targetBranch}
</code>{' '}
branch
</li>
<li>Ensure the code compiles and tests pass</li>
<li>Complete the merge automatically</li>
</ul> </ul>
</div> </div>
{worktree.hasChanges && (
<div className="flex items-start gap-2 p-3 rounded-md bg-yellow-500/10 border border-yellow-500/20 mt-2">
<AlertTriangle className="w-4 h-4 text-yellow-500 mt-0.5 flex-shrink-0" />
<span className="text-yellow-500 text-sm">
This worktree has {worktree.changedFilesCount} uncommitted change(s). Please
commit or discard them before merging.
</span>
</div>
)}
{affectedFeatureCount > 0 && (
<div className="flex items-start gap-2 p-3 rounded-md bg-blue-500/10 border border-blue-500/20 mt-2">
<AlertTriangle className="w-4 h-4 text-blue-500 mt-0.5 flex-shrink-0" />
<span className="text-blue-500 text-sm">
{affectedFeatureCount} feature{affectedFeatureCount !== 1 ? 's' : ''}{' '}
{affectedFeatureCount !== 1 ? 'are' : 'is'} assigned to this branch and will
be unassigned after merge.
</span>
</div>
)}
</div> </div>
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}> <Button variant="ghost" onClick={() => setMergeConflict(null)}>
Back
</Button>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel Cancel
</Button> </Button>
<Button <Button
onClick={handleProceedToVerify} onClick={handleCreateConflictResolutionFeature}
disabled={worktree.hasChanges} className="bg-purple-600 hover:bg-purple-700 text-white"
className="bg-green-600 hover:bg-green-700 text-white"
> >
<GitMerge className="w-4 h-4 mr-2" /> <Wrench className="w-4 h-4 mr-2" />
Continue Create Resolve Conflicts Feature
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -168,52 +246,86 @@ export function MergeWorktreeDialog({
); );
} }
// Second step: Type confirmation
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-orange-500" /> <GitMerge className="w-5 h-5 text-green-600" />
Confirm Merge Merge Branch
</DialogTitle> </DialogTitle>
<DialogDescription asChild> <DialogDescription asChild>
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-start gap-2 p-3 rounded-md bg-orange-500/10 border border-orange-500/20"> <span className="block">
<AlertTriangle className="w-4 h-4 text-orange-500 mt-0.5 flex-shrink-0" /> Merge <code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>{' '}
<span className="text-orange-600 dark:text-orange-400 text-sm"> into:
This action cannot be undone. The branch{' '}
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code> will be
permanently deleted after merging.
</span> </span>
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="confirm-merge" className="text-sm text-foreground"> <Label htmlFor="target-branch" className="text-sm text-foreground">
Type <span className="font-bold text-foreground">{confirmationWord}</span> to Target Branch
confirm:
</Label> </Label>
<Input {loadingBranches ? (
id="confirm-merge" <div className="flex items-center gap-2 text-sm text-muted-foreground">
value={confirmText} <Spinner size="sm" />
onChange={(e) => setConfirmText(e.target.value)} Loading branches...
placeholder={confirmationWord}
disabled={isLoading}
className="font-mono"
autoComplete="off"
/>
</div> </div>
) : (
<BranchAutocomplete
value={targetBranch}
onChange={setTargetBranch}
branches={availableBranches}
placeholder="Select target branch..."
data-testid="merge-target-branch"
/>
)}
</div>
{worktree.hasChanges && (
<div className="flex items-start gap-2 p-3 rounded-md bg-yellow-500/10 border border-yellow-500/20">
<AlertTriangle className="w-4 h-4 text-yellow-500 mt-0.5 flex-shrink-0" />
<span className="text-yellow-500 text-sm">
This worktree has {worktree.changedFilesCount} uncommitted change(s). Please
commit or discard them before merging.
</span>
</div>
)}
</div> </div>
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex items-center space-x-2 py-2">
<Checkbox
id="delete-worktree-branch"
checked={deleteWorktreeAndBranch}
onCheckedChange={(checked) => setDeleteWorktreeAndBranch(checked === true)}
/>
<Label
htmlFor="delete-worktree-branch"
className="text-sm cursor-pointer flex items-center gap-1.5"
>
<Trash2 className="w-3.5 h-3.5 text-destructive" />
Delete worktree and branch after merging
</Label>
</div>
{deleteWorktreeAndBranch && (
<div className="flex items-start gap-2 p-3 rounded-md bg-orange-500/10 border border-orange-500/20">
<AlertTriangle className="w-4 h-4 text-orange-500 mt-0.5 flex-shrink-0" />
<span className="text-orange-500 text-sm">
The worktree and branch will be permanently deleted. Any features assigned to this
branch will be unassigned.
</span>
</div>
)}
<DialogFooter> <DialogFooter>
<Button variant="ghost" onClick={() => setStep('confirm')} disabled={isLoading}> <Button variant="ghost" onClick={() => onOpenChange(false)} disabled={isLoading}>
Back Cancel
</Button> </Button>
<Button <Button
onClick={handleMerge} onClick={handleMerge}
disabled={isLoading || !isConfirmValid} disabled={worktree.hasChanges || !targetBranch || loadingBranches || isLoading}
className="bg-green-600 hover:bg-green-700 text-white" className="bg-green-600 hover:bg-green-700 text-white"
> >
{isLoading ? ( {isLoading ? (
@@ -223,8 +335,8 @@ export function MergeWorktreeDialog({
</> </>
) : ( ) : (
<> <>
<CheckCircle2 className="w-4 h-4 mr-2" /> <GitMerge className="w-4 h-4 mr-2" />
Merge to Main Merge
</> </>
)} )}
</Button> </Button>

View File

@@ -0,0 +1,303 @@
import { useState, useEffect } from 'react';
import { createLogger } from '@automaker/utils/logger';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { getHttpApiClient } from '@/lib/http-api-client';
import { toast } from 'sonner';
import { GitMerge, RefreshCw, AlertTriangle } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
interface WorktreeInfo {
path: string;
branch: string;
isMain: boolean;
hasChanges?: boolean;
changedFilesCount?: number;
}
interface RemoteBranch {
name: string;
fullRef: string;
}
interface RemoteInfo {
name: string;
url: string;
branches: RemoteBranch[];
}
const logger = createLogger('PullResolveConflictsDialog');
interface PullResolveConflictsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
worktree: WorktreeInfo | null;
onConfirm: (worktree: WorktreeInfo, remoteBranch: string) => void;
}
export function PullResolveConflictsDialog({
open,
onOpenChange,
worktree,
onConfirm,
}: PullResolveConflictsDialogProps) {
const [remotes, setRemotes] = useState<RemoteInfo[]>([]);
const [selectedRemote, setSelectedRemote] = useState<string>('');
const [selectedBranch, setSelectedBranch] = useState<string>('');
const [isLoading, setIsLoading] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
// Fetch remotes when dialog opens
useEffect(() => {
if (open && worktree) {
fetchRemotes();
}
}, [open, worktree]);
// Reset state when dialog closes
useEffect(() => {
if (!open) {
setSelectedRemote('');
setSelectedBranch('');
setError(null);
}
}, [open]);
// Auto-select default remote and branch when remotes are loaded
useEffect(() => {
if (remotes.length > 0 && !selectedRemote) {
// Default to 'origin' if available, otherwise first remote
const defaultRemote = remotes.find((r) => r.name === 'origin') || remotes[0];
setSelectedRemote(defaultRemote.name);
// Try to select a matching branch name or default to main/master
if (defaultRemote.branches.length > 0 && worktree) {
const matchingBranch = defaultRemote.branches.find((b) => b.name === worktree.branch);
const mainBranch = defaultRemote.branches.find(
(b) => b.name === 'main' || b.name === 'master'
);
const defaultBranch = matchingBranch || mainBranch || defaultRemote.branches[0];
setSelectedBranch(defaultBranch.fullRef);
}
}
}, [remotes, selectedRemote, worktree]);
// Update selected branch when remote changes
useEffect(() => {
if (selectedRemote && remotes.length > 0 && worktree) {
const remote = remotes.find((r) => r.name === selectedRemote);
if (remote && remote.branches.length > 0) {
// Try to select a matching branch name or default to main/master
const matchingBranch = remote.branches.find((b) => b.name === worktree.branch);
const mainBranch = remote.branches.find((b) => b.name === 'main' || b.name === 'master');
const defaultBranch = matchingBranch || mainBranch || remote.branches[0];
setSelectedBranch(defaultBranch.fullRef);
} else {
setSelectedBranch('');
}
}
}, [selectedRemote, remotes, worktree]);
const fetchRemotes = async () => {
if (!worktree) return;
setIsLoading(true);
setError(null);
try {
const api = getHttpApiClient();
const result = await api.worktree.listRemotes(worktree.path);
if (result.success && result.result) {
setRemotes(result.result.remotes);
if (result.result.remotes.length === 0) {
setError('No remotes found in this repository');
}
} else {
setError(result.error || 'Failed to fetch remotes');
}
} catch (err) {
logger.error('Failed to fetch remotes:', err);
setError('Failed to fetch remotes');
} finally {
setIsLoading(false);
}
};
const handleRefresh = async () => {
if (!worktree) return;
setIsRefreshing(true);
setError(null);
try {
const api = getHttpApiClient();
const result = await api.worktree.listRemotes(worktree.path);
if (result.success && result.result) {
setRemotes(result.result.remotes);
toast.success('Remotes refreshed');
} else {
toast.error(result.error || 'Failed to refresh remotes');
}
} catch (err) {
logger.error('Failed to refresh remotes:', err);
toast.error('Failed to refresh remotes');
} finally {
setIsRefreshing(false);
}
};
const handleConfirm = () => {
if (!worktree || !selectedBranch) return;
onConfirm(worktree, selectedBranch);
onOpenChange(false);
};
const selectedRemoteData = remotes.find((r) => r.name === selectedRemote);
const branches = selectedRemoteData?.branches || [];
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<GitMerge className="w-5 h-5 text-purple-500" />
Pull & Resolve Conflicts
</DialogTitle>
<DialogDescription>
Select a remote branch to pull from and resolve conflicts with{' '}
<span className="font-mono text-foreground">
{worktree?.branch || 'current branch'}
</span>
</DialogDescription>
</DialogHeader>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Spinner size="lg" />
</div>
) : error ? (
<div className="flex flex-col items-center gap-4 py-6">
<div className="flex items-center gap-2 text-destructive">
<AlertTriangle className="w-5 h-5" />
<span className="text-sm">{error}</span>
</div>
<Button variant="outline" size="sm" onClick={fetchRemotes}>
<RefreshCw className="w-4 h-4 mr-2" />
Retry
</Button>
</div>
) : (
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<div className="flex items-center justify-between">
<Label htmlFor="remote-select">Remote</Label>
<Button
variant="ghost"
size="sm"
onClick={handleRefresh}
disabled={isRefreshing}
className="h-6 px-2 text-xs"
>
{isRefreshing ? (
<Spinner size="xs" className="mr-1" />
) : (
<RefreshCw className="w-3 h-3 mr-1" />
)}
Refresh
</Button>
</div>
<Select value={selectedRemote} onValueChange={setSelectedRemote}>
<SelectTrigger id="remote-select">
<SelectValue placeholder="Select a remote" />
</SelectTrigger>
<SelectContent>
{remotes.map((remote) => (
<SelectItem key={remote.name} value={remote.name}>
<div className="flex flex-col items-start">
<span className="font-medium">{remote.name}</span>
<span className="text-xs text-muted-foreground truncate max-w-[300px]">
{remote.url}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="branch-select">Branch</Label>
<Select
value={selectedBranch}
onValueChange={setSelectedBranch}
disabled={!selectedRemote || branches.length === 0}
>
<SelectTrigger id="branch-select">
<SelectValue placeholder="Select a branch" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>{selectedRemote} branches</SelectLabel>
{branches.map((branch) => (
<SelectItem key={branch.fullRef} value={branch.fullRef}>
{branch.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
{selectedRemote && branches.length === 0 && (
<p className="text-sm text-muted-foreground">No branches found for this remote</p>
)}
</div>
{selectedBranch && (
<div className="mt-2 p-3 rounded-md bg-muted/50 border border-border">
<p className="text-sm text-muted-foreground">
This will create a feature task to pull from{' '}
<span className="font-mono text-foreground">{selectedBranch}</span> into{' '}
<span className="font-mono text-foreground">{worktree?.branch}</span> and resolve
any merge conflicts.
</p>
</div>
)}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={handleConfirm}
disabled={!selectedBranch || isLoading}
className="bg-purple-600 hover:bg-purple-700 text-white"
>
<GitMerge className="w-4 h-4 mr-2" />
Pull & Resolve
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,242 @@
import { useState, useEffect } from 'react';
import { createLogger } from '@automaker/utils/logger';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { getHttpApiClient } from '@/lib/http-api-client';
import { toast } from 'sonner';
import { Upload, RefreshCw, AlertTriangle, Sparkles } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import type { WorktreeInfo } from '../worktree-panel/types';
interface RemoteInfo {
name: string;
url: string;
}
const logger = createLogger('PushToRemoteDialog');
interface PushToRemoteDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
worktree: WorktreeInfo | null;
onConfirm: (worktree: WorktreeInfo, remote: string) => void;
}
export function PushToRemoteDialog({
open,
onOpenChange,
worktree,
onConfirm,
}: PushToRemoteDialogProps) {
const [remotes, setRemotes] = useState<RemoteInfo[]>([]);
const [selectedRemote, setSelectedRemote] = useState<string>('');
const [isLoading, setIsLoading] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
// Fetch remotes when dialog opens
useEffect(() => {
if (open && worktree) {
fetchRemotes();
}
}, [open, worktree]);
// Reset state when dialog closes
useEffect(() => {
if (!open) {
setSelectedRemote('');
setError(null);
}
}, [open]);
// Auto-select default remote when remotes are loaded
useEffect(() => {
if (remotes.length > 0 && !selectedRemote) {
// Default to 'origin' if available, otherwise first remote
const defaultRemote = remotes.find((r) => r.name === 'origin') || remotes[0];
setSelectedRemote(defaultRemote.name);
}
}, [remotes, selectedRemote]);
const fetchRemotes = async () => {
if (!worktree) return;
setIsLoading(true);
setError(null);
try {
const api = getHttpApiClient();
const result = await api.worktree.listRemotes(worktree.path);
if (result.success && result.result) {
// Extract just the remote info (name and URL), not the branches
const remoteInfos: RemoteInfo[] = result.result.remotes.map((r) => ({
name: r.name,
url: r.url,
}));
setRemotes(remoteInfos);
if (remoteInfos.length === 0) {
setError('No remotes found in this repository. Please add a remote first.');
}
} else {
setError(result.error || 'Failed to fetch remotes');
}
} catch (err) {
logger.error('Failed to fetch remotes:', err);
setError('Failed to fetch remotes');
} finally {
setIsLoading(false);
}
};
const handleRefresh = async () => {
if (!worktree) return;
setIsRefreshing(true);
setError(null);
try {
const api = getHttpApiClient();
const result = await api.worktree.listRemotes(worktree.path);
if (result.success && result.result) {
const remoteInfos: RemoteInfo[] = result.result.remotes.map((r) => ({
name: r.name,
url: r.url,
}));
setRemotes(remoteInfos);
toast.success('Remotes refreshed');
} else {
toast.error(result.error || 'Failed to refresh remotes');
}
} catch (err) {
logger.error('Failed to refresh remotes:', err);
toast.error('Failed to refresh remotes');
} finally {
setIsRefreshing(false);
}
};
const handleConfirm = () => {
if (!worktree || !selectedRemote) return;
onConfirm(worktree, selectedRemote);
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[450px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Upload className="w-5 h-5 text-primary" />
Push New Branch to Remote
<span className="inline-flex items-center gap-1 text-xs font-medium bg-primary/10 text-primary px-2 py-0.5 rounded-full ml-2">
<Sparkles className="w-3 h-3" />
new
</span>
</DialogTitle>
<DialogDescription>
Push{' '}
<span className="font-mono text-foreground">
{worktree?.branch || 'current branch'}
</span>{' '}
to a remote repository for the first time.
</DialogDescription>
</DialogHeader>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Spinner size="lg" />
</div>
) : error ? (
<div className="flex flex-col items-center gap-4 py-6">
<div className="flex items-center gap-2 text-destructive">
<AlertTriangle className="w-5 h-5" />
<span className="text-sm">{error}</span>
</div>
<Button variant="outline" size="sm" onClick={fetchRemotes}>
<RefreshCw className="w-4 h-4 mr-2" />
Retry
</Button>
</div>
) : (
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<div className="flex items-center justify-between">
<Label htmlFor="remote-select">Select Remote</Label>
<Button
variant="ghost"
size="sm"
onClick={handleRefresh}
disabled={isRefreshing}
className="h-6 px-2 text-xs"
>
{isRefreshing ? (
<Spinner size="xs" className="mr-1" />
) : (
<RefreshCw className="w-3 h-3 mr-1" />
)}
Refresh
</Button>
</div>
<Select value={selectedRemote} onValueChange={setSelectedRemote}>
<SelectTrigger id="remote-select">
<SelectValue placeholder="Select a remote" />
</SelectTrigger>
<SelectContent>
{remotes.map((remote) => (
<SelectItem key={remote.name} value={remote.name}>
<div className="flex flex-col items-start">
<span className="font-medium">{remote.name}</span>
<span className="text-xs text-muted-foreground truncate max-w-[300px]">
{remote.url}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedRemote && (
<div className="mt-2 p-3 rounded-md bg-muted/50 border border-border">
<p className="text-sm text-muted-foreground">
This will create a new remote branch{' '}
<span className="font-mono text-foreground">
{selectedRemote}/{worktree?.branch}
</span>{' '}
and set up tracking.
</p>
</div>
)}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleConfirm} disabled={!selectedRemote || isLoading}>
<Upload className="w-4 h-4 mr-2" />
Push to {selectedRemote || 'Remote'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -92,6 +92,7 @@ export function useBoardActions({
skipVerificationInAutoMode, skipVerificationInAutoMode,
isPrimaryWorktreeBranch, isPrimaryWorktreeBranch,
getPrimaryWorktreeBranch, getPrimaryWorktreeBranch,
getAutoModeState,
} = useAppStore(); } = useAppStore();
const autoMode = useAutoMode(); const autoMode = useAutoMode();
@@ -485,10 +486,22 @@ export function useBoardActions({
const handleStartImplementation = useCallback( const handleStartImplementation = useCallback(
async (feature: Feature) => { async (feature: Feature) => {
if (!autoMode.canStartNewTask) { // Check capacity for the feature's specific worktree, not the current view
const featureBranchName = feature.branchName ?? null;
const featureWorktreeState = currentProject
? getAutoModeState(currentProject.id, featureBranchName)
: null;
const featureMaxConcurrency = featureWorktreeState?.maxConcurrency ?? autoMode.maxConcurrency;
const featureRunningCount = featureWorktreeState?.runningTasks?.length ?? 0;
const canStartInWorktree = featureRunningCount < featureMaxConcurrency;
if (!canStartInWorktree) {
const worktreeDesc = featureBranchName
? `worktree "${featureBranchName}"`
: 'main worktree';
toast.error('Concurrency limit reached', { toast.error('Concurrency limit reached', {
description: `You can only have ${autoMode.maxConcurrency} task${ description: `${worktreeDesc} can only have ${featureMaxConcurrency} task${
autoMode.maxConcurrency > 1 ? 's' : '' featureMaxConcurrency > 1 ? 's' : ''
} running at a time. Wait for a task to complete or increase the limit.`, } running at a time. Wait for a task to complete or increase the limit.`,
}); });
return false; return false;
@@ -552,6 +565,8 @@ export function useBoardActions({
updateFeature, updateFeature,
persistFeatureUpdate, persistFeatureUpdate,
handleRunFeature, handleRunFeature,
currentProject,
getAutoModeState,
] ]
); );

View File

@@ -8,6 +8,11 @@ import { COLUMNS, ColumnId } from '../constants';
const logger = createLogger('BoardDragDrop'); const logger = createLogger('BoardDragDrop');
export interface PendingDependencyLink {
draggedFeature: Feature;
targetFeature: Feature;
}
interface UseBoardDragDropProps { interface UseBoardDragDropProps {
features: Feature[]; features: Feature[];
currentProject: { path: string; id: string } | null; currentProject: { path: string; id: string } | null;
@@ -24,7 +29,10 @@ export function useBoardDragDrop({
handleStartImplementation, handleStartImplementation,
}: UseBoardDragDropProps) { }: UseBoardDragDropProps) {
const [activeFeature, setActiveFeature] = useState<Feature | null>(null); const [activeFeature, setActiveFeature] = useState<Feature | null>(null);
const { moveFeature } = useAppStore(); const [pendingDependencyLink, setPendingDependencyLink] = useState<PendingDependencyLink | null>(
null
);
const { moveFeature, updateFeature } = useAppStore();
// Note: getOrCreateWorktreeForFeature removed - worktrees are now created server-side // Note: getOrCreateWorktreeForFeature removed - worktrees are now created server-side
// at execution time based on feature.branchName // at execution time based on feature.branchName
@@ -40,6 +48,11 @@ export function useBoardDragDrop({
[features] [features]
); );
// Clear pending dependency link
const clearPendingDependencyLink = useCallback(() => {
setPendingDependencyLink(null);
}, []);
const handleDragEnd = useCallback( const handleDragEnd = useCallback(
async (event: DragEndEvent) => { async (event: DragEndEvent) => {
const { active, over } = event; const { active, over } = event;
@@ -57,6 +70,85 @@ export function useBoardDragDrop({
// Check if this is a running task (non-skipTests, TDD) // Check if this is a running task (non-skipTests, TDD)
const isRunningTask = runningAutoTasks.includes(featureId); const isRunningTask = runningAutoTasks.includes(featureId);
// Check if dropped on another card (for creating dependency links)
if (overId.startsWith('card-drop-')) {
const cardData = over.data.current as {
type: string;
featureId: string;
};
if (cardData?.type === 'card') {
const targetFeatureId = cardData.featureId;
// Don't link to self
if (targetFeatureId === featureId) {
return;
}
const targetFeature = features.find((f) => f.id === targetFeatureId);
if (!targetFeature) return;
// Only allow linking backlog features (both must be in backlog)
if (draggedFeature.status !== 'backlog' || targetFeature.status !== 'backlog') {
toast.error('Cannot link features', {
description: 'Both features must be in the backlog to create a dependency link.',
});
return;
}
// Set pending dependency link to trigger dialog
setPendingDependencyLink({
draggedFeature,
targetFeature,
});
return;
}
}
// Check if dropped on a worktree tab
if (overId.startsWith('worktree-drop-')) {
// Handle dropping on a worktree - change the feature's branchName
const worktreeData = over.data.current as {
type: string;
branch: string;
path: string;
isMain: boolean;
};
if (worktreeData?.type === 'worktree') {
// Don't allow moving running tasks to a different worktree
if (isRunningTask) {
logger.debug('Cannot move running feature to different worktree');
toast.error('Cannot move feature', {
description: 'This feature is currently running and cannot be moved.',
});
return;
}
const targetBranch = worktreeData.branch;
const currentBranch = draggedFeature.branchName;
// If already on the same branch, nothing to do
if (currentBranch === targetBranch) {
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 });
const branchDisplay = worktreeData.isMain ? targetBranch : targetBranch;
toast.success('Feature moved to branch', {
description: `Moved to ${branchDisplay}: ${draggedFeature.description.slice(0, 40)}${draggedFeature.description.length > 40 ? '...' : ''}`,
});
return;
}
}
// Determine if dragging is allowed based on status and skipTests // Determine if dragging is allowed based on status and skipTests
// - Backlog items can always be dragged // - Backlog items can always be dragged
// - waiting_approval items can always be dragged (to allow manual verification via drag) // - waiting_approval items can always be dragged (to allow manual verification via drag)
@@ -205,12 +297,21 @@ export function useBoardDragDrop({
} }
} }
}, },
[features, runningAutoTasks, moveFeature, persistFeatureUpdate, handleStartImplementation] [
features,
runningAutoTasks,
moveFeature,
updateFeature,
persistFeatureUpdate,
handleStartImplementation,
]
); );
return { return {
activeFeature, activeFeature,
handleDragStart, handleDragStart,
handleDragEnd, handleDragEnd,
pendingDependencyLink,
clearPendingDependencyLink,
}; };
} }

View File

@@ -1,6 +1,5 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { getElectronAPI } from '@/lib/electron'; import { getElectronAPI } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
import { createLogger } from '@automaker/utils/logger'; import { createLogger } from '@automaker/utils/logger';
const logger = createLogger('BoardEffects'); const logger = createLogger('BoardEffects');
@@ -65,37 +64,8 @@ export function useBoardEffects({
}; };
}, [specCreatingForProject, setSpecCreatingForProject]); }, [specCreatingForProject, setSpecCreatingForProject]);
// Sync running tasks from electron backend on mount // Note: Running tasks sync is now handled by useAutoMode hook in BoardView
useEffect(() => { // which correctly handles worktree/branch scoping.
if (!currentProject) return;
const syncRunningTasks = async () => {
try {
const api = getElectronAPI();
if (!api?.autoMode?.status) return;
const status = await api.autoMode.status(currentProject.path);
if (status.success) {
const projectId = currentProject.id;
const { clearRunningTasks, addRunningTask } = useAppStore.getState();
if (status.runningFeatures) {
logger.info('Syncing running tasks from backend:', status.runningFeatures);
clearRunningTasks(projectId);
status.runningFeatures.forEach((featureId: string) => {
addRunningTask(projectId, featureId);
});
}
}
} catch (error) {
logger.error('Failed to sync running tasks:', error);
}
};
syncRunningTasks();
}, [currentProject]);
// Check which features have context files // Check which features have context files
useEffect(() => { useEffect(() => {

View File

@@ -123,7 +123,9 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
} else if (event.type === 'auto_mode_error') { } else if (event.type === 'auto_mode_error') {
// Remove from running tasks // Remove from running tasks
if (event.featureId) { if (event.featureId) {
removeRunningTask(eventProjectId, event.featureId); const eventBranchName =
'branchName' in event && event.branchName !== undefined ? event.branchName : null;
removeRunningTask(eventProjectId, eventBranchName, event.featureId);
} }
// Show error toast // Show error toast

View File

@@ -1,6 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useMemo } from 'react';
import type { ReactNode, UIEvent, RefObject } from 'react'; import { DragOverlay } from '@dnd-kit/core';
import { DndContext, DragOverlay } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { KanbanColumn, KanbanCard, EmptyStateCard } from './components'; import { KanbanColumn, KanbanCard, EmptyStateCard } from './components';
@@ -11,10 +10,6 @@ import { getColumnsWithPipeline, type ColumnId } from './constants';
import type { PipelineConfig } from '@automaker/types'; import type { PipelineConfig } from '@automaker/types';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
interface KanbanBoardProps { interface KanbanBoardProps {
sensors: any;
collisionDetectionStrategy: (args: any) => any;
onDragStart: (event: any) => void;
onDragEnd: (event: any) => void;
activeFeature: Feature | null; activeFeature: Feature | null;
getColumnFeatures: (columnId: ColumnId) => Feature[]; getColumnFeatures: (columnId: ColumnId) => Feature[];
backgroundImageStyle: React.CSSProperties; backgroundImageStyle: React.CSSProperties;
@@ -259,10 +254,6 @@ function VirtualizedList<Item extends VirtualListItem>({
} }
export function KanbanBoard({ export function KanbanBoard({
sensors,
collisionDetectionStrategy,
onDragStart,
onDragEnd,
activeFeature, activeFeature,
getColumnFeatures, getColumnFeatures,
backgroundImageStyle, backgroundImageStyle,
@@ -318,12 +309,6 @@ export function KanbanBoard({
className className
)} )}
style={backgroundImageStyle} style={backgroundImageStyle}
>
<DndContext
sensors={sensors}
collisionDetection={collisionDetectionStrategy}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
> >
<div className="h-full py-1" style={containerStyle}> <div className="h-full py-1" style={containerStyle}>
{columns.map((column) => { {columns.map((column) => {
@@ -555,9 +540,7 @@ export function KanbanBoard({
onResume={() => onResume(feature)} onResume={() => onResume(feature)}
onForceStop={() => onForceStop(feature)} onForceStop={() => onForceStop(feature)}
onManualVerify={() => onManualVerify(feature)} onManualVerify={() => onManualVerify(feature)}
onMoveBackToInProgress={() => onMoveBackToInProgress={() => onMoveBackToInProgress(feature)}
onMoveBackToInProgress(feature)
}
onFollowUp={() => onFollowUp(feature)} onFollowUp={() => onFollowUp(feature)}
onComplete={() => onComplete(feature)} onComplete={() => onComplete(feature)}
onImplement={() => onImplement(feature)} onImplement={() => onImplement(feature)}
@@ -575,9 +558,7 @@ export function KanbanBoard({
isSelectionMode={isSelectionMode} isSelectionMode={isSelectionMode}
selectionTarget={selectionTarget} selectionTarget={selectionTarget}
isSelected={selectedFeatureIds.has(feature.id)} isSelected={selectedFeatureIds.has(feature.id)}
onToggleSelect={() => onToggleSelect={() => onToggleFeatureSelection?.(feature.id)}
onToggleFeatureSelection?.(feature.id)
}
/> />
</div> </div>
); );
@@ -669,7 +650,6 @@ export function KanbanBoard({
</div> </div>
)} )}
</DragOverlay> </DragOverlay>
</DndContext>
</div> </div>
); );
} }

View File

@@ -27,11 +27,12 @@ import {
Copy, Copy,
Eye, Eye,
ScrollText, ScrollText,
Sparkles,
Terminal, Terminal,
SquarePlus, SquarePlus,
SplitSquareHorizontal, SplitSquareHorizontal,
Zap,
Undo2, Undo2,
Zap,
} from 'lucide-react'; } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -51,6 +52,7 @@ interface WorktreeActionsDropdownProps {
isSelected: boolean; isSelected: boolean;
aheadCount: number; aheadCount: number;
behindCount: number; behindCount: number;
hasRemoteBranch: boolean;
isPulling: boolean; isPulling: boolean;
isPushing: boolean; isPushing: boolean;
isStartingDevServer: boolean; isStartingDevServer: boolean;
@@ -64,6 +66,7 @@ interface WorktreeActionsDropdownProps {
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
onPull: (worktree: WorktreeInfo) => void; onPull: (worktree: WorktreeInfo) => void;
onPush: (worktree: WorktreeInfo) => void; onPush: (worktree: WorktreeInfo) => void;
onPushNewBranch: (worktree: WorktreeInfo) => void;
onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void; onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void;
onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void; onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void;
onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void; onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void;
@@ -73,7 +76,6 @@ interface WorktreeActionsDropdownProps {
onCreatePR: (worktree: WorktreeInfo) => void; onCreatePR: (worktree: WorktreeInfo) => void;
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void; onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
onResolveConflicts: (worktree: WorktreeInfo) => void; onResolveConflicts: (worktree: WorktreeInfo) => void;
onMerge: (worktree: WorktreeInfo) => void;
onDeleteWorktree: (worktree: WorktreeInfo) => void; onDeleteWorktree: (worktree: WorktreeInfo) => void;
onStartDevServer: (worktree: WorktreeInfo) => void; onStartDevServer: (worktree: WorktreeInfo) => void;
onStopDevServer: (worktree: WorktreeInfo) => void; onStopDevServer: (worktree: WorktreeInfo) => void;
@@ -81,6 +83,7 @@ interface WorktreeActionsDropdownProps {
onViewDevServerLogs: (worktree: WorktreeInfo) => void; onViewDevServerLogs: (worktree: WorktreeInfo) => void;
onRunInitScript: (worktree: WorktreeInfo) => void; onRunInitScript: (worktree: WorktreeInfo) => void;
onToggleAutoMode?: (worktree: WorktreeInfo) => void; onToggleAutoMode?: (worktree: WorktreeInfo) => void;
onMerge: (worktree: WorktreeInfo) => void;
hasInitScript: boolean; hasInitScript: boolean;
} }
@@ -89,6 +92,7 @@ export function WorktreeActionsDropdown({
isSelected, isSelected,
aheadCount, aheadCount,
behindCount, behindCount,
hasRemoteBranch,
isPulling, isPulling,
isPushing, isPushing,
isStartingDevServer, isStartingDevServer,
@@ -100,6 +104,7 @@ export function WorktreeActionsDropdown({
onOpenChange, onOpenChange,
onPull, onPull,
onPush, onPush,
onPushNewBranch,
onOpenInEditor, onOpenInEditor,
onOpenInIntegratedTerminal, onOpenInIntegratedTerminal,
onOpenInExternalTerminal, onOpenInExternalTerminal,
@@ -109,7 +114,6 @@ export function WorktreeActionsDropdown({
onCreatePR, onCreatePR,
onAddressPRComments, onAddressPRComments,
onResolveConflicts, onResolveConflicts,
onMerge,
onDeleteWorktree, onDeleteWorktree,
onStartDevServer, onStartDevServer,
onStopDevServer, onStopDevServer,
@@ -117,6 +121,7 @@ export function WorktreeActionsDropdown({
onViewDevServerLogs, onViewDevServerLogs,
onRunInitScript, onRunInitScript,
onToggleAutoMode, onToggleAutoMode,
onMerge,
hasInitScript, hasInitScript,
}: WorktreeActionsDropdownProps) { }: WorktreeActionsDropdownProps) {
// Get available editors for the "Open In" submenu // Get available editors for the "Open In" submenu
@@ -264,14 +269,27 @@ export function WorktreeActionsDropdown({
</TooltipWrapper> </TooltipWrapper>
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}> <TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
<DropdownMenuItem <DropdownMenuItem
onClick={() => canPerformGitOps && onPush(worktree)} onClick={() => {
disabled={isPushing || aheadCount === 0 || !canPerformGitOps} if (!canPerformGitOps) return;
if (!hasRemoteBranch) {
onPushNewBranch(worktree);
} else {
onPush(worktree);
}
}}
disabled={isPushing || (hasRemoteBranch && aheadCount === 0) || !canPerformGitOps}
className={cn('text-xs', !canPerformGitOps && 'opacity-50 cursor-not-allowed')} className={cn('text-xs', !canPerformGitOps && 'opacity-50 cursor-not-allowed')}
> >
<Upload className={cn('w-3.5 h-3.5 mr-2', isPushing && 'animate-pulse')} /> <Upload className={cn('w-3.5 h-3.5 mr-2', isPushing && 'animate-pulse')} />
{isPushing ? 'Pushing...' : 'Push'} {isPushing ? 'Pushing...' : 'Push'}
{!canPerformGitOps && <AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />} {!canPerformGitOps && <AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />}
{canPerformGitOps && aheadCount > 0 && ( {canPerformGitOps && !hasRemoteBranch && (
<span className="ml-auto inline-flex items-center gap-0.5 text-[10px] bg-primary/20 text-primary px-1.5 py-0.5 rounded">
<Sparkles className="w-2.5 h-2.5" />
new
</span>
)}
{canPerformGitOps && hasRemoteBranch && aheadCount > 0 && (
<span className="ml-auto text-[10px] bg-primary/20 text-primary px-1.5 py-0.5 rounded"> <span className="ml-auto text-[10px] bg-primary/20 text-primary px-1.5 py-0.5 rounded">
{aheadCount} ahead {aheadCount} ahead
</span> </span>
@@ -292,27 +310,6 @@ export function WorktreeActionsDropdown({
{!canPerformGitOps && <AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />} {!canPerformGitOps && <AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />}
</DropdownMenuItem> </DropdownMenuItem>
</TooltipWrapper> </TooltipWrapper>
{!worktree.isMain && (
<TooltipWrapper
showTooltip={!!gitOpsDisabledReason}
tooltipContent={gitOpsDisabledReason}
>
<DropdownMenuItem
onClick={() => canPerformGitOps && onMerge(worktree)}
disabled={!canPerformGitOps}
className={cn(
'text-xs text-green-600 focus:text-green-700',
!canPerformGitOps && 'opacity-50 cursor-not-allowed'
)}
>
<GitMerge className="w-3.5 h-3.5 mr-2" />
Merge to Main
{!canPerformGitOps && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)}
</DropdownMenuItem>
</TooltipWrapper>
)}
<DropdownMenuSeparator /> <DropdownMenuSeparator />
{/* Open in editor - split button: click main area for default, chevron for other options */} {/* Open in editor - split button: click main area for default, chevron for other options */}
{effectiveDefaultEditor && ( {effectiveDefaultEditor && (
@@ -546,6 +543,26 @@ export function WorktreeActionsDropdown({
)} )}
{!worktree.isMain && ( {!worktree.isMain && (
<> <>
<TooltipWrapper
showTooltip={!!gitOpsDisabledReason}
tooltipContent={gitOpsDisabledReason}
>
<DropdownMenuItem
onClick={() => canPerformGitOps && onMerge(worktree)}
disabled={!canPerformGitOps}
className={cn(
'text-xs text-green-600 focus:text-green-700',
!canPerformGitOps && 'opacity-50 cursor-not-allowed'
)}
>
<GitMerge className="w-3.5 h-3.5 mr-2" />
Merge Branch
{!canPerformGitOps && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)}
</DropdownMenuItem>
</TooltipWrapper>
<DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
onClick={() => onDeleteWorktree(worktree)} onClick={() => onDeleteWorktree(worktree)}
className="text-xs text-destructive focus:text-destructive" className="text-xs text-destructive focus:text-destructive"

View File

@@ -4,6 +4,7 @@ import { Globe, CircleDot, GitPullRequest } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner'; import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { useDroppable } from '@dnd-kit/core';
import type { WorktreeInfo, BranchInfo, DevServerInfo, PRInfo, GitRepoStatus } from '../types'; import type { WorktreeInfo, BranchInfo, DevServerInfo, PRInfo, GitRepoStatus } from '../types';
import { BranchSwitchDropdown } from './branch-switch-dropdown'; import { BranchSwitchDropdown } from './branch-switch-dropdown';
import { WorktreeActionsDropdown } from './worktree-actions-dropdown'; import { WorktreeActionsDropdown } from './worktree-actions-dropdown';
@@ -28,6 +29,7 @@ interface WorktreeTabProps {
isStartingDevServer: boolean; isStartingDevServer: boolean;
aheadCount: number; aheadCount: number;
behindCount: number; behindCount: number;
hasRemoteBranch: boolean;
gitRepoStatus: GitRepoStatus; gitRepoStatus: GitRepoStatus;
/** Whether auto mode is running for this worktree */ /** Whether auto mode is running for this worktree */
isAutoModeRunning?: boolean; isAutoModeRunning?: boolean;
@@ -39,6 +41,7 @@ interface WorktreeTabProps {
onCreateBranch: (worktree: WorktreeInfo) => void; onCreateBranch: (worktree: WorktreeInfo) => void;
onPull: (worktree: WorktreeInfo) => void; onPull: (worktree: WorktreeInfo) => void;
onPush: (worktree: WorktreeInfo) => void; onPush: (worktree: WorktreeInfo) => void;
onPushNewBranch: (worktree: WorktreeInfo) => void;
onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void; onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void;
onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void; onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void;
onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void; onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void;
@@ -79,6 +82,7 @@ export function WorktreeTab({
isStartingDevServer, isStartingDevServer,
aheadCount, aheadCount,
behindCount, behindCount,
hasRemoteBranch,
gitRepoStatus, gitRepoStatus,
isAutoModeRunning = false, isAutoModeRunning = false,
onSelectWorktree, onSelectWorktree,
@@ -89,6 +93,7 @@ export function WorktreeTab({
onCreateBranch, onCreateBranch,
onPull, onPull,
onPush, onPush,
onPushNewBranch,
onOpenInEditor, onOpenInEditor,
onOpenInIntegratedTerminal, onOpenInIntegratedTerminal,
onOpenInExternalTerminal, onOpenInExternalTerminal,
@@ -108,6 +113,16 @@ export function WorktreeTab({
onToggleAutoMode, onToggleAutoMode,
hasInitScript, hasInitScript,
}: WorktreeTabProps) { }: WorktreeTabProps) {
// Make the worktree tab a drop target for feature cards
const { setNodeRef, isOver } = useDroppable({
id: `worktree-drop-${worktree.branch}`,
data: {
type: 'worktree',
branch: worktree.branch,
path: worktree.path,
isMain: worktree.isMain,
},
});
let prBadge: JSX.Element | null = null; let prBadge: JSX.Element | null = null;
if (worktree.pr) { if (worktree.pr) {
const prState = worktree.pr.state?.toLowerCase() ?? 'open'; const prState = worktree.pr.state?.toLowerCase() ?? 'open';
@@ -194,7 +209,13 @@ export function WorktreeTab({
} }
return ( return (
<div className="flex items-center rounded-md"> <div
ref={setNodeRef}
className={cn(
'flex items-center rounded-md transition-all duration-150',
isOver && 'ring-2 ring-primary ring-offset-2 ring-offset-background scale-105'
)}
>
{worktree.isMain ? ( {worktree.isMain ? (
<> <>
<Button <Button
@@ -366,6 +387,7 @@ export function WorktreeTab({
isSelected={isSelected} isSelected={isSelected}
aheadCount={aheadCount} aheadCount={aheadCount}
behindCount={behindCount} behindCount={behindCount}
hasRemoteBranch={hasRemoteBranch}
isPulling={isPulling} isPulling={isPulling}
isPushing={isPushing} isPushing={isPushing}
isStartingDevServer={isStartingDevServer} isStartingDevServer={isStartingDevServer}
@@ -376,6 +398,7 @@ export function WorktreeTab({
onOpenChange={onActionsDropdownOpenChange} onOpenChange={onActionsDropdownOpenChange}
onPull={onPull} onPull={onPull}
onPush={onPush} onPush={onPush}
onPushNewBranch={onPushNewBranch}
onOpenInEditor={onOpenInEditor} onOpenInEditor={onOpenInEditor}
onOpenInIntegratedTerminal={onOpenInIntegratedTerminal} onOpenInIntegratedTerminal={onOpenInIntegratedTerminal}
onOpenInExternalTerminal={onOpenInExternalTerminal} onOpenInExternalTerminal={onOpenInExternalTerminal}

View File

@@ -22,6 +22,7 @@ export function useBranches() {
const branches = branchData?.branches ?? []; const branches = branchData?.branches ?? [];
const aheadCount = branchData?.aheadCount ?? 0; const aheadCount = branchData?.aheadCount ?? 0;
const behindCount = branchData?.behindCount ?? 0; const behindCount = branchData?.behindCount ?? 0;
const hasRemoteBranch = branchData?.hasRemoteBranch ?? false;
// Use conservative defaults (false) until data is confirmed // Use conservative defaults (false) until data is confirmed
// This prevents the UI from assuming git capabilities before the query completes // This prevents the UI from assuming git capabilities before the query completes
const gitRepoStatus: GitRepoStatus = { const gitRepoStatus: GitRepoStatus = {
@@ -55,6 +56,7 @@ export function useBranches() {
filteredBranches, filteredBranches,
aheadCount, aheadCount,
behindCount, behindCount,
hasRemoteBranch,
isLoadingBranches, isLoadingBranches,
branchFilter, branchFilter,
setBranchFilter, setBranchFilter,

View File

@@ -17,6 +17,11 @@ export function useRunningFeatures({ runningFeatureIds, features }: UseRunningFe
// Match by branchName only (worktreePath is no longer stored) // Match by branchName only (worktreePath is no longer stored)
if (feature.branchName) { 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;
}
return worktree.branch === feature.branchName; return worktree.branch === feature.branchName;
} }

View File

@@ -61,6 +61,12 @@ export interface PRInfo {
}>; }>;
} }
export interface MergeConflictInfo {
sourceBranch: string;
targetBranch: string;
targetWorktreePath: string;
}
export interface WorktreePanelProps { export interface WorktreePanelProps {
projectPath: string; projectPath: string;
onCreateWorktree: () => void; onCreateWorktree: () => void;
@@ -70,7 +76,9 @@ export interface WorktreePanelProps {
onCreateBranch: (worktree: WorktreeInfo) => void; onCreateBranch: (worktree: WorktreeInfo) => void;
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void; onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
onResolveConflicts: (worktree: WorktreeInfo) => void; onResolveConflicts: (worktree: WorktreeInfo) => void;
onMerge: (worktree: WorktreeInfo) => void; onCreateMergeConflictResolutionFeature?: (conflictInfo: MergeConflictInfo) => void;
/** Called when a branch is deleted during merge - features should be reassigned to main */
onBranchDeletedDuringMerge?: (branchName: string) => void;
onRemovedWorktrees?: (removedWorktrees: Array<{ path: string; branch: string }>) => void; onRemovedWorktrees?: (removedWorktrees: Array<{ path: string; branch: string }>) => void;
runningFeatureIds?: string[]; runningFeatureIds?: string[];
features?: FeatureInfo[]; features?: FeatureInfo[];

View File

@@ -23,9 +23,10 @@ import {
BranchSwitchDropdown, BranchSwitchDropdown,
} from './components'; } from './components';
import { useAppStore } from '@/store/app-store'; import { useAppStore } from '@/store/app-store';
import { ViewWorktreeChangesDialog } from '../dialogs'; import { ViewWorktreeChangesDialog, PushToRemoteDialog, MergeWorktreeDialog } from '../dialogs';
import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { Undo2 } from 'lucide-react'; import { Undo2 } from 'lucide-react';
import { getElectronAPI } from '@/lib/electron';
export function WorktreePanel({ export function WorktreePanel({
projectPath, projectPath,
@@ -36,7 +37,8 @@ export function WorktreePanel({
onCreateBranch, onCreateBranch,
onAddressPRComments, onAddressPRComments,
onResolveConflicts, onResolveConflicts,
onMerge, onCreateMergeConflictResolutionFeature,
onBranchDeletedDuringMerge,
onRemovedWorktrees, onRemovedWorktrees,
runningFeatureIds = [], runningFeatureIds = [],
features = [], features = [],
@@ -67,6 +69,7 @@ export function WorktreePanel({
filteredBranches, filteredBranches,
aheadCount, aheadCount,
behindCount, behindCount,
hasRemoteBranch,
isLoadingBranches, isLoadingBranches,
branchFilter, branchFilter,
setBranchFilter, setBranchFilter,
@@ -170,6 +173,14 @@ export function WorktreePanel({
const [logPanelOpen, setLogPanelOpen] = useState(false); const [logPanelOpen, setLogPanelOpen] = useState(false);
const [logPanelWorktree, setLogPanelWorktree] = useState<WorktreeInfo | null>(null); const [logPanelWorktree, setLogPanelWorktree] = useState<WorktreeInfo | null>(null);
// Push to remote dialog state
const [pushToRemoteDialogOpen, setPushToRemoteDialogOpen] = useState(false);
const [pushToRemoteWorktree, setPushToRemoteWorktree] = useState<WorktreeInfo | null>(null);
// Merge branch dialog state
const [mergeDialogOpen, setMergeDialogOpen] = useState(false);
const [mergeWorktree, setMergeWorktree] = useState<WorktreeInfo | null>(null);
const isMobile = useIsMobile(); const isMobile = useIsMobile();
// Periodic interval check (5 seconds) to detect branch changes on disk // Periodic interval check (5 seconds) to detect branch changes on disk
@@ -280,6 +291,54 @@ export function WorktreePanel({
// Keep logPanelWorktree set for smooth close animation // Keep logPanelWorktree set for smooth close animation
}, []); }, []);
// Handle opening the push to remote dialog
const handlePushNewBranch = useCallback((worktree: WorktreeInfo) => {
setPushToRemoteWorktree(worktree);
setPushToRemoteDialogOpen(true);
}, []);
// Handle confirming the push to remote dialog
const handleConfirmPushToRemote = useCallback(
async (worktree: WorktreeInfo, remote: string) => {
try {
const api = getElectronAPI();
if (!api?.worktree?.push) {
toast.error('Push API not available');
return;
}
const result = await api.worktree.push(worktree.path, false, remote);
if (result.success && result.result) {
toast.success(result.result.message);
fetchBranches(worktree.path);
fetchWorktrees();
} else {
toast.error(result.error || 'Failed to push changes');
}
} catch (error) {
toast.error('Failed to push changes');
}
},
[fetchBranches, fetchWorktrees]
);
// Handle opening the merge dialog
const handleMerge = useCallback((worktree: WorktreeInfo) => {
setMergeWorktree(worktree);
setMergeDialogOpen(true);
}, []);
// Handle merge completion - refresh worktrees and reassign features if branch was deleted
const handleMerged = useCallback(
(mergedWorktree: WorktreeInfo, deletedBranch: boolean) => {
fetchWorktrees();
// If the branch was deleted, notify parent to reassign features to main
if (deletedBranch && onBranchDeletedDuringMerge) {
onBranchDeletedDuringMerge(mergedWorktree.branch);
}
},
[fetchWorktrees, onBranchDeletedDuringMerge]
);
const mainWorktree = worktrees.find((w) => w.isMain); const mainWorktree = worktrees.find((w) => w.isMain);
const nonMainWorktrees = worktrees.filter((w) => !w.isMain); const nonMainWorktrees = worktrees.filter((w) => !w.isMain);
@@ -325,6 +384,7 @@ export function WorktreePanel({
standalone={true} standalone={true}
aheadCount={aheadCount} aheadCount={aheadCount}
behindCount={behindCount} behindCount={behindCount}
hasRemoteBranch={hasRemoteBranch}
isPulling={isPulling} isPulling={isPulling}
isPushing={isPushing} isPushing={isPushing}
isStartingDevServer={isStartingDevServer} isStartingDevServer={isStartingDevServer}
@@ -335,6 +395,7 @@ export function WorktreePanel({
onOpenChange={handleActionsDropdownOpenChange(selectedWorktree)} onOpenChange={handleActionsDropdownOpenChange(selectedWorktree)}
onPull={handlePull} onPull={handlePull}
onPush={handlePush} onPush={handlePush}
onPushNewBranch={handlePushNewBranch}
onOpenInEditor={handleOpenInEditor} onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal} onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
onOpenInExternalTerminal={handleOpenInExternalTerminal} onOpenInExternalTerminal={handleOpenInExternalTerminal}
@@ -344,7 +405,7 @@ export function WorktreePanel({
onCreatePR={onCreatePR} onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments} onAddressPRComments={onAddressPRComments}
onResolveConflicts={onResolveConflicts} onResolveConflicts={onResolveConflicts}
onMerge={onMerge} onMerge={handleMerge}
onDeleteWorktree={onDeleteWorktree} onDeleteWorktree={onDeleteWorktree}
onStartDevServer={handleStartDevServer} onStartDevServer={handleStartDevServer}
onStopDevServer={handleStopDevServer} onStopDevServer={handleStopDevServer}
@@ -415,6 +476,24 @@ export function WorktreePanel({
onStopDevServer={handleStopDevServer} onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl} onOpenDevServerUrl={handleOpenDevServerUrl}
/> />
{/* Push to Remote Dialog */}
<PushToRemoteDialog
open={pushToRemoteDialogOpen}
onOpenChange={setPushToRemoteDialogOpen}
worktree={pushToRemoteWorktree}
onConfirm={handleConfirmPushToRemote}
/>
{/* Merge Branch Dialog */}
<MergeWorktreeDialog
open={mergeDialogOpen}
onOpenChange={setMergeDialogOpen}
projectPath={projectPath}
worktree={mergeWorktree}
onMerged={handleMerged}
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
/>
</div> </div>
); );
} }
@@ -448,6 +527,7 @@ export function WorktreePanel({
isStartingDevServer={isStartingDevServer} isStartingDevServer={isStartingDevServer}
aheadCount={aheadCount} aheadCount={aheadCount}
behindCount={behindCount} behindCount={behindCount}
hasRemoteBranch={hasRemoteBranch}
gitRepoStatus={gitRepoStatus} gitRepoStatus={gitRepoStatus}
isAutoModeRunning={isAutoModeRunningForWorktree(mainWorktree)} isAutoModeRunning={isAutoModeRunningForWorktree(mainWorktree)}
onSelectWorktree={handleSelectWorktree} onSelectWorktree={handleSelectWorktree}
@@ -458,6 +538,7 @@ export function WorktreePanel({
onCreateBranch={onCreateBranch} onCreateBranch={onCreateBranch}
onPull={handlePull} onPull={handlePull}
onPush={handlePush} onPush={handlePush}
onPushNewBranch={handlePushNewBranch}
onOpenInEditor={handleOpenInEditor} onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal} onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
onOpenInExternalTerminal={handleOpenInExternalTerminal} onOpenInExternalTerminal={handleOpenInExternalTerminal}
@@ -467,7 +548,7 @@ export function WorktreePanel({
onCreatePR={onCreatePR} onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments} onAddressPRComments={onAddressPRComments}
onResolveConflicts={onResolveConflicts} onResolveConflicts={onResolveConflicts}
onMerge={onMerge} onMerge={handleMerge}
onDeleteWorktree={onDeleteWorktree} onDeleteWorktree={onDeleteWorktree}
onStartDevServer={handleStartDevServer} onStartDevServer={handleStartDevServer}
onStopDevServer={handleStopDevServer} onStopDevServer={handleStopDevServer}
@@ -512,6 +593,7 @@ export function WorktreePanel({
isStartingDevServer={isStartingDevServer} isStartingDevServer={isStartingDevServer}
aheadCount={aheadCount} aheadCount={aheadCount}
behindCount={behindCount} behindCount={behindCount}
hasRemoteBranch={hasRemoteBranch}
gitRepoStatus={gitRepoStatus} gitRepoStatus={gitRepoStatus}
isAutoModeRunning={isAutoModeRunningForWorktree(worktree)} isAutoModeRunning={isAutoModeRunningForWorktree(worktree)}
onSelectWorktree={handleSelectWorktree} onSelectWorktree={handleSelectWorktree}
@@ -522,6 +604,7 @@ export function WorktreePanel({
onCreateBranch={onCreateBranch} onCreateBranch={onCreateBranch}
onPull={handlePull} onPull={handlePull}
onPush={handlePush} onPush={handlePush}
onPushNewBranch={handlePushNewBranch}
onOpenInEditor={handleOpenInEditor} onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal} onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
onOpenInExternalTerminal={handleOpenInExternalTerminal} onOpenInExternalTerminal={handleOpenInExternalTerminal}
@@ -531,7 +614,7 @@ export function WorktreePanel({
onCreatePR={onCreatePR} onCreatePR={onCreatePR}
onAddressPRComments={onAddressPRComments} onAddressPRComments={onAddressPRComments}
onResolveConflicts={onResolveConflicts} onResolveConflicts={onResolveConflicts}
onMerge={onMerge} onMerge={handleMerge}
onDeleteWorktree={onDeleteWorktree} onDeleteWorktree={onDeleteWorktree}
onStartDevServer={handleStartDevServer} onStartDevServer={handleStartDevServer}
onStopDevServer={handleStopDevServer} onStopDevServer={handleStopDevServer}
@@ -602,6 +685,24 @@ export function WorktreePanel({
onStopDevServer={handleStopDevServer} onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl} onOpenDevServerUrl={handleOpenDevServerUrl}
/> />
{/* Push to Remote Dialog */}
<PushToRemoteDialog
open={pushToRemoteDialogOpen}
onOpenChange={setPushToRemoteDialogOpen}
worktree={pushToRemoteWorktree}
onConfirm={handleConfirmPushToRemote}
/>
{/* Merge Branch Dialog */}
<MergeWorktreeDialog
open={mergeDialogOpen}
onOpenChange={setMergeDialogOpen}
projectPath={projectPath}
worktree={mergeWorktree}
onMerged={handleMerged}
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
/>
</div> </div>
); );
} }

View File

@@ -160,6 +160,7 @@ interface BranchesResult {
branches: BranchInfo[]; branches: BranchInfo[];
aheadCount: number; aheadCount: number;
behindCount: number; behindCount: number;
hasRemoteBranch: boolean;
isGitRepo: boolean; isGitRepo: boolean;
hasCommits: boolean; hasCommits: boolean;
} }
@@ -186,6 +187,7 @@ export function useWorktreeBranches(worktreePath: string | undefined, includeRem
branches: [], branches: [],
aheadCount: 0, aheadCount: 0,
behindCount: 0, behindCount: 0,
hasRemoteBranch: false,
isGitRepo: false, isGitRepo: false,
hasCommits: false, hasCommits: false,
}; };
@@ -195,6 +197,7 @@ export function useWorktreeBranches(worktreePath: string | undefined, includeRem
branches: [], branches: [],
aheadCount: 0, aheadCount: 0,
behindCount: 0, behindCount: 0,
hasRemoteBranch: false,
isGitRepo: true, isGitRepo: true,
hasCommits: false, hasCommits: false,
}; };
@@ -208,6 +211,7 @@ export function useWorktreeBranches(worktreePath: string | undefined, includeRem
branches: result.result?.branches ?? [], branches: result.result?.branches ?? [],
aheadCount: result.result?.aheadCount ?? 0, aheadCount: result.result?.aheadCount ?? 0,
behindCount: result.result?.behindCount ?? 0, behindCount: result.result?.behindCount ?? 0,
hasRemoteBranch: result.result?.hasRemoteBranch ?? false,
isGitRepo: true, isGitRepo: true,
hasCommits: true, hasCommits: true,
}; };

View File

@@ -93,10 +93,12 @@ export function useAutoMode(worktree?: WorktreeInfo) {
})) }))
); );
// Derive branchName from worktree: main worktree uses null, feature worktrees use their branch // Derive branchName from worktree:
// If worktree is provided, use its branch name (even for main worktree, as it might be on a feature branch)
// If not provided, default to null (main worktree default)
const branchName = useMemo(() => { const branchName = useMemo(() => {
if (!worktree) return null; if (!worktree) return null;
return worktree.isMain ? null : worktree.branch; return worktree.isMain ? null : worktree.branch || null;
}, [worktree]); }, [worktree]);
// Helper to look up project ID from path // Helper to look up project ID from path
@@ -155,7 +157,13 @@ export function useAutoMode(worktree?: WorktreeInfo) {
logger.info( logger.info(
`[AutoMode] Syncing UI state with backend for ${worktreeDesc} in ${currentProject.path}: ${backendIsRunning ? 'ON' : 'OFF'}` `[AutoMode] Syncing UI state with backend for ${worktreeDesc} in ${currentProject.path}: ${backendIsRunning ? 'ON' : 'OFF'}`
); );
setAutoModeRunning(currentProject.id, branchName, backendIsRunning); setAutoModeRunning(
currentProject.id,
branchName,
backendIsRunning,
result.maxConcurrency,
result.runningFeatures
);
setAutoModeSessionForWorktree(currentProject.path, branchName, backendIsRunning); setAutoModeSessionForWorktree(currentProject.path, branchName, backendIsRunning);
} }
} }
@@ -165,7 +173,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
}; };
syncWithBackend(); syncWithBackend();
}, [currentProject, branchName, isAutoModeRunning, setAutoModeRunning]); }, [currentProject, branchName, setAutoModeRunning]);
// Handle auto mode events - listen globally for all projects/worktrees // Handle auto mode events - listen globally for all projects/worktrees
useEffect(() => { useEffect(() => {
@@ -215,6 +223,26 @@ export function useAutoMode(worktree?: WorktreeInfo) {
} }
break; break;
case 'auto_mode_resuming_features':
// Backend is resuming features from saved state
if (eventProjectId && 'features' in event && Array.isArray(event.features)) {
logger.info(`[AutoMode] Resuming ${event.features.length} feature(s) from saved state`);
// Use per-feature branchName if available, fallback to event-level branchName
event.features.forEach((feature: { id: string; branchName?: string | null }) => {
const featureBranchName = feature.branchName ?? eventBranchName;
addRunningTask(eventProjectId, featureBranchName, feature.id);
});
} else if (eventProjectId && 'featureIds' in event && Array.isArray(event.featureIds)) {
// Fallback for older event format without per-feature branchName
logger.info(
`[AutoMode] Resuming ${event.featureIds.length} feature(s) from saved state (legacy format)`
);
event.featureIds.forEach((featureId: string) => {
addRunningTask(eventProjectId, eventBranchName, featureId);
});
}
break;
case 'auto_mode_stopped': case 'auto_mode_stopped':
// Backend stopped auto loop - update UI state // Backend stopped auto loop - update UI state
{ {
@@ -484,11 +512,16 @@ export function useAutoMode(worktree?: WorktreeInfo) {
logger.info(`[AutoMode] Starting auto loop for ${worktreeDesc} in ${currentProject.path}`); logger.info(`[AutoMode] Starting auto loop for ${worktreeDesc} in ${currentProject.path}`);
// Optimistically update UI state (backend will confirm via event) // Optimistically update UI state (backend will confirm via event)
const currentMaxConcurrency = getMaxConcurrencyForWorktree(currentProject.id, branchName);
setAutoModeSessionForWorktree(currentProject.path, branchName, true); setAutoModeSessionForWorktree(currentProject.path, branchName, true);
setAutoModeRunning(currentProject.id, branchName, true); setAutoModeRunning(currentProject.id, branchName, true, currentMaxConcurrency);
// Call backend to start the auto loop (backend uses stored concurrency) // Call backend to start the auto loop (pass current max concurrency)
const result = await api.autoMode.start(currentProject.path, branchName); const result = await api.autoMode.start(
currentProject.path,
branchName,
currentMaxConcurrency
);
if (!result.success) { if (!result.success) {
// Revert UI state on failure // Revert UI state on failure

View File

@@ -212,6 +212,8 @@ export function parseLocalStorageSettings(): Partial<GlobalSettings> | null {
claudeApiProfiles: (state.claudeApiProfiles as GlobalSettings['claudeApiProfiles']) ?? [], claudeApiProfiles: (state.claudeApiProfiles as GlobalSettings['claudeApiProfiles']) ?? [],
activeClaudeApiProfileId: activeClaudeApiProfileId:
(state.activeClaudeApiProfileId as GlobalSettings['activeClaudeApiProfileId']) ?? null, (state.activeClaudeApiProfileId as GlobalSettings['activeClaudeApiProfileId']) ?? null,
// Event hooks
eventHooks: state.eventHooks as GlobalSettings['eventHooks'],
}; };
} catch (error) { } catch (error) {
logger.error('Failed to parse localStorage settings:', error); logger.error('Failed to parse localStorage settings:', error);

View File

@@ -1566,15 +1566,18 @@ function createMockWorktreeAPI(): WorktreeAPI {
projectPath: string, projectPath: string,
branchName: string, branchName: string,
worktreePath: string, worktreePath: string,
targetBranch?: string,
options?: object options?: object
) => { ) => {
const target = targetBranch || 'main';
console.log('[Mock] Merging feature:', { console.log('[Mock] Merging feature:', {
projectPath, projectPath,
branchName, branchName,
worktreePath, worktreePath,
targetBranch: target,
options, options,
}); });
return { success: true, mergedBranch: branchName }; return { success: true, mergedBranch: branchName, targetBranch: target };
}, },
getInfo: async (projectPath: string, featureId: string) => { getInfo: async (projectPath: string, featureId: string) => {
@@ -1684,14 +1687,15 @@ function createMockWorktreeAPI(): WorktreeAPI {
}; };
}, },
push: async (worktreePath: string, force?: boolean) => { push: async (worktreePath: string, force?: boolean, remote?: string) => {
console.log('[Mock] Pushing worktree:', { worktreePath, force }); const targetRemote = remote || 'origin';
console.log('[Mock] Pushing worktree:', { worktreePath, force, remote: targetRemote });
return { return {
success: true, success: true,
result: { result: {
branch: 'feature-branch', branch: 'feature-branch',
pushed: true, pushed: true,
message: 'Successfully pushed to origin/feature-branch', message: `Successfully pushed to ${targetRemote}/feature-branch`,
}, },
}; };
}, },
@@ -1777,6 +1781,7 @@ function createMockWorktreeAPI(): WorktreeAPI {
], ],
aheadCount: 2, aheadCount: 2,
behindCount: 0, behindCount: 0,
hasRemoteBranch: true,
}, },
}; };
}, },
@@ -1793,6 +1798,26 @@ function createMockWorktreeAPI(): WorktreeAPI {
}; };
}, },
listRemotes: async (worktreePath: string) => {
console.log('[Mock] Listing remotes for:', worktreePath);
return {
success: true,
result: {
remotes: [
{
name: 'origin',
url: 'git@github.com:example/repo.git',
branches: [
{ name: 'main', fullRef: 'origin/main' },
{ name: 'develop', fullRef: 'origin/develop' },
{ name: 'feature/example', fullRef: 'origin/feature/example' },
],
},
],
},
};
},
openInEditor: async (worktreePath: string, editorCommand?: string) => { openInEditor: async (worktreePath: string, editorCommand?: string) => {
const ANTIGRAVITY_EDITOR_COMMAND = 'antigravity'; const ANTIGRAVITY_EDITOR_COMMAND = 'antigravity';
const ANTIGRAVITY_LEGACY_COMMAND = 'agy'; const ANTIGRAVITY_LEGACY_COMMAND = 'agy';

View File

@@ -1763,8 +1763,16 @@ export class HttpApiClient implements ElectronAPI {
projectPath: string, projectPath: string,
branchName: string, branchName: string,
worktreePath: string, worktreePath: string,
targetBranch?: string,
options?: object options?: object
) => this.post('/api/worktree/merge', { projectPath, branchName, worktreePath, options }), ) =>
this.post('/api/worktree/merge', {
projectPath,
branchName,
worktreePath,
targetBranch,
options,
}),
getInfo: (projectPath: string, featureId: string) => getInfo: (projectPath: string, featureId: string) =>
this.post('/api/worktree/info', { projectPath, featureId }), this.post('/api/worktree/info', { projectPath, featureId }),
getStatus: (projectPath: string, featureId: string) => getStatus: (projectPath: string, featureId: string) =>
@@ -1788,8 +1796,8 @@ export class HttpApiClient implements ElectronAPI {
this.post('/api/worktree/commit', { worktreePath, message }), this.post('/api/worktree/commit', { worktreePath, message }),
generateCommitMessage: (worktreePath: string) => generateCommitMessage: (worktreePath: string) =>
this.post('/api/worktree/generate-commit-message', { worktreePath }), this.post('/api/worktree/generate-commit-message', { worktreePath }),
push: (worktreePath: string, force?: boolean) => push: (worktreePath: string, force?: boolean, remote?: string) =>
this.post('/api/worktree/push', { worktreePath, force }), this.post('/api/worktree/push', { worktreePath, force, remote }),
createPR: (worktreePath: string, options?: any) => createPR: (worktreePath: string, options?: any) =>
this.post('/api/worktree/create-pr', { worktreePath, ...options }), this.post('/api/worktree/create-pr', { worktreePath, ...options }),
getDiffs: (projectPath: string, featureId: string) => getDiffs: (projectPath: string, featureId: string) =>
@@ -1807,6 +1815,8 @@ export class HttpApiClient implements ElectronAPI {
this.post('/api/worktree/list-branches', { worktreePath, includeRemote }), this.post('/api/worktree/list-branches', { worktreePath, includeRemote }),
switchBranch: (worktreePath: string, branchName: string) => switchBranch: (worktreePath: string, branchName: string) =>
this.post('/api/worktree/switch-branch', { worktreePath, branchName }), this.post('/api/worktree/switch-branch', { worktreePath, branchName }),
listRemotes: (worktreePath: string) =>
this.post('/api/worktree/list-remotes', { worktreePath }),
openInEditor: (worktreePath: string, editorCommand?: string) => openInEditor: (worktreePath: string, editorCommand?: string) =>
this.post('/api/worktree/open-in-editor', { worktreePath, editorCommand }), this.post('/api/worktree/open-in-editor', { worktreePath, editorCommand }),
getDefaultEditor: () => this.get('/api/worktree/default-editor'), getDefaultEditor: () => this.get('/api/worktree/default-editor'),

View File

@@ -1074,7 +1074,8 @@ export interface AppActions {
projectId: string, projectId: string,
branchName: string | null, branchName: string | null,
running: boolean, running: boolean,
maxConcurrency?: number maxConcurrency?: number,
runningTasks?: string[]
) => void; ) => void;
addRunningTask: (projectId: string, branchName: string | null, taskId: string) => void; addRunningTask: (projectId: string, branchName: string | null, taskId: string) => void;
removeRunningTask: (projectId: string, branchName: string | null, taskId: string) => void; removeRunningTask: (projectId: string, branchName: string | null, taskId: string) => void;
@@ -2155,10 +2156,19 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
// Auto Mode actions (per-worktree) // Auto Mode actions (per-worktree)
getWorktreeKey: (projectId, branchName) => { getWorktreeKey: (projectId, branchName) => {
return `${projectId}::${branchName ?? '__main__'}`; // Normalize 'main' to null so it matches the main worktree key
// The backend sometimes sends 'main' while the UI uses null for the main worktree
const normalizedBranch = branchName === 'main' ? null : branchName;
return `${projectId}::${normalizedBranch ?? '__main__'}`;
}, },
setAutoModeRunning: (projectId, branchName, running, maxConcurrency?: number) => { setAutoModeRunning: (
projectId: string,
branchName: string | null,
running: boolean,
maxConcurrency?: number,
runningTasks?: string[]
) => {
const worktreeKey = get().getWorktreeKey(projectId, branchName); const worktreeKey = get().getWorktreeKey(projectId, branchName);
const current = get().autoModeByWorktree; const current = get().autoModeByWorktree;
const worktreeState = current[worktreeKey] || { const worktreeState = current[worktreeKey] || {
@@ -2175,6 +2185,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
isRunning: running, isRunning: running,
branchName, branchName,
maxConcurrency: maxConcurrency ?? worktreeState.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY, maxConcurrency: maxConcurrency ?? worktreeState.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY,
runningTasks: runningTasks ?? worktreeState.runningTasks,
}, },
}, },
}); });

View File

@@ -219,6 +219,7 @@ export type AutoModeEvent =
type: 'pipeline_step_started'; type: 'pipeline_step_started';
featureId: string; featureId: string;
projectPath?: string; projectPath?: string;
branchName?: string | null;
stepId: string; stepId: string;
stepName: string; stepName: string;
stepIndex: number; stepIndex: number;
@@ -228,6 +229,7 @@ export type AutoModeEvent =
type: 'pipeline_step_complete'; type: 'pipeline_step_complete';
featureId: string; featureId: string;
projectPath?: string; projectPath?: string;
branchName?: string | null;
stepId: string; stepId: string;
stepName: string; stepName: string;
stepIndex: number; stepIndex: number;
@@ -247,6 +249,7 @@ export type AutoModeEvent =
featureId: string; featureId: string;
projectId?: string; projectId?: string;
projectPath?: string; projectPath?: string;
branchName?: string | null;
phase: 'planning' | 'action' | 'verification'; phase: 'planning' | 'action' | 'verification';
message: string; message: string;
} }
@@ -254,6 +257,7 @@ export type AutoModeEvent =
type: 'auto_mode_ultrathink_preparation'; type: 'auto_mode_ultrathink_preparation';
featureId: string; featureId: string;
projectPath?: string; projectPath?: string;
branchName?: string | null;
warnings: string[]; warnings: string[];
recommendations: string[]; recommendations: string[];
estimatedCost?: number; estimatedCost?: number;
@@ -263,6 +267,7 @@ export type AutoModeEvent =
type: 'plan_approval_required'; type: 'plan_approval_required';
featureId: string; featureId: string;
projectPath?: string; projectPath?: string;
branchName?: string | null;
planContent: string; planContent: string;
planningMode: 'lite' | 'spec' | 'full'; planningMode: 'lite' | 'spec' | 'full';
planVersion?: number; planVersion?: number;
@@ -271,6 +276,7 @@ export type AutoModeEvent =
type: 'plan_auto_approved'; type: 'plan_auto_approved';
featureId: string; featureId: string;
projectPath?: string; projectPath?: string;
branchName?: string | null;
planContent: string; planContent: string;
planningMode: 'lite' | 'spec' | 'full'; planningMode: 'lite' | 'spec' | 'full';
} }
@@ -278,6 +284,7 @@ export type AutoModeEvent =
type: 'plan_approved'; type: 'plan_approved';
featureId: string; featureId: string;
projectPath?: string; projectPath?: string;
branchName?: string | null;
hasEdits: boolean; hasEdits: boolean;
planVersion?: number; planVersion?: number;
} }
@@ -285,12 +292,14 @@ export type AutoModeEvent =
type: 'plan_rejected'; type: 'plan_rejected';
featureId: string; featureId: string;
projectPath?: string; projectPath?: string;
branchName?: string | null;
feedback?: string; feedback?: string;
} }
| { | {
type: 'plan_revision_requested'; type: 'plan_revision_requested';
featureId: string; featureId: string;
projectPath?: string; projectPath?: string;
branchName?: string | null;
feedback?: string; feedback?: string;
hasEdits?: boolean; hasEdits?: boolean;
planVersion?: number; planVersion?: number;
@@ -298,6 +307,7 @@ export type AutoModeEvent =
| { | {
type: 'planning_started'; type: 'planning_started';
featureId: string; featureId: string;
branchName?: string | null;
mode: 'lite' | 'spec' | 'full'; mode: 'lite' | 'spec' | 'full';
message: string; message: string;
} }
@@ -718,18 +728,25 @@ export interface FileDiffResult {
} }
export interface WorktreeAPI { export interface WorktreeAPI {
// Merge worktree branch into main and clean up // Merge worktree branch into a target branch (defaults to 'main') and optionally clean up
mergeFeature: ( mergeFeature: (
projectPath: string, projectPath: string,
branchName: string, branchName: string,
worktreePath: string, worktreePath: string,
targetBranch?: string,
options?: { options?: {
squash?: boolean; squash?: boolean;
message?: string; message?: string;
deleteWorktreeAndBranch?: boolean;
} }
) => Promise<{ ) => Promise<{
success: boolean; success: boolean;
mergedBranch?: string; mergedBranch?: string;
targetBranch?: string;
deleted?: {
worktreeDeleted: boolean;
branchDeleted: boolean;
};
error?: string; error?: string;
}>; }>;
@@ -839,7 +856,8 @@ export interface WorktreeAPI {
// Push a worktree branch to remote // Push a worktree branch to remote
push: ( push: (
worktreePath: string, worktreePath: string,
force?: boolean force?: boolean,
remote?: string
) => Promise<{ ) => Promise<{
success: boolean; success: boolean;
result?: { result?: {
@@ -932,6 +950,7 @@ export interface WorktreeAPI {
}>; }>;
aheadCount: number; aheadCount: number;
behindCount: number; behindCount: number;
hasRemoteBranch: boolean;
}; };
error?: string; error?: string;
code?: 'NOT_GIT_REPO' | 'NO_COMMITS'; // Error codes for git status issues code?: 'NOT_GIT_REPO' | 'NO_COMMITS'; // Error codes for git status issues
@@ -952,6 +971,23 @@ export interface WorktreeAPI {
code?: 'NOT_GIT_REPO' | 'NO_COMMITS' | 'UNCOMMITTED_CHANGES'; code?: 'NOT_GIT_REPO' | 'NO_COMMITS' | 'UNCOMMITTED_CHANGES';
}>; }>;
// List all remotes and their branches
listRemotes: (worktreePath: string) => Promise<{
success: boolean;
result?: {
remotes: Array<{
name: string;
url: string;
branches: Array<{
name: string;
fullRef: string;
}>;
}>;
};
error?: string;
code?: 'NOT_GIT_REPO' | 'NO_COMMITS';
}>;
// Open a worktree directory in the editor // Open a worktree directory in the editor
openInEditor: ( openInEditor: (
worktreePath: string, worktreePath: string,

View File

@@ -0,0 +1,162 @@
/**
* List View Priority Column E2E Test
*
* Verifies that the list view shows a priority column and allows sorting by priority
*/
import { test, expect } from '@playwright/test';
import * as fs from 'fs';
import * as path from 'path';
import {
createTempDirPath,
cleanupTempDir,
setupRealProject,
waitForNetworkIdle,
authenticateForTests,
handleLoginScreenIfPresent,
} from '../utils';
const TEST_TEMP_DIR = createTempDirPath('list-view-priority-test');
test.describe('List View Priority Column', () => {
let projectPath: string;
const projectName = `test-project-${Date.now()}`;
test.beforeAll(async () => {
if (!fs.existsSync(TEST_TEMP_DIR)) {
fs.mkdirSync(TEST_TEMP_DIR, { recursive: true });
}
projectPath = path.join(TEST_TEMP_DIR, projectName);
fs.mkdirSync(projectPath, { recursive: true });
fs.writeFileSync(
path.join(projectPath, 'package.json'),
JSON.stringify({ name: projectName, version: '1.0.0' }, null, 2)
);
const automakerDir = path.join(projectPath, '.automaker');
fs.mkdirSync(automakerDir, { recursive: true });
const featuresDir = path.join(automakerDir, 'features');
fs.mkdirSync(featuresDir, { recursive: true });
fs.mkdirSync(path.join(automakerDir, 'context'), { recursive: true });
// Create test features with different priorities
const features = [
{
id: 'feature-high-priority',
description: 'High priority feature',
priority: 1,
status: 'backlog',
category: 'test',
createdAt: new Date().toISOString(),
},
{
id: 'feature-medium-priority',
description: 'Medium priority feature',
priority: 2,
status: 'backlog',
category: 'test',
createdAt: new Date().toISOString(),
},
{
id: 'feature-low-priority',
description: 'Low priority feature',
priority: 3,
status: 'backlog',
category: 'test',
createdAt: new Date().toISOString(),
},
];
// Write each feature to its own directory
for (const feature of features) {
const featureDir = path.join(featuresDir, feature.id);
fs.mkdirSync(featureDir, { recursive: true });
fs.writeFileSync(path.join(featureDir, 'feature.json'), JSON.stringify(feature, null, 2));
}
fs.writeFileSync(
path.join(automakerDir, 'categories.json'),
JSON.stringify({ categories: ['test'] }, null, 2)
);
fs.writeFileSync(
path.join(automakerDir, 'app_spec.txt'),
`# ${projectName}\n\nA test project for e2e testing.`
);
});
test.afterAll(async () => {
cleanupTempDir(TEST_TEMP_DIR);
});
test('should display priority column in list view and allow sorting', async ({ page }) => {
await setupRealProject(page, projectPath, projectName, { setAsCurrent: true });
// Authenticate before navigating
await authenticateForTests(page);
await page.goto('/board');
await page.waitForLoadState('load');
await handleLoginScreenIfPresent(page);
await waitForNetworkIdle(page);
await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 10000 });
// Switch to list view
await page.click('[data-testid="view-toggle-list"]');
await page.waitForTimeout(500);
// Verify list view is active
await expect(page.locator('[data-testid="list-view"]')).toBeVisible({ timeout: 5000 });
// Verify priority column header exists
await expect(page.locator('[data-testid="list-header-priority"]')).toBeVisible();
await expect(page.locator('[data-testid="list-header-priority"]')).toContainText('Priority');
// Verify priority cells are displayed for our test features
await expect(
page.locator('[data-testid="list-row-priority-feature-high-priority"]')
).toBeVisible();
await expect(
page.locator('[data-testid="list-row-priority-feature-medium-priority"]')
).toBeVisible();
await expect(
page.locator('[data-testid="list-row-priority-feature-low-priority"]')
).toBeVisible();
// Verify priority badges show H, M, L
const highPriorityCell = page.locator(
'[data-testid="list-row-priority-feature-high-priority"]'
);
const mediumPriorityCell = page.locator(
'[data-testid="list-row-priority-feature-medium-priority"]'
);
const lowPriorityCell = page.locator('[data-testid="list-row-priority-feature-low-priority"]');
await expect(highPriorityCell).toContainText('H');
await expect(mediumPriorityCell).toContainText('M');
await expect(lowPriorityCell).toContainText('L');
// Click on priority header to sort
await page.click('[data-testid="list-header-priority"]');
await page.waitForTimeout(300);
// Get all rows within the backlog group and verify they are sorted by priority
// (High priority first when sorted ascending by priority value 1, 2, 3)
const backlogGroup = page.locator('[data-testid="list-group-backlog"]');
const rows = backlogGroup.locator('[data-testid^="list-row-feature-"]');
// The first row should be high priority (value 1 = lowest number = first in ascending)
const firstRow = rows.first();
await expect(firstRow).toHaveAttribute('data-testid', 'list-row-feature-high-priority');
// Click again to reverse sort (descending - low priority first)
await page.click('[data-testid="list-header-priority"]');
await page.waitForTimeout(300);
// Now the first row should be low priority (value 3 = highest number = first in descending)
const firstRowDesc = rows.first();
await expect(firstRowDesc).toHaveAttribute('data-testid', 'list-row-feature-low-priority');
});
});

View File

@@ -339,7 +339,7 @@ IMPORTANT CONTEXT (automatically injected):
- When deleting a feature, identify which other features depend on it - When deleting a feature, identify which other features depend on it
Your task is to analyze the request and produce a structured JSON plan with: Your task is to analyze the request and produce a structured JSON plan with:
1. Features to ADD (include title, description, category, and dependencies) 1. Features to ADD (include id, title, description, category, and dependencies)
2. Features to UPDATE (specify featureId and the updates) 2. Features to UPDATE (specify featureId and the updates)
3. Features to DELETE (specify featureId) 3. Features to DELETE (specify featureId)
4. A summary of the changes 4. A summary of the changes
@@ -352,6 +352,7 @@ Respond with ONLY a JSON object in this exact format:
{ {
"type": "add", "type": "add",
"feature": { "feature": {
"id": "descriptive-kebab-case-id",
"title": "Feature title", "title": "Feature title",
"description": "Feature description", "description": "Feature description",
"category": "feature" | "bug" | "enhancement" | "refactor", "category": "feature" | "bug" | "enhancement" | "refactor",
@@ -386,6 +387,8 @@ Respond with ONLY a JSON object in this exact format:
\`\`\` \`\`\`
Important rules: Important rules:
- CRITICAL: For new features, always include a descriptive "id" in kebab-case (e.g., "user-authentication", "design-system-foundation")
- Dependencies must reference these exact IDs - both for existing features and new features being added in the same plan
- Only include fields that need to change in updates - Only include fields that need to change in updates
- Ensure dependency references are valid (don't reference deleted features) - Ensure dependency references are valid (don't reference deleted features)
- Provide clear, actionable descriptions - Provide clear, actionable descriptions

View File

@@ -802,6 +802,18 @@ export interface GlobalSettings {
* When set, the corresponding profile's settings will be used for Claude API calls * When set, the corresponding profile's settings will be used for Claude API calls
*/ */
activeClaudeApiProfileId?: string | null; activeClaudeApiProfileId?: string | null;
/**
* Per-worktree auto mode settings
* Key: "${projectId}::${branchName ?? '__main__'}"
*/
autoModeByWorktree?: Record<
string,
{
maxConcurrency: number;
branchName: string | null;
}
>;
} }
/** /**
@@ -1071,6 +1083,7 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
subagentsSources: ['user', 'project'], subagentsSources: ['user', 'project'],
claudeApiProfiles: [], claudeApiProfiles: [],
activeClaudeApiProfileId: null, activeClaudeApiProfileId: null,
autoModeByWorktree: {},
}; };
/** Default credentials (empty strings - user must provide API keys) */ /** Default credentials (empty strings - user must provide API keys) */

View File

@@ -9,7 +9,7 @@ set -e
# ============================================================================ # ============================================================================
# CONFIGURATION & CONSTANTS # CONFIGURATION & CONSTANTS
# ============================================================================ # ============================================================================
export $(grep -v '^#' .env | xargs)
APP_NAME="Automaker" APP_NAME="Automaker"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
HISTORY_FILE="${HOME}/.automaker_launcher_history" HISTORY_FILE="${HOME}/.automaker_launcher_history"
@@ -579,7 +579,7 @@ validate_terminal_size() {
echo "${C_YELLOW}${RESET} Terminal size ${term_width}x${term_height} is smaller than recommended ${MIN_TERM_WIDTH}x${MIN_TERM_HEIGHT}" echo "${C_YELLOW}${RESET} Terminal size ${term_width}x${term_height} is smaller than recommended ${MIN_TERM_WIDTH}x${MIN_TERM_HEIGHT}"
echo " Some elements may not display correctly." echo " Some elements may not display correctly."
echo "" echo ""
return 1 return 0
fi fi
} }
@@ -1154,6 +1154,7 @@ fi
# Execute the appropriate command # Execute the appropriate command
case $MODE in case $MODE in
web) web)
export $(grep -v '^#' .env | xargs)
export TEST_PORT="$WEB_PORT" export TEST_PORT="$WEB_PORT"
export VITE_SERVER_URL="http://${APP_HOST}:$SERVER_PORT" export VITE_SERVER_URL="http://${APP_HOST}:$SERVER_PORT"
export PORT="$SERVER_PORT" export PORT="$SERVER_PORT"