mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
apply the patches
This commit is contained in:
@@ -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 () => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
127
apps/server/src/routes/worktree/routes/list-remotes.ts
Normal file
127
apps/server/src/routes/worktree/routes/list-remotes.ts
Normal 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) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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) });
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
40
apps/ui/src/types/electron.d.ts
vendored
40
apps/ui/src/types/electron.d.ts
vendored
@@ -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,
|
||||||
|
|||||||
162
apps/ui/tests/features/list-view-priority.spec.ts
Normal file
162
apps/ui/tests/features/list-view-priority.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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) */
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user