refactor: Improve all git operations, add stash support, add improved pull request flow, add worktree file copy options, address code review comments, add cherry pick options

This commit is contained in:
gsxdsm
2026-02-17 22:02:58 -08:00
parent f4e87d4c25
commit 9af63bc1ef
89 changed files with 6811 additions and 351 deletions

View File

@@ -69,6 +69,7 @@ import { CodexModelCacheService } from './services/codex-model-cache-service.js'
import { createZaiRoutes } from './routes/zai/index.js';
import { ZaiUsageService } from './services/zai-usage-service.js';
import { createGeminiRoutes } from './routes/gemini/index.js';
import { GeminiUsageService } from './services/gemini-usage-service.js';
import { createGitHubRoutes } from './routes/github/index.js';
import { createContextRoutes } from './routes/context/index.js';
import { createBacklogPlanRoutes } from './routes/backlog-plan/index.js';
@@ -332,6 +333,7 @@ const codexAppServerService = new CodexAppServerService();
const codexModelCacheService = new CodexModelCacheService(DATA_DIR, codexAppServerService);
const codexUsageService = new CodexUsageService(codexAppServerService);
const zaiUsageService = new ZaiUsageService();
const geminiUsageService = new GeminiUsageService();
const mcpTestService = new MCPTestService(settingsService);
const ideationService = new IdeationService(events, settingsService, featureLoader);
@@ -494,7 +496,7 @@ app.use('/api/settings', createSettingsRoutes(settingsService));
app.use('/api/claude', createClaudeRoutes(claudeUsageService));
app.use('/api/codex', createCodexRoutes(codexUsageService, codexModelCacheService));
app.use('/api/zai', createZaiRoutes(zaiUsageService, settingsService));
app.use('/api/gemini', createGeminiRoutes());
app.use('/api/gemini', createGeminiRoutes(geminiUsageService, events));
app.use('/api/github', createGitHubRoutes(events, settingsService));
app.use('/api/context', createContextRoutes(settingsService));
app.use('/api/backlog-plan', createBacklogPlanRoutes(events, settingsService));

View File

@@ -788,7 +788,7 @@ export class CodexProvider extends BaseProvider {
overrides.push({ key: 'features.web_search_request', value: true });
}
buildConfigOverrides(overrides);
const configOverrideArgs = buildConfigOverrides(overrides);
const preExecArgs: string[] = [];
// Add additional directories with write access
@@ -807,6 +807,7 @@ export class CodexProvider extends BaseProvider {
CODEX_MODEL_FLAG,
options.model,
CODEX_JSON_FLAG,
...configOverrideArgs,
'-', // Read prompt from stdin to avoid shell escaping issues
];

View File

@@ -31,7 +31,7 @@ import type {
} from './types.js';
import { validateBareModelId } from '@automaker/types';
import { validateApiKey } from '../lib/auth-utils.js';
import { getEffectivePermissions } from '../services/cursor-config-service.js';
import { getEffectivePermissions, detectProfile } from '../services/cursor-config-service.js';
import {
type CursorStreamEvent,
type CursorSystemEvent,
@@ -878,8 +878,12 @@ export class CursorProvider extends CliProvider {
logger.debug(`CursorProvider.executeQuery called with model: "${options.model}"`);
// Get effective permissions for this project
await getEffectivePermissions(options.cwd || process.cwd());
// Get effective permissions for this project and detect the active profile
const effectivePermissions = await getEffectivePermissions(options.cwd || process.cwd());
const activeProfile = detectProfile(effectivePermissions);
logger.debug(
`Active permission profile: ${activeProfile ?? 'none'}, permissions: ${JSON.stringify(effectivePermissions)}`
);
// Debug: log raw events when AUTOMAKER_DEBUG_RAW_OUTPUT is enabled
const debugRawEvents =

View File

@@ -58,6 +58,9 @@ export function createApplyHandler() {
if (feature.dependencies?.includes(change.featureId)) {
const newDeps = feature.dependencies.filter((d) => d !== change.featureId);
await featureLoader.update(projectPath, feature.id, { dependencies: newDeps });
// Mutate the in-memory feature object so subsequent deletions use the updated
// dependency list and don't reintroduce already-removed dependency IDs.
feature.dependencies = newDeps;
logger.info(
`[BacklogPlan] Removed dependency ${change.featureId} from ${feature.id}`
);

View File

@@ -19,6 +19,7 @@ import { createBrowseHandler } from './routes/browse.js';
import { createImageHandler } from './routes/image.js';
import { createSaveBoardBackgroundHandler } from './routes/save-board-background.js';
import { createDeleteBoardBackgroundHandler } from './routes/delete-board-background.js';
import { createBrowseProjectFilesHandler } from './routes/browse-project-files.js';
export function createFsRoutes(_events: EventEmitter): Router {
const router = Router();
@@ -37,6 +38,7 @@ export function createFsRoutes(_events: EventEmitter): Router {
router.get('/image', createImageHandler());
router.post('/save-board-background', createSaveBoardBackgroundHandler());
router.post('/delete-board-background', createDeleteBoardBackgroundHandler());
router.post('/browse-project-files', createBrowseProjectFilesHandler());
return router;
}

View File

@@ -0,0 +1,186 @@
/**
* POST /browse-project-files endpoint - Browse files and directories within a project
*
* Unlike /browse which only lists directories (for project folder selection),
* this endpoint lists both files and directories relative to a project root.
* Used by the file selector for "Copy files to worktree" settings.
*
* Features:
* - Lists both files and directories
* - Hides .git, .worktrees, node_modules, and other build artifacts
* - Returns entries relative to the project root
* - Supports navigating into subdirectories
* - Security: prevents path traversal outside project root
*/
import type { Request, Response } from 'express';
import * as secureFs from '../../../lib/secure-fs.js';
import path from 'path';
import { PathNotAllowedError } from '@automaker/platform';
import { getErrorMessage, logError } from '../common.js';
// Directories to hide from the listing (build artifacts, caches, etc.)
const HIDDEN_DIRECTORIES = new Set([
'.git',
'.worktrees',
'node_modules',
'.automaker',
'__pycache__',
'.cache',
'.next',
'.nuxt',
'.svelte-kit',
'.turbo',
'.vercel',
'.output',
'coverage',
'.nyc_output',
'dist',
'build',
'out',
'.tmp',
'tmp',
'.venv',
'venv',
'target',
'vendor',
'.gradle',
'.idea',
'.vscode',
]);
interface ProjectFileEntry {
name: string;
relativePath: string;
isDirectory: boolean;
isFile: boolean;
}
export function createBrowseProjectFilesHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, relativePath } = req.body as {
projectPath: string;
relativePath?: string; // Relative path within the project to browse (empty = project root)
};
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
const resolvedProjectPath = path.resolve(projectPath);
// Determine the target directory to browse
let targetPath = resolvedProjectPath;
let currentRelativePath = '';
if (relativePath) {
// Security: normalize and validate the relative path
const normalized = path.normalize(relativePath);
if (normalized.startsWith('..') || path.isAbsolute(normalized)) {
res.status(400).json({
success: false,
error: 'Invalid relative path - must be within the project directory',
});
return;
}
targetPath = path.join(resolvedProjectPath, normalized);
currentRelativePath = normalized;
// Double-check the resolved path is within the project
const resolvedTarget = path.resolve(targetPath);
if (!resolvedTarget.startsWith(resolvedProjectPath)) {
res.status(400).json({
success: false,
error: 'Path traversal detected',
});
return;
}
}
// Determine parent relative path
let parentRelativePath: string | null = null;
if (currentRelativePath) {
const parent = path.dirname(currentRelativePath);
parentRelativePath = parent === '.' ? '' : parent;
}
try {
const stat = await secureFs.stat(targetPath);
if (!stat.isDirectory()) {
res.status(400).json({ success: false, error: 'Path is not a directory' });
return;
}
// Read directory contents
const dirEntries = await secureFs.readdir(targetPath, { withFileTypes: true });
// Filter and map entries
const entries: ProjectFileEntry[] = dirEntries
.filter((entry) => {
// Skip hidden directories (build artifacts, etc.)
if (entry.isDirectory() && HIDDEN_DIRECTORIES.has(entry.name)) {
return false;
}
// Skip entries starting with . (hidden files) except common config files
// We keep hidden files visible since users often need .env, .eslintrc, etc.
return true;
})
.map((entry) => {
const entryRelativePath = currentRelativePath
? `${currentRelativePath}/${entry.name}`
: entry.name;
return {
name: entry.name,
relativePath: entryRelativePath,
isDirectory: entry.isDirectory(),
isFile: entry.isFile(),
};
})
// Sort: directories first, then files, alphabetically within each group
.sort((a, b) => {
if (a.isDirectory !== b.isDirectory) {
return a.isDirectory ? -1 : 1;
}
return a.name.localeCompare(b.name);
});
res.json({
success: true,
currentRelativePath,
parentRelativePath,
entries,
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to read directory';
const isPermissionError = errorMessage.includes('EPERM') || errorMessage.includes('EACCES');
if (isPermissionError) {
res.json({
success: true,
currentRelativePath,
parentRelativePath,
entries: [],
warning: 'Permission denied - unable to read this directory',
});
} else {
res.status(400).json({
success: false,
error: errorMessage,
});
}
}
} catch (error) {
if (error instanceof PathNotAllowedError) {
res.status(403).json({ success: false, error: getErrorMessage(error) });
return;
}
logError(error, 'Browse project files failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -1,17 +1,20 @@
import { Router, Request, Response } from 'express';
import { GeminiProvider } from '../../providers/gemini-provider.js';
import { getGeminiUsageService } from '../../services/gemini-usage-service.js';
import { GeminiUsageService } from '../../services/gemini-usage-service.js';
import { createLogger } from '@automaker/utils';
import type { EventEmitter } from '../../lib/events.js';
const logger = createLogger('Gemini');
export function createGeminiRoutes(): Router {
export function createGeminiRoutes(
usageService: GeminiUsageService,
_events: EventEmitter
): Router {
const router = Router();
// Get current usage/quota data from Google Cloud API
router.get('/usage', async (_req: Request, res: Response) => {
try {
const usageService = getGeminiUsageService();
const usageData = await usageService.fetchUsageData();
res.json(usageData);

View File

@@ -110,6 +110,7 @@ export function createVerifyClaudeAuthHandler() {
let authenticated = false;
let errorMessage = '';
let receivedAnyContent = false;
let cleanupEnv: (() => void) | undefined;
// Create secure auth session
const sessionId = `claude-auth-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
@@ -151,7 +152,7 @@ export function createVerifyClaudeAuthHandler() {
AuthSessionManager.createSession(sessionId, authMethod || 'api_key', apiKey, 'anthropic');
// Create temporary environment override for SDK call
const _cleanupEnv = createTempEnvOverride(authEnv);
cleanupEnv = createTempEnvOverride(authEnv);
// Run a minimal query to verify authentication
const stream = query({
@@ -313,6 +314,8 @@ export function createVerifyClaudeAuthHandler() {
}
} finally {
clearTimeout(timeoutId);
// Restore process.env to its original state
cleanupEnv?.();
// Clean up the auth session
AuthSessionManager.destroySession(sessionId);
}

View File

@@ -51,9 +51,17 @@ import {
createDeleteInitScriptHandler,
createRunInitScriptHandler,
} from './routes/init-script.js';
import { createCommitLogHandler } from './routes/commit-log.js';
import { createDiscardChangesHandler } from './routes/discard-changes.js';
import { createListRemotesHandler } from './routes/list-remotes.js';
import { createAddRemoteHandler } from './routes/add-remote.js';
import { createStashPushHandler } from './routes/stash-push.js';
import { createStashListHandler } from './routes/stash-list.js';
import { createStashApplyHandler } from './routes/stash-apply.js';
import { createStashDropHandler } from './routes/stash-drop.js';
import { createCherryPickHandler } from './routes/cherry-pick.js';
import { createBranchCommitLogHandler } from './routes/branch-commit-log.js';
import { createGeneratePRDescriptionHandler } from './routes/generate-pr-description.js';
import type { SettingsService } from '../../services/settings-service.js';
export function createWorktreeRoutes(
@@ -73,7 +81,11 @@ export function createWorktreeRoutes(
requireValidProject,
createMergeHandler()
);
router.post('/create', validatePathParams('projectPath'), createCreateHandler(events));
router.post(
'/create',
validatePathParams('projectPath'),
createCreateHandler(events, settingsService)
);
router.post('/delete', validatePathParams('projectPath', 'worktreePath'), createDeleteHandler());
router.post('/create-pr', createCreatePRHandler());
router.post('/pr-info', createPRInfoHandler());
@@ -192,5 +204,63 @@ export function createWorktreeRoutes(
createAddRemoteHandler()
);
// Commit log route
router.post(
'/commit-log',
validatePathParams('worktreePath'),
requireValidWorktree,
createCommitLogHandler()
);
// Stash routes
router.post(
'/stash-push',
validatePathParams('worktreePath'),
requireGitRepoOnly,
createStashPushHandler()
);
router.post(
'/stash-list',
validatePathParams('worktreePath'),
requireGitRepoOnly,
createStashListHandler()
);
router.post(
'/stash-apply',
validatePathParams('worktreePath'),
requireGitRepoOnly,
createStashApplyHandler()
);
router.post(
'/stash-drop',
validatePathParams('worktreePath'),
requireGitRepoOnly,
createStashDropHandler()
);
// Cherry-pick route
router.post(
'/cherry-pick',
validatePathParams('worktreePath'),
requireValidWorktree,
createCherryPickHandler()
);
// Generate PR description route
router.post(
'/generate-pr-description',
validatePathParams('worktreePath'),
requireGitRepoOnly,
createGeneratePRDescriptionHandler(settingsService)
);
// Branch commit log route (get commits from a specific branch)
router.post(
'/branch-commit-log',
validatePathParams('worktreePath'),
requireValidWorktree,
createBranchCommitLogHandler()
);
return router;
}

View File

@@ -0,0 +1,123 @@
/**
* POST /branch-commit-log endpoint - Get recent commit history for a specific branch
*
* Similar to commit-log but allows specifying a branch name to get commits from
* any branch, not just the currently checked out one. Useful for cherry-pick workflows
* where you need to browse commits from other branches.
*
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
* the requireValidWorktree middleware in index.ts
*/
import type { Request, Response } from 'express';
import { execGitCommand, getErrorMessage, logError } from '../common.js';
export function createBranchCommitLogHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const {
worktreePath,
branchName,
limit = 20,
} = req.body as {
worktreePath: string;
branchName?: string;
limit?: number;
};
if (!worktreePath) {
res.status(400).json({
success: false,
error: 'worktreePath required',
});
return;
}
// Clamp limit to a reasonable range
const commitLimit = Math.min(Math.max(1, Number(limit) || 20), 100);
// Use the specified branch or default to HEAD
const targetRef = branchName || 'HEAD';
// Get detailed commit log for the specified branch
const logOutput = await execGitCommand(
[
'log',
targetRef,
`--max-count=${commitLimit}`,
'--format=%H%n%h%n%an%n%ae%n%aI%n%s%n%b%n---END---',
],
worktreePath
);
// Parse the output into structured commit objects
const commits: Array<{
hash: string;
shortHash: string;
author: string;
authorEmail: string;
date: string;
subject: string;
body: string;
files: string[];
}> = [];
const commitBlocks = logOutput.split('---END---\n').filter((block) => block.trim());
for (const block of commitBlocks) {
const lines = block.split('\n');
if (lines.length >= 6) {
const hash = lines[0].trim();
// Get list of files changed in this commit
let files: string[] = [];
try {
const filesOutput = await execGitCommand(
['diff-tree', '--no-commit-id', '--name-only', '-r', hash],
worktreePath
);
files = filesOutput
.trim()
.split('\n')
.filter((f) => f.trim());
} catch {
// Ignore errors getting file list
}
commits.push({
hash,
shortHash: lines[1].trim(),
author: lines[2].trim(),
authorEmail: lines[3].trim(),
date: lines[4].trim(),
subject: lines[5].trim(),
body: lines.slice(6).join('\n').trim(),
files,
});
}
}
// If branchName wasn't specified, get current branch for display
let displayBranch = branchName;
if (!displayBranch) {
const branchOutput = await execGitCommand(
['rev-parse', '--abbrev-ref', 'HEAD'],
worktreePath
);
displayBranch = branchOutput.trim();
}
res.json({
success: true,
result: {
branch: displayBranch,
commits,
total: commits.length,
},
});
} catch (error) {
logError(error, 'Get branch commit log failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -15,9 +15,10 @@ import { getErrorMessage, logError, isValidBranchName, execGitCommand } from '..
export function createCheckoutBranchHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath, branchName } = req.body as {
const { worktreePath, branchName, baseBranch } = req.body as {
worktreePath: string;
branchName: string;
baseBranch?: string; // Optional base branch to create from (defaults to current HEAD)
};
if (!worktreePath) {
@@ -46,6 +47,16 @@ export function createCheckoutBranchHandler() {
return;
}
// Validate base branch if provided
if (baseBranch && !isValidBranchName(baseBranch) && baseBranch !== 'HEAD') {
res.status(400).json({
success: false,
error:
'Invalid base branch name. Must contain only letters, numbers, dots, dashes, underscores, or slashes.',
});
return;
}
// Resolve and validate worktreePath to prevent traversal attacks.
// The validatePathParams middleware checks against ALLOWED_ROOT_DIRECTORY,
// but we also resolve the path and verify it exists as a directory.
@@ -88,7 +99,12 @@ export function createCheckoutBranchHandler() {
}
// Create and checkout the new branch (using argument array to avoid shell injection)
await execGitCommand(['checkout', '-b', branchName], resolvedPath);
// If baseBranch is provided, create the branch from that starting point
const checkoutArgs = ['checkout', '-b', branchName];
if (baseBranch) {
checkoutArgs.push(baseBranch);
}
await execGitCommand(checkoutArgs, resolvedPath);
res.json({
success: true,

View File

@@ -0,0 +1,128 @@
/**
* POST /cherry-pick endpoint - Cherry-pick one or more commits into the current branch
*
* Applies commits from another branch onto the current branch.
* Supports single or multiple commit cherry-picks.
*
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
* the requireValidWorktree middleware in index.ts
*/
import type { Request, Response } from 'express';
import { execGitCommand, getErrorMessage, logError } from '../common.js';
import { createLogger } from '@automaker/utils';
const logger = createLogger('Worktree');
export function createCherryPickHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath, commitHashes, options } = req.body as {
worktreePath: string;
commitHashes: string[];
options?: {
noCommit?: boolean;
};
};
if (!worktreePath) {
res.status(400).json({
success: false,
error: 'worktreePath is required',
});
return;
}
if (!commitHashes || !Array.isArray(commitHashes) || commitHashes.length === 0) {
res.status(400).json({
success: false,
error: 'commitHashes array is required and must contain at least one commit hash',
});
return;
}
// Validate each commit hash format (should be hex string)
for (const hash of commitHashes) {
if (!/^[a-fA-F0-9]+$/.test(hash)) {
res.status(400).json({
success: false,
error: `Invalid commit hash format: "${hash}"`,
});
return;
}
}
// Verify each commit exists
for (const hash of commitHashes) {
try {
await execGitCommand(['rev-parse', '--verify', hash], worktreePath);
} catch {
res.status(400).json({
success: false,
error: `Commit "${hash}" does not exist`,
});
return;
}
}
// Build cherry-pick command args
const args = ['cherry-pick'];
if (options?.noCommit) {
args.push('--no-commit');
}
// Add commit hashes in order
args.push(...commitHashes);
// Execute the cherry-pick
try {
await execGitCommand(args, worktreePath);
// Get current branch name
const branchOutput = await execGitCommand(
['rev-parse', '--abbrev-ref', 'HEAD'],
worktreePath
);
res.json({
success: true,
result: {
cherryPicked: true,
commitHashes,
branch: branchOutput.trim(),
message: `Successfully cherry-picked ${commitHashes.length} commit(s)`,
},
});
} catch (cherryPickError: unknown) {
// Check if this is a cherry-pick conflict
const err = cherryPickError as { stdout?: string; stderr?: string; message?: string };
const output = `${err.stdout || ''} ${err.stderr || ''} ${err.message || ''}`;
const hasConflicts =
output.includes('CONFLICT') ||
output.includes('cherry-pick failed') ||
output.includes('could not apply');
if (hasConflicts) {
// Abort the cherry-pick to leave the repo in a clean state
try {
await execGitCommand(['cherry-pick', '--abort'], worktreePath);
} catch {
logger.warn('Failed to abort cherry-pick after conflict');
}
res.status(409).json({
success: false,
error: `Cherry-pick CONFLICT: Could not apply commit(s) cleanly. Conflicts need to be resolved manually.`,
hasConflicts: true,
});
return;
}
// Re-throw non-conflict errors
throw cherryPickError;
}
} catch (error) {
logError(error, 'Cherry-pick failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,112 @@
/**
* POST /commit-log endpoint - Get recent commit history for a worktree
*
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
* the requireValidWorktree middleware in index.ts
*/
import type { Request, Response } from 'express';
import { execGitCommand, getErrorMessage, logError } from '../common.js';
export function createCommitLogHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath, limit = 20 } = req.body as {
worktreePath: string;
limit?: number;
};
if (!worktreePath) {
res.status(400).json({
success: false,
error: 'worktreePath required',
});
return;
}
// Clamp limit to a reasonable range
const commitLimit = Math.min(Math.max(1, Number(limit) || 20), 100);
// Get detailed commit log using the secure execGitCommand helper
const logOutput = await execGitCommand(
['log', `--max-count=${commitLimit}`, '--format=%H%n%h%n%an%n%ae%n%aI%n%s%n%b%n---END---'],
worktreePath
);
// Parse the output into structured commit objects
const commits: Array<{
hash: string;
shortHash: string;
author: string;
authorEmail: string;
date: string;
subject: string;
body: string;
files: string[];
}> = [];
const commitBlocks = logOutput.split('---END---\n').filter((block) => block.trim());
for (const block of commitBlocks) {
const lines = block.split('\n');
if (lines.length >= 6) {
const hash = lines[0].trim();
// Get list of files changed in this commit
let files: string[] = [];
try {
const filesOutput = await execGitCommand(
// -m causes merge commits to be diffed against each parent,
// showing all files touched by the merge (without -m, diff-tree
// produces no output for merge commits because they have 2+ parents)
['diff-tree', '--no-commit-id', '--name-only', '-r', '-m', hash],
worktreePath
);
// Deduplicate: -m can list the same file multiple times
// (once per parent diff for merge commits)
files = [
...new Set(
filesOutput
.trim()
.split('\n')
.filter((f) => f.trim())
),
];
} catch {
// Ignore errors getting file list
}
commits.push({
hash,
shortHash: lines[1].trim(),
author: lines[2].trim(),
authorEmail: lines[3].trim(),
date: lines[4].trim(),
subject: lines[5].trim(),
body: lines.slice(6).join('\n').trim(),
files,
});
}
}
// Get current branch name
const branchOutput = await execGitCommand(
['rev-parse', '--abbrev-ref', 'HEAD'],
worktreePath
);
const branch = branchOutput.trim();
res.json({
success: true,
result: {
branch,
commits,
total: commits.length,
},
});
} catch (error) {
logError(error, 'Get commit log failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -6,11 +6,12 @@
*/
import type { Request, Response } from 'express';
import { exec } from 'child_process';
import { exec, execFile } from 'child_process';
import { promisify } from 'util';
import { getErrorMessage, logError } from '../common.js';
const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
export function createCommitHandler() {
return async (req: Request, res: Response): Promise<void> => {
@@ -48,19 +49,18 @@ export function createCommitHandler() {
// Stage changes - either specific files or all changes
if (files && files.length > 0) {
// Reset any previously staged changes first
await execAsync('git reset HEAD', { cwd: worktreePath }).catch(() => {
await execFileAsync('git', ['reset', 'HEAD'], { cwd: worktreePath }).catch(() => {
// Ignore errors from reset (e.g., if nothing is staged)
});
// Stage only the selected files
const escapedFiles = files.map((f) => `"${f.replace(/"/g, '\\"')}"`).join(' ');
await execAsync(`git add ${escapedFiles}`, { cwd: worktreePath });
// Stage only the selected files (args array avoids shell injection)
await execFileAsync('git', ['add', ...files], { cwd: worktreePath });
} else {
// Stage all changes (original behavior)
await execAsync('git add -A', { cwd: worktreePath });
await execFileAsync('git', ['add', '-A'], { cwd: worktreePath });
}
// Create commit
await execAsync(`git commit -m "${message.replace(/"/g, '\\"')}"`, {
// Create commit (pass message as arg to avoid shell injection)
await execFileAsync('git', ['commit', '-m', message], {
cwd: worktreePath,
});

View File

@@ -20,16 +20,25 @@ const logger = createLogger('CreatePR');
export function createCreatePRHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath, projectPath, commitMessage, prTitle, prBody, baseBranch, draft } =
req.body as {
worktreePath: string;
projectPath?: string;
commitMessage?: string;
prTitle?: string;
prBody?: string;
baseBranch?: string;
draft?: boolean;
};
const {
worktreePath,
projectPath,
commitMessage,
prTitle,
prBody,
baseBranch,
draft,
remote,
} = req.body as {
worktreePath: string;
projectPath?: string;
commitMessage?: string;
prTitle?: string;
prBody?: string;
baseBranch?: string;
draft?: boolean;
remote?: string;
};
if (!worktreePath) {
res.status(400).json({
@@ -110,17 +119,18 @@ export function createCreatePRHandler() {
}
}
// Push the branch to remote
// Push the branch to remote (use selected remote or default to 'origin')
const pushRemote = remote || 'origin';
let pushError: string | null = null;
try {
await execAsync(`git push -u origin ${branchName}`, {
await execAsync(`git push -u ${pushRemote} ${branchName}`, {
cwd: worktreePath,
env: execEnv,
});
} catch {
// If push fails, try with --set-upstream
try {
await execAsync(`git push --set-upstream origin ${branchName}`, {
await execAsync(`git push --set-upstream ${pushRemote} ${branchName}`, {
cwd: worktreePath,
env: execEnv,
});

View File

@@ -11,8 +11,10 @@ import type { Request, Response } from 'express';
import { exec } from 'child_process';
import { promisify } from 'util';
import path from 'path';
import fs from 'fs/promises';
import * as secureFs from '../../../lib/secure-fs.js';
import type { EventEmitter } from '../../../lib/events.js';
import type { SettingsService } from '../../../services/settings-service.js';
import { isGitRepo } from '@automaker/git-utils';
import {
getErrorMessage,
@@ -81,7 +83,66 @@ async function findExistingWorktreeForBranch(
}
}
export function createCreateHandler(events: EventEmitter) {
/**
* Copy configured files from project root into the new worktree.
* Reads worktreeCopyFiles from project settings and copies each file/directory.
* Silently skips files that don't exist in the source.
*/
async function copyConfiguredFiles(
projectPath: string,
worktreePath: string,
settingsService?: SettingsService
): Promise<void> {
if (!settingsService) return;
try {
const projectSettings = await settingsService.getProjectSettings(projectPath);
const copyFiles = projectSettings.worktreeCopyFiles;
if (!copyFiles || copyFiles.length === 0) return;
for (const relativePath of copyFiles) {
// Security: prevent path traversal
const normalized = path.normalize(relativePath);
if (normalized.startsWith('..') || path.isAbsolute(normalized)) {
logger.warn(`Skipping suspicious copy path: ${relativePath}`);
continue;
}
const sourcePath = path.join(projectPath, normalized);
const destPath = path.join(worktreePath, normalized);
try {
// Check if source exists
const stat = await fs.stat(sourcePath);
// Ensure destination directory exists
const destDir = path.dirname(destPath);
await fs.mkdir(destDir, { recursive: true });
if (stat.isDirectory()) {
// Recursively copy directory
await fs.cp(sourcePath, destPath, { recursive: true, force: true });
logger.info(`Copied directory "${normalized}" to worktree`);
} else {
// Copy single file
await fs.copyFile(sourcePath, destPath);
logger.info(`Copied file "${normalized}" to worktree`);
}
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
logger.debug(`Skipping copy of "${normalized}" - file not found in project root`);
} else {
logger.warn(`Failed to copy "${normalized}" to worktree:`, err);
}
}
}
} catch (error) {
logger.warn('Failed to read project settings for file copying:', error);
}
}
export function createCreateHandler(events: EventEmitter, settingsService?: SettingsService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, branchName, baseBranch } = req.body as {
@@ -200,6 +261,10 @@ export function createCreateHandler(events: EventEmitter) {
// normalizePath converts to forward slashes for API consistency
const absoluteWorktreePath = path.resolve(worktreePath);
// Copy configured files into the new worktree before responding
// This runs synchronously to ensure files are in place before any init script
await copyConfiguredFiles(projectPath, absoluteWorktreePath, settingsService);
// Respond immediately (non-blocking)
res.json({
success: true,

View File

@@ -79,10 +79,12 @@ export function createDiscardChangesHandler() {
const branchName = branchOutput.trim();
// Parse the status output to categorize files
// Git --porcelain format: XY PATH where X=index status, Y=worktree status
// Preserve the exact two-character XY status (no trim) to keep index vs worktree info
const statusLines = status.trim().split('\n').filter(Boolean);
const allFiles = statusLines.map((line) => {
const fileStatus = line.substring(0, 2).trim();
const filePath = line.substring(3).trim();
const fileStatus = line.substring(0, 2);
const filePath = line.slice(3).trim();
return { status: fileStatus, path: filePath };
});
@@ -112,18 +114,21 @@ export function createDiscardChangesHandler() {
for (const file of allFiles) {
if (!filesToDiscard.has(file.path)) continue;
if (file.status === '?') {
// file.status is the raw two-character XY git porcelain status (no trim)
// X = index/staging status, Y = worktree status
const xy = file.status.substring(0, 2);
const indexStatus = xy.charAt(0);
const workTreeStatus = xy.charAt(1);
if (indexStatus === '?' && workTreeStatus === '?') {
untrackedFiles.push(file.path);
} else {
// Check if the file has staged changes (first character of status)
const indexStatus = statusLines
.find((l) => l.substring(3).trim() === file.path)
?.charAt(0);
if (indexStatus && indexStatus !== ' ' && indexStatus !== '?') {
// Check if the file has staged changes (index status X)
if (indexStatus !== ' ' && indexStatus !== '?') {
stagedFiles.push(file.path);
}
// Check for working tree changes (tracked files)
if (file.status === 'M' || file.status === 'D' || file.status === 'A') {
// Check for working tree changes (worktree status Y): handles MM, AM, MD, etc.
if (workTreeStatus === 'M' || workTreeStatus === 'D' || workTreeStatus === 'A') {
trackedModified.push(file.path);
}
}

View File

@@ -0,0 +1,410 @@
/**
* POST /worktree/generate-pr-description endpoint - Generate an AI PR description from git diff
*
* Uses the configured model (via phaseModels.commitMessageModel) to generate a pull request
* title and description from the branch's changes compared to the base branch.
* Defaults to Claude Haiku for speed.
*/
import type { Request, Response } from 'express';
import { exec } from 'child_process';
import { promisify } from 'util';
import { existsSync } from 'fs';
import { join } from 'path';
import { createLogger } from '@automaker/utils';
import { isCursorModel, stripProviderPrefix } from '@automaker/types';
import { resolvePhaseModel } from '@automaker/model-resolver';
import { ProviderFactory } from '../../../providers/provider-factory.js';
import type { SettingsService } from '../../../services/settings-service.js';
import { getErrorMessage, logError } from '../common.js';
import { getPhaseModelWithOverrides } from '../../../lib/settings-helpers.js';
const logger = createLogger('GeneratePRDescription');
const execAsync = promisify(exec);
/** Timeout for AI provider calls in milliseconds (30 seconds) */
const AI_TIMEOUT_MS = 30_000;
/** Max diff size to send to AI (characters) */
const MAX_DIFF_SIZE = 15_000;
const PR_DESCRIPTION_SYSTEM_PROMPT = `You are a pull request description generator. Your task is to create a clear, well-structured PR title and description based on the git diff and branch information provided.
Output your response in EXACTLY this format (including the markers):
---TITLE---
<a concise PR title, 50-72 chars, imperative mood>
---BODY---
## Summary
<1-3 bullet points describing the key changes>
## Changes
<Detailed list of what was changed and why>
Rules:
- The title should be concise and descriptive (50-72 characters)
- Use imperative mood for the title (e.g., "Add dark mode toggle" not "Added dark mode toggle")
- The description should explain WHAT changed and WHY
- Group related changes together
- Use markdown formatting for the body
- Do NOT include the branch name in the title
- Focus on the user-facing impact when possible
- If there are breaking changes, mention them prominently
- The diff may include both committed changes and uncommitted working directory changes. Treat all changes as part of the PR since uncommitted changes will be committed when the PR is created
- Do NOT distinguish between committed and uncommitted changes in the output - describe all changes as a unified set of PR changes`;
/**
* Wraps an async generator with a timeout.
*/
async function* withTimeout<T>(
generator: AsyncIterable<T>,
timeoutMs: number
): AsyncGenerator<T, void, unknown> {
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error(`AI provider timed out after ${timeoutMs}ms`)), timeoutMs);
});
const iterator = generator[Symbol.asyncIterator]();
let done = false;
while (!done) {
const result = await Promise.race([iterator.next(), timeoutPromise]);
if (result.done) {
done = true;
} else {
yield result.value;
}
}
}
interface GeneratePRDescriptionRequestBody {
worktreePath: string;
baseBranch?: string;
}
interface GeneratePRDescriptionSuccessResponse {
success: true;
title: string;
body: string;
}
interface GeneratePRDescriptionErrorResponse {
success: false;
error: string;
}
export function createGeneratePRDescriptionHandler(
settingsService?: SettingsService
): (req: Request, res: Response) => Promise<void> {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath, baseBranch } = req.body as GeneratePRDescriptionRequestBody;
if (!worktreePath || typeof worktreePath !== 'string') {
const response: GeneratePRDescriptionErrorResponse = {
success: false,
error: 'worktreePath is required and must be a string',
};
res.status(400).json(response);
return;
}
// Validate that the directory exists
if (!existsSync(worktreePath)) {
const response: GeneratePRDescriptionErrorResponse = {
success: false,
error: 'worktreePath does not exist',
};
res.status(400).json(response);
return;
}
// Validate that it's a git repository
const gitPath = join(worktreePath, '.git');
if (!existsSync(gitPath)) {
const response: GeneratePRDescriptionErrorResponse = {
success: false,
error: 'worktreePath is not a git repository',
};
res.status(400).json(response);
return;
}
logger.info(`Generating PR description for worktree: ${worktreePath}`);
// Get current branch name
const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', {
cwd: worktreePath,
});
const branchName = branchOutput.trim();
// Determine the base branch for comparison
const base = baseBranch || 'main';
// Get the diff between current branch and base branch (committed changes)
// Track whether the diff method used only includes committed changes.
// `git diff base...HEAD` and `git diff origin/base...HEAD` only show committed changes,
// while the fallback methods (`git diff HEAD`, `git diff --cached + git diff`) already
// include uncommitted working directory changes.
let diff = '';
let diffIncludesUncommitted = false;
try {
// First, try to get diff against the base branch
const { stdout: branchDiff } = await execAsync(`git diff ${base}...HEAD`, {
cwd: worktreePath,
maxBuffer: 1024 * 1024 * 5, // 5MB buffer
});
diff = branchDiff;
// git diff base...HEAD only shows committed changes
diffIncludesUncommitted = false;
} catch {
// If branch comparison fails (e.g., base branch doesn't exist locally),
// try fetching and comparing against remote base
try {
const { stdout: remoteDiff } = await execAsync(`git diff origin/${base}...HEAD`, {
cwd: worktreePath,
maxBuffer: 1024 * 1024 * 5,
});
diff = remoteDiff;
// git diff origin/base...HEAD only shows committed changes
diffIncludesUncommitted = false;
} catch {
// Fall back to getting all uncommitted + committed changes
try {
const { stdout: allDiff } = await execAsync('git diff HEAD', {
cwd: worktreePath,
maxBuffer: 1024 * 1024 * 5,
});
diff = allDiff;
// git diff HEAD includes uncommitted changes
diffIncludesUncommitted = true;
} catch {
// Last resort: get staged + unstaged changes
const { stdout: stagedDiff } = await execAsync('git diff --cached', {
cwd: worktreePath,
maxBuffer: 1024 * 1024 * 5,
});
const { stdout: unstagedDiff } = await execAsync('git diff', {
cwd: worktreePath,
maxBuffer: 1024 * 1024 * 5,
});
diff = stagedDiff + unstagedDiff;
// These already include uncommitted changes
diffIncludesUncommitted = true;
}
}
}
// Check for uncommitted changes (staged + unstaged) to include in the description.
// When creating a PR, uncommitted changes will be auto-committed, so they should be
// reflected in the generated description. We only need to fetch uncommitted diffs
// when the primary diff method (base...HEAD) was used, since it only shows committed changes.
let hasUncommittedChanges = false;
try {
const { stdout: statusOutput } = await execAsync('git status --porcelain', {
cwd: worktreePath,
});
hasUncommittedChanges = statusOutput.trim().length > 0;
if (hasUncommittedChanges && !diffIncludesUncommitted) {
logger.info('Uncommitted changes detected, including in PR description context');
let uncommittedDiff = '';
// Get staged changes
try {
const { stdout: stagedDiff } = await execAsync('git diff --cached', {
cwd: worktreePath,
maxBuffer: 1024 * 1024 * 5,
});
if (stagedDiff.trim()) {
uncommittedDiff += stagedDiff;
}
} catch {
// Ignore staged diff errors
}
// Get unstaged changes (tracked files only)
try {
const { stdout: unstagedDiff } = await execAsync('git diff', {
cwd: worktreePath,
maxBuffer: 1024 * 1024 * 5,
});
if (unstagedDiff.trim()) {
uncommittedDiff += unstagedDiff;
}
} catch {
// Ignore unstaged diff errors
}
// Get list of untracked files for context
const untrackedFiles = statusOutput
.split('\n')
.filter((line) => line.startsWith('??'))
.map((line) => line.substring(3).trim());
if (untrackedFiles.length > 0) {
// Add a summary of untracked (new) files as context
uncommittedDiff += `\n# New untracked files:\n${untrackedFiles.map((f) => `# + ${f}`).join('\n')}\n`;
}
// Append uncommitted changes to the committed diff
if (uncommittedDiff.trim()) {
diff = diff + uncommittedDiff;
}
}
} catch {
// Ignore errors checking for uncommitted changes
}
// Also get the commit log for context
let commitLog = '';
try {
const { stdout: logOutput } = await execAsync(
`git log ${base}..HEAD --oneline --no-decorate 2>/dev/null || git log --oneline -10 --no-decorate`,
{
cwd: worktreePath,
maxBuffer: 1024 * 1024,
}
);
commitLog = logOutput.trim();
} catch {
// Ignore commit log errors
}
if (!diff.trim() && !commitLog.trim()) {
const response: GeneratePRDescriptionErrorResponse = {
success: false,
error: 'No changes found to generate a PR description from',
};
res.status(400).json(response);
return;
}
// Truncate diff if too long
const truncatedDiff =
diff.length > MAX_DIFF_SIZE
? diff.substring(0, MAX_DIFF_SIZE) + '\n\n[... diff truncated ...]'
: diff;
// Build the user prompt
let userPrompt = `Generate a pull request title and description for the following changes.\n\nBranch: ${branchName}\nBase Branch: ${base}\n`;
if (commitLog) {
userPrompt += `\nCommit History:\n${commitLog}\n`;
}
if (hasUncommittedChanges) {
userPrompt += `\nNote: This branch has uncommitted changes that will be included in the PR.\n`;
}
if (truncatedDiff) {
userPrompt += `\n\`\`\`diff\n${truncatedDiff}\n\`\`\``;
}
// Get model from phase settings with provider info
const {
phaseModel: phaseModelEntry,
provider: claudeCompatibleProvider,
credentials,
} = await getPhaseModelWithOverrides(
'commitMessageModel',
settingsService,
worktreePath,
'[GeneratePRDescription]'
);
const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry);
logger.info(
`Using model for PR description: ${model}`,
claudeCompatibleProvider ? `via provider: ${claudeCompatibleProvider.name}` : 'direct API'
);
// Get provider for the model type
const aiProvider = ProviderFactory.getProviderForModel(model);
const bareModel = stripProviderPrefix(model);
// For Cursor models, combine prompts
const effectivePrompt = isCursorModel(model)
? `${PR_DESCRIPTION_SYSTEM_PROMPT}\n\n${userPrompt}`
: userPrompt;
const effectiveSystemPrompt = isCursorModel(model) ? undefined : PR_DESCRIPTION_SYSTEM_PROMPT;
logger.info(`Using ${aiProvider.getName()} provider for model: ${model}`);
let responseText = '';
const stream = aiProvider.executeQuery({
prompt: effectivePrompt,
model: bareModel,
cwd: worktreePath,
systemPrompt: effectiveSystemPrompt,
maxTurns: 1,
allowedTools: [],
readOnly: true,
thinkingLevel,
claudeCompatibleProvider,
credentials,
});
// Wrap with timeout
for await (const msg of withTimeout(stream, AI_TIMEOUT_MS)) {
if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text' && block.text) {
responseText += block.text;
}
}
} else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) {
responseText = msg.result;
}
}
const fullResponse = responseText.trim();
if (!fullResponse || fullResponse.length === 0) {
logger.warn('Received empty response from model');
const response: GeneratePRDescriptionErrorResponse = {
success: false,
error: 'Failed to generate PR description - empty response',
};
res.status(500).json(response);
return;
}
// Parse the response to extract title and body
let title = '';
let body = '';
const titleMatch = fullResponse.match(/---TITLE---\s*\n([\s\S]*?)(?=---BODY---|$)/);
const bodyMatch = fullResponse.match(/---BODY---\s*\n([\s\S]*?)$/);
if (titleMatch && bodyMatch) {
title = titleMatch[1].trim();
body = bodyMatch[1].trim();
} else {
// Fallback: treat first line as title, rest as body
const lines = fullResponse.split('\n');
title = lines[0].trim();
body = lines.slice(1).join('\n').trim();
}
// Clean up title - remove any markdown or quotes
title = title.replace(/^#+\s*/, '').replace(/^["']|["']$/g, '');
logger.info(`Generated PR title: ${title.substring(0, 100)}...`);
const response: GeneratePRDescriptionSuccessResponse = {
success: true,
title,
body,
};
res.json(response);
} catch (error) {
logError(error, 'Generate PR description failed');
const response: GeneratePRDescriptionErrorResponse = {
success: false,
error: getErrorMessage(error),
};
res.status(500).json(response);
}
};
}

View File

@@ -6,11 +6,12 @@
*/
import type { Request, Response } from 'express';
import { exec } from 'child_process';
import { exec, execFile } from 'child_process';
import { promisify } from 'util';
import { getErrorMessage, logWorktreeError } from '../common.js';
const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
interface BranchInfo {
name: string;
@@ -131,15 +132,17 @@ export function createListBranchesHandler() {
let hasRemoteBranch = false;
try {
// First check if there's a remote tracking branch
const { stdout: upstreamOutput } = await execAsync(
`git rev-parse --abbrev-ref ${currentBranch}@{upstream}`,
const { stdout: upstreamOutput } = await execFileAsync(
'git',
['rev-parse', '--abbrev-ref', `${currentBranch}@{upstream}`],
{ cwd: worktreePath }
);
if (upstreamOutput.trim()) {
hasRemoteBranch = true;
const { stdout: aheadBehindOutput } = await execAsync(
`git rev-list --left-right --count ${currentBranch}@{upstream}...HEAD`,
const { stdout: aheadBehindOutput } = await execFileAsync(
'git',
['rev-list', '--left-right', '--count', `${currentBranch}@{upstream}...HEAD`],
{ cwd: worktreePath }
);
const [behind, ahead] = aheadBehindOutput.trim().split(/\s+/).map(Number);
@@ -150,8 +153,9 @@ export function createListBranchesHandler() {
// 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}`,
const { stdout: remoteBranchOutput } = await execFileAsync(
'git',
['ls-remote', '--heads', 'origin', currentBranch],
{ cwd: worktreePath, timeout: 5000 }
);
hasRemoteBranch = remoteBranchOutput.trim().length > 0;

View File

@@ -15,8 +15,9 @@ const execAsync = promisify(exec);
export function createPullHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath } = req.body as {
const { worktreePath, remote } = req.body as {
worktreePath: string;
remote?: string;
};
if (!worktreePath) {
@@ -33,8 +34,11 @@ export function createPullHandler() {
});
const branchName = branchOutput.trim();
// Use specified remote or default to 'origin'
const targetRemote = remote || 'origin';
// Fetch latest from remote
await execAsync('git fetch origin', { cwd: worktreePath });
await execAsync(`git fetch ${targetRemote}`, { cwd: worktreePath });
// Check if there are local changes that would be overwritten
const { stdout: status } = await execAsync('git status --porcelain', {
@@ -52,7 +56,7 @@ export function createPullHandler() {
// Pull latest changes
try {
const { stdout: pullOutput } = await execAsync(`git pull origin ${branchName}`, {
const { stdout: pullOutput } = await execAsync(`git pull ${targetRemote} ${branchName}`, {
cwd: worktreePath,
});
@@ -75,7 +79,7 @@ export function createPullHandler() {
if (errorMsg.includes('no tracking information')) {
res.status(400).json({
success: false,
error: `Branch '${branchName}' has no upstream branch. Push it first or set upstream with: git branch --set-upstream-to=origin/${branchName}`,
error: `Branch '${branchName}' has no upstream branch. Push it first or set upstream with: git branch --set-upstream-to=${targetRemote}/${branchName}`,
});
return;
}

View File

@@ -0,0 +1,103 @@
/**
* POST /stash-apply endpoint - Apply or pop a stash in a worktree
*
* Applies a specific stash entry to the working directory.
* Can either "apply" (keep stash) or "pop" (remove stash after applying).
*
* Note: Git repository validation (isGitRepo) is handled by
* the requireGitRepoOnly middleware in index.ts
*/
import type { Request, Response } from 'express';
import { execFile } from 'child_process';
import { promisify } from 'util';
import { getErrorMessage, logError } from '../common.js';
const execFileAsync = promisify(execFile);
export function createStashApplyHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath, stashIndex, pop } = req.body as {
worktreePath: string;
stashIndex: number;
pop?: boolean;
};
if (!worktreePath) {
res.status(400).json({
success: false,
error: 'worktreePath required',
});
return;
}
if (stashIndex === undefined || stashIndex === null) {
res.status(400).json({
success: false,
error: 'stashIndex required',
});
return;
}
const stashRef = `stash@{${stashIndex}}`;
const operation = pop ? 'pop' : 'apply';
try {
const { stdout, stderr } = await execFileAsync('git', ['stash', operation, stashRef], {
cwd: worktreePath,
});
const output = `${stdout}\n${stderr}`;
// Check for conflict markers in the output
if (output.includes('CONFLICT') || output.includes('Merge conflict')) {
res.json({
success: true,
result: {
applied: true,
hasConflicts: true,
operation,
stashIndex,
message: `Stash ${operation === 'pop' ? 'popped' : 'applied'} with conflicts. Please resolve the conflicts.`,
},
});
return;
}
res.json({
success: true,
result: {
applied: true,
hasConflicts: false,
operation,
stashIndex,
message: `Stash ${operation === 'pop' ? 'popped' : 'applied'} successfully`,
},
});
} catch (error) {
const errorMsg = getErrorMessage(error);
// Check if the error is due to conflicts
if (errorMsg.includes('CONFLICT') || errorMsg.includes('Merge conflict')) {
res.json({
success: true,
result: {
applied: true,
hasConflicts: true,
operation,
stashIndex,
message: `Stash ${operation === 'pop' ? 'popped' : 'applied'} with conflicts. Please resolve the conflicts.`,
},
});
return;
}
throw error;
}
} catch (error) {
logError(error, 'Stash apply failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,60 @@
/**
* POST /stash-drop endpoint - Drop (delete) a stash entry
*
* Removes a specific stash entry from the stash list.
*
* Note: Git repository validation (isGitRepo) is handled by
* the requireGitRepoOnly middleware in index.ts
*/
import type { Request, Response } from 'express';
import { execFile } from 'child_process';
import { promisify } from 'util';
import { getErrorMessage, logError } from '../common.js';
const execFileAsync = promisify(execFile);
export function createStashDropHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath, stashIndex } = req.body as {
worktreePath: string;
stashIndex: number;
};
if (!worktreePath) {
res.status(400).json({
success: false,
error: 'worktreePath required',
});
return;
}
if (stashIndex === undefined || stashIndex === null) {
res.status(400).json({
success: false,
error: 'stashIndex required',
});
return;
}
const stashRef = `stash@{${stashIndex}}`;
await execFileAsync('git', ['stash', 'drop', stashRef], {
cwd: worktreePath,
});
res.json({
success: true,
result: {
dropped: true,
stashIndex,
message: `Stash ${stashRef} dropped successfully`,
},
});
} catch (error) {
logError(error, 'Stash drop failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,122 @@
/**
* POST /stash-list endpoint - List all stashes in a worktree
*
* Returns a list of all stash entries with their index, message, branch, and date.
* Also includes the list of files changed in each stash.
*
* Note: Git repository validation (isGitRepo) is handled by
* the requireGitRepoOnly middleware in index.ts
*/
import type { Request, Response } from 'express';
import { execFile } from 'child_process';
import { promisify } from 'util';
import { getErrorMessage, logError } from '../common.js';
const execFileAsync = promisify(execFile);
interface StashEntry {
index: number;
message: string;
branch: string;
date: string;
files: string[];
}
export function createStashListHandler() {
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 stash list with format: index, message, date
// Use %aI (strict ISO 8601) instead of %ai to ensure cross-browser compatibility
const { stdout: stashOutput } = await execFileAsync(
'git',
['stash', 'list', '--format=%gd|||%s|||%aI'],
{ cwd: worktreePath }
);
if (!stashOutput.trim()) {
res.json({
success: true,
result: {
stashes: [],
total: 0,
},
});
return;
}
const stashLines = stashOutput
.trim()
.split('\n')
.filter((l) => l.trim());
const stashes: StashEntry[] = [];
for (const line of stashLines) {
const parts = line.split('|||');
if (parts.length < 3) continue;
const refSpec = parts[0].trim(); // e.g., "stash@{0}"
const message = parts[1].trim();
const date = parts[2].trim();
// Extract index from stash@{N}
const indexMatch = refSpec.match(/stash@\{(\d+)\}/);
const index = indexMatch ? parseInt(indexMatch[1], 10) : 0;
// Extract branch name from message (format: "WIP on branch: hash message" or "On branch: hash message")
let branch = '';
const branchMatch = message.match(/^(?:WIP on|On) ([^:]+):/);
if (branchMatch) {
branch = branchMatch[1];
}
// Get list of files in this stash
let files: string[] = [];
try {
const { stdout: filesOutput } = await execFileAsync(
'git',
['stash', 'show', refSpec, '--name-only'],
{ cwd: worktreePath }
);
files = filesOutput
.trim()
.split('\n')
.filter((f) => f.trim());
} catch {
// Ignore errors getting file list
}
stashes.push({
index,
message,
branch,
date,
files,
});
}
res.json({
success: true,
result: {
stashes,
total: stashes.length,
},
});
} catch (error) {
logError(error, 'Stash list failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,87 @@
/**
* POST /stash-push endpoint - Stash changes in a worktree
*
* Stashes uncommitted changes (including untracked files) with an optional message.
* Supports selective file stashing when a files array is provided.
*
* Note: Git repository validation (isGitRepo) is handled by
* the requireGitRepoOnly middleware in index.ts
*/
import type { Request, Response } from 'express';
import { execFile } from 'child_process';
import { promisify } from 'util';
import { getErrorMessage, logError } from '../common.js';
const execFileAsync = promisify(execFile);
export function createStashPushHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath, message, files } = req.body as {
worktreePath: string;
message?: string;
files?: string[];
};
if (!worktreePath) {
res.status(400).json({
success: false,
error: 'worktreePath required',
});
return;
}
// Check for any changes to stash
const { stdout: status } = await execFileAsync('git', ['status', '--porcelain'], {
cwd: worktreePath,
});
if (!status.trim()) {
res.json({
success: true,
result: {
stashed: false,
message: 'No changes to stash',
},
});
return;
}
// Build stash push command args
const args = ['stash', 'push', '--include-untracked'];
if (message && message.trim()) {
args.push('-m', message.trim());
}
// If specific files are provided, add them as pathspecs after '--'
if (files && files.length > 0) {
args.push('--');
args.push(...files);
}
// Execute stash push
await execFileAsync('git', args, { cwd: worktreePath });
// Get current branch name
const { stdout: branchOutput } = await execFileAsync(
'git',
['rev-parse', '--abbrev-ref', 'HEAD'],
{ cwd: worktreePath }
);
const branchName = branchOutput.trim();
res.json({
success: true,
result: {
stashed: true,
branch: branchName,
message: message?.trim() || `WIP on ${branchName}`,
},
});
} catch (error) {
logError(error, 'Stash push failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -16,47 +16,22 @@
*/
import type { Request, Response } from 'express';
import { exec } from 'child_process';
import { execFile } from 'child_process';
import { promisify } from 'util';
import { getErrorMessage, logError } from '../common.js';
const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
function isExcludedWorktreeLine(line: string): boolean {
return line.includes('.worktrees/') || line.endsWith('.worktrees');
}
function isUntrackedLine(line: string): boolean {
return line.startsWith('?? ');
}
function isBlockingChangeLine(line: string): boolean {
if (!line.trim()) return false;
if (isExcludedWorktreeLine(line)) return false;
if (isUntrackedLine(line)) return false;
return true;
}
/**
* Check if there are uncommitted changes in the working directory
* Excludes .worktrees/ directory which is created by automaker
*/
async function hasUncommittedChanges(cwd: string): Promise<boolean> {
try {
const { stdout } = await execAsync('git status --porcelain', { cwd });
const lines = stdout.trim().split('\n').filter(isBlockingChangeLine);
return lines.length > 0;
} catch {
return false;
}
}
/**
* Check if there are any changes at all (including untracked) that should be stashed
*/
async function hasAnyChanges(cwd: string): Promise<boolean> {
try {
const { stdout } = await execAsync('git status --porcelain', { cwd });
const { stdout } = await execFileAsync('git', ['status', '--porcelain'], { cwd });
const lines = stdout
.trim()
.split('\n')
@@ -78,17 +53,17 @@ async function hasAnyChanges(cwd: string): Promise<boolean> {
async function stashChanges(cwd: string, message: string): Promise<boolean> {
try {
// Get stash count before
const { stdout: beforeCount } = await execAsync('git stash list', { cwd });
const { stdout: beforeCount } = await execFileAsync('git', ['stash', 'list'], { cwd });
const countBefore = beforeCount
.trim()
.split('\n')
.filter((l) => l.trim()).length;
// Stash including untracked files
await execAsync(`git stash push --include-untracked -m "${message}"`, { cwd });
await execFileAsync('git', ['stash', 'push', '--include-untracked', '-m', message], { cwd });
// Get stash count after to verify something was stashed
const { stdout: afterCount } = await execAsync('git stash list', { cwd });
const { stdout: afterCount } = await execFileAsync('git', ['stash', 'list'], { cwd });
const countAfter = afterCount
.trim()
.split('\n')
@@ -108,7 +83,7 @@ async function popStash(
cwd: string
): Promise<{ success: boolean; hasConflicts: boolean; error?: string }> {
try {
const { stdout, stderr } = await execAsync('git stash pop', { cwd });
const { stdout, stderr } = await execFileAsync('git', ['stash', 'pop'], { cwd });
const output = `${stdout}\n${stderr}`;
// Check for conflict markers in the output
if (output.includes('CONFLICT') || output.includes('Merge conflict')) {
@@ -129,7 +104,7 @@ async function popStash(
*/
async function fetchRemotes(cwd: string): Promise<void> {
try {
await execAsync('git fetch --all --quiet', {
await execFileAsync('git', ['fetch', '--all', '--quiet'], {
cwd,
timeout: 15000, // 15 second timeout
});
@@ -155,7 +130,9 @@ function parseRemoteBranch(branchName: string): { remote: string; branch: string
*/
async function isRemoteBranch(cwd: string, branchName: string): Promise<boolean> {
try {
const { stdout } = await execAsync('git branch -r --format="%(refname:short)"', { cwd });
const { stdout } = await execFileAsync('git', ['branch', '-r', '--format=%(refname:short)'], {
cwd,
});
const remoteBranches = stdout
.trim()
.split('\n')
@@ -172,7 +149,7 @@ async function isRemoteBranch(cwd: string, branchName: string): Promise<boolean>
*/
async function localBranchExists(cwd: string, branchName: string): Promise<boolean> {
try {
await execAsync(`git rev-parse --verify "refs/heads/${branchName}"`, { cwd });
await execFileAsync('git', ['rev-parse', '--verify', `refs/heads/${branchName}`], { cwd });
return true;
} catch {
return false;
@@ -204,9 +181,11 @@ export function createSwitchBranchHandler() {
}
// Get current branch
const { stdout: currentBranchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', {
cwd: worktreePath,
});
const { stdout: currentBranchOutput } = await execFileAsync(
'git',
['rev-parse', '--abbrev-ref', 'HEAD'],
{ cwd: worktreePath }
);
const previousBranch = currentBranchOutput.trim();
// Determine the actual target branch name for checkout
@@ -243,7 +222,7 @@ export function createSwitchBranchHandler() {
// Check if target branch exists (locally or as remote ref)
if (!isRemote) {
try {
await execAsync(`git rev-parse --verify "${branchName}"`, {
await execFileAsync('git', ['rev-parse', '--verify', branchName], {
cwd: worktreePath,
});
} catch {
@@ -271,16 +250,16 @@ export function createSwitchBranchHandler() {
if (parsed) {
if (await localBranchExists(worktreePath, parsed.branch)) {
// Local branch exists, just checkout
await execAsync(`git checkout "${parsed.branch}"`, { cwd: worktreePath });
await execFileAsync('git', ['checkout', parsed.branch], { cwd: worktreePath });
} else {
// Create local tracking branch from remote
await execAsync(`git checkout -b "${parsed.branch}" "${branchName}"`, {
await execFileAsync('git', ['checkout', '-b', parsed.branch, branchName], {
cwd: worktreePath,
});
}
}
} else {
await execAsync(`git checkout "${targetBranch}"`, { cwd: worktreePath });
await execFileAsync('git', ['checkout', targetBranch], { cwd: worktreePath });
}
// Fetch latest from remotes after switching

View File

@@ -64,7 +64,52 @@ export function createZaiRoutes(
router.post('/configure', async (req: Request, res: Response) => {
try {
const { apiToken, apiHost } = req.body;
const result = await usageService.configure({ apiToken, apiHost }, settingsService);
// Validate apiToken: must be present and a string
if (apiToken === undefined || apiToken === null || typeof apiToken !== 'string') {
res.status(400).json({
success: false,
error: 'Invalid request: apiToken is required and must be a string',
});
return;
}
// Validate apiHost if provided: must be a string and a well-formed URL
if (apiHost !== undefined && apiHost !== null) {
if (typeof apiHost !== 'string') {
res.status(400).json({
success: false,
error: 'Invalid request: apiHost must be a string',
});
return;
}
// Validate that apiHost is a well-formed URL
try {
const parsedUrl = new URL(apiHost);
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
res.status(400).json({
success: false,
error: 'Invalid request: apiHost must be a valid HTTP or HTTPS URL',
});
return;
}
} catch {
res.status(400).json({
success: false,
error: 'Invalid request: apiHost must be a well-formed URL',
});
return;
}
}
// Pass only the sanitized values to the service
const sanitizedToken = apiToken.trim();
const sanitizedHost = typeof apiHost === 'string' ? apiHost.trim() : undefined;
const result = await usageService.configure(
{ apiToken: sanitizedToken, apiHost: sanitizedHost },
settingsService
);
res.json(result);
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';

View File

@@ -4,6 +4,7 @@
import type { Feature } from '@automaker/types';
import { createLogger, classifyError } from '@automaker/utils';
import { areDependenciesSatisfied } from '@automaker/dependency-resolver';
import type { TypedEventBus } from './typed-event-bus.js';
import type { ConcurrencyManager } from './concurrency-manager.js';
import type { SettingsService } from './settings-service.js';
@@ -64,6 +65,7 @@ export type ClearExecutionStateFn = (
) => Promise<void>;
export type ResetStuckFeaturesFn = (projectPath: string) => Promise<void>;
export type IsFeatureFinishedFn = (feature: Feature) => boolean;
export type LoadAllFeaturesFn = (projectPath: string) => Promise<Feature[]>;
export class AutoLoopCoordinator {
private autoLoopsByProject = new Map<string, ProjectAutoLoopState>();
@@ -78,7 +80,8 @@ export class AutoLoopCoordinator {
private clearExecutionStateFn: ClearExecutionStateFn,
private resetStuckFeaturesFn: ResetStuckFeaturesFn,
private isFeatureFinishedFn: IsFeatureFinishedFn,
private isFeatureRunningFn: (featureId: string) => boolean
private isFeatureRunningFn: (featureId: string) => boolean,
private loadAllFeaturesFn?: LoadAllFeaturesFn
) {}
/**
@@ -178,9 +181,31 @@ export class AutoLoopCoordinator {
await this.sleep(10000, projectState.abortController.signal);
continue;
}
const nextFeature = pendingFeatures.find(
(f) => !this.isFeatureRunningFn(f.id) && !this.isFeatureFinishedFn(f)
// Load all features for dependency checking (if callback provided)
const allFeatures = this.loadAllFeaturesFn
? await this.loadAllFeaturesFn(projectPath)
: pendingFeatures;
// Filter to eligible features: not running, not finished, and dependencies satisfied
const eligibleFeatures = pendingFeatures.filter(
(f) =>
!this.isFeatureRunningFn(f.id) &&
!this.isFeatureFinishedFn(f) &&
areDependenciesSatisfied(f, allFeatures)
);
// Sort eligible features by priority (lower number = higher priority, default 2)
eligibleFeatures.sort((a, b) => (a.priority ?? 2) - (b.priority ?? 2));
const nextFeature = eligibleFeatures[0] ?? null;
if (nextFeature) {
logger.info(
`Auto-loop selected feature "${nextFeature.title || nextFeature.id}" ` +
`(priority=${nextFeature.priority ?? 2}) from ${eligibleFeatures.length} eligible features`
);
}
if (nextFeature) {
projectState.hasEmittedIdleEvent = false;
this.executeFeatureFn(

View File

@@ -324,7 +324,8 @@ export class AutoModeServiceFacade {
feature.status === 'completed' ||
feature.status === 'verified' ||
feature.status === 'waiting_approval',
(featureId) => concurrencyManager.isRunning(featureId)
(featureId) => concurrencyManager.isRunning(featureId),
async (pPath) => featureLoader.getAll(pPath)
);
// ExecutionService - runAgentFn calls AgentExecutor.execute

View File

@@ -729,6 +729,7 @@ export class SettingsService {
anthropic: { configured: boolean; masked: string };
google: { configured: boolean; masked: string };
openai: { configured: boolean; masked: string };
zai: { configured: boolean; masked: string };
}> {
const credentials = await this.getCredentials();
@@ -750,6 +751,10 @@ export class SettingsService {
configured: !!credentials.apiKeys.openai,
masked: maskKey(credentials.apiKeys.openai),
},
zai: {
configured: !!credentials.apiKeys.zai,
masked: maskKey(credentials.apiKeys.zai),
},
};
}

View File

@@ -171,7 +171,11 @@ export class ZaiUsageService {
*/
getApiHost(): string {
// Priority: 1. Instance host, 2. Z_AI_API_HOST env, 3. Default
return process.env.Z_AI_API_HOST ? `https://${process.env.Z_AI_API_HOST}` : this.apiHost;
if (process.env.Z_AI_API_HOST) {
const envHost = process.env.Z_AI_API_HOST.trim();
return envHost.startsWith('http') ? envHost : `https://${envHost}`;
}
return this.apiHost;
}
/**
@@ -242,8 +246,7 @@ export class ZaiUsageService {
}
const quotaUrl =
process.env.Z_AI_QUOTA_URL ||
`${process.env.Z_AI_API_HOST ? `https://${process.env.Z_AI_API_HOST}` : 'https://api.z.ai'}/api/monitor/usage/quota/limit`;
process.env.Z_AI_QUOTA_URL || `${this.getApiHost()}/api/monitor/usage/quota/limit`;
logger.info(`[verify] Testing API key against: ${quotaUrl}`);

View File

@@ -64,7 +64,7 @@ describe('CLI Detection Framework', () => {
});
it('should handle unsupported platform', () => {
const instructions = getInstallInstructions('claude', 'unknown-platform' as any);
const instructions = getInstallInstructions('claude', 'unknown-platform' as NodeJS.Platform);
expect(instructions).toContain('No installation instructions available');
});
});
@@ -339,15 +339,17 @@ describe('Performance Tests', () => {
// Edge Cases
describe('Edge Cases', () => {
it('should handle empty CLI names', async () => {
await expect(detectCli('' as any)).rejects.toThrow();
await expect(detectCli('' as unknown as Parameters<typeof detectCli>[0])).rejects.toThrow();
});
it('should handle null CLI names', async () => {
await expect(detectCli(null as any)).rejects.toThrow();
await expect(detectCli(null as unknown as Parameters<typeof detectCli>[0])).rejects.toThrow();
});
it('should handle undefined CLI names', async () => {
await expect(detectCli(undefined as any)).rejects.toThrow();
await expect(
detectCli(undefined as unknown as Parameters<typeof detectCli>[0])
).rejects.toThrow();
});
it('should handle malformed error objects', () => {

View File

@@ -6,7 +6,7 @@ vi.mock('child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('child_process')>();
return {
...actual,
exec: vi.fn(),
execFile: vi.fn(),
};
});
@@ -18,10 +18,10 @@ vi.mock('util', async (importOriginal) => {
};
});
import { exec } from 'child_process';
import { execFile } from 'child_process';
import { createSwitchBranchHandler } from '@/routes/worktree/routes/switch-branch.js';
const mockExec = exec as Mock;
const mockExecFile = execFile as Mock;
describe('switch-branch route', () => {
let req: Request;
@@ -40,20 +40,21 @@ describe('switch-branch route', () => {
branchName: 'feature/test',
};
mockExec.mockImplementation(async (command: string) => {
mockExecFile.mockImplementation(async (file: string, args: string[]) => {
const command = `${file} ${args.join(' ')}`;
if (command === 'git rev-parse --abbrev-ref HEAD') {
return { stdout: 'main\n', stderr: '' };
}
if (command === 'git rev-parse --verify "feature/test"') {
if (command === 'git rev-parse --verify feature/test') {
return { stdout: 'abc123\n', stderr: '' };
}
if (command === 'git branch -r --format="%(refname:short)"') {
if (command === 'git branch -r --format=%(refname:short)') {
return { stdout: '', stderr: '' };
}
if (command === 'git status --porcelain') {
return { stdout: '?? .automaker/\n?? notes.txt\n', stderr: '' };
}
if (command === 'git checkout "feature/test"') {
if (command === 'git checkout feature/test') {
return { stdout: '', stderr: '' };
}
if (command === 'git fetch --all --quiet') {
@@ -84,7 +85,11 @@ describe('switch-branch route', () => {
stashedChanges: false,
},
});
expect(mockExec).toHaveBeenCalledWith('git checkout "feature/test"', { cwd: '/repo/path' });
expect(mockExecFile).toHaveBeenCalledWith(
'git',
['checkout', 'feature/test'],
expect.objectContaining({ cwd: '/repo/path' })
);
});
it('should stash changes and switch when tracked files are modified', async () => {
@@ -93,23 +98,25 @@ describe('switch-branch route', () => {
branchName: 'feature/test',
};
mockExec.mockImplementation(async (command: string) => {
let stashListCallCount = 0;
mockExecFile.mockImplementation(async (file: string, args: string[]) => {
const command = `${file} ${args.join(' ')}`;
if (command === 'git rev-parse --abbrev-ref HEAD') {
return { stdout: 'main\n', stderr: '' };
}
if (command === 'git rev-parse --verify "feature/test"') {
if (command === 'git rev-parse --verify feature/test') {
return { stdout: 'abc123\n', stderr: '' };
}
if (command === 'git status --porcelain') {
return { stdout: ' M src/index.ts\n?? notes.txt\n', stderr: '' };
}
if (command === 'git branch -r --format="%(refname:short)"') {
if (command === 'git branch -r --format=%(refname:short)') {
return { stdout: '', stderr: '' };
}
if (command === 'git stash list') {
// Return different counts before and after stash to indicate stash was created
if (!mockExec._stashCalled) {
mockExec._stashCalled = true;
stashListCallCount++;
if (stashListCallCount === 1) {
return { stdout: '', stderr: '' };
}
return { stdout: 'stash@{0}: automaker-branch-switch\n', stderr: '' };
@@ -117,7 +124,7 @@ describe('switch-branch route', () => {
if (command.startsWith('git stash push')) {
return { stdout: '', stderr: '' };
}
if (command === 'git checkout "feature/test"') {
if (command === 'git checkout feature/test') {
return { stdout: '', stderr: '' };
}
if (command === 'git fetch --all --quiet') {

View File

@@ -6,6 +6,7 @@ import {
type ProjectAutoLoopState,
type ExecuteFeatureFn,
type LoadPendingFeaturesFn,
type LoadAllFeaturesFn,
type SaveExecutionStateFn,
type ClearExecutionStateFn,
type ResetStuckFeaturesFn,
@@ -25,6 +26,7 @@ describe('auto-loop-coordinator.ts', () => {
// Callback mocks
let mockExecuteFeature: ExecuteFeatureFn;
let mockLoadPendingFeatures: LoadPendingFeaturesFn;
let mockLoadAllFeatures: LoadAllFeaturesFn;
let mockSaveExecutionState: SaveExecutionStateFn;
let mockClearExecutionState: ClearExecutionStateFn;
let mockResetStuckFeatures: ResetStuckFeaturesFn;
@@ -65,6 +67,7 @@ describe('auto-loop-coordinator.ts', () => {
// Callback mocks
mockExecuteFeature = vi.fn().mockResolvedValue(undefined);
mockLoadPendingFeatures = vi.fn().mockResolvedValue([]);
mockLoadAllFeatures = vi.fn().mockResolvedValue([]);
mockSaveExecutionState = vi.fn().mockResolvedValue(undefined);
mockClearExecutionState = vi.fn().mockResolvedValue(undefined);
mockResetStuckFeatures = vi.fn().mockResolvedValue(undefined);
@@ -81,7 +84,8 @@ describe('auto-loop-coordinator.ts', () => {
mockClearExecutionState,
mockResetStuckFeatures,
mockIsFeatureFinished,
mockIsFeatureRunning
mockIsFeatureRunning,
mockLoadAllFeatures
);
});
@@ -326,6 +330,282 @@ describe('auto-loop-coordinator.ts', () => {
});
});
describe('priority-based feature selection', () => {
it('selects highest priority feature first (lowest number)', async () => {
const lowPriority: Feature = {
...testFeature,
id: 'feature-low',
priority: 3,
title: 'Low Priority',
};
const highPriority: Feature = {
...testFeature,
id: 'feature-high',
priority: 1,
title: 'High Priority',
};
const medPriority: Feature = {
...testFeature,
id: 'feature-med',
priority: 2,
title: 'Med Priority',
};
// Return features in non-priority order
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([
lowPriority,
medPriority,
highPriority,
]);
vi.mocked(mockLoadAllFeatures).mockResolvedValue([lowPriority, medPriority, highPriority]);
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
await coordinator.startAutoLoopForProject('/test/project', null, 1);
await vi.advanceTimersByTimeAsync(3000);
await coordinator.stopAutoLoopForProject('/test/project', null);
// Should execute the highest priority feature (priority=1)
expect(mockExecuteFeature).toHaveBeenCalledWith('/test/project', 'feature-high', true, true);
});
it('uses default priority of 2 when not specified', async () => {
const noPriority: Feature = { ...testFeature, id: 'feature-none', title: 'No Priority' };
const highPriority: Feature = {
...testFeature,
id: 'feature-high',
priority: 1,
title: 'High Priority',
};
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([noPriority, highPriority]);
vi.mocked(mockLoadAllFeatures).mockResolvedValue([noPriority, highPriority]);
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
await coordinator.startAutoLoopForProject('/test/project', null, 1);
await vi.advanceTimersByTimeAsync(3000);
await coordinator.stopAutoLoopForProject('/test/project', null);
// High priority (1) should be selected over default priority (2)
expect(mockExecuteFeature).toHaveBeenCalledWith('/test/project', 'feature-high', true, true);
});
it('selects first feature when priorities are equal', async () => {
const featureA: Feature = {
...testFeature,
id: 'feature-a',
priority: 2,
title: 'Feature A',
};
const featureB: Feature = {
...testFeature,
id: 'feature-b',
priority: 2,
title: 'Feature B',
};
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([featureA, featureB]);
vi.mocked(mockLoadAllFeatures).mockResolvedValue([featureA, featureB]);
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
await coordinator.startAutoLoopForProject('/test/project', null, 1);
await vi.advanceTimersByTimeAsync(3000);
await coordinator.stopAutoLoopForProject('/test/project', null);
// When priorities equal, the first feature from the filtered list should be chosen
expect(mockExecuteFeature).toHaveBeenCalledWith('/test/project', 'feature-a', true, true);
});
});
describe('dependency-aware feature selection', () => {
it('skips features with unsatisfied dependencies', async () => {
const depFeature: Feature = {
...testFeature,
id: 'feature-dep',
status: 'in_progress',
title: 'Dependency Feature',
};
const blockedFeature: Feature = {
...testFeature,
id: 'feature-blocked',
dependencies: ['feature-dep'],
priority: 1,
title: 'Blocked Feature',
};
const readyFeature: Feature = {
...testFeature,
id: 'feature-ready',
priority: 2,
title: 'Ready Feature',
};
// Pending features (backlog/ready status)
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([blockedFeature, readyFeature]);
// All features (including the in-progress dependency)
vi.mocked(mockLoadAllFeatures).mockResolvedValue([depFeature, blockedFeature, readyFeature]);
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
await coordinator.startAutoLoopForProject('/test/project', null, 1);
await vi.advanceTimersByTimeAsync(3000);
await coordinator.stopAutoLoopForProject('/test/project', null);
// Should skip blocked feature (dependency not complete) and execute ready feature
expect(mockExecuteFeature).toHaveBeenCalledWith('/test/project', 'feature-ready', true, true);
expect(mockExecuteFeature).not.toHaveBeenCalledWith(
'/test/project',
'feature-blocked',
true,
true
);
});
it('picks features whose dependencies are completed', async () => {
const completedDep: Feature = {
...testFeature,
id: 'feature-dep',
status: 'completed',
title: 'Completed Dependency',
};
const unblockedFeature: Feature = {
...testFeature,
id: 'feature-unblocked',
dependencies: ['feature-dep'],
priority: 1,
title: 'Unblocked Feature',
};
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([unblockedFeature]);
vi.mocked(mockLoadAllFeatures).mockResolvedValue([completedDep, unblockedFeature]);
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
await coordinator.startAutoLoopForProject('/test/project', null, 1);
await vi.advanceTimersByTimeAsync(3000);
await coordinator.stopAutoLoopForProject('/test/project', null);
// Should execute the unblocked feature since its dependency is completed
expect(mockExecuteFeature).toHaveBeenCalledWith(
'/test/project',
'feature-unblocked',
true,
true
);
});
it('picks features whose dependencies are verified', async () => {
const verifiedDep: Feature = {
...testFeature,
id: 'feature-dep',
status: 'verified',
title: 'Verified Dependency',
};
const unblockedFeature: Feature = {
...testFeature,
id: 'feature-unblocked',
dependencies: ['feature-dep'],
priority: 1,
title: 'Unblocked Feature',
};
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([unblockedFeature]);
vi.mocked(mockLoadAllFeatures).mockResolvedValue([verifiedDep, unblockedFeature]);
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
await coordinator.startAutoLoopForProject('/test/project', null, 1);
await vi.advanceTimersByTimeAsync(3000);
await coordinator.stopAutoLoopForProject('/test/project', null);
expect(mockExecuteFeature).toHaveBeenCalledWith(
'/test/project',
'feature-unblocked',
true,
true
);
});
it('respects both priority and dependencies together', async () => {
const completedDep: Feature = {
...testFeature,
id: 'feature-dep',
status: 'completed',
title: 'Completed Dep',
};
const blockedHighPriority: Feature = {
...testFeature,
id: 'feature-blocked-hp',
dependencies: ['feature-not-done'],
priority: 1,
title: 'Blocked High Priority',
};
const unblockedLowPriority: Feature = {
...testFeature,
id: 'feature-unblocked-lp',
dependencies: ['feature-dep'],
priority: 3,
title: 'Unblocked Low Priority',
};
const unblockedMedPriority: Feature = {
...testFeature,
id: 'feature-unblocked-mp',
priority: 2,
title: 'Unblocked Med Priority',
};
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([
blockedHighPriority,
unblockedLowPriority,
unblockedMedPriority,
]);
vi.mocked(mockLoadAllFeatures).mockResolvedValue([
completedDep,
blockedHighPriority,
unblockedLowPriority,
unblockedMedPriority,
]);
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
await coordinator.startAutoLoopForProject('/test/project', null, 1);
await vi.advanceTimersByTimeAsync(3000);
await coordinator.stopAutoLoopForProject('/test/project', null);
// Should skip blocked high-priority and pick the unblocked medium-priority
expect(mockExecuteFeature).toHaveBeenCalledWith(
'/test/project',
'feature-unblocked-mp',
true,
true
);
expect(mockExecuteFeature).not.toHaveBeenCalledWith(
'/test/project',
'feature-blocked-hp',
true,
true
);
});
it('handles features with no dependencies (always eligible)', async () => {
const noDeps: Feature = {
...testFeature,
id: 'feature-no-deps',
priority: 2,
title: 'No Dependencies',
};
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([noDeps]);
vi.mocked(mockLoadAllFeatures).mockResolvedValue([noDeps]);
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
await coordinator.startAutoLoopForProject('/test/project', null, 1);
await vi.advanceTimersByTimeAsync(3000);
await coordinator.stopAutoLoopForProject('/test/project', null);
expect(mockExecuteFeature).toHaveBeenCalledWith(
'/test/project',
'feature-no-deps',
true,
true
);
});
});
describe('failure tracking', () => {
it('trackFailureAndCheckPauseForProject returns true after threshold', async () => {
await coordinator.startAutoLoopForProject('/test/project', null, 1);

View File

@@ -0,0 +1,436 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import {
FolderOpen,
Folder,
FileCode,
ChevronRight,
ArrowLeft,
Check,
Search,
X,
} from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import { Kbd, KbdGroup } from '@/components/ui/kbd';
import { useOSDetection } from '@/hooks';
import { apiPost } from '@/lib/api-fetch';
import { cn } from '@/lib/utils';
interface ProjectFileEntry {
name: string;
relativePath: string;
isDirectory: boolean;
isFile: boolean;
}
interface BrowseResult {
success: boolean;
currentRelativePath: string;
parentRelativePath: string | null;
entries: ProjectFileEntry[];
warning?: string;
error?: string;
}
interface ProjectFileSelectorDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSelect: (paths: string[]) => void;
projectPath: string;
existingFiles?: string[];
title?: string;
description?: string;
}
export function ProjectFileSelectorDialog({
open,
onOpenChange,
onSelect,
projectPath,
existingFiles = [],
title = 'Select Files to Copy',
description = 'Browse your project and select files or directories to copy into new worktrees.',
}: ProjectFileSelectorDialogProps) {
const { isMac } = useOSDetection();
const [currentRelativePath, setCurrentRelativePath] = useState('');
const [parentRelativePath, setParentRelativePath] = useState<string | null>(null);
const [entries, setEntries] = useState<ProjectFileEntry[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [warning, setWarning] = useState('');
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set());
const [searchQuery, setSearchQuery] = useState('');
// Track the path segments for breadcrumb navigation
const breadcrumbs = useMemo(() => {
if (!currentRelativePath) return [];
const parts = currentRelativePath.split('/').filter(Boolean);
return parts.map((part, index) => ({
name: part,
path: parts.slice(0, index + 1).join('/'),
}));
}, [currentRelativePath]);
const browseDirectory = useCallback(
async (relativePath?: string) => {
setLoading(true);
setError('');
setWarning('');
setSearchQuery('');
try {
const result = await apiPost<BrowseResult>('/api/fs/browse-project-files', {
projectPath,
relativePath: relativePath || '',
});
if (result.success) {
setCurrentRelativePath(result.currentRelativePath);
setParentRelativePath(result.parentRelativePath);
setEntries(result.entries);
setWarning(result.warning || '');
} else {
setError(result.error || 'Failed to browse directory');
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load directory contents');
} finally {
setLoading(false);
}
},
[projectPath]
);
// Reset state when dialog opens/closes
useEffect(() => {
if (open) {
setSelectedPaths(new Set());
setSearchQuery('');
browseDirectory();
} else {
setCurrentRelativePath('');
setParentRelativePath(null);
setEntries([]);
setError('');
setWarning('');
setSelectedPaths(new Set());
setSearchQuery('');
}
}, [open, browseDirectory]);
const handleNavigateInto = useCallback(
(entry: ProjectFileEntry) => {
if (entry.isDirectory) {
browseDirectory(entry.relativePath);
}
},
[browseDirectory]
);
const handleGoBack = useCallback(() => {
if (parentRelativePath !== null) {
browseDirectory(parentRelativePath || undefined);
}
}, [parentRelativePath, browseDirectory]);
const handleGoToRoot = useCallback(() => {
browseDirectory();
}, [browseDirectory]);
const handleBreadcrumbClick = useCallback(
(path: string) => {
browseDirectory(path);
},
[browseDirectory]
);
const handleToggleSelect = useCallback((entry: ProjectFileEntry) => {
setSelectedPaths((prev) => {
const next = new Set(prev);
if (next.has(entry.relativePath)) {
next.delete(entry.relativePath);
} else {
next.add(entry.relativePath);
}
return next;
});
}, []);
const handleConfirmSelection = useCallback(() => {
const paths = Array.from(selectedPaths);
if (paths.length > 0) {
onSelect(paths);
onOpenChange(false);
}
}, [selectedPaths, onSelect, onOpenChange]);
// Check if a path is already configured
const isAlreadyConfigured = useCallback(
(relativePath: string) => {
return existingFiles.includes(relativePath);
},
[existingFiles]
);
// Filter entries based on search query
const filteredEntries = useMemo(() => {
if (!searchQuery.trim()) return entries;
const query = searchQuery.toLowerCase();
return entries.filter((entry) => entry.name.toLowerCase().includes(query));
}, [entries, searchQuery]);
// Handle Command/Ctrl+Enter keyboard shortcut
useEffect(() => {
if (!open) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
if (selectedPaths.size > 0 && !loading) {
handleConfirmSelection();
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [open, selectedPaths, loading, handleConfirmSelection]);
const selectedCount = selectedPaths.size;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="bg-popover border-border max-w-2xl max-h-[80vh] overflow-hidden flex flex-col p-4 focus:outline-none focus-visible:outline-none">
<DialogHeader className="pb-1">
<DialogTitle className="flex items-center gap-2 text-base">
<FolderOpen className="w-4 h-4 text-brand-500" />
{title}
</DialogTitle>
<DialogDescription className="text-muted-foreground text-xs">
{description}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-2 min-h-[300px] flex-1 overflow-hidden py-1">
{/* Navigation bar */}
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={handleGoBack}
className="h-7 w-7 shrink-0"
disabled={loading || parentRelativePath === null}
aria-label="Go back"
>
<ArrowLeft className="w-4 h-4" />
</Button>
{/* Breadcrumb path */}
<div className="flex items-center gap-1 min-w-0 flex-1 h-8 px-3 rounded-md border border-input bg-background/50 overflow-x-auto scrollbar-none">
<button
onClick={handleGoToRoot}
className={cn(
'text-xs font-mono shrink-0 transition-colors',
currentRelativePath
? 'text-muted-foreground hover:text-foreground'
: 'text-foreground font-medium'
)}
disabled={loading}
>
Project Root
</button>
{breadcrumbs.map((crumb) => (
<span key={crumb.path} className="flex items-center gap-1 shrink-0">
<ChevronRight className="w-3 h-3 text-muted-foreground/50" />
<button
onClick={() => handleBreadcrumbClick(crumb.path)}
className={cn(
'text-xs font-mono truncate max-w-[150px] transition-colors',
crumb.path === currentRelativePath
? 'text-foreground font-medium'
: 'text-muted-foreground hover:text-foreground'
)}
disabled={loading}
>
{crumb.name}
</button>
</span>
))}
</div>
</div>
{/* Search filter */}
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Filter files and directories..."
className="h-8 text-xs pl-8 pr-8"
disabled={loading}
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
{/* Selected items indicator */}
{selectedCount > 0 && (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-md bg-brand-500/10 border border-brand-500/20 text-xs">
<Check className="w-3.5 h-3.5 text-brand-500" />
<span className="text-brand-500 font-medium">
{selectedCount} {selectedCount === 1 ? 'item' : 'items'} selected
</span>
<button
onClick={() => setSelectedPaths(new Set())}
className="ml-auto text-muted-foreground hover:text-foreground transition-colors"
>
Clear
</button>
</div>
)}
{/* File/directory list */}
<div className="flex-1 overflow-y-auto border border-sidebar-border rounded-md scrollbar-styled">
{loading && (
<div className="flex items-center justify-center h-full p-4">
<div className="text-xs text-muted-foreground">Loading...</div>
</div>
)}
{error && (
<div className="flex items-center justify-center h-full p-4">
<div className="text-xs text-destructive">{error}</div>
</div>
)}
{warning && (
<div className="p-2 bg-yellow-500/10 border border-yellow-500/30 rounded-md mb-1">
<div className="text-xs text-yellow-500">{warning}</div>
</div>
)}
{!loading && !error && filteredEntries.length === 0 && (
<div className="flex items-center justify-center h-full p-4">
<div className="text-xs text-muted-foreground">
{searchQuery ? 'No matching files or directories' : 'This directory is empty'}
</div>
</div>
)}
{!loading && !error && filteredEntries.length > 0 && (
<div className="divide-y divide-sidebar-border">
{filteredEntries.map((entry) => {
const isSelected = selectedPaths.has(entry.relativePath);
const isConfigured = isAlreadyConfigured(entry.relativePath);
return (
<div
key={entry.relativePath}
className={cn(
'w-full flex items-center gap-2 px-2 py-1.5 transition-colors text-left group',
isConfigured
? 'opacity-50'
: isSelected
? 'bg-brand-500/10'
: 'hover:bg-sidebar-accent/10'
)}
>
{/* Checkbox for selection */}
<Checkbox
checked={isSelected}
onCheckedChange={() => handleToggleSelect(entry)}
disabled={isConfigured}
className="shrink-0"
aria-label={`Select ${entry.name}`}
/>
{/* Icon */}
{entry.isDirectory ? (
<Folder className="w-4 h-4 text-brand-500 shrink-0" />
) : (
<FileCode className="w-4 h-4 text-muted-foreground/60 shrink-0" />
)}
{/* File/directory name */}
<span
className="flex-1 truncate text-xs font-mono cursor-pointer"
onClick={() => {
if (!isConfigured) {
handleToggleSelect(entry);
}
}}
>
{entry.name}
</span>
{/* Already configured badge */}
{isConfigured && (
<span className="text-[10px] text-muted-foreground bg-muted/50 px-1.5 py-0.5 rounded shrink-0">
Already added
</span>
)}
{/* Navigate into directory button */}
{entry.isDirectory && (
<button
onClick={(e) => {
e.stopPropagation();
handleNavigateInto(entry);
}}
className="opacity-0 group-hover:opacity-100 p-0.5 rounded hover:bg-accent/50 transition-all shrink-0"
title={`Open ${entry.name}`}
>
<ChevronRight className="w-3.5 h-3.5 text-muted-foreground" />
</button>
)}
</div>
);
})}
</div>
)}
</div>
<div className="text-[10px] text-muted-foreground">
Select files or directories to copy into new worktrees. Directories are copied
recursively. Click the arrow to browse into a directory.
</div>
</div>
<DialogFooter className="border-t border-border pt-3 gap-2 mt-1">
<Button variant="ghost" size="sm" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
size="sm"
onClick={handleConfirmSelection}
disabled={selectedCount === 0 || loading}
title={`Add ${selectedCount} selected items (${isMac ? '⌘' : 'Ctrl'}+Enter)`}
>
<Check className="w-3.5 h-3.5 mr-1.5" />
Add {selectedCount > 0 ? `${selectedCount} ` : ''}
{selectedCount === 1 ? 'Item' : 'Items'}
<KbdGroup className="ml-1">
<Kbd>{isMac ? '⌘' : 'Ctrl'}</Kbd>
<Kbd></Kbd>
</KbdGroup>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -37,18 +37,21 @@ export function SandboxRiskDialog({ open, onConfirm, onDeny }: SandboxRiskDialog
return (
<Dialog open={open} onOpenChange={() => {}}>
<DialogContent
className="bg-popover border-border max-w-lg"
className="bg-popover border-border max-w-lg flex flex-col"
onPointerDownOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
showCloseButton={false}
>
<DialogHeader>
<DialogHeader className="flex-shrink-0">
<DialogTitle className="flex items-center gap-2 text-destructive">
<ShieldAlert className="w-6 h-6" />
<ShieldAlert className="w-6 h-6 flex-shrink-0" />
Sandbox Environment Not Detected
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto min-h-0 -mx-6 px-6">
<DialogDescription asChild>
<div className="space-y-4 pt-2">
<div className="space-y-4 pt-2 pb-2">
<p className="text-muted-foreground">
<strong>Warning:</strong> This application is running outside of a containerized
sandbox environment. AI agents will have direct access to your filesystem and can
@@ -94,9 +97,9 @@ export function SandboxRiskDialog({ open, onConfirm, onDeny }: SandboxRiskDialog
</div>
</div>
</DialogDescription>
</DialogHeader>
</div>
<DialogFooter className="flex-col gap-4 sm:flex-col pt-4">
<DialogFooter className="flex-col gap-4 sm:flex-col pt-4 flex-shrink-0 border-t border-border mt-4">
<div className="flex items-center space-x-2 self-start">
<Checkbox
id="skip-sandbox-warning"

View File

@@ -1,6 +1,6 @@
import { useEffect, useRef, useState, memo, useCallback, useMemo } from 'react';
import type { LucideIcon } from 'lucide-react';
import { Edit2, Trash2, Palette, ChevronRight, Moon, Sun, Monitor } from 'lucide-react';
import { Edit2, Trash2, Palette, ChevronRight, Moon, Sun, Monitor, LogOut } from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import { type ThemeMode, useAppStore } from '@/store/app-store';
@@ -196,11 +196,13 @@ export function ProjectContextMenu({
const menuRef = useRef<HTMLDivElement>(null);
const {
moveProjectToTrash,
removeProject,
theme: globalTheme,
setProjectTheme,
setPreviewTheme,
} = useAppStore();
const [showRemoveDialog, setShowRemoveDialog] = useState(false);
const [showRemoveFromAutomakerDialog, setShowRemoveFromAutomakerDialog] = useState(false);
const [showThemeSubmenu, setShowThemeSubmenu] = useState(false);
const themeSubmenuRef = useRef<HTMLDivElement>(null);
const closeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -282,7 +284,7 @@ export function ProjectContextMenu({
useEffect(() => {
const handleClickOutside = (event: globalThis.MouseEvent) => {
// Don't close if a confirmation dialog is open (dialog is in a portal)
if (showRemoveDialog) return;
if (showRemoveDialog || showRemoveFromAutomakerDialog) return;
if (menuRef.current && !menuRef.current.contains(event.target as globalThis.Node)) {
setPreviewTheme(null);
@@ -292,7 +294,7 @@ export function ProjectContextMenu({
const handleEscape = (event: globalThis.KeyboardEvent) => {
// Don't close if a confirmation dialog is open (let the dialog handle escape)
if (showRemoveDialog) return;
if (showRemoveDialog || showRemoveFromAutomakerDialog) return;
if (event.key === 'Escape') {
setPreviewTheme(null);
@@ -307,7 +309,7 @@ export function ProjectContextMenu({
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleEscape);
};
}, [onClose, setPreviewTheme, showRemoveDialog]);
}, [onClose, setPreviewTheme, showRemoveDialog, showRemoveFromAutomakerDialog]);
const handleEdit = () => {
onEdit(project);
@@ -359,10 +361,31 @@ export function ProjectContextMenu({
[onClose]
);
const handleRemoveFromAutomaker = () => {
setShowRemoveFromAutomakerDialog(true);
};
const handleConfirmRemoveFromAutomaker = useCallback(() => {
removeProject(project.id);
toast.success('Project removed from Automaker', {
description: `${project.name} has been removed. The folder remains on disk.`,
});
}, [removeProject, project.id, project.name]);
const handleRemoveFromAutomakerDialogClose = useCallback(
(isOpen: boolean) => {
setShowRemoveFromAutomakerDialog(isOpen);
if (!isOpen) {
onClose();
}
},
[onClose]
);
return (
<>
{/* Hide context menu when confirm dialog is open */}
{!showRemoveDialog && (
{!showRemoveDialog && !showRemoveFromAutomakerDialog && (
<div
ref={menuRef}
className={cn(
@@ -509,7 +532,22 @@ export function ProjectContextMenu({
data-testid="remove-project-button"
>
<Trash2 className="w-4 h-4" />
<span>Remove Project</span>
<span>Move to Trash</span>
</button>
<button
onClick={handleRemoveFromAutomaker}
className={cn(
'w-full flex items-center gap-2 px-3 py-2 rounded-md',
'text-sm font-medium text-left',
'text-muted-foreground hover:text-foreground hover:bg-accent',
'transition-colors',
'focus:outline-none focus:bg-accent'
)}
data-testid="remove-from-automaker-button"
>
<LogOut className="w-4 h-4" />
<span>Remove from Automaker</span>
</button>
</div>
</div>
@@ -519,13 +557,25 @@ export function ProjectContextMenu({
open={showRemoveDialog}
onOpenChange={handleDialogClose}
onConfirm={handleConfirmRemove}
title="Remove Project"
description={`Are you sure you want to remove "${project.name}" from the project list? This won't delete any files on disk.`}
title="Move to Trash"
description={`Are you sure you want to move "${project.name}" to Trash? You can restore it later from the Recycle Bin.`}
icon={Trash2}
iconClassName="text-destructive"
confirmText="Remove"
confirmText="Move to Trash"
confirmVariant="destructive"
/>
<ConfirmDialog
open={showRemoveFromAutomakerDialog}
onOpenChange={handleRemoveFromAutomakerDialogClose}
onConfirm={handleConfirmRemoveFromAutomaker}
title="Remove from Automaker"
description={`Remove "${project.name}" from Automaker? The folder will remain on disk and can be re-added later by opening it.`}
icon={LogOut}
iconClassName="text-muted-foreground"
confirmText="Remove from Automaker"
confirmVariant="secondary"
/>
</>
);
}

View File

@@ -11,6 +11,7 @@ import {
RotateCcw,
Trash2,
Search,
LogOut,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { formatShortcut, type ThemeMode, useAppStore } from '@/store/app-store';
@@ -47,6 +48,8 @@ interface ProjectSelectorWithOptionsProps {
setIsProjectPickerOpen: (value: boolean | ((prev: boolean) => boolean)) => void;
/** Callback to show the delete project confirmation dialog */
setShowDeleteProjectDialog: (show: boolean) => void;
/** Callback to show the remove from automaker confirmation dialog */
setShowRemoveFromAutomakerDialog: (show: boolean) => void;
}
/**
@@ -70,6 +73,7 @@ export function ProjectSelectorWithOptions({
isProjectPickerOpen,
setIsProjectPickerOpen,
setShowDeleteProjectDialog,
setShowRemoveFromAutomakerDialog,
}: ProjectSelectorWithOptionsProps) {
const {
projects,
@@ -371,8 +375,16 @@ export function ProjectSelectorWithOptions({
</>
)}
{/* Move to Trash Section */}
{/* Remove / Trash Section */}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setShowRemoveFromAutomakerDialog(true)}
className="text-muted-foreground focus:text-foreground"
data-testid="remove-from-automaker"
>
<LogOut className="w-4 h-4 mr-2" />
<span>Remove from Automaker</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setShowDeleteProjectDialog(true)}
className="text-destructive focus:text-destructive focus:bg-destructive/10"

View File

@@ -39,6 +39,7 @@ import { EditProjectDialog } from '../project-switcher/components/edit-project-d
// Import shared dialogs
import { DeleteProjectDialog } from '@/components/views/settings-view/components/delete-project-dialog';
import { RemoveFromAutomakerDialog } from '@/components/views/settings-view/components/remove-from-automaker-dialog';
import { NewProjectModal } from '@/components/dialogs/new-project-modal';
import { CreateSpecDialog } from '@/components/views/spec-view/dialogs';
@@ -65,6 +66,7 @@ export function Sidebar() {
cyclePrevProject,
cycleNextProject,
moveProjectToTrash,
removeProject,
specCreatingForProject,
setSpecCreatingForProject,
setCurrentProject,
@@ -91,6 +93,8 @@ export function Sidebar() {
// State for delete project confirmation dialog
const [showDeleteProjectDialog, setShowDeleteProjectDialog] = useState(false);
// State for remove from automaker confirmation dialog
const [showRemoveFromAutomakerDialog, setShowRemoveFromAutomakerDialog] = useState(false);
// State for trash dialog
const [showTrashDialog, setShowTrashDialog] = useState(false);
@@ -488,6 +492,14 @@ export function Sidebar() {
onConfirm={moveProjectToTrash}
/>
{/* Remove from Automaker Confirmation Dialog */}
<RemoveFromAutomakerDialog
open={showRemoveFromAutomakerDialog}
onOpenChange={setShowRemoveFromAutomakerDialog}
project={currentProject}
onConfirm={removeProject}
/>
{/* New Project Modal */}
<NewProjectModal
open={showNewProjectModal}

View File

@@ -106,8 +106,11 @@ SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> & {
/** Content to display below the item text in the dropdown only (not shown in trigger). */
description?: React.ReactNode;
}
>(({ className, children, description, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
@@ -122,7 +125,14 @@ const SelectItem = React.forwardRef<
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
{description ? (
<div className="flex flex-col items-start">
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
{description}
</div>
) : (
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
)}
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;

View File

@@ -34,13 +34,18 @@ function formatResetTime(unixTimestamp: number, isMilliseconds = false): string
const now = new Date();
const diff = date.getTime() - now.getTime();
// Guard against past timestamps: clamp negative diffs to a friendly fallback
if (diff <= 0) {
return 'Resets now';
}
if (diff < 3600000) {
const mins = Math.ceil(diff / 60000);
const mins = Math.max(0, Math.ceil(diff / 60000));
return `Resets in ${mins}m`;
}
if (diff < 86400000) {
const hours = Math.floor(diff / 3600000);
const mins = Math.ceil((diff % 3600000) / 60000);
const hours = Math.max(0, Math.floor(diff / 3600000));
const mins = Math.max(0, Math.ceil((diff % 3600000) / 60000));
return `Resets in ${hours}h ${mins > 0 ? `${mins}m` : ''}`;
}
return `Resets ${date.toLocaleDateString()} at ${date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;

View File

@@ -49,12 +49,13 @@ import {
ArchiveAllVerifiedDialog,
DeleteCompletedFeatureDialog,
DependencyLinkDialog,
DuplicateCountDialog,
EditFeatureDialog,
FollowUpDialog,
PlanApprovalDialog,
PullResolveConflictsDialog,
MergeRebaseDialog,
} from './board-view/dialogs';
import type { DependencyLinkType } from './board-view/dialogs';
import type { DependencyLinkType, PullStrategy } from './board-view/dialogs';
import { PipelineSettingsDialog } from './board-view/dialogs/pipeline-settings-dialog';
import { CreateWorktreeDialog } from './board-view/dialogs/create-worktree-dialog';
import { DeleteWorktreeDialog } from './board-view/dialogs/delete-worktree-dialog';
@@ -170,13 +171,16 @@ export function BoardView() {
// State for spawn task mode
const [spawnParentFeature, setSpawnParentFeature] = useState<Feature | null>(null);
// State for duplicate as child multiple times dialog
const [duplicateMultipleFeature, setDuplicateMultipleFeature] = useState<Feature | null>(null);
// Worktree dialog states
const [showCreateWorktreeDialog, setShowCreateWorktreeDialog] = useState(false);
const [showDeleteWorktreeDialog, setShowDeleteWorktreeDialog] = useState(false);
const [showCommitWorktreeDialog, setShowCommitWorktreeDialog] = useState(false);
const [showCreatePRDialog, setShowCreatePRDialog] = useState(false);
const [showCreateBranchDialog, setShowCreateBranchDialog] = useState(false);
const [showPullResolveConflictsDialog, setShowPullResolveConflictsDialog] = useState(false);
const [showMergeRebaseDialog, setShowMergeRebaseDialog] = useState(false);
const [selectedWorktreeForAction, setSelectedWorktreeForAction] = useState<WorktreeInfo | null>(
null
);
@@ -596,6 +600,7 @@ export function BoardView() {
handleStartNextFeatures,
handleArchiveAllVerified,
handleDuplicateFeature,
handleDuplicateAsChildMultiple,
} = useBoardActions({
currentProject,
features: hookFeatures,
@@ -917,17 +922,25 @@ export function BoardView() {
// Handler for resolving conflicts - opens dialog to select remote branch, then creates a feature
const handleResolveConflicts = useCallback((worktree: WorktreeInfo) => {
setSelectedWorktreeForAction(worktree);
setShowPullResolveConflictsDialog(true);
setShowMergeRebaseDialog(true);
}, []);
// Handler called when user confirms the pull & resolve conflicts dialog
// Handler called when user confirms the merge & rebase 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.`;
async (worktree: WorktreeInfo, remoteBranch: string, strategy: PullStrategy) => {
const isRebase = strategy === 'rebase';
const description = isRebase
? `Fetch the latest changes from ${remoteBranch} and rebase the current branch (${worktree.branch}) onto ${remoteBranch}. Use "git fetch" followed by "git rebase ${remoteBranch}" to replay commits on top of the remote branch for a linear history. If rebase conflicts arise, resolve them one commit at a time using "git rebase --continue" after fixing each conflict. After completing the rebase, ensure the code compiles and tests pass.`
: `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 title = isRebase
? `Rebase & Resolve Conflicts: ${worktree.branch} onto ${remoteBranch}`
: `Resolve Merge Conflicts: ${remoteBranch}${worktree.branch}`;
// Create the feature
const featureData = {
title: `Resolve Merge Conflicts: ${remoteBranch}${worktree.branch}`,
title,
category: 'Maintenance',
description,
images: [],
@@ -1562,6 +1575,7 @@ export function BoardView() {
},
onDuplicate: (feature) => handleDuplicateFeature(feature, false),
onDuplicateAsChild: (feature) => handleDuplicateFeature(feature, true),
onDuplicateAsChildMultiple: (feature) => setDuplicateMultipleFeature(feature),
}}
runningAutoTasks={runningAutoTasksAllWorktrees}
pipelineConfig={pipelineConfig}
@@ -1603,6 +1617,7 @@ export function BoardView() {
}}
onDuplicate={(feature) => handleDuplicateFeature(feature, false)}
onDuplicateAsChild={(feature) => handleDuplicateFeature(feature, true)}
onDuplicateAsChildMultiple={(feature) => setDuplicateMultipleFeature(feature)}
featuresWithContext={featuresWithContext}
runningAutoTasks={runningAutoTasksAllWorktrees}
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
@@ -1752,6 +1767,21 @@ export function BoardView() {
branchName={outputFeature?.branchName}
/>
{/* Duplicate as Child Multiple Times Dialog */}
<DuplicateCountDialog
open={duplicateMultipleFeature !== null}
onOpenChange={(open) => {
if (!open) setDuplicateMultipleFeature(null);
}}
onConfirm={async (count) => {
if (duplicateMultipleFeature) {
await handleDuplicateAsChildMultiple(duplicateMultipleFeature, count);
setDuplicateMultipleFeature(null);
}
}}
featureTitle={duplicateMultipleFeature?.title || duplicateMultipleFeature?.description}
/>
{/* Archive All Verified Dialog */}
<ArchiveAllVerifiedDialog
open={showArchiveAllVerifiedDialog}
@@ -1899,10 +1929,10 @@ export function BoardView() {
}}
/>
{/* Pull & Resolve Conflicts Dialog */}
<PullResolveConflictsDialog
open={showPullResolveConflictsDialog}
onOpenChange={setShowPullResolveConflictsDialog}
{/* Merge & Rebase Dialog */}
<MergeRebaseDialog
open={showMergeRebaseDialog}
onOpenChange={setShowMergeRebaseDialog}
worktree={selectedWorktreeForAction}
onConfirm={handleConfirmResolveConflicts}
/>

View File

@@ -48,7 +48,7 @@ interface AgentInfoPanelProps {
projectPath: string;
contextContent?: string;
summary?: string;
isCurrentAutoTask?: boolean;
isActivelyRunning?: boolean;
}
export const AgentInfoPanel = memo(function AgentInfoPanel({
@@ -56,7 +56,7 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
projectPath,
contextContent,
summary,
isCurrentAutoTask,
isActivelyRunning,
}: AgentInfoPanelProps) {
const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false);
const [isTodosExpanded, setIsTodosExpanded] = useState(false);
@@ -107,7 +107,7 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
// - If not receiving WebSocket events but in_progress: use normal interval (3s)
// - Otherwise: no polling
const pollingInterval = useMemo((): number | false => {
if (!(isCurrentAutoTask || feature.status === 'in_progress')) {
if (!(isActivelyRunning || feature.status === 'in_progress')) {
return false;
}
// If receiving WebSocket events, use longer polling interval as fallback
@@ -116,7 +116,7 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
}
// Default polling interval
return 3000;
}, [isCurrentAutoTask, feature.status, isReceivingWsEvents]);
}, [isActivelyRunning, feature.status, isReceivingWsEvents]);
// Fetch fresh feature data for planSpec (store data can be stale for task progress)
const { data: freshFeature } = useFeature(projectPath, feature.id, {

View File

@@ -23,6 +23,7 @@ import {
ChevronUp,
GitFork,
Copy,
Repeat,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { CountUpTimer } from '@/components/ui/count-up-timer';
@@ -33,9 +34,11 @@ import { getProviderIconForModel } from '@/components/ui/provider-icon';
function DuplicateMenuItems({
onDuplicate,
onDuplicateAsChild,
onDuplicateAsChildMultiple,
}: {
onDuplicate?: () => void;
onDuplicateAsChild?: () => void;
onDuplicateAsChildMultiple?: () => void;
}) {
if (!onDuplicate) return null;
@@ -55,25 +58,23 @@ function DuplicateMenuItems({
);
}
// When sub-child action is available, render a proper DropdownMenuSub with
// DropdownMenuSubTrigger and DropdownMenuSubContent per Radix conventions
// Split-button pattern: main click duplicates immediately, disclosure arrow shows submenu
return (
<DropdownMenuSub>
<DropdownMenuSubTrigger className="text-xs">
<Copy className="w-3 h-3 mr-2" />
Duplicate
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<div className="flex items-center">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDuplicate();
}}
className="text-xs"
className="flex-1 pr-0 rounded-r-none text-xs"
>
<Copy className="w-3 h-3 mr-2" />
Duplicate
</DropdownMenuItem>
<DropdownMenuSubTrigger className="px-1 rounded-l-none border-l border-border/30 h-8 text-xs" />
</div>
<DropdownMenuSubContent>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
@@ -84,6 +85,18 @@ function DuplicateMenuItems({
<GitFork className="w-3 h-3 mr-2" />
Duplicate as Child
</DropdownMenuItem>
{onDuplicateAsChildMultiple && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDuplicateAsChildMultiple();
}}
className="text-xs"
>
<Repeat className="w-3 h-3 mr-2" />
Duplicate as Child ×N
</DropdownMenuItem>
)}
</DropdownMenuSubContent>
</DropdownMenuSub>
);
@@ -100,6 +113,7 @@ interface CardHeaderProps {
onSpawnTask?: () => void;
onDuplicate?: () => void;
onDuplicateAsChild?: () => void;
onDuplicateAsChildMultiple?: () => void;
dragHandleListeners?: DraggableSyntheticListeners;
dragHandleAttributes?: DraggableAttributes;
}
@@ -115,6 +129,7 @@ export const CardHeaderSection = memo(function CardHeaderSection({
onSpawnTask,
onDuplicate,
onDuplicateAsChild,
onDuplicateAsChildMultiple,
dragHandleListeners,
dragHandleAttributes,
}: CardHeaderProps) {
@@ -183,6 +198,7 @@ export const CardHeaderSection = memo(function CardHeaderSection({
<DuplicateMenuItems
onDuplicate={onDuplicate}
onDuplicateAsChild={onDuplicateAsChild}
onDuplicateAsChildMultiple={onDuplicateAsChildMultiple}
/>
{/* Model info in dropdown */}
{(() => {
@@ -251,6 +267,7 @@ export const CardHeaderSection = memo(function CardHeaderSection({
<DuplicateMenuItems
onDuplicate={onDuplicate}
onDuplicateAsChild={onDuplicateAsChild}
onDuplicateAsChildMultiple={onDuplicateAsChildMultiple}
/>
</DropdownMenuContent>
</DropdownMenu>
@@ -343,6 +360,7 @@ export const CardHeaderSection = memo(function CardHeaderSection({
<DuplicateMenuItems
onDuplicate={onDuplicate}
onDuplicateAsChild={onDuplicateAsChild}
onDuplicateAsChildMultiple={onDuplicateAsChildMultiple}
/>
</DropdownMenuContent>
</DropdownMenu>
@@ -417,6 +435,7 @@ export const CardHeaderSection = memo(function CardHeaderSection({
<DuplicateMenuItems
onDuplicate={onDuplicate}
onDuplicateAsChild={onDuplicateAsChild}
onDuplicateAsChildMultiple={onDuplicateAsChildMultiple}
/>
{/* Model info in dropdown */}
{(() => {

View File

@@ -54,6 +54,7 @@ interface KanbanCardProps {
onSpawnTask?: () => void;
onDuplicate?: () => void;
onDuplicateAsChild?: () => void;
onDuplicateAsChildMultiple?: () => void;
hasContext?: boolean;
isCurrentAutoTask?: boolean;
shortcutKey?: string;
@@ -90,6 +91,7 @@ export const KanbanCard = memo(function KanbanCard({
onSpawnTask,
onDuplicate,
onDuplicateAsChild,
onDuplicateAsChildMultiple,
hasContext,
isCurrentAutoTask,
shortcutKey,
@@ -266,6 +268,7 @@ export const KanbanCard = memo(function KanbanCard({
onSpawnTask={onSpawnTask}
onDuplicate={onDuplicate}
onDuplicateAsChild={onDuplicateAsChild}
onDuplicateAsChildMultiple={onDuplicateAsChildMultiple}
dragHandleListeners={isDraggable ? listeners : undefined}
dragHandleAttributes={isDraggable ? attributes : undefined}
/>
@@ -280,7 +283,7 @@ export const KanbanCard = memo(function KanbanCard({
projectPath={currentProject?.path ?? ''}
contextContent={contextContent}
summary={summary}
isCurrentAutoTask={isCurrentAutoTask}
isActivelyRunning={isActivelyRunning}
/>
{/* Actions */}

View File

@@ -45,6 +45,7 @@ export interface ListViewActionHandlers {
onSpawnTask?: (feature: Feature) => void;
onDuplicate?: (feature: Feature) => void;
onDuplicateAsChild?: (feature: Feature) => void;
onDuplicateAsChildMultiple?: (feature: Feature) => void;
}
export interface ListViewProps {
@@ -332,6 +333,12 @@ export const ListView = memo(function ListView({
if (f) actionHandlers.onDuplicateAsChild?.(f);
}
: undefined,
duplicateAsChildMultiple: actionHandlers.onDuplicateAsChildMultiple
? (id) => {
const f = allFeatures.find((f) => f.id === id);
if (f) actionHandlers.onDuplicateAsChildMultiple?.(f);
}
: undefined,
});
},
[actionHandlers, allFeatures]

View File

@@ -15,6 +15,7 @@ import {
GitFork,
ExternalLink,
Copy,
Repeat,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
@@ -49,6 +50,7 @@ export interface RowActionHandlers {
onSpawnTask?: () => void;
onDuplicate?: () => void;
onDuplicateAsChild?: () => void;
onDuplicateAsChildMultiple?: () => void;
}
export interface RowActionsProps {
@@ -443,6 +445,13 @@ export const RowActions = memo(function RowActions({
label="Duplicate as Child"
onClick={withClose(handlers.onDuplicateAsChild)}
/>
{handlers.onDuplicateAsChildMultiple && (
<MenuItem
icon={Repeat}
label="Duplicate as Child ×N"
onClick={withClose(handlers.onDuplicateAsChildMultiple)}
/>
)}
</DropdownMenuSubContent>
)}
</DropdownMenuSub>
@@ -565,6 +574,13 @@ export const RowActions = memo(function RowActions({
label="Duplicate as Child"
onClick={withClose(handlers.onDuplicateAsChild)}
/>
{handlers.onDuplicateAsChildMultiple && (
<MenuItem
icon={Repeat}
label="Duplicate as Child ×N"
onClick={withClose(handlers.onDuplicateAsChildMultiple)}
/>
)}
</DropdownMenuSubContent>
)}
</DropdownMenuSub>
@@ -636,6 +652,13 @@ export const RowActions = memo(function RowActions({
label="Duplicate as Child"
onClick={withClose(handlers.onDuplicateAsChild)}
/>
{handlers.onDuplicateAsChildMultiple && (
<MenuItem
icon={Repeat}
label="Duplicate as Child ×N"
onClick={withClose(handlers.onDuplicateAsChildMultiple)}
/>
)}
</DropdownMenuSubContent>
)}
</DropdownMenuSub>
@@ -712,6 +735,13 @@ export const RowActions = memo(function RowActions({
label="Duplicate as Child"
onClick={withClose(handlers.onDuplicateAsChild)}
/>
{handlers.onDuplicateAsChildMultiple && (
<MenuItem
icon={Repeat}
label="Duplicate as Child ×N"
onClick={withClose(handlers.onDuplicateAsChildMultiple)}
/>
)}
</DropdownMenuSubContent>
)}
</DropdownMenuSub>
@@ -764,6 +794,13 @@ export const RowActions = memo(function RowActions({
label="Duplicate as Child"
onClick={withClose(handlers.onDuplicateAsChild)}
/>
{handlers.onDuplicateAsChildMultiple && (
<MenuItem
icon={Repeat}
label="Duplicate as Child ×N"
onClick={withClose(handlers.onDuplicateAsChildMultiple)}
/>
)}
</DropdownMenuSubContent>
)}
</DropdownMenuSub>
@@ -804,6 +841,7 @@ export function createRowActionHandlers(
spawnTask?: (id: string) => void;
duplicate?: (id: string) => void;
duplicateAsChild?: (id: string) => void;
duplicateAsChildMultiple?: (id: string) => void;
}
): RowActionHandlers {
return {
@@ -824,5 +862,8 @@ export function createRowActionHandlers(
onDuplicateAsChild: actions.duplicateAsChild
? () => actions.duplicateAsChild!(featureId)
: undefined,
onDuplicateAsChildMultiple: actions.duplicateAsChildMultiple
? () => actions.duplicateAsChildMultiple!(featureId)
: undefined,
};
}

View File

@@ -0,0 +1,757 @@
import { useState, useEffect, useCallback } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
GitCommit,
AlertTriangle,
Wrench,
User,
Clock,
Copy,
Check,
Cherry,
ChevronDown,
ChevronRight,
FileText,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { getHttpApiClient } from '@/lib/http-api-client';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import type { WorktreeInfo, MergeConflictInfo } from '../worktree-panel/types';
export interface CherryPickConflictInfo {
commitHashes: string[];
targetBranch: string;
targetWorktreePath: string;
}
interface RemoteInfo {
name: string;
url: string;
branches: Array<{
name: string;
fullRef: string;
}>;
}
interface CommitInfo {
hash: string;
shortHash: string;
author: string;
authorEmail: string;
date: string;
subject: string;
body: string;
files: string[];
}
interface CherryPickDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
worktree: WorktreeInfo | null;
onCherryPicked: () => void;
onCreateConflictResolutionFeature?: (conflictInfo: MergeConflictInfo) => void;
}
function formatRelativeDate(dateStr: string): string {
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSecs = Math.floor(diffMs / 1000);
const diffMins = Math.floor(diffSecs / 60);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
const diffWeeks = Math.floor(diffDays / 7);
const diffMonths = Math.floor(diffDays / 30);
if (diffSecs < 60) return 'just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
if (diffWeeks < 5) return `${diffWeeks}w ago`;
if (diffMonths < 12) return `${diffMonths}mo ago`;
return date.toLocaleDateString();
}
function CopyHashButton({ hash }: { hash: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = async (e: React.MouseEvent) => {
e.stopPropagation();
try {
await navigator.clipboard.writeText(hash);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
toast.error('Failed to copy hash');
}
};
return (
<button
onClick={handleCopy}
className="inline-flex items-center gap-1 font-mono text-[11px] bg-muted hover:bg-muted/80 px-1.5 py-0.5 rounded cursor-pointer transition-colors"
title={`Copy full hash: ${hash}`}
>
{copied ? (
<Check className="w-2.5 h-2.5 text-green-500" />
) : (
<Copy className="w-2.5 h-2.5 text-muted-foreground" />
)}
<span className="text-muted-foreground">{hash.slice(0, 7)}</span>
</button>
);
}
type Step = 'select-branch' | 'select-commits' | 'conflict';
export function CherryPickDialog({
open,
onOpenChange,
worktree,
onCherryPicked,
onCreateConflictResolutionFeature,
}: CherryPickDialogProps) {
// Step management
const [step, setStep] = useState<Step>('select-branch');
// Branch selection state
const [remotes, setRemotes] = useState<RemoteInfo[]>([]);
const [localBranches, setLocalBranches] = useState<string[]>([]);
const [selectedRemote, setSelectedRemote] = useState<string>('');
const [selectedBranch, setSelectedBranch] = useState<string>('');
const [loadingBranches, setLoadingBranches] = useState(false);
// Commits state
const [commits, setCommits] = useState<CommitInfo[]>([]);
const [selectedCommitHashes, setSelectedCommitHashes] = useState<Set<string>>(new Set());
const [expandedCommits, setExpandedCommits] = useState<Set<string>>(new Set());
const [loadingCommits, setLoadingCommits] = useState(false);
const [loadingMoreCommits, setLoadingMoreCommits] = useState(false);
const [commitsError, setCommitsError] = useState<string | null>(null);
const [commitLimit, setCommitLimit] = useState(30);
const [hasMoreCommits, setHasMoreCommits] = useState(false);
// Cherry-pick state
const [isCherryPicking, setIsCherryPicking] = useState(false);
// Conflict state
const [conflictInfo, setConflictInfo] = useState<CherryPickConflictInfo | null>(null);
// All available branch options for the current remote selection
const branchOptions =
selectedRemote === '__local__'
? localBranches.filter((b) => b !== worktree?.branch)
: (remotes.find((r) => r.name === selectedRemote)?.branches || []).map((b) => b.fullRef);
// Reset state when dialog opens
useEffect(() => {
if (open) {
setStep('select-branch');
setSelectedRemote('');
setSelectedBranch('');
setCommits([]);
setSelectedCommitHashes(new Set());
setExpandedCommits(new Set());
setConflictInfo(null);
setCommitsError(null);
setCommitLimit(30);
setHasMoreCommits(false);
}
}, [open]);
// Fetch remotes and local branches when dialog opens
useEffect(() => {
if (!open || !worktree) return;
const fetchBranchData = async () => {
setLoadingBranches(true);
try {
const api = getHttpApiClient();
// Fetch remotes and local branches in parallel
const [remotesResult, branchesResult] = await Promise.all([
api.worktree.listRemotes(worktree.path),
api.worktree.listBranches(worktree.path, false),
]);
if (remotesResult.success && remotesResult.result) {
setRemotes(remotesResult.result.remotes);
// Default to first remote if available, otherwise local
if (remotesResult.result.remotes.length > 0) {
setSelectedRemote(remotesResult.result.remotes[0].name);
} else {
setSelectedRemote('__local__');
}
}
if (branchesResult.success && branchesResult.result) {
const branches = branchesResult.result.branches
.filter(
(b: { isRemote: boolean; name: string }) => !b.isRemote && b.name !== worktree.branch
)
.map((b: { name: string }) => b.name);
setLocalBranches(branches);
}
} catch (err) {
console.error('Failed to fetch branch data:', err);
} finally {
setLoadingBranches(false);
}
};
fetchBranchData();
}, [open, worktree]);
// Fetch commits when branch is selected
const fetchCommits = useCallback(
async (limit: number = 30, append: boolean = false) => {
if (!worktree || !selectedBranch) return;
if (append) {
setLoadingMoreCommits(true);
} else {
setLoadingCommits(true);
setCommitsError(null);
setCommits([]);
setSelectedCommitHashes(new Set());
}
try {
const api = getHttpApiClient();
const result = await api.worktree.getBranchCommitLog(worktree.path, selectedBranch, limit);
if (result.success && result.result) {
setCommits(result.result.commits);
// If we got exactly the limit, there may be more commits
setHasMoreCommits(result.result.commits.length >= limit);
} else {
setCommitsError(result.error || 'Failed to load commits');
}
} catch (err) {
setCommitsError(err instanceof Error ? err.message : 'Failed to load commits');
} finally {
setLoadingCommits(false);
setLoadingMoreCommits(false);
}
},
[worktree, selectedBranch]
);
// Handle proceeding from branch selection to commit selection
const handleProceedToCommits = useCallback(() => {
if (!selectedBranch) return;
setStep('select-commits');
fetchCommits(commitLimit);
}, [selectedBranch, fetchCommits, commitLimit]);
// Handle loading more commits
const handleLoadMore = useCallback(() => {
const newLimit = Math.min(commitLimit + 30, 100);
setCommitLimit(newLimit);
fetchCommits(newLimit, true);
}, [commitLimit, fetchCommits]);
// Toggle commit selection
const toggleCommitSelection = useCallback((hash: string) => {
setSelectedCommitHashes((prev) => {
const next = new Set(prev);
if (next.has(hash)) {
next.delete(hash);
} else {
next.add(hash);
}
return next;
});
}, []);
// Toggle commit file list expansion
const toggleCommitExpanded = useCallback((hash: string, e: React.MouseEvent) => {
e.stopPropagation();
setExpandedCommits((prev) => {
const next = new Set(prev);
if (next.has(hash)) {
next.delete(hash);
} else {
next.add(hash);
}
return next;
});
}, []);
// Handle cherry-pick execution
const handleCherryPick = useCallback(async () => {
if (!worktree || selectedCommitHashes.size === 0) return;
setIsCherryPicking(true);
try {
const api = getHttpApiClient();
// Order commits from oldest to newest (reverse of display order)
// so they're applied in chronological order
const orderedHashes = commits
.filter((c) => selectedCommitHashes.has(c.hash))
.reverse()
.map((c) => c.hash);
const result = await api.worktree.cherryPick(worktree.path, orderedHashes);
if (result.success) {
toast.success(`Cherry-picked ${orderedHashes.length} commit(s)`, {
description: `Successfully applied to ${worktree.branch}`,
});
onCherryPicked();
onOpenChange(false);
} else {
// Check for conflicts
const errorMessage = result.error || '';
const hasConflicts =
errorMessage.toLowerCase().includes('conflict') ||
(result as { hasConflicts?: boolean }).hasConflicts;
if (hasConflicts && onCreateConflictResolutionFeature) {
setConflictInfo({
commitHashes: orderedHashes,
targetBranch: worktree.branch,
targetWorktreePath: worktree.path,
});
setStep('conflict');
toast.error('Cherry-pick conflicts detected', {
description: 'The cherry-pick has conflicts that need to be resolved.',
});
} else {
toast.error('Cherry-pick failed', {
description: result.error,
});
}
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
const hasConflicts =
errorMessage.toLowerCase().includes('conflict') ||
errorMessage.toLowerCase().includes('cherry-pick failed');
if (hasConflicts && onCreateConflictResolutionFeature) {
const orderedHashes = commits
.filter((c) => selectedCommitHashes.has(c.hash))
.reverse()
.map((c) => c.hash);
setConflictInfo({
commitHashes: orderedHashes,
targetBranch: worktree.branch,
targetWorktreePath: worktree.path,
});
setStep('conflict');
toast.error('Cherry-pick conflicts detected', {
description: 'The cherry-pick has conflicts that need to be resolved.',
});
} else {
toast.error('Cherry-pick failed', {
description: errorMessage,
});
}
} finally {
setIsCherryPicking(false);
}
}, [
worktree,
selectedCommitHashes,
commits,
onCherryPicked,
onOpenChange,
onCreateConflictResolutionFeature,
]);
// Handle creating a conflict resolution feature
const handleCreateConflictResolutionFeature = useCallback(() => {
if (conflictInfo && onCreateConflictResolutionFeature) {
onCreateConflictResolutionFeature({
sourceBranch: selectedBranch,
targetBranch: conflictInfo.targetBranch,
targetWorktreePath: conflictInfo.targetWorktreePath,
});
onOpenChange(false);
}
}, [conflictInfo, selectedBranch, onCreateConflictResolutionFeature, onOpenChange]);
if (!worktree) return null;
// Conflict resolution UI
if (step === 'conflict' && conflictInfo) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-orange-500" />
Cherry-Pick Conflicts Detected
</DialogTitle>
<DialogDescription asChild>
<div className="space-y-4">
<span className="block">
There are conflicts when cherry-picking commits from{' '}
<code className="font-mono bg-muted px-1 rounded">{selectedBranch}</code> into{' '}
<code className="font-mono bg-muted px-1 rounded">
{conflictInfo.targetBranch}
</code>
.
</span>
<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 cherry-pick could not be completed automatically. You can create a feature
task to resolve the conflicts in the{' '}
<code className="font-mono bg-muted px-0.5 rounded">
{conflictInfo.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>
Cherry-pick the selected commit(s) from{' '}
<code className="font-mono bg-muted px-0.5 rounded">{selectedBranch}</code>
</li>
<li>Resolve any merge conflicts</li>
<li>Ensure the code compiles and tests pass</li>
</ul>
</div>
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={() => setStep('select-commits')}>
Back
</Button>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={handleCreateConflictResolutionFeature}
className="bg-purple-600 hover:bg-purple-700 text-white"
>
<Wrench className="w-4 h-4 mr-2" />
Create Resolve Conflicts Feature
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// Step 2: Select commits
if (step === 'select-commits') {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[640px] sm:max-h-[100dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Cherry className="w-5 h-5 text-black dark:text-black" />
Cherry Pick Commits
</DialogTitle>
<DialogDescription>
Select commits from{' '}
<code className="font-mono bg-muted px-1 rounded">{selectedBranch}</code> to apply to{' '}
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>
</DialogDescription>
</DialogHeader>
<div className="flex-1 sm:min-h-[400px] sm:max-h-[60vh] overflow-y-auto scrollbar-visible -mx-6 -mb-6">
<div className="h-full px-6 pb-6">
{loadingCommits && (
<div className="flex items-center justify-center py-12">
<Spinner size="md" />
<span className="ml-2 text-sm text-muted-foreground">Loading commits...</span>
</div>
)}
{commitsError && (
<div className="flex items-center justify-center py-12">
<p className="text-sm text-destructive">{commitsError}</p>
</div>
)}
{!loadingCommits && !commitsError && commits.length === 0 && (
<div className="flex items-center justify-center py-12">
<p className="text-sm text-muted-foreground">No commits found on this branch</p>
</div>
)}
{!loadingCommits && !commitsError && commits.length > 0 && (
<div className="space-y-0.5 mt-2">
{commits.map((commit, index) => {
const isSelected = selectedCommitHashes.has(commit.hash);
const isExpanded = expandedCommits.has(commit.hash);
const hasFiles = commit.files && commit.files.length > 0;
return (
<div
key={commit.hash}
className={cn(
'group relative rounded-md transition-colors',
isSelected
? 'bg-primary/10 border border-primary/30'
: 'border border-transparent',
index === 0 && !isSelected && 'bg-muted/30'
)}
>
<div
onClick={() => toggleCommitSelection(commit.hash)}
className={cn(
'flex gap-3 py-2.5 px-3 cursor-pointer rounded-md transition-colors',
!isSelected && 'hover:bg-muted/50'
)}
>
{/* Checkbox */}
<div className="flex items-start pt-1 shrink-0">
<Checkbox
checked={isSelected}
onCheckedChange={() => toggleCommitSelection(commit.hash)}
onClick={(e) => e.stopPropagation()}
className="mt-0.5"
/>
</div>
{/* Commit content */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium leading-snug break-words">
{commit.subject}
</p>
<CopyHashButton hash={commit.hash} />
</div>
{commit.body && (
<p className="text-xs text-muted-foreground mt-1 whitespace-pre-wrap break-words line-clamp-2">
{commit.body}
</p>
)}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 mt-1.5 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1">
<User className="w-3 h-3" />
{commit.author}
</span>
<span className="inline-flex items-center gap-1">
<Clock className="w-3 h-3" />
<time
dateTime={commit.date}
title={new Date(commit.date).toLocaleString()}
>
{formatRelativeDate(commit.date)}
</time>
</span>
{hasFiles && (
<button
onClick={(e) => toggleCommitExpanded(commit.hash, e)}
className="inline-flex items-center gap-1 hover:text-foreground transition-colors cursor-pointer"
>
{isExpanded ? (
<ChevronDown className="w-3 h-3" />
) : (
<ChevronRight className="w-3 h-3" />
)}
<FileText className="w-3 h-3" />
{commit.files.length} file{commit.files.length !== 1 ? 's' : ''}
</button>
)}
</div>
</div>
</div>
{/* Expanded file list */}
{isExpanded && hasFiles && (
<div className="border-t mx-3 px-3 py-2 bg-muted/30 rounded-b-md ml-8">
<div className="space-y-0.5">
{commit.files.map((file) => (
<div
key={file}
className="flex items-center gap-2 text-xs text-muted-foreground py-0.5"
>
<FileText className="w-3 h-3 shrink-0" />
<span className="font-mono break-all">{file}</span>
</div>
))}
</div>
</div>
)}
</div>
);
})}
{/* Load More button */}
{hasMoreCommits && commitLimit < 100 && (
<div className="flex justify-center pt-3 pb-1">
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleLoadMore();
}}
disabled={loadingMoreCommits}
className="text-xs text-muted-foreground hover:text-foreground"
>
{loadingMoreCommits ? (
<>
<Spinner size="sm" className="mr-2" />
Loading...
</>
) : (
<>
<ChevronDown className="w-3.5 h-3.5 mr-1.5" />
Load More Commits
</>
)}
</Button>
</div>
)}
</div>
)}
</div>
</div>
<DialogFooter className="mt-4 pt-4 border-t">
<Button
variant="ghost"
onClick={() => {
setStep('select-branch');
setSelectedBranch('');
}}
>
Back
</Button>
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={isCherryPicking}>
Cancel
</Button>
<Button
onClick={handleCherryPick}
disabled={selectedCommitHashes.size === 0 || isCherryPicking}
>
{isCherryPicking ? (
<>
<Spinner size="sm" variant="foreground" className="mr-2" />
Cherry Picking...
</>
) : (
<>
<Cherry className="w-4 h-4 mr-2" />
Cherry Pick
{selectedCommitHashes.size > 0 ? ` (${selectedCommitHashes.size})` : ''}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// Step 1: Select branch (and optionally remote)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Cherry className="w-5 h-5 text-black dark:text-black" />
Cherry Pick
</DialogTitle>
<DialogDescription asChild>
<div className="space-y-4">
<span className="block">
Select a branch to cherry-pick commits from into{' '}
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>
</span>
{loadingBranches ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Spinner size="sm" />
Loading branches...
</div>
) : (
<>
{/* Remote selector - only show if there are remotes */}
{remotes.length > 0 && (
<div className="space-y-2">
<Label className="text-sm text-foreground">Source</Label>
<Select
value={selectedRemote}
onValueChange={(value) => {
setSelectedRemote(value);
setSelectedBranch('');
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select source..." />
</SelectTrigger>
<SelectContent className="text-black dark:text-black">
<SelectItem value="__local__">Local Branches</SelectItem>
{remotes.map((remote) => (
<SelectItem key={remote.name} value={remote.name}>
{remote.name} ({remote.url})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Branch selector */}
<div className="space-y-2">
<Label className="text-sm text-foreground">Branch</Label>
{branchOptions.length === 0 ? (
<p className="text-sm text-muted-foreground">No other branches available</p>
) : (
<Select value={selectedBranch} onValueChange={setSelectedBranch}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a branch..." />
</SelectTrigger>
<SelectContent className="text-black dark:text-black">
{branchOptions.map((branch) => (
<SelectItem key={branch} value={branch}>
{branch}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</>
)}
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleProceedToCommits} disabled={!selectedBranch || loadingBranches}>
<GitCommit className="w-4 h-4 mr-2" />
View Commits
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -307,6 +307,8 @@ export function CommitWorktreeDialog({
setSelectedFiles(new Set());
setExpandedFile(null);
let cancelled = false;
const loadDiffs = async () => {
try {
const api = getElectronAPI();
@@ -314,20 +316,24 @@ export function CommitWorktreeDialog({
const result = await api.git.getDiffs(worktree.path);
if (result.success) {
const fileList = result.files ?? [];
setFiles(fileList);
setDiffContent(result.diff ?? '');
if (!cancelled) setFiles(fileList);
if (!cancelled) setDiffContent(result.diff ?? '');
// Select all files by default
setSelectedFiles(new Set(fileList.map((f) => f.path)));
if (!cancelled) setSelectedFiles(new Set(fileList.map((f) => f.path)));
}
}
} catch (err) {
console.warn('Failed to load diffs for commit dialog:', err);
} finally {
setIsLoadingDiffs(false);
if (!cancelled) setIsLoadingDiffs(false);
}
};
loadDiffs();
return () => {
cancelled = true;
};
}
}, [open, worktree]);

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger';
import {
Dialog,
@@ -11,9 +11,20 @@ import {
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { getElectronAPI } from '@/lib/electron';
import { getHttpApiClient } from '@/lib/http-api-client';
import { toast } from 'sonner';
import { GitBranchPlus } from 'lucide-react';
import { GitBranchPlus, RefreshCw } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
interface WorktreeInfo {
@@ -24,6 +35,12 @@ interface WorktreeInfo {
changedFilesCount?: number;
}
interface BranchInfo {
name: string;
isCurrent: boolean;
isRemote: boolean;
}
const logger = createLogger('CreateBranchDialog');
interface CreateBranchDialogProps {
@@ -40,16 +57,45 @@ export function CreateBranchDialog({
onCreated,
}: CreateBranchDialogProps) {
const [branchName, setBranchName] = useState('');
const [baseBranch, setBaseBranch] = useState('');
const [branches, setBranches] = useState<BranchInfo[]>([]);
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [error, setError] = useState<string | null>(null);
// Reset state when dialog opens/closes
const fetchBranches = useCallback(async () => {
if (!worktree) return;
setIsLoadingBranches(true);
try {
const api = getHttpApiClient();
const result = await api.worktree.listBranches(worktree.path, true);
if (result.success && result.result) {
setBranches(result.result.branches);
// Default to current branch
if (result.result.currentBranch) {
setBaseBranch(result.result.currentBranch);
}
}
} catch (err) {
logger.error('Failed to fetch branches:', err);
} finally {
setIsLoadingBranches(false);
}
}, [worktree]);
// Reset state and fetch branches when dialog opens
useEffect(() => {
if (open) {
setBranchName('');
setBaseBranch('');
setError(null);
setBranches([]);
fetchBranches();
}
}, [open]);
}, [open, fetchBranches]);
const handleCreate = async () => {
if (!worktree || !branchName.trim()) return;
@@ -71,7 +117,13 @@ export function CreateBranchDialog({
return;
}
const result = await api.worktree.checkoutBranch(worktree.path, branchName.trim());
// Pass baseBranch if user selected one different from the current branch
const selectedBase = baseBranch || undefined;
const result = await api.worktree.checkoutBranch(
worktree.path,
branchName.trim(),
selectedBase
);
if (result.success && result.result) {
toast.success(result.result.message);
@@ -88,6 +140,10 @@ export function CreateBranchDialog({
}
};
// Separate local and remote branches
const localBranches = branches.filter((b) => !b.isRemote);
const remoteBranches = branches.filter((b) => b.isRemote);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
@@ -96,12 +152,7 @@ export function CreateBranchDialog({
<GitBranchPlus className="w-5 h-5" />
Create New Branch
</DialogTitle>
<DialogDescription>
Create a new branch from{' '}
<span className="font-mono text-foreground">
{worktree?.branch || 'current branch'}
</span>
</DialogDescription>
<DialogDescription>Create a new branch from a base branch</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
@@ -123,8 +174,74 @@ export function CreateBranchDialog({
disabled={isCreating}
autoFocus
/>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<div className="grid gap-2">
<div className="flex items-center justify-between">
<Label htmlFor="base-branch">Base Branch</Label>
<Button
variant="ghost"
size="sm"
onClick={fetchBranches}
disabled={isLoadingBranches || isCreating}
className="h-6 px-2 text-xs"
>
{isLoadingBranches ? (
<Spinner size="xs" className="mr-1" />
) : (
<RefreshCw className="w-3 h-3 mr-1" />
)}
Refresh
</Button>
</div>
{isLoadingBranches && branches.length === 0 ? (
<div className="flex items-center justify-center py-3 border rounded-md border-input">
<Spinner size="sm" className="mr-2" />
<span className="text-sm text-muted-foreground">Loading branches...</span>
</div>
) : (
<Select value={baseBranch} onValueChange={setBaseBranch} disabled={isCreating}>
<SelectTrigger id="base-branch">
<SelectValue placeholder="Select base branch" />
</SelectTrigger>
<SelectContent>
{localBranches.length > 0 && (
<SelectGroup>
<SelectLabel>Local Branches</SelectLabel>
{localBranches.map((branch) => (
<SelectItem key={branch.name} value={branch.name}>
<span className={branch.isCurrent ? 'font-medium' : ''}>
{branch.name}
{branch.isCurrent ? ' (current)' : ''}
</span>
</SelectItem>
))}
</SelectGroup>
)}
{remoteBranches.length > 0 && (
<>
{localBranches.length > 0 && <SelectSeparator />}
<SelectGroup>
<SelectLabel>Remote Branches</SelectLabel>
{remoteBranches.map((branch) => (
<SelectItem key={branch.name} value={branch.name}>
{branch.name}
</SelectItem>
))}
</SelectGroup>
</>
)}
{localBranches.length === 0 && remoteBranches.length === 0 && (
<SelectItem value="HEAD" disabled>
No branches found
</SelectItem>
)}
</SelectContent>
</Select>
)}
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<DialogFooter>

View File

@@ -13,12 +13,25 @@ import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { BranchAutocomplete } from '@/components/ui/branch-autocomplete';
import { GitPullRequest, ExternalLink } from 'lucide-react';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { GitPullRequest, ExternalLink, Sparkles, RefreshCw } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI } from '@/lib/electron';
import { getHttpApiClient } from '@/lib/http-api-client';
import { toast } from 'sonner';
import { useWorktreeBranches } from '@/hooks/queries';
interface RemoteInfo {
name: string;
url: string;
}
interface WorktreeInfo {
path: string;
branch: string;
@@ -58,6 +71,14 @@ export function CreatePRDialog({
// Track whether an operation completed that warrants a refresh
const operationCompletedRef = useRef(false);
// Remote selection state
const [remotes, setRemotes] = useState<RemoteInfo[]>([]);
const [selectedRemote, setSelectedRemote] = useState<string>('');
const [isLoadingRemotes, setIsLoadingRemotes] = useState(false);
// Generate description state
const [isGeneratingDescription, setIsGeneratingDescription] = useState(false);
// Use React Query for branch fetching - only enabled when dialog is open
const { data: branchesData, isLoading: isLoadingBranches } = useWorktreeBranches(
open ? worktree?.path : undefined,
@@ -70,6 +91,44 @@ export function CreatePRDialog({
return branchesData.branches.map((b) => b.name).filter((name) => name !== worktree?.branch);
}, [branchesData?.branches, worktree?.branch]);
// Fetch remotes when dialog opens
const fetchRemotes = useCallback(async () => {
if (!worktree) return;
setIsLoadingRemotes(true);
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: string; url: string }) => ({
name: r.name,
url: r.url,
})
);
setRemotes(remoteInfos);
// Auto-select 'origin' if available, otherwise first remote
if (remoteInfos.length > 0) {
const defaultRemote = remoteInfos.find((r) => r.name === 'origin') || remoteInfos[0];
setSelectedRemote(defaultRemote.name);
}
}
} catch {
// Silently fail - remotes selector will just not show
} finally {
setIsLoadingRemotes(false);
}
}, [worktree]);
useEffect(() => {
if (open && worktree) {
fetchRemotes();
}
}, [open, worktree, fetchRemotes]);
// Common state reset function to avoid duplication
const resetState = useCallback(() => {
setTitle('');
@@ -81,6 +140,9 @@ export function CreatePRDialog({
setPrUrl(null);
setBrowserUrl(null);
setShowBrowserFallback(false);
setRemotes([]);
setSelectedRemote('');
setIsGeneratingDescription(false);
operationCompletedRef.current = false;
}, [defaultBaseBranch]);
@@ -90,6 +152,37 @@ export function CreatePRDialog({
resetState();
}, [open, worktree?.path, resetState]);
const handleGenerateDescription = async () => {
if (!worktree) return;
setIsGeneratingDescription(true);
try {
const api = getHttpApiClient();
const result = await api.worktree.generatePRDescription(worktree.path, baseBranch);
if (result.success) {
if (result.title) {
setTitle(result.title);
}
if (result.body) {
setBody(result.body);
}
toast.success('PR description generated');
} else {
toast.error('Failed to generate description', {
description: result.error || 'Unknown error',
});
}
} catch (err) {
toast.error('Failed to generate description', {
description: err instanceof Error ? err.message : 'Unknown error',
});
} finally {
setIsGeneratingDescription(false);
}
};
const handleCreate = async () => {
if (!worktree) return;
@@ -109,6 +202,7 @@ export function CreatePRDialog({
prBody: body || `Changes from branch ${worktree.branch}`,
baseBranch,
draft: isDraft,
remote: selectedRemote || undefined,
});
if (result.success && result.result) {
@@ -329,7 +423,33 @@ export function CreatePRDialog({
)}
<div className="grid gap-2">
<Label htmlFor="pr-title">PR Title</Label>
<div className="flex items-center justify-between">
<Label htmlFor="pr-title">PR Title</Label>
<Button
variant="ghost"
size="sm"
onClick={handleGenerateDescription}
disabled={isGeneratingDescription || isLoading}
className="h-6 px-2 text-xs"
title={
worktree.hasChanges
? 'Generate title and description from commits and uncommitted changes'
: 'Generate title and description from commits'
}
>
{isGeneratingDescription ? (
<>
<Spinner size="xs" className="mr-1" />
Generating...
</>
) : (
<>
<Sparkles className="w-3 h-3 mr-1" />
Generate with AI
</>
)}
</Button>
</div>
<Input
id="pr-title"
placeholder={worktree.branch}
@@ -350,6 +470,49 @@ export function CreatePRDialog({
</div>
<div className="flex flex-col gap-4">
{/* Remote selector - only show if multiple remotes are available */}
{remotes.length > 1 && (
<div className="grid gap-2">
<div className="flex items-center justify-between">
<Label htmlFor="remote-select">Push to Remote</Label>
<Button
variant="ghost"
size="sm"
onClick={fetchRemotes}
disabled={isLoadingRemotes}
className="h-6 px-2 text-xs"
>
{isLoadingRemotes ? (
<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}
description={
<span className="text-xs text-muted-foreground truncate max-w-[300px]">
{remote.url}
</span>
}
>
<span className="font-medium">{remote.name}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="grid gap-2">
<Label htmlFor="base-branch">Base Branch</Label>
<BranchAutocomplete

View File

@@ -313,8 +313,8 @@ export function DiscardWorktreeChangesDialog({
const fileList = result.files ?? [];
setFiles(fileList);
setDiffContent(result.diff ?? '');
// Select all files by default
setSelectedFiles(new Set(fileList.map((f) => f.path)));
// No files selected by default
setSelectedFiles(new Set());
}
}
} catch (err) {

View File

@@ -0,0 +1,110 @@
import { useState, useEffect } from 'react';
import { Copy } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { HotkeyButton } from '@/components/ui/hotkey-button';
import { Input } from '@/components/ui/input';
interface DuplicateCountDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: (count: number) => void;
featureTitle?: string;
}
export function DuplicateCountDialog({
open,
onOpenChange,
onConfirm,
featureTitle,
}: DuplicateCountDialogProps) {
const [count, setCount] = useState(2);
// Reset count when dialog opens
useEffect(() => {
if (open) {
setCount(2);
}
}, [open]);
const handleConfirm = () => {
if (count >= 1 && count <= 50) {
onConfirm(count);
onOpenChange(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="bg-popover border-border max-w-sm">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Copy className="w-5 h-5 text-primary" />
Duplicate as Child ×N
</DialogTitle>
<DialogDescription className="text-muted-foreground">
Creates a chain of duplicates where each is a child of the previous, so they execute
sequentially.
{featureTitle && (
<span className="block mt-1 text-xs">
Source: <span className="font-medium">{featureTitle}</span>
</span>
)}
</DialogDescription>
</DialogHeader>
<div className="py-2">
<label htmlFor="duplicate-count" className="text-sm text-muted-foreground mb-2 block">
Number of copies
</label>
<Input
id="duplicate-count"
type="number"
min={1}
max={50}
value={count}
onChange={(e) => {
const val = parseInt(e.target.value, 10);
if (!isNaN(val)) {
setCount(Math.min(50, Math.max(1, val)));
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleConfirm();
}
}}
className="w-full"
autoFocus
/>
<p className="text-xs text-muted-foreground mt-1.5">Enter a number between 1 and 50</p>
</div>
<DialogFooter className="gap-2 sm:gap-2 pt-2">
<Button variant="ghost" onClick={() => onOpenChange(false)} className="px-4">
Cancel
</Button>
<HotkeyButton
variant="default"
onClick={handleConfirm}
hotkey={{ key: 'Enter', cmdCtrl: true }}
hotkeyActive={open}
className="px-4"
disabled={count < 1 || count > 50}
>
<Copy className="w-4 h-4 mr-2" />
Create {count} {count === 1 ? 'Copy' : 'Copies'}
</HotkeyButton>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -5,14 +5,20 @@ export { CompletedFeaturesModal } from './completed-features-modal';
export { ArchiveAllVerifiedDialog } from './archive-all-verified-dialog';
export { DeleteCompletedFeatureDialog } from './delete-completed-feature-dialog';
export { DependencyLinkDialog, type DependencyLinkType } from './dependency-link-dialog';
export { DuplicateCountDialog } from './duplicate-count-dialog';
export { DiscardWorktreeChangesDialog } from './discard-worktree-changes-dialog';
export { EditFeatureDialog } from './edit-feature-dialog';
export { FollowUpDialog, type FollowUpHistoryEntry } from './follow-up-dialog';
export { MergeWorktreeDialog, type MergeConflictInfo } from './merge-worktree-dialog';
export { PlanApprovalDialog } from './plan-approval-dialog';
export { MassEditDialog } from './mass-edit-dialog';
export { PullResolveConflictsDialog } from './pull-resolve-conflicts-dialog';
export { MergeRebaseDialog, type PullStrategy } from './merge-rebase-dialog';
export { PushToRemoteDialog } from './push-to-remote-dialog';
export { SelectRemoteDialog, type SelectRemoteOperation } from './select-remote-dialog';
export { ViewCommitsDialog } from './view-commits-dialog';
export { ViewWorktreeChangesDialog } from './view-worktree-changes-dialog';
export { ExportFeaturesDialog } from './export-features-dialog';
export { ImportFeaturesDialog } from './import-features-dialog';
export { StashChangesDialog } from './stash-changes-dialog';
export { ViewStashesDialog } from './view-stashes-dialog';
export { CherryPickDialog } from './cherry-pick-dialog';

View File

@@ -21,10 +21,12 @@ import {
} from '@/components/ui/select';
import { getHttpApiClient } from '@/lib/http-api-client';
import { toast } from 'sonner';
import { GitMerge, RefreshCw, AlertTriangle } from 'lucide-react';
import { GitMerge, RefreshCw, AlertTriangle, GitBranch } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import type { WorktreeInfo } from '../worktree-panel/types';
export type PullStrategy = 'merge' | 'rebase';
interface RemoteBranch {
name: string;
fullRef: string;
@@ -36,24 +38,29 @@ interface RemoteInfo {
branches: RemoteBranch[];
}
const logger = createLogger('PullResolveConflictsDialog');
const logger = createLogger('MergeRebaseDialog');
interface PullResolveConflictsDialogProps {
interface MergeRebaseDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
worktree: WorktreeInfo | null;
onConfirm: (worktree: WorktreeInfo, remoteBranch: string) => void | Promise<void>;
onConfirm: (
worktree: WorktreeInfo,
remoteBranch: string,
strategy: PullStrategy
) => void | Promise<void>;
}
export function PullResolveConflictsDialog({
export function MergeRebaseDialog({
open,
onOpenChange,
worktree,
onConfirm,
}: PullResolveConflictsDialogProps) {
}: MergeRebaseDialogProps) {
const [remotes, setRemotes] = useState<RemoteInfo[]>([]);
const [selectedRemote, setSelectedRemote] = useState<string>('');
const [selectedBranch, setSelectedBranch] = useState<string>('');
const [selectedStrategy, setSelectedStrategy] = useState<PullStrategy>('merge');
const [isLoading, setIsLoading] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -70,6 +77,7 @@ export function PullResolveConflictsDialog({
if (!open) {
setSelectedRemote('');
setSelectedBranch('');
setSelectedStrategy('merge');
setError(null);
}
}, [open]);
@@ -161,7 +169,7 @@ export function PullResolveConflictsDialog({
const handleConfirm = () => {
if (!worktree || !selectedBranch) return;
onConfirm(worktree, selectedBranch);
onConfirm(worktree, selectedBranch, selectedStrategy);
onOpenChange(false);
};
@@ -174,10 +182,10 @@ export function PullResolveConflictsDialog({
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<GitMerge className="w-5 h-5 text-purple-500" />
Pull & Resolve Conflicts
Merge & Rebase
</DialogTitle>
<DialogDescription>
Select a remote branch to pull from and resolve conflicts with{' '}
Select a remote branch to merge or rebase with{' '}
<span className="font-mono text-foreground">
{worktree?.branch || 'current branch'}
</span>
@@ -225,13 +233,16 @@ export function PullResolveConflictsDialog({
</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>
<SelectItem
key={remote.name}
value={remote.name}
description={
<span className="text-xs text-muted-foreground truncate max-w-[300px]">
{remote.url}
</span>
</div>
}
>
<span className="font-medium">{remote.name}</span>
</SelectItem>
))}
</SelectContent>
@@ -264,13 +275,62 @@ export function PullResolveConflictsDialog({
)}
</div>
<div className="grid gap-2">
<Label htmlFor="strategy-select">Strategy</Label>
<Select
value={selectedStrategy}
onValueChange={(value) => setSelectedStrategy(value as PullStrategy)}
>
<SelectTrigger id="strategy-select">
<SelectValue placeholder="Select a strategy" />
</SelectTrigger>
<SelectContent>
<SelectItem
value="merge"
description={
<span className="text-xs text-muted-foreground">
Creates a merge commit preserving history
</span>
}
>
<span className="flex items-center gap-2">
<GitMerge className="w-3.5 h-3.5 text-purple-500" />
<span className="font-medium">Merge</span>
</span>
</SelectItem>
<SelectItem
value="rebase"
description={
<span className="text-xs text-muted-foreground">
Replays commits on top for linear history
</span>
}
>
<span className="flex items-center gap-2">
<GitBranch className="w-3.5 h-3.5 text-blue-500" />
<span className="font-medium">Rebase</span>
</span>
</SelectItem>
</SelectContent>
</Select>
</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.
This will create a feature task to{' '}
{selectedStrategy === 'rebase' ? (
<>
rebase <span className="font-mono text-foreground">{worktree?.branch}</span>{' '}
onto <span className="font-mono text-foreground">{selectedBranch}</span>
</>
) : (
<>
merge <span className="font-mono text-foreground">{selectedBranch}</span> into{' '}
<span className="font-mono text-foreground">{worktree?.branch}</span>
</>
)}{' '}
and resolve any conflicts.
</p>
</div>
)}
@@ -287,7 +347,7 @@ export function PullResolveConflictsDialog({
className="bg-purple-600 hover:bg-purple-700 text-white"
>
<GitMerge className="w-4 h-4 mr-2" />
Pull & Resolve
Merge & Rebase
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -306,13 +306,16 @@ export function PushToRemoteDialog({
</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>
<SelectItem
key={remote.name}
value={remote.name}
description={
<span className="text-xs text-muted-foreground truncate max-w-[300px]">
{remote.url}
</span>
</div>
}
>
<span className="font-medium">{remote.name}</span>
</SelectItem>
))}
</SelectContent>

View File

@@ -0,0 +1,264 @@
import { useState, useEffect, useCallback } 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 { getErrorMessage } from '@/lib/utils';
import { Download, Upload, RefreshCw, AlertTriangle } 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('SelectRemoteDialog');
export type SelectRemoteOperation = 'pull' | 'push';
interface SelectRemoteDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
worktree: WorktreeInfo | null;
operation: SelectRemoteOperation;
onConfirm: (worktree: WorktreeInfo, remote: string) => void;
}
export function SelectRemoteDialog({
open,
onOpenChange,
worktree,
operation,
onConfirm,
}: SelectRemoteDialogProps) {
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);
const fetchRemotes = useCallback(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) {
const remoteInfos = result.result.remotes.map((r: { name: string; url: string }) => ({
name: r.name,
url: r.url,
}));
setRemotes(remoteInfos);
} else {
setError(result.error || 'Failed to fetch remotes');
}
} catch (err) {
logger.error('Failed to fetch remotes:', err);
setError(getErrorMessage(err));
} finally {
setIsLoading(false);
}
}, [worktree]);
// Fetch remotes when dialog opens
useEffect(() => {
if (open && worktree) {
fetchRemotes();
}
}, [open, worktree, fetchRemotes]);
// 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 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 = result.result.remotes.map((r: { name: string; url: string }) => ({
name: r.name,
url: r.url,
}));
setRemotes(remoteInfos);
} else {
setError(result.error || 'Failed to refresh remotes');
}
} catch (err) {
logger.error('Failed to refresh remotes:', err);
setError(getErrorMessage(err));
} finally {
setIsRefreshing(false);
}
};
const handleConfirm = () => {
if (!worktree || !selectedRemote) return;
onConfirm(worktree, selectedRemote);
onOpenChange(false);
};
const isPull = operation === 'pull';
const Icon = isPull ? Download : Upload;
const title = isPull ? 'Pull from Remote' : 'Push to Remote';
const actionLabel = isPull
? `Pull from ${selectedRemote || 'Remote'}`
: `Push to ${selectedRemote || 'Remote'}`;
const description = isPull ? (
<>
Select a remote to pull changes into{' '}
<span className="font-mono text-foreground">{worktree?.branch || 'current branch'}</span>
</>
) : (
<>
Select a remote to push{' '}
<span className="font-mono text-foreground">{worktree?.branch || 'current branch'}</span> to
</>
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[450px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Icon className="w-5 h-5 text-primary" />
{title}
</DialogTitle>
<DialogDescription>{description}</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}
description={
<span className="text-xs text-muted-foreground truncate max-w-[300px]">
{remote.url}
</span>
}
>
<span className="font-medium">{remote.name}</span>
</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">
{isPull ? (
<>
This will pull changes from{' '}
<span className="font-mono text-foreground">
{selectedRemote}/{worktree?.branch}
</span>{' '}
into your local branch.
</>
) : (
<>
This will push your local changes to{' '}
<span className="font-mono text-foreground">
{selectedRemote}/{worktree?.branch}
</span>
.
</>
)}
</p>
</div>
)}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleConfirm} disabled={!selectedRemote || isLoading}>
<Icon className="w-4 h-4 mr-2" />
{actionLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,623 @@
import { useState, useEffect, useMemo, useCallback } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import {
Archive,
FilePlus,
FileX,
FilePen,
FileText,
File,
ChevronDown,
ChevronRight,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { getHttpApiClient } from '@/lib/http-api-client';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import type { FileStatus } from '@/types/electron';
interface WorktreeInfo {
path: string;
branch: string;
isMain: boolean;
hasChanges?: boolean;
changedFilesCount?: number;
}
interface StashChangesDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
worktree: WorktreeInfo | null;
onStashed?: () => void;
}
interface ParsedDiffHunk {
header: string;
lines: {
type: 'context' | 'addition' | 'deletion' | 'header';
content: string;
lineNumber?: { old?: number; new?: number };
}[];
}
interface ParsedFileDiff {
filePath: string;
hunks: ParsedDiffHunk[];
isNew?: boolean;
isDeleted?: boolean;
isRenamed?: boolean;
}
const getFileIcon = (status: string) => {
switch (status) {
case 'A':
case '?':
return <FilePlus className="w-3.5 h-3.5 text-green-500 flex-shrink-0" />;
case 'D':
return <FileX className="w-3.5 h-3.5 text-red-500 flex-shrink-0" />;
case 'M':
case 'U':
return <FilePen className="w-3.5 h-3.5 text-amber-500 flex-shrink-0" />;
case 'R':
case 'C':
return <File className="w-3.5 h-3.5 text-blue-500 flex-shrink-0" />;
default:
return <FileText className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />;
}
};
const getStatusLabel = (status: string) => {
switch (status) {
case 'A':
return 'Added';
case '?':
return 'Untracked';
case 'D':
return 'Deleted';
case 'M':
return 'Modified';
case 'U':
return 'Updated';
case 'R':
return 'Renamed';
case 'C':
return 'Copied';
default:
return 'Changed';
}
};
const getStatusBadgeColor = (status: string) => {
switch (status) {
case 'A':
case '?':
return 'bg-green-500/20 text-green-400 border-green-500/30';
case 'D':
return 'bg-red-500/20 text-red-400 border-red-500/30';
case 'M':
case 'U':
return 'bg-amber-500/20 text-amber-400 border-amber-500/30';
case 'R':
case 'C':
return 'bg-blue-500/20 text-blue-400 border-blue-500/30';
default:
return 'bg-muted text-muted-foreground border-border';
}
};
/**
* Parse unified diff format into structured data
*/
function parseDiff(diffText: string): ParsedFileDiff[] {
if (!diffText) return [];
const files: ParsedFileDiff[] = [];
const lines = diffText.split('\n');
let currentFile: ParsedFileDiff | null = null;
let currentHunk: ParsedDiffHunk | null = null;
let oldLineNum = 0;
let newLineNum = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.startsWith('diff --git')) {
if (currentFile) {
if (currentHunk) currentFile.hunks.push(currentHunk);
files.push(currentFile);
}
const match = line.match(/diff --git a\/(.*?) b\/(.*)/);
currentFile = {
filePath: match ? match[2] : 'unknown',
hunks: [],
};
currentHunk = null;
continue;
}
if (line.startsWith('new file mode')) {
if (currentFile) currentFile.isNew = true;
continue;
}
if (line.startsWith('deleted file mode')) {
if (currentFile) currentFile.isDeleted = true;
continue;
}
if (line.startsWith('rename from') || line.startsWith('rename to')) {
if (currentFile) currentFile.isRenamed = true;
continue;
}
if (line.startsWith('index ') || line.startsWith('--- ') || line.startsWith('+++ ')) {
continue;
}
if (line.startsWith('@@')) {
if (currentHunk && currentFile) currentFile.hunks.push(currentHunk);
const hunkMatch = line.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
oldLineNum = hunkMatch ? parseInt(hunkMatch[1], 10) : 1;
newLineNum = hunkMatch ? parseInt(hunkMatch[2], 10) : 1;
currentHunk = {
header: line,
lines: [{ type: 'header', content: line }],
};
continue;
}
if (currentHunk) {
if (line.startsWith('+')) {
currentHunk.lines.push({
type: 'addition',
content: line.substring(1),
lineNumber: { new: newLineNum },
});
newLineNum++;
} else if (line.startsWith('-')) {
currentHunk.lines.push({
type: 'deletion',
content: line.substring(1),
lineNumber: { old: oldLineNum },
});
oldLineNum++;
} else if (line.startsWith(' ') || line === '') {
currentHunk.lines.push({
type: 'context',
content: line.substring(1) || '',
lineNumber: { old: oldLineNum, new: newLineNum },
});
oldLineNum++;
newLineNum++;
}
}
}
if (currentFile) {
if (currentHunk) currentFile.hunks.push(currentHunk);
files.push(currentFile);
}
return files;
}
function DiffLine({
type,
content,
lineNumber,
}: {
type: 'context' | 'addition' | 'deletion' | 'header';
content: string;
lineNumber?: { old?: number; new?: number };
}) {
const bgClass = {
context: 'bg-transparent',
addition: 'bg-green-500/10',
deletion: 'bg-red-500/10',
header: 'bg-blue-500/10',
};
const textClass = {
context: 'text-foreground-secondary',
addition: 'text-green-400',
deletion: 'text-red-400',
header: 'text-blue-400',
};
const prefix = {
context: ' ',
addition: '+',
deletion: '-',
header: '',
};
if (type === 'header') {
return (
<div className={cn('px-2 py-1 font-mono text-xs', bgClass[type], textClass[type])}>
{content}
</div>
);
}
return (
<div className={cn('flex font-mono text-xs', bgClass[type])}>
<span className="w-10 flex-shrink-0 text-right pr-1.5 text-muted-foreground select-none border-r border-border-glass text-[10px]">
{lineNumber?.old ?? ''}
</span>
<span className="w-10 flex-shrink-0 text-right pr-1.5 text-muted-foreground select-none border-r border-border-glass text-[10px]">
{lineNumber?.new ?? ''}
</span>
<span className={cn('w-4 flex-shrink-0 text-center select-none', textClass[type])}>
{prefix[type]}
</span>
<span className={cn('flex-1 px-1.5 whitespace-pre-wrap break-all', textClass[type])}>
{content || '\u00A0'}
</span>
</div>
);
}
export function StashChangesDialog({
open,
onOpenChange,
worktree,
onStashed,
}: StashChangesDialogProps) {
const [message, setMessage] = useState('');
const [isStashing, setIsStashing] = useState(false);
// File selection state
const [files, setFiles] = useState<FileStatus[]>([]);
const [diffContent, setDiffContent] = useState('');
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
const [expandedFile, setExpandedFile] = useState<string | null>(null);
const [isLoadingDiffs, setIsLoadingDiffs] = useState(false);
// Parse diffs
const parsedDiffs = useMemo(() => parseDiff(diffContent), [diffContent]);
// Create a map of file path to parsed diff for quick lookup
const diffsByFile = useMemo(() => {
const map = new Map<string, ParsedFileDiff>();
for (const diff of parsedDiffs) {
map.set(diff.filePath, diff);
}
return map;
}, [parsedDiffs]);
// Load diffs when dialog opens
useEffect(() => {
if (open && worktree) {
setIsLoadingDiffs(true);
setFiles([]);
setDiffContent('');
setSelectedFiles(new Set());
setExpandedFile(null);
let cancelled = false;
const loadDiffs = async () => {
try {
const api = getHttpApiClient();
const result = await api.git.getDiffs(worktree.path);
if (result.success) {
const fileList = result.files ?? [];
if (!cancelled) setFiles(fileList);
if (!cancelled) setDiffContent(result.diff ?? '');
// Select all files by default
if (!cancelled) setSelectedFiles(new Set(fileList.map((f: FileStatus) => f.path)));
}
} catch (err) {
console.warn('Failed to load diffs for stash dialog:', err);
} finally {
if (!cancelled) setIsLoadingDiffs(false);
}
};
loadDiffs();
return () => {
cancelled = true;
};
}
}, [open, worktree]);
const handleToggleFile = useCallback((filePath: string) => {
setSelectedFiles((prev) => {
const next = new Set(prev);
if (next.has(filePath)) {
next.delete(filePath);
} else {
next.add(filePath);
}
return next;
});
}, []);
const handleToggleAll = useCallback(() => {
setSelectedFiles((prev) => {
if (prev.size === files.length) {
return new Set();
}
return new Set(files.map((f) => f.path));
});
}, [files]);
const handleFileClick = useCallback((filePath: string) => {
setExpandedFile((prev) => (prev === filePath ? null : filePath));
}, []);
const handleStash = async () => {
if (!worktree || selectedFiles.size === 0) return;
setIsStashing(true);
try {
const api = getHttpApiClient();
// Pass selected files if not all files are selected
const filesToStash =
selectedFiles.size === files.length ? undefined : Array.from(selectedFiles);
const result = await api.worktree.stashPush(
worktree.path,
message.trim() || undefined,
filesToStash
);
if (result.success && result.result) {
if (result.result.stashed) {
toast.success('Changes stashed', {
description: result.result.message || 'Your changes have been stashed',
});
setMessage('');
onOpenChange(false);
onStashed?.();
} else {
toast.info('No changes to stash');
}
} else {
toast.error('Failed to stash changes', {
description: result.error || 'Unknown error',
});
}
} catch (err) {
toast.error('Failed to stash changes', {
description: err instanceof Error ? err.message : 'Unknown error',
});
} finally {
setIsStashing(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && !isStashing && selectedFiles.size > 0) {
e.preventDefault();
handleStash();
}
};
if (!worktree) return null;
const allSelected = selectedFiles.size === files.length && files.length > 0;
return (
<Dialog
open={open}
onOpenChange={(isOpen) => {
if (!isOpen) {
setMessage('');
}
onOpenChange(isOpen);
}}
>
<DialogContent
className="sm:max-w-[700px] max-h-[85vh] flex flex-col"
onKeyDown={handleKeyDown}
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Archive className="w-5 h-5" />
Stash Changes
</DialogTitle>
<DialogDescription>
Stash uncommitted changes on{' '}
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3 py-2 min-h-0 flex-1 overflow-hidden">
{/* File Selection */}
<div className="flex flex-col min-h-0">
<div className="flex items-center justify-between mb-1.5">
<Label className="text-sm font-medium flex items-center gap-2">
Files to stash
{isLoadingDiffs ? (
<Spinner size="sm" />
) : (
<span className="text-xs text-muted-foreground font-normal">
({selectedFiles.size}/{files.length} selected)
</span>
)}
</Label>
{files.length > 0 && (
<button
onClick={handleToggleAll}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
{allSelected ? 'Deselect all' : 'Select all'}
</button>
)}
</div>
{isLoadingDiffs ? (
<div className="flex items-center justify-center py-6 text-muted-foreground border border-border rounded-lg">
<Spinner size="sm" className="mr-2" />
<span className="text-sm">Loading changes...</span>
</div>
) : files.length === 0 ? (
<div className="flex items-center justify-center py-6 text-muted-foreground border border-border rounded-lg">
<span className="text-sm">No changes detected</span>
</div>
) : (
<div className="border border-border rounded-lg overflow-hidden max-h-[300px] overflow-y-auto scrollbar-visible">
{files.map((file) => {
const isChecked = selectedFiles.has(file.path);
const isExpanded = expandedFile === file.path;
const fileDiff = diffsByFile.get(file.path);
const additions = fileDiff
? fileDiff.hunks.reduce(
(acc, hunk) => acc + hunk.lines.filter((l) => l.type === 'addition').length,
0
)
: 0;
const deletions = fileDiff
? fileDiff.hunks.reduce(
(acc, hunk) => acc + hunk.lines.filter((l) => l.type === 'deletion').length,
0
)
: 0;
return (
<div key={file.path} className="border-b border-border last:border-b-0">
<div
className={cn(
'flex items-center gap-2 px-3 py-1.5 hover:bg-accent/50 transition-colors group',
isExpanded && 'bg-accent/30'
)}
>
{/* Checkbox */}
<Checkbox
checked={isChecked}
onCheckedChange={() => handleToggleFile(file.path)}
className="flex-shrink-0"
/>
{/* Clickable file row to show diff */}
<button
onClick={() => handleFileClick(file.path)}
className="flex items-center gap-2 flex-1 min-w-0 text-left"
>
{isExpanded ? (
<ChevronDown className="w-3 h-3 text-muted-foreground flex-shrink-0" />
) : (
<ChevronRight className="w-3 h-3 text-muted-foreground flex-shrink-0" />
)}
{getFileIcon(file.status)}
<span className="text-xs font-mono truncate flex-1 text-foreground">
{file.path}
</span>
<span
className={cn(
'text-[10px] px-1.5 py-0.5 rounded border font-medium flex-shrink-0',
getStatusBadgeColor(file.status)
)}
>
{getStatusLabel(file.status)}
</span>
{additions > 0 && (
<span className="text-[10px] text-green-400 flex-shrink-0">
+{additions}
</span>
)}
{deletions > 0 && (
<span className="text-[10px] text-red-400 flex-shrink-0">
-{deletions}
</span>
)}
</button>
</div>
{/* Expanded diff view */}
{isExpanded && fileDiff && (
<div className="bg-background border-t border-border max-h-[200px] overflow-y-auto scrollbar-visible">
{fileDiff.hunks.map((hunk, hunkIndex) => (
<div
key={hunkIndex}
className="border-b border-border-glass last:border-b-0"
>
{hunk.lines.map((line, lineIndex) => (
<DiffLine
key={lineIndex}
type={line.type}
content={line.content}
lineNumber={line.lineNumber}
/>
))}
</div>
))}
</div>
)}
{isExpanded && !fileDiff && (
<div className="px-4 py-3 text-xs text-muted-foreground bg-background border-t border-border">
{file.status === '?' ? (
<span>New file - diff preview not available</span>
) : file.status === 'D' ? (
<span>File deleted</span>
) : (
<span>Diff content not available</span>
)}
</div>
)}
</div>
);
})}
</div>
)}
</div>
{/* Stash Message */}
<div className="space-y-2">
<label htmlFor="stash-message" className="text-sm font-medium">
Stash message <span className="text-muted-foreground">(optional)</span>
</label>
<Input
id="stash-message"
placeholder="e.g., Work in progress on login page"
value={message}
onChange={(e) => setMessage(e.target.value)}
disabled={isStashing}
autoFocus
/>
<p className="text-xs text-muted-foreground">
A descriptive message helps identify this stash later. Press{' '}
<kbd className="px-1 py-0.5 text-[10px] bg-muted rounded border">
{navigator.platform.includes('Mac') ? '⌘' : 'Ctrl'}+Enter
</kbd>{' '}
to stash.
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isStashing}>
Cancel
</Button>
<Button onClick={handleStash} disabled={isStashing || selectedFiles.size === 0}>
{isStashing ? (
<>
<Spinner size="xs" className="mr-2" />
Stashing...
</>
) : (
<>
<Archive className="w-4 h-4 mr-2" />
Stash
{selectedFiles.size > 0 && selectedFiles.size < files.length
? ` (${selectedFiles.size} file${selectedFiles.size > 1 ? 's' : ''})`
: ' Changes'}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,323 @@
import { useCallback, useEffect, useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
GitCommit,
User,
Clock,
Copy,
Check,
ChevronDown,
ChevronRight,
FileText,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { getHttpApiClient } from '@/lib/http-api-client';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
interface WorktreeInfo {
path: string;
branch: string;
isMain: boolean;
hasChanges?: boolean;
changedFilesCount?: number;
}
interface CommitInfo {
hash: string;
shortHash: string;
author: string;
authorEmail: string;
date: string;
subject: string;
body: string;
files: string[];
}
interface ViewCommitsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
worktree: WorktreeInfo | null;
}
function formatRelativeDate(dateStr: string): string {
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSecs = Math.floor(diffMs / 1000);
const diffMins = Math.floor(diffSecs / 60);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
const diffWeeks = Math.floor(diffDays / 7);
const diffMonths = Math.floor(diffDays / 30);
if (diffSecs < 60) return 'just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
if (diffWeeks < 5) return `${diffWeeks}w ago`;
if (diffMonths < 12) return `${diffMonths}mo ago`;
return date.toLocaleDateString();
}
function CopyHashButton({ hash }: { hash: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = async (e: React.MouseEvent) => {
e.stopPropagation();
try {
await navigator.clipboard.writeText(hash);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
toast.error('Failed to copy hash');
}
};
return (
<button
onClick={handleCopy}
className="inline-flex items-center gap-1 font-mono text-[11px] bg-muted hover:bg-muted/80 px-1.5 py-0.5 rounded cursor-pointer transition-colors"
title={`Copy full hash: ${hash}`}
>
{copied ? (
<Check className="w-2.5 h-2.5 text-green-500" />
) : (
<Copy className="w-2.5 h-2.5 text-muted-foreground" />
)}
<span className="text-muted-foreground">{hash.slice(0, 7)}</span>
</button>
);
}
function CommitEntryItem({
commit,
index,
isLast,
}: {
commit: CommitInfo;
index: number;
isLast: boolean;
}) {
const [expanded, setExpanded] = useState(false);
const hasFiles = commit.files && commit.files.length > 0;
return (
<div
className={cn('group relative rounded-md transition-colors', index === 0 && 'bg-muted/30')}
>
<div className="flex gap-3 py-2.5 px-3 hover:bg-muted/50 transition-colors rounded-md">
{/* Timeline dot and line */}
<div className="flex flex-col items-center pt-1.5 shrink-0">
<div
className={cn(
'w-2 h-2 rounded-full border-2',
index === 0 ? 'border-primary bg-primary' : 'border-muted-foreground/40 bg-background'
)}
/>
{!isLast && <div className="w-px flex-1 bg-border mt-1" />}
</div>
{/* Commit content */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium leading-snug break-words">{commit.subject}</p>
<CopyHashButton hash={commit.hash} />
</div>
{commit.body && (
<p className="text-xs text-muted-foreground mt-1 whitespace-pre-wrap break-words line-clamp-3">
{commit.body}
</p>
)}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 mt-1.5 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1">
<User className="w-3 h-3" />
{commit.author}
</span>
<span className="inline-flex items-center gap-1">
<Clock className="w-3 h-3" />
<time dateTime={commit.date} title={new Date(commit.date).toLocaleString()}>
{formatRelativeDate(commit.date)}
</time>
</span>
{hasFiles && (
<button
onClick={() => setExpanded(!expanded)}
className="inline-flex items-center gap-1 hover:text-foreground transition-colors cursor-pointer"
>
{expanded ? (
<ChevronDown className="w-3 h-3" />
) : (
<ChevronRight className="w-3 h-3" />
)}
<FileText className="w-3 h-3" />
{commit.files.length} file{commit.files.length !== 1 ? 's' : ''}
</button>
)}
</div>
</div>
</div>
{/* Expanded file list */}
{expanded && hasFiles && (
<div className="border-t px-3 py-2 bg-muted/30">
<div className="space-y-0.5">
{commit.files.map((file) => (
<div
key={file}
className="flex items-center gap-2 text-xs text-muted-foreground py-0.5"
>
<FileText className="w-3 h-3 shrink-0" />
<span className="font-mono break-all">{file}</span>
</div>
))}
</div>
</div>
)}
</div>
);
}
const INITIAL_COMMIT_LIMIT = 30;
const LOAD_MORE_INCREMENT = 30;
const MAX_COMMIT_LIMIT = 100;
export function ViewCommitsDialog({ open, onOpenChange, worktree }: ViewCommitsDialogProps) {
const [commits, setCommits] = useState<CommitInfo[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const [limit, setLimit] = useState(INITIAL_COMMIT_LIMIT);
const [hasMore, setHasMore] = useState(false);
const fetchCommits = useCallback(
async (fetchLimit: number, isLoadMore = false) => {
if (isLoadMore) {
setIsLoadingMore(true);
} else {
setIsLoading(true);
setError(null);
setCommits([]);
}
try {
const api = getHttpApiClient();
const result = await api.worktree.getCommitLog(worktree!.path, fetchLimit);
if (result.success && result.result) {
// Ensure each commit has a files array (backwards compat if server hasn't been rebuilt)
const fetchedCommits = result.result.commits.map((c: CommitInfo) => ({
...c,
files: c.files || [],
}));
setCommits(fetchedCommits);
// If we got back exactly as many commits as we requested, there may be more
setHasMore(fetchedCommits.length === fetchLimit && fetchLimit < MAX_COMMIT_LIMIT);
} else {
setError(result.error || 'Failed to load commits');
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load commits');
} finally {
setIsLoading(false);
setIsLoadingMore(false);
}
},
[worktree]
);
useEffect(() => {
if (!open || !worktree) return;
setLimit(INITIAL_COMMIT_LIMIT);
setHasMore(false);
fetchCommits(INITIAL_COMMIT_LIMIT);
}, [open, worktree, fetchCommits]);
const handleLoadMore = () => {
const newLimit = Math.min(limit + LOAD_MORE_INCREMENT, MAX_COMMIT_LIMIT);
setLimit(newLimit);
fetchCommits(newLimit, true);
};
if (!worktree) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[640px] sm:max-h-[100dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<GitCommit className="w-5 h-5" />
Commit History
</DialogTitle>
<DialogDescription>
Recent commits on{' '}
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>
</DialogDescription>
</DialogHeader>
<div className="flex-1 sm:min-h-[400px] sm:max-h-[60vh] overflow-y-auto scrollbar-visible -mx-6 -mb-6">
<div className="h-full px-6 pb-6">
{isLoading && (
<div className="flex items-center justify-center py-12">
<Spinner size="md" />
<span className="ml-2 text-sm text-muted-foreground">Loading commits...</span>
</div>
)}
{error && (
<div className="flex items-center justify-center py-12">
<p className="text-sm text-destructive">{error}</p>
</div>
)}
{!isLoading && !error && commits.length === 0 && (
<div className="flex items-center justify-center py-12">
<p className="text-sm text-muted-foreground">No commits found</p>
</div>
)}
{!isLoading && !error && commits.length > 0 && (
<div className="space-y-0.5 mt-2">
{commits.map((commit, index) => (
<CommitEntryItem
key={commit.hash}
commit={commit}
index={index}
isLast={index === commits.length - 1 && !hasMore}
/>
))}
{hasMore && (
<div className="flex justify-center pt-3 pb-1">
<button
onClick={handleLoadMore}
disabled={isLoadingMore}
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer px-4 py-2 rounded-md hover:bg-muted/50"
>
{isLoadingMore ? (
<>
<Spinner size="sm" />
Loading more commits...
</>
) : (
<>
<ChevronDown className="w-4 h-4" />
Load more commits
</>
)}
</button>
</div>
)}
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,410 @@
import { useEffect, useState, useCallback } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import {
Archive,
ChevronDown,
ChevronRight,
Clock,
FileText,
GitBranch,
Play,
Trash2,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { getHttpApiClient } from '@/lib/http-api-client';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
interface WorktreeInfo {
path: string;
branch: string;
isMain: boolean;
hasChanges?: boolean;
changedFilesCount?: number;
}
interface StashEntry {
index: number;
message: string;
branch: string;
date: string;
files: string[];
}
interface ViewStashesDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
worktree: WorktreeInfo | null;
onStashApplied?: () => void;
}
function formatRelativeDate(dateStr: string): string {
const date = new Date(dateStr);
if (isNaN(date.getTime())) return 'Unknown date';
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSecs = Math.floor(diffMs / 1000);
const diffMins = Math.floor(diffSecs / 60);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
const diffWeeks = Math.floor(diffDays / 7);
const diffMonths = Math.floor(diffDays / 30);
if (diffSecs < 60) return 'just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
if (diffWeeks < 5) return `${diffWeeks}w ago`;
if (diffMonths < 12) return `${diffMonths}mo ago`;
return date.toLocaleDateString();
}
function StashEntryItem({
stash,
onApply,
onPop,
onDrop,
isApplying,
isDropping,
}: {
stash: StashEntry;
onApply: (index: number) => void;
onPop: (index: number) => void;
onDrop: (index: number) => void;
isApplying: boolean;
isDropping: boolean;
}) {
const [expanded, setExpanded] = useState(false);
const isBusy = isApplying || isDropping;
// Clean up the stash message for display
const displayMessage =
stash.message.replace(/^(WIP on|On) [^:]+:\s*[a-f0-9]+\s*/, '').trim() || stash.message;
return (
<div
className={cn(
'group relative rounded-md border bg-card transition-colors',
'hover:border-primary/30'
)}
>
{/* Header */}
<div className="flex items-start gap-3 p-3">
{/* Expand toggle & stash icon */}
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-1 pt-0.5 text-muted-foreground hover:text-foreground transition-colors"
disabled={stash.files.length === 0}
>
{stash.files.length > 0 ? (
expanded ? (
<ChevronDown className="w-3.5 h-3.5" />
) : (
<ChevronRight className="w-3.5 h-3.5" />
)
) : (
<span className="w-3.5" />
)}
<Archive className="w-3.5 h-3.5" />
</button>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="text-sm font-medium leading-snug break-words">{displayMessage}</p>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 mt-1 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1 font-mono bg-muted px-1.5 py-0.5 rounded text-[10px]">
stash@{'{' + stash.index + '}'}
</span>
{stash.branch && (
<span className="inline-flex items-center gap-1">
<GitBranch className="w-3 h-3" />
{stash.branch}
</span>
)}
<span className="inline-flex items-center gap-1">
<Clock className="w-3 h-3" />
<time
dateTime={stash.date}
title={
!isNaN(new Date(stash.date).getTime())
? new Date(stash.date).toLocaleString()
: stash.date
}
>
{formatRelativeDate(stash.date)}
</time>
</span>
{stash.files.length > 0 && (
<span className="inline-flex items-center gap-1">
<FileText className="w-3 h-3" />
{stash.files.length} file{stash.files.length !== 1 ? 's' : ''}
</span>
)}
</div>
</div>
{/* Action buttons */}
<div className="flex items-center gap-1 shrink-0">
<Button
size="sm"
variant="outline"
className="h-7 text-xs px-2"
onClick={() => onApply(stash.index)}
disabled={isBusy}
title="Apply stash (keep in stash list)"
>
{isApplying ? <Spinner size="xs" /> : <Play className="w-3 h-3 mr-1" />}
Apply
</Button>
<Button
size="sm"
variant="outline"
className="h-7 text-xs px-2"
onClick={() => onPop(stash.index)}
disabled={isBusy}
title="Pop stash (apply and remove from stash list)"
>
{isApplying ? <Spinner size="xs" /> : 'Pop'}
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 text-destructive hover:text-destructive"
onClick={() => onDrop(stash.index)}
disabled={isBusy}
title="Delete this stash"
>
{isDropping ? <Spinner size="xs" /> : <Trash2 className="w-3 h-3" />}
</Button>
</div>
</div>
</div>
</div>
{/* Expanded file list */}
{expanded && stash.files.length > 0 && (
<div className="border-t px-3 py-2 bg-muted/30">
<div className="space-y-0.5">
{stash.files.map((file) => (
<div
key={file}
className="flex items-center gap-2 text-xs text-muted-foreground py-0.5"
>
<FileText className="w-3 h-3 shrink-0" />
<span className="font-mono break-all">{file}</span>
</div>
))}
</div>
</div>
)}
</div>
);
}
export function ViewStashesDialog({
open,
onOpenChange,
worktree,
onStashApplied,
}: ViewStashesDialogProps) {
const [stashes, setStashes] = useState<StashEntry[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [applyingIndex, setApplyingIndex] = useState<number | null>(null);
const [droppingIndex, setDroppingIndex] = useState<number | null>(null);
const fetchStashes = useCallback(async () => {
if (!worktree) return;
setIsLoading(true);
setError(null);
try {
const api = getHttpApiClient();
const result = await api.worktree.stashList(worktree.path);
if (result.success && result.result) {
setStashes(result.result.stashes);
} else {
setError(result.error || 'Failed to load stashes');
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load stashes');
} finally {
setIsLoading(false);
}
}, [worktree]);
useEffect(() => {
if (open && worktree) {
fetchStashes();
}
if (!open) {
setStashes([]);
setError(null);
}
}, [open, worktree, fetchStashes]);
const handleApply = async (stashIndex: number) => {
if (!worktree) return;
setApplyingIndex(stashIndex);
try {
const api = getHttpApiClient();
const result = await api.worktree.stashApply(worktree.path, stashIndex, false);
if (result.success && result.result) {
if (result.result.hasConflicts) {
toast.warning('Stash applied with conflicts', {
description: 'Please resolve the merge conflicts.',
});
} else {
toast.success('Stash applied');
}
onStashApplied?.();
} else {
toast.error('Failed to apply stash', {
description: result.error || 'Unknown error',
});
}
} catch (err) {
toast.error('Failed to apply stash', {
description: err instanceof Error ? err.message : 'Unknown error',
});
} finally {
setApplyingIndex(null);
}
};
const handlePop = async (stashIndex: number) => {
if (!worktree) return;
setApplyingIndex(stashIndex);
try {
const api = getHttpApiClient();
const result = await api.worktree.stashApply(worktree.path, stashIndex, true);
if (result.success && result.result) {
if (result.result.hasConflicts) {
toast.warning('Stash popped with conflicts', {
description: 'Please resolve the merge conflicts. The stash was removed.',
});
} else {
toast.success('Stash popped', {
description: 'Changes applied and stash removed.',
});
}
// Refresh the stash list since the stash was removed
await fetchStashes();
onStashApplied?.();
} else {
toast.error('Failed to pop stash', {
description: result.error || 'Unknown error',
});
}
} catch (err) {
toast.error('Failed to pop stash', {
description: err instanceof Error ? err.message : 'Unknown error',
});
} finally {
setApplyingIndex(null);
}
};
const handleDrop = async (stashIndex: number) => {
if (!worktree) return;
setDroppingIndex(stashIndex);
try {
const api = getHttpApiClient();
const result = await api.worktree.stashDrop(worktree.path, stashIndex);
if (result.success) {
toast.success('Stash deleted');
// Refresh the stash list
await fetchStashes();
} else {
toast.error('Failed to delete stash', {
description: result.error || 'Unknown error',
});
}
} catch (err) {
toast.error('Failed to delete stash', {
description: err instanceof Error ? err.message : 'Unknown error',
});
} finally {
setDroppingIndex(null);
}
};
if (!worktree) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[640px] sm:max-h-[100dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Archive className="w-5 h-5" />
Stashes
</DialogTitle>
<DialogDescription>
Stashed changes in{' '}
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>
</DialogDescription>
</DialogHeader>
<div className="flex-1 sm:min-h-[300px] sm:max-h-[60vh] overflow-y-auto scrollbar-visible -mx-6 -mb-6">
<div className="h-full px-6 pb-6">
{isLoading && (
<div className="flex items-center justify-center py-12">
<Spinner size="md" />
<span className="ml-2 text-sm text-muted-foreground">Loading stashes...</span>
</div>
)}
{error && (
<div className="flex items-center justify-center py-12">
<p className="text-sm text-destructive">{error}</p>
</div>
)}
{!isLoading && !error && stashes.length === 0 && (
<div className="flex flex-col items-center justify-center py-12 gap-2">
<Archive className="w-8 h-8 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground">No stashes found</p>
<p className="text-xs text-muted-foreground">
Use &quot;Stash Changes&quot; to save your uncommitted changes
</p>
</div>
)}
{!isLoading && !error && stashes.length > 0 && (
<div className="space-y-2 mt-2">
{stashes.map((stash) => (
<StashEntryItem
key={stash.index}
stash={stash}
onApply={handleApply}
onPop={handlePop}
onDrop={handleDrop}
isApplying={applyingIndex === stash.index}
isDropping={droppingIndex === stash.index}
/>
))}
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -660,9 +660,28 @@ export function useBoardActions({
const handleVerifyFeature = useCallback(
async (feature: Feature) => {
if (!currentProject) return;
verifyFeatureMutation.mutate(feature.id);
try {
const result = await verifyFeatureMutation.mutateAsync(feature.id);
if (result.passes) {
// Immediately move card to verified column (optimistic update)
moveFeature(feature.id, 'verified');
persistFeatureUpdate(feature.id, {
status: 'verified',
justFinishedAt: undefined,
});
toast.success('Verification passed', {
description: `Verified: ${truncateDescription(feature.description)}`,
});
} else {
toast.error('Verification failed', {
description: `Tests did not pass for: ${truncateDescription(feature.description)}`,
});
}
} catch {
// Error toast is already shown by the mutation's onError handler
}
},
[currentProject, verifyFeatureMutation]
[currentProject, verifyFeatureMutation, moveFeature, persistFeatureUpdate]
);
const handleResumeFeature = useCallback(
@@ -1176,6 +1195,49 @@ export function useBoardActions({
[handleAddFeature]
);
const handleDuplicateAsChildMultiple = useCallback(
async (feature: Feature, count: number) => {
// Create a chain of duplicates, each a child of the previous, so they execute sequentially
let parentFeature = feature;
for (let i = 0; i < count; i++) {
const {
id: _id,
status: _status,
startedAt: _startedAt,
error: _error,
summary: _summary,
spec: _spec,
passes: _passes,
planSpec: _planSpec,
descriptionHistory: _descriptionHistory,
titleGenerating: _titleGenerating,
...featureData
} = parentFeature;
const duplicatedFeatureData = {
...featureData,
// Each duplicate depends on the previous one in the chain
dependencies: [parentFeature.id],
};
await handleAddFeature(duplicatedFeatureData);
// Get the newly created feature (last added feature) to use as parent for next iteration
const currentFeatures = useAppStore.getState().features;
const newestFeature = currentFeatures[currentFeatures.length - 1];
if (newestFeature) {
parentFeature = newestFeature;
}
}
toast.success(`Created ${count} chained duplicates`, {
description: `Created ${count} sequential copies of: ${truncateDescription(feature.description || feature.title || '')}`,
});
},
[handleAddFeature]
);
return {
handleAddFeature,
handleUpdateFeature,
@@ -1197,5 +1259,6 @@ export function useBoardActions({
handleStartNextFeatures,
handleArchiveAllVerified,
handleDuplicateFeature,
handleDuplicateAsChildMultiple,
};
}

View File

@@ -180,19 +180,17 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
(existing) => (existing ? existing.filter((f) => f.id !== featureId) : existing)
);
try {
const api = getElectronAPI();
if (!api.features) {
// Rollback optimistic deletion since we can't persist
if (previousFeatures) {
queryClient.setQueryData(queryKeys.features.all(currentProject.path), previousFeatures);
}
queryClient.invalidateQueries({
queryKey: queryKeys.features.all(currentProject.path),
});
throw new Error('Features API not available');
}
const api = getElectronAPI();
if (!api.features) {
// Rollback optimistic deletion since we can't persist
queryClient.setQueryData(queryKeys.features.all(currentProject.path), previousFeatures);
queryClient.invalidateQueries({
queryKey: queryKeys.features.all(currentProject.path),
});
throw new Error('Features API not available');
}
try {
await api.features.delete(currentProject.path, featureId);
// Invalidate to sync with server state
queryClient.invalidateQueries({
@@ -207,6 +205,7 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
queryClient.invalidateQueries({
queryKey: queryKeys.features.all(currentProject.path),
});
throw error;
}
},
[currentProject, queryClient]

View File

@@ -48,6 +48,7 @@ interface KanbanBoardProps {
onSpawnTask?: (feature: Feature) => void;
onDuplicate?: (feature: Feature) => void;
onDuplicateAsChild?: (feature: Feature) => void;
onDuplicateAsChildMultiple?: (feature: Feature) => void;
featuresWithContext: Set<string>;
runningAutoTasks: string[];
onArchiveAllVerified: () => void;
@@ -286,6 +287,7 @@ export function KanbanBoard({
onSpawnTask,
onDuplicate,
onDuplicateAsChild,
onDuplicateAsChildMultiple,
featuresWithContext,
runningAutoTasks,
onArchiveAllVerified,
@@ -575,6 +577,11 @@ export function KanbanBoard({
onSpawnTask={() => onSpawnTask?.(feature)}
onDuplicate={() => onDuplicate?.(feature)}
onDuplicateAsChild={() => onDuplicateAsChild?.(feature)}
onDuplicateAsChildMultiple={
onDuplicateAsChildMultiple
? () => onDuplicateAsChildMultiple(feature)
: undefined
}
hasContext={featuresWithContext.has(feature.id)}
isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
shortcutKey={shortcutKey}
@@ -619,6 +626,11 @@ export function KanbanBoard({
onSpawnTask={() => onSpawnTask?.(feature)}
onDuplicate={() => onDuplicate?.(feature)}
onDuplicateAsChild={() => onDuplicateAsChild?.(feature)}
onDuplicateAsChildMultiple={
onDuplicateAsChildMultiple
? () => onDuplicateAsChildMultiple(feature)
: undefined
}
hasContext={featuresWithContext.has(feature.id)}
isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
shortcutKey={shortcutKey}

View File

@@ -34,9 +34,13 @@ import {
Undo2,
Zap,
FlaskConical,
History,
Archive,
Cherry,
} from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import { Spinner } from '@/components/ui/spinner';
import type { WorktreeInfo, DevServerInfo, PRInfo, GitRepoStatus, TestSessionInfo } from '../types';
import { TooltipWrapper } from './tooltip-wrapper';
import { useAvailableEditors, useEffectiveDefaultEditor } from '../hooks/use-available-editors';
@@ -60,6 +64,8 @@ interface WorktreeActionsDropdownProps {
isDevServerRunning: boolean;
devServerInfo?: DevServerInfo;
gitRepoStatus: GitRepoStatus;
/** When true, git repo status is still being loaded */
isLoadingGitStatus?: boolean;
/** When true, renders as a standalone button (not attached to another element) */
standalone?: boolean;
/** Whether auto mode is running for this worktree */
@@ -80,6 +86,7 @@ interface WorktreeActionsDropdownProps {
onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void;
onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void;
onViewChanges: (worktree: WorktreeInfo) => void;
onViewCommits: (worktree: WorktreeInfo) => void;
onDiscardChanges: (worktree: WorktreeInfo) => void;
onCommit: (worktree: WorktreeInfo) => void;
onCreatePR: (worktree: WorktreeInfo) => void;
@@ -99,6 +106,12 @@ interface WorktreeActionsDropdownProps {
onStopTests?: (worktree: WorktreeInfo) => void;
/** View test logs for this worktree */
onViewTestLogs?: (worktree: WorktreeInfo) => void;
/** Stash changes for this worktree */
onStashChanges?: (worktree: WorktreeInfo) => void;
/** View stashes for this worktree */
onViewStashes?: (worktree: WorktreeInfo) => void;
/** Cherry-pick commits from another branch */
onCherryPick?: (worktree: WorktreeInfo) => void;
hasInitScript: boolean;
}
@@ -114,6 +127,7 @@ export function WorktreeActionsDropdown({
isDevServerRunning,
devServerInfo,
gitRepoStatus,
isLoadingGitStatus = false,
standalone = false,
isAutoModeRunning = false,
hasTestCommand = false,
@@ -128,6 +142,7 @@ export function WorktreeActionsDropdown({
onOpenInIntegratedTerminal,
onOpenInExternalTerminal,
onViewChanges,
onViewCommits,
onDiscardChanges,
onCommit,
onCreatePR,
@@ -144,6 +159,9 @@ export function WorktreeActionsDropdown({
onStartTests,
onStopTests,
onViewTestLogs,
onStashChanges,
onViewStashes,
onCherryPick,
hasInitScript,
}: WorktreeActionsDropdownProps) {
// Get available editors for the "Open In" submenu
@@ -203,8 +221,18 @@ export function WorktreeActionsDropdown({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
{/* Warning label when git operations are not available */}
{!canPerformGitOps && (
{/* Loading indicator while git status is being determined */}
{isLoadingGitStatus && (
<>
<DropdownMenuLabel className="text-xs flex items-center gap-2 text-muted-foreground">
<Spinner size="xs" variant="muted" />
Checking git status...
</DropdownMenuLabel>
<DropdownMenuSeparator />
</>
)}
{/* Warning label when git operations are not available (only show once loaded) */}
{!isLoadingGitStatus && !canPerformGitOps && (
<>
<DropdownMenuLabel className="text-xs flex items-center gap-2 text-amber-600 dark:text-amber-400">
<AlertCircle className="w-3.5 h-3.5" />
@@ -387,10 +415,90 @@ export function WorktreeActionsDropdown({
)}
>
<GitMerge className="w-3.5 h-3.5 mr-2" />
Pull & Resolve Conflicts
Merge & Rebase
{!canPerformGitOps && <AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />}
</DropdownMenuItem>
</TooltipWrapper>
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
<DropdownMenuItem
onClick={() => canPerformGitOps && onViewCommits(worktree)}
disabled={!canPerformGitOps}
className={cn('text-xs', !canPerformGitOps && 'opacity-50 cursor-not-allowed')}
>
<History className="w-3.5 h-3.5 mr-2" />
View Commits
{!canPerformGitOps && <AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />}
</DropdownMenuItem>
</TooltipWrapper>
{/* Cherry-pick commits from another branch */}
{onCherryPick && (
<TooltipWrapper
showTooltip={!!gitOpsDisabledReason}
tooltipContent={gitOpsDisabledReason}
>
<DropdownMenuItem
onClick={() => canPerformGitOps && onCherryPick(worktree)}
disabled={!canPerformGitOps}
className={cn('text-xs', !canPerformGitOps && 'opacity-50 cursor-not-allowed')}
>
<Cherry className="w-3.5 h-3.5 mr-2" />
Cherry Pick
{!canPerformGitOps && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)}
</DropdownMenuItem>
</TooltipWrapper>
)}
{/* Stash operations - combined submenu */}
{(onStashChanges || onViewStashes) && (
<TooltipWrapper
showTooltip={!gitRepoStatus.isGitRepo}
tooltipContent="Not a git repository"
>
<DropdownMenuSub>
<div className="flex items-center">
{/* Main clickable area - stash changes (primary action) */}
<DropdownMenuItem
onClick={() => {
if (!gitRepoStatus.isGitRepo) return;
if (worktree.hasChanges && onStashChanges) {
onStashChanges(worktree);
} else if (onViewStashes) {
onViewStashes(worktree);
}
}}
disabled={!gitRepoStatus.isGitRepo}
className={cn(
'text-xs flex-1 pr-0 rounded-r-none',
!gitRepoStatus.isGitRepo && 'opacity-50 cursor-not-allowed'
)}
>
<Archive className="w-3.5 h-3.5 mr-2" />
{worktree.hasChanges && onStashChanges ? 'Stash Changes' : 'Stashes'}
{!gitRepoStatus.isGitRepo && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)}
</DropdownMenuItem>
{/* Chevron trigger for submenu with stash options */}
<DropdownMenuSubTrigger
className={cn(
'text-xs px-1 rounded-l-none border-l border-border/30 h-8',
!gitRepoStatus.isGitRepo && 'opacity-50 cursor-not-allowed'
)}
disabled={!gitRepoStatus.isGitRepo}
/>
</div>
<DropdownMenuSubContent>
{onViewStashes && (
<DropdownMenuItem onClick={() => onViewStashes(worktree)} className="text-xs">
<Eye className="w-3.5 h-3.5 mr-2" />
View Stashes
</DropdownMenuItem>
)}
</DropdownMenuSubContent>
</DropdownMenuSub>
</TooltipWrapper>
)}
<DropdownMenuSeparator />
{/* Open in editor - split button: click main area for default, chevron for other options */}
{effectiveDefaultEditor && (

View File

@@ -91,6 +91,7 @@ export interface WorktreeDropdownProps {
onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void;
onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void;
onViewChanges: (worktree: WorktreeInfo) => void;
onViewCommits: (worktree: WorktreeInfo) => void;
onDiscardChanges: (worktree: WorktreeInfo) => void;
onCommit: (worktree: WorktreeInfo) => void;
onCreatePR: (worktree: WorktreeInfo) => void;
@@ -107,6 +108,12 @@ export interface WorktreeDropdownProps {
onStartTests: (worktree: WorktreeInfo) => void;
onStopTests: (worktree: WorktreeInfo) => void;
onViewTestLogs: (worktree: WorktreeInfo) => void;
/** Stash changes for this worktree */
onStashChanges?: (worktree: WorktreeInfo) => void;
/** View stashes for this worktree */
onViewStashes?: (worktree: WorktreeInfo) => void;
/** Cherry-pick commits from another branch */
onCherryPick?: (worktree: WorktreeInfo) => void;
}
/**
@@ -168,6 +175,7 @@ export function WorktreeDropdown({
onOpenInIntegratedTerminal,
onOpenInExternalTerminal,
onViewChanges,
onViewCommits,
onDiscardChanges,
onCommit,
onCreatePR,
@@ -184,6 +192,9 @@ export function WorktreeDropdown({
onStartTests,
onStopTests,
onViewTestLogs,
onStashChanges,
onViewStashes,
onCherryPick,
}: WorktreeDropdownProps) {
// Find the currently selected worktree to display in the trigger
const selectedWorktree = worktrees.find((w) => isWorktreeSelected(w));
@@ -442,6 +453,7 @@ export function WorktreeDropdown({
isDevServerRunning={isDevServerRunning(selectedWorktree)}
devServerInfo={getDevServerInfo(selectedWorktree)}
gitRepoStatus={gitRepoStatus}
isLoadingGitStatus={isLoadingBranches}
isAutoModeRunning={isAutoModeRunningForWorktree(selectedWorktree)}
hasTestCommand={hasTestCommand}
isStartingTests={isStartingTests}
@@ -455,6 +467,7 @@ export function WorktreeDropdown({
onOpenInIntegratedTerminal={onOpenInIntegratedTerminal}
onOpenInExternalTerminal={onOpenInExternalTerminal}
onViewChanges={onViewChanges}
onViewCommits={onViewCommits}
onDiscardChanges={onDiscardChanges}
onCommit={onCommit}
onCreatePR={onCreatePR}
@@ -471,6 +484,9 @@ export function WorktreeDropdown({
onStartTests={onStartTests}
onStopTests={onStopTests}
onViewTestLogs={onViewTestLogs}
onStashChanges={onStashChanges}
onViewStashes={onViewStashes}
onCherryPick={onCherryPick}
hasInitScript={hasInitScript}
/>
)}

View File

@@ -59,6 +59,7 @@ interface WorktreeTabProps {
onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void;
onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void;
onViewChanges: (worktree: WorktreeInfo) => void;
onViewCommits: (worktree: WorktreeInfo) => void;
onDiscardChanges: (worktree: WorktreeInfo) => void;
onCommit: (worktree: WorktreeInfo) => void;
onCreatePR: (worktree: WorktreeInfo) => void;
@@ -78,6 +79,12 @@ interface WorktreeTabProps {
onStopTests?: (worktree: WorktreeInfo) => void;
/** View test logs for this worktree */
onViewTestLogs?: (worktree: WorktreeInfo) => void;
/** Stash changes for this worktree */
onStashChanges?: (worktree: WorktreeInfo) => void;
/** View stashes for this worktree */
onViewStashes?: (worktree: WorktreeInfo) => void;
/** Cherry-pick commits from another branch */
onCherryPick?: (worktree: WorktreeInfo) => void;
hasInitScript: boolean;
/** Whether a test command is configured in project settings */
hasTestCommand?: boolean;
@@ -122,6 +129,7 @@ export function WorktreeTab({
onOpenInIntegratedTerminal,
onOpenInExternalTerminal,
onViewChanges,
onViewCommits,
onDiscardChanges,
onCommit,
onCreatePR,
@@ -138,6 +146,9 @@ export function WorktreeTab({
onStartTests,
onStopTests,
onViewTestLogs,
onStashChanges,
onViewStashes,
onCherryPick,
hasInitScript,
hasTestCommand = false,
}: WorktreeTabProps) {
@@ -418,6 +429,7 @@ export function WorktreeTab({
isDevServerRunning={isDevServerRunning}
devServerInfo={devServerInfo}
gitRepoStatus={gitRepoStatus}
isLoadingGitStatus={isLoadingBranches}
isAutoModeRunning={isAutoModeRunning}
hasTestCommand={hasTestCommand}
isStartingTests={isStartingTests}
@@ -431,6 +443,7 @@ export function WorktreeTab({
onOpenInIntegratedTerminal={onOpenInIntegratedTerminal}
onOpenInExternalTerminal={onOpenInExternalTerminal}
onViewChanges={onViewChanges}
onViewCommits={onViewCommits}
onDiscardChanges={onDiscardChanges}
onCommit={onCommit}
onCreatePR={onCreatePR}
@@ -447,6 +460,9 @@ export function WorktreeTab({
onStartTests={onStartTests}
onStopTests={onStopTests}
onViewTestLogs={onViewTestLogs}
onStashChanges={onStashChanges}
onViewStashes={onViewStashes}
onCherryPick={onCherryPick}
hasInitScript={hasInitScript}
/>
</div>

View File

@@ -46,18 +46,22 @@ export function useWorktreeActions(options?: UseWorktreeActionsOptions) {
);
const handlePull = useCallback(
async (worktree: WorktreeInfo) => {
async (worktree: WorktreeInfo, remote?: string) => {
if (pullMutation.isPending) return;
pullMutation.mutate(worktree.path);
pullMutation.mutate({
worktreePath: worktree.path,
remote,
});
},
[pullMutation]
);
const handlePush = useCallback(
async (worktree: WorktreeInfo) => {
async (worktree: WorktreeInfo, remote?: string) => {
if (pushMutation.isPending) return;
pushMutation.mutate({
worktreePath: worktree.path,
remote,
});
},
[pushMutation]

View File

@@ -33,10 +33,16 @@ import {
import { useAppStore } from '@/store/app-store';
import {
ViewWorktreeChangesDialog,
ViewCommitsDialog,
PushToRemoteDialog,
MergeWorktreeDialog,
DiscardWorktreeChangesDialog,
SelectRemoteDialog,
StashChangesDialog,
ViewStashesDialog,
CherryPickDialog,
} from '../dialogs';
import type { SelectRemoteOperation } from '../dialogs';
import { TestLogsPanel } from '@/components/ui/test-logs-panel';
import { getElectronAPI } from '@/lib/electron';
@@ -380,6 +386,10 @@ export function WorktreePanel({
const [viewChangesDialogOpen, setViewChangesDialogOpen] = useState(false);
const [viewChangesWorktree, setViewChangesWorktree] = useState<WorktreeInfo | null>(null);
// View commits dialog state
const [viewCommitsDialogOpen, setViewCommitsDialogOpen] = useState(false);
const [viewCommitsWorktree, setViewCommitsWorktree] = useState<WorktreeInfo | null>(null);
// Discard changes confirmation dialog state
const [discardChangesDialogOpen, setDiscardChangesDialogOpen] = useState(false);
const [discardChangesWorktree, setDiscardChangesWorktree] = useState<WorktreeInfo | null>(null);
@@ -396,6 +406,21 @@ export function WorktreePanel({
const [mergeDialogOpen, setMergeDialogOpen] = useState(false);
const [mergeWorktree, setMergeWorktree] = useState<WorktreeInfo | null>(null);
// Select remote dialog state (for pull/push with multiple remotes)
const [selectRemoteDialogOpen, setSelectRemoteDialogOpen] = useState(false);
const [selectRemoteWorktree, setSelectRemoteWorktree] = useState<WorktreeInfo | null>(null);
const [selectRemoteOperation, setSelectRemoteOperation] = useState<SelectRemoteOperation>('pull');
// Stash dialog states
const [stashChangesDialogOpen, setStashChangesDialogOpen] = useState(false);
const [stashChangesWorktree, setStashChangesWorktree] = useState<WorktreeInfo | null>(null);
const [viewStashesDialogOpen, setViewStashesDialogOpen] = useState(false);
const [viewStashesWorktree, setViewStashesWorktree] = useState<WorktreeInfo | null>(null);
// Cherry-pick dialog states
const [cherryPickDialogOpen, setCherryPickDialogOpen] = useState(false);
const [cherryPickWorktree, setCherryPickWorktree] = useState<WorktreeInfo | null>(null);
const isMobile = useIsMobile();
// Periodic interval check (30 seconds) to detect branch changes on disk
@@ -464,6 +489,11 @@ export function WorktreePanel({
setViewChangesDialogOpen(true);
}, []);
const handleViewCommits = useCallback((worktree: WorktreeInfo) => {
setViewCommitsWorktree(worktree);
setViewCommitsDialogOpen(true);
}, []);
const handleDiscardChanges = useCallback((worktree: WorktreeInfo) => {
setDiscardChangesWorktree(worktree);
setDiscardChangesDialogOpen(true);
@@ -473,6 +503,36 @@ export function WorktreePanel({
fetchWorktrees({ silent: true });
}, [fetchWorktrees]);
// Handle stash changes dialog
const handleStashChanges = useCallback((worktree: WorktreeInfo) => {
setStashChangesWorktree(worktree);
setStashChangesDialogOpen(true);
}, []);
const handleStashCompleted = useCallback(() => {
fetchWorktrees({ silent: true });
}, [fetchWorktrees]);
// Handle view stashes dialog
const handleViewStashes = useCallback((worktree: WorktreeInfo) => {
setViewStashesWorktree(worktree);
setViewStashesDialogOpen(true);
}, []);
const handleStashApplied = useCallback(() => {
fetchWorktrees({ silent: true });
}, [fetchWorktrees]);
// Handle cherry-pick dialog
const handleCherryPick = useCallback((worktree: WorktreeInfo) => {
setCherryPickWorktree(worktree);
setCherryPickDialogOpen(true);
}, []);
const handleCherryPicked = useCallback(() => {
fetchWorktrees({ silent: true });
}, [fetchWorktrees]);
// Handle opening the log panel for a specific worktree
const handleViewDevServerLogs = useCallback((worktree: WorktreeInfo) => {
setLogPanelWorktree(worktree);
@@ -491,6 +551,68 @@ export function WorktreePanel({
setPushToRemoteDialogOpen(true);
}, []);
// Handle pull with remote selection when multiple remotes exist
const handlePullWithRemoteSelection = useCallback(
async (worktree: WorktreeInfo) => {
try {
const api = getHttpApiClient();
const result = await api.worktree.listRemotes(worktree.path);
if (result.success && result.result && result.result.remotes.length > 1) {
// Multiple remotes - show selection dialog
setSelectRemoteWorktree(worktree);
setSelectRemoteOperation('pull');
setSelectRemoteDialogOpen(true);
} else {
// Single or no remote - proceed with default behavior
handlePull(worktree);
}
} catch {
// If listing remotes fails, fall back to default behavior
handlePull(worktree);
}
},
[handlePull]
);
// Handle push with remote selection when multiple remotes exist
const handlePushWithRemoteSelection = useCallback(
async (worktree: WorktreeInfo) => {
try {
const api = getHttpApiClient();
const result = await api.worktree.listRemotes(worktree.path);
if (result.success && result.result && result.result.remotes.length > 1) {
// Multiple remotes - show selection dialog
setSelectRemoteWorktree(worktree);
setSelectRemoteOperation('push');
setSelectRemoteDialogOpen(true);
} else {
// Single or no remote - proceed with default behavior
handlePush(worktree);
}
} catch {
// If listing remotes fails, fall back to default behavior
handlePush(worktree);
}
},
[handlePush]
);
// Handle confirming remote selection for pull/push
const handleConfirmSelectRemote = useCallback(
async (worktree: WorktreeInfo, remote: string) => {
if (selectRemoteOperation === 'pull') {
handlePull(worktree, remote);
} else {
handlePush(worktree, remote);
}
fetchBranches(worktree.path);
fetchWorktrees();
},
[selectRemoteOperation, handlePull, handlePush, fetchBranches, fetchWorktrees]
);
// Handle confirming the push to remote dialog
const handleConfirmPushToRemote = useCallback(
async (worktree: WorktreeInfo, remote: string) => {
@@ -585,19 +707,21 @@ export function WorktreePanel({
isDevServerRunning={isDevServerRunning(selectedWorktree)}
devServerInfo={getDevServerInfo(selectedWorktree)}
gitRepoStatus={gitRepoStatus}
isLoadingGitStatus={isLoadingBranches}
isAutoModeRunning={isAutoModeRunningForWorktree(selectedWorktree)}
hasTestCommand={hasTestCommand}
isStartingTests={isStartingTests}
isTestRunning={isTestRunningForWorktree(selectedWorktree)}
testSessionInfo={getTestSessionInfo(selectedWorktree)}
onOpenChange={handleActionsDropdownOpenChange(selectedWorktree)}
onPull={handlePull}
onPush={handlePush}
onPull={handlePullWithRemoteSelection}
onPush={handlePushWithRemoteSelection}
onPushNewBranch={handlePushNewBranch}
onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
onOpenInExternalTerminal={handleOpenInExternalTerminal}
onViewChanges={handleViewChanges}
onViewCommits={handleViewCommits}
onDiscardChanges={handleDiscardChanges}
onCommit={onCommit}
onCreatePR={onCreatePR}
@@ -614,6 +738,9 @@ export function WorktreePanel({
onStartTests={handleStartTests}
onStopTests={handleStopTests}
onViewTestLogs={handleViewTestLogs}
onStashChanges={handleStashChanges}
onViewStashes={handleViewStashes}
onCherryPick={handleCherryPick}
hasInitScript={hasInitScript}
/>
)}
@@ -656,6 +783,13 @@ export function WorktreePanel({
projectPath={projectPath}
/>
{/* View Commits Dialog */}
<ViewCommitsDialog
open={viewCommitsDialogOpen}
onOpenChange={setViewCommitsDialogOpen}
worktree={viewCommitsWorktree}
/>
{/* Discard Changes Dialog */}
<DiscardWorktreeChangesDialog
open={discardChangesDialogOpen}
@@ -664,6 +798,31 @@ export function WorktreePanel({
onDiscarded={handleDiscardCompleted}
/>
{/* Stash Changes Dialog */}
<StashChangesDialog
open={stashChangesDialogOpen}
onOpenChange={setStashChangesDialogOpen}
worktree={stashChangesWorktree}
onStashed={handleStashCompleted}
/>
{/* View Stashes Dialog */}
<ViewStashesDialog
open={viewStashesDialogOpen}
onOpenChange={setViewStashesDialogOpen}
worktree={viewStashesWorktree}
onStashApplied={handleStashApplied}
/>
{/* Cherry Pick Dialog */}
<CherryPickDialog
open={cherryPickDialogOpen}
onOpenChange={setCherryPickDialogOpen}
worktree={cherryPickWorktree}
onCherryPicked={handleCherryPicked}
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
/>
{/* Dev Server Logs Panel */}
<DevServerLogsPanel
open={logPanelOpen}
@@ -681,6 +840,15 @@ export function WorktreePanel({
onConfirm={handleConfirmPushToRemote}
/>
{/* Select Remote Dialog (for pull/push with multiple remotes) */}
<SelectRemoteDialog
open={selectRemoteDialogOpen}
onOpenChange={setSelectRemoteDialogOpen}
worktree={selectRemoteWorktree}
operation={selectRemoteOperation}
onConfirm={handleConfirmSelectRemote}
/>
{/* Merge Branch Dialog */}
<MergeWorktreeDialog
open={mergeDialogOpen}
@@ -753,13 +921,14 @@ export function WorktreePanel({
isStartingTests={isStartingTests}
hasInitScript={hasInitScript}
onActionsDropdownOpenChange={handleActionsDropdownOpenChange}
onPull={handlePull}
onPush={handlePush}
onPull={handlePullWithRemoteSelection}
onPush={handlePushWithRemoteSelection}
onPushNewBranch={handlePushNewBranch}
onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
onOpenInExternalTerminal={handleOpenInExternalTerminal}
onViewChanges={handleViewChanges}
onViewCommits={handleViewCommits}
onDiscardChanges={handleDiscardChanges}
onCommit={onCommit}
onCreatePR={onCreatePR}
@@ -776,6 +945,9 @@ export function WorktreePanel({
onStartTests={handleStartTests}
onStopTests={handleStopTests}
onViewTestLogs={handleViewTestLogs}
onStashChanges={handleStashChanges}
onViewStashes={handleViewStashes}
onCherryPick={handleCherryPick}
/>
{useWorktreesEnabled && (
@@ -846,13 +1018,14 @@ export function WorktreePanel({
onBranchFilterChange={setBranchFilter}
onSwitchBranch={handleSwitchBranch}
onCreateBranch={onCreateBranch}
onPull={handlePull}
onPush={handlePush}
onPull={handlePullWithRemoteSelection}
onPush={handlePushWithRemoteSelection}
onPushNewBranch={handlePushNewBranch}
onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
onOpenInExternalTerminal={handleOpenInExternalTerminal}
onViewChanges={handleViewChanges}
onViewCommits={handleViewCommits}
onDiscardChanges={handleDiscardChanges}
onCommit={onCommit}
onCreatePR={onCreatePR}
@@ -869,6 +1042,8 @@ export function WorktreePanel({
onStartTests={handleStartTests}
onStopTests={handleStopTests}
onViewTestLogs={handleViewTestLogs}
onStashChanges={handleStashChanges}
onViewStashes={handleViewStashes}
hasInitScript={hasInitScript}
hasTestCommand={hasTestCommand}
/>
@@ -919,13 +1094,14 @@ export function WorktreePanel({
onBranchFilterChange={setBranchFilter}
onSwitchBranch={handleSwitchBranch}
onCreateBranch={onCreateBranch}
onPull={handlePull}
onPush={handlePush}
onPull={handlePullWithRemoteSelection}
onPush={handlePushWithRemoteSelection}
onPushNewBranch={handlePushNewBranch}
onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
onOpenInExternalTerminal={handleOpenInExternalTerminal}
onViewChanges={handleViewChanges}
onViewCommits={handleViewCommits}
onDiscardChanges={handleDiscardChanges}
onCommit={onCommit}
onCreatePR={onCreatePR}
@@ -942,6 +1118,8 @@ export function WorktreePanel({
onStartTests={handleStartTests}
onStopTests={handleStopTests}
onViewTestLogs={handleViewTestLogs}
onStashChanges={handleStashChanges}
onViewStashes={handleViewStashes}
hasInitScript={hasInitScript}
hasTestCommand={hasTestCommand}
/>
@@ -987,6 +1165,13 @@ export function WorktreePanel({
projectPath={projectPath}
/>
{/* View Commits Dialog */}
<ViewCommitsDialog
open={viewCommitsDialogOpen}
onOpenChange={setViewCommitsDialogOpen}
worktree={viewCommitsWorktree}
/>
{/* Discard Changes Dialog */}
<DiscardWorktreeChangesDialog
open={discardChangesDialogOpen}
@@ -1012,6 +1197,15 @@ export function WorktreePanel({
onConfirm={handleConfirmPushToRemote}
/>
{/* Select Remote Dialog (for pull/push with multiple remotes) */}
<SelectRemoteDialog
open={selectRemoteDialogOpen}
onOpenChange={setSelectRemoteDialogOpen}
worktree={selectRemoteWorktree}
operation={selectRemoteOperation}
onConfirm={handleConfirmSelectRemote}
/>
{/* Merge Branch Dialog */}
<MergeWorktreeDialog
open={mergeDialogOpen}
@@ -1032,6 +1226,31 @@ export function WorktreePanel({
testLogsPanelWorktree ? () => handleStopTests(testLogsPanelWorktree) : undefined
}
/>
{/* Stash Changes Dialog */}
<StashChangesDialog
open={stashChangesDialogOpen}
onOpenChange={setStashChangesDialogOpen}
worktree={stashChangesWorktree}
onStashed={handleStashCompleted}
/>
{/* View Stashes Dialog */}
<ViewStashesDialog
open={viewStashesDialogOpen}
onOpenChange={setViewStashesDialogOpen}
worktree={viewStashesWorktree}
onStashApplied={handleStashApplied}
/>
{/* Cherry Pick Dialog */}
<CherryPickDialog
open={cherryPickDialogOpen}
onOpenChange={setCherryPickDialogOpen}
worktree={cherryPickWorktree}
onCherryPicked={handleCherryPicked}
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
/>
</div>
);
}

View File

@@ -397,7 +397,7 @@ export function LoginView() {
// Login form (awaiting_login or logging_in)
const isLoggingIn = state.phase === 'logging_in';
const apiKey = state.phase === 'awaiting_login' ? state.apiKey : state.apiKey;
const apiKey = state.apiKey;
const error = state.phase === 'awaiting_login' ? state.error : null;
return (

View File

@@ -10,6 +10,7 @@ import { ProjectModelsSection } from './project-models-section';
import { DataManagementSection } from './data-management-section';
import { DangerZoneSection } from '../settings-view/danger-zone/danger-zone-section';
import { DeleteProjectDialog } from '../settings-view/components/delete-project-dialog';
import { RemoveFromAutomakerDialog } from '../settings-view/components/remove-from-automaker-dialog';
import { ProjectSettingsNavigation } from './components/project-settings-navigation';
import { useProjectSettingsView } from './hooks/use-project-settings-view';
import type { Project as ElectronProject } from '@/lib/electron';
@@ -28,8 +29,9 @@ interface SettingsProject {
}
export function ProjectSettingsView() {
const { currentProject, moveProjectToTrash } = useAppStore();
const { currentProject, moveProjectToTrash, removeProject } = useAppStore();
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [showRemoveFromAutomakerDialog, setShowRemoveFromAutomakerDialog] = useState(false);
// Use project settings view navigation hook
const { activeView, navigateTo } = useProjectSettingsView();
@@ -98,6 +100,7 @@ export function ProjectSettingsView() {
<DangerZoneSection
project={settingsProject}
onDeleteClick={() => setShowDeleteDialog(true)}
onRemoveFromAutomakerClick={() => setShowRemoveFromAutomakerDialog(true)}
/>
);
default:
@@ -178,6 +181,14 @@ export function ProjectSettingsView() {
project={currentProject}
onConfirm={moveProjectToTrash}
/>
{/* Remove from Automaker Confirmation Dialog */}
<RemoveFromAutomakerDialog
open={showRemoveFromAutomakerDialog}
onOpenChange={setShowRemoveFromAutomakerDialog}
project={currentProject}
onConfirm={removeProject}
/>
</div>
);
}

View File

@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { ShellSyntaxEditor } from '@/components/ui/shell-syntax-editor';
import {
GitBranch,
@@ -11,6 +12,9 @@ import {
RotateCcw,
Trash2,
PanelBottomClose,
Copy,
Plus,
FolderOpen,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
@@ -19,6 +23,7 @@ import { toast } from 'sonner';
import { useAppStore } from '@/store/app-store';
import { getHttpApiClient } from '@/lib/http-api-client';
import type { Project } from '@/lib/electron';
import { ProjectFileSelectorDialog } from '@/components/dialogs/project-file-selector-dialog';
interface WorktreePreferencesSectionProps {
project: Project;
@@ -42,6 +47,8 @@ export function WorktreePreferencesSection({ project }: WorktreePreferencesSecti
const setDefaultDeleteBranch = useAppStore((s) => s.setDefaultDeleteBranch);
const getAutoDismissInitScriptIndicator = useAppStore((s) => s.getAutoDismissInitScriptIndicator);
const setAutoDismissInitScriptIndicator = useAppStore((s) => s.setAutoDismissInitScriptIndicator);
const getWorktreeCopyFiles = useAppStore((s) => s.getWorktreeCopyFiles);
const setWorktreeCopyFiles = useAppStore((s) => s.setWorktreeCopyFiles);
// Get effective worktrees setting (project override or global fallback)
const projectUseWorktrees = getProjectUseWorktrees(project.path);
@@ -54,6 +61,11 @@ export function WorktreePreferencesSection({ project }: WorktreePreferencesSecti
const [isSaving, setIsSaving] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
// Copy files state
const [newCopyFilePath, setNewCopyFilePath] = useState('');
const [fileSelectorOpen, setFileSelectorOpen] = useState(false);
const copyFiles = getWorktreeCopyFiles(project.path);
// Get the current settings for this project
const showIndicator = getShowInitScriptIndicator(project.path);
const defaultDeleteBranch = getDefaultDeleteBranch(project.path);
@@ -93,6 +105,9 @@ export function WorktreePreferencesSection({ project }: WorktreePreferencesSecti
response.settings.autoDismissInitScriptIndicator
);
}
if (response.settings.worktreeCopyFiles !== undefined) {
setWorktreeCopyFiles(currentPath, response.settings.worktreeCopyFiles);
}
}
} catch (error) {
if (!isCancelled) {
@@ -112,6 +127,7 @@ export function WorktreePreferencesSection({ project }: WorktreePreferencesSecti
setShowInitScriptIndicator,
setDefaultDeleteBranch,
setAutoDismissInitScriptIndicator,
setWorktreeCopyFiles,
]);
// Load init script content when project changes
@@ -219,6 +235,97 @@ export function WorktreePreferencesSection({ project }: WorktreePreferencesSecti
setScriptContent(value);
}, []);
// Add a new file path to copy list
const handleAddCopyFile = useCallback(async () => {
const trimmed = newCopyFilePath.trim();
if (!trimmed) return;
// Normalize: remove leading ./ or /
const normalized = trimmed.replace(/^\.\//, '').replace(/^\//, '');
if (!normalized) return;
// Check for duplicates
const currentFiles = getWorktreeCopyFiles(project.path);
if (currentFiles.includes(normalized)) {
toast.error('File already in list', {
description: `"${normalized}" is already configured for copying.`,
});
return;
}
const updatedFiles = [...currentFiles, normalized];
setWorktreeCopyFiles(project.path, updatedFiles);
setNewCopyFilePath('');
// Persist to server
try {
const httpClient = getHttpApiClient();
await httpClient.settings.updateProject(project.path, {
worktreeCopyFiles: updatedFiles,
});
toast.success('Copy file added', {
description: `"${normalized}" will be copied to new worktrees.`,
});
} catch (error) {
console.error('Failed to persist worktreeCopyFiles:', error);
toast.error('Failed to save copy files setting');
}
}, [project.path, newCopyFilePath, getWorktreeCopyFiles, setWorktreeCopyFiles]);
// Remove a file path from copy list
const handleRemoveCopyFile = useCallback(
async (filePath: string) => {
const currentFiles = getWorktreeCopyFiles(project.path);
const updatedFiles = currentFiles.filter((f) => f !== filePath);
setWorktreeCopyFiles(project.path, updatedFiles);
// Persist to server
try {
const httpClient = getHttpApiClient();
await httpClient.settings.updateProject(project.path, {
worktreeCopyFiles: updatedFiles,
});
toast.success('Copy file removed');
} catch (error) {
console.error('Failed to persist worktreeCopyFiles:', error);
toast.error('Failed to save copy files setting');
}
},
[project.path, getWorktreeCopyFiles, setWorktreeCopyFiles]
);
// Handle files selected from the file selector dialog
const handleFileSelectorSelect = useCallback(
async (paths: string[]) => {
const currentFiles = getWorktreeCopyFiles(project.path);
// Filter out duplicates
const newPaths = paths.filter((p) => !currentFiles.includes(p));
if (newPaths.length === 0) {
toast.info('All selected files are already in the list');
return;
}
const updatedFiles = [...currentFiles, ...newPaths];
setWorktreeCopyFiles(project.path, updatedFiles);
// Persist to server
try {
const httpClient = getHttpApiClient();
await httpClient.settings.updateProject(project.path, {
worktreeCopyFiles: updatedFiles,
});
toast.success(`${newPaths.length} ${newPaths.length === 1 ? 'file' : 'files'} added`, {
description: newPaths.map((p) => `"${p}"`).join(', '),
});
} catch (error) {
console.error('Failed to persist worktreeCopyFiles:', error);
toast.error('Failed to save copy files setting');
}
},
[project.path, getWorktreeCopyFiles, setWorktreeCopyFiles]
);
return (
<div
className={cn(
@@ -387,6 +494,92 @@ export function WorktreePreferencesSection({ project }: WorktreePreferencesSecti
{/* Separator */}
<div className="border-t border-border/30" />
{/* Copy Files Section */}
<div className="space-y-3">
<div className="flex items-center gap-2">
<Copy className="w-4 h-4 text-brand-500" />
<Label className="text-foreground font-medium">Copy Files to Worktrees</Label>
</div>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
Specify files or directories (relative to project root) to automatically copy into new
worktrees. Useful for untracked files like{' '}
<code className="font-mono text-foreground/60">.env</code>,{' '}
<code className="font-mono text-foreground/60">.env.local</code>, or local config files
that aren&apos;t committed to git.
</p>
{/* Current file list */}
{copyFiles.length > 0 && (
<div className="space-y-1.5">
{copyFiles.map((filePath) => (
<div
key={filePath}
className="flex items-center gap-2 group/item px-3 py-1.5 rounded-lg bg-accent/20 hover:bg-accent/40 transition-colors"
>
<FileCode className="w-3.5 h-3.5 text-muted-foreground/60 flex-shrink-0" />
<code className="font-mono text-sm text-foreground/80 flex-1 truncate">
{filePath}
</code>
<button
onClick={() => handleRemoveCopyFile(filePath)}
className="p-0.5 rounded text-muted-foreground/50 hover:bg-destructive/10 hover:text-destructive transition-all flex-shrink-0"
title={`Remove ${filePath}`}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
)}
{/* Add new file input */}
<div className="flex items-center gap-2">
<Input
value={newCopyFilePath}
onChange={(e) => setNewCopyFilePath(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddCopyFile();
}
}}
placeholder=".env, config/local.json, etc."
className="flex-1 h-8 text-sm font-mono"
/>
<Button
variant="outline"
size="sm"
onClick={handleAddCopyFile}
disabled={!newCopyFilePath.trim()}
className="gap-1.5 h-8"
>
<Plus className="w-3.5 h-3.5" />
Add
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setFileSelectorOpen(true)}
className="gap-1.5 h-8"
>
<FolderOpen className="w-3.5 h-3.5" />
Browse
</Button>
</div>
{/* File selector dialog */}
<ProjectFileSelectorDialog
open={fileSelectorOpen}
onOpenChange={setFileSelectorOpen}
onSelect={handleFileSelectorSelect}
projectPath={project.path}
existingFiles={copyFiles}
/>
</div>
{/* Separator */}
<div className="border-t border-border/30" />
{/* Init Script Section */}
<div className="space-y-3">
<div className="flex items-center justify-between">

View File

@@ -1,4 +1,5 @@
export { DeleteProjectDialog } from './delete-project-dialog';
export { RemoveFromAutomakerDialog } from './remove-from-automaker-dialog';
export { KeyboardMapDialog } from './keyboard-map-dialog';
export { SettingsHeader } from './settings-header';
export { SettingsNavigation } from './settings-navigation';

View File

@@ -0,0 +1,49 @@
import { Folder, LogOut } from 'lucide-react';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import type { Project } from '@/lib/electron';
interface RemoveFromAutomakerDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
project: Project | null;
onConfirm: (projectId: string) => void;
}
export function RemoveFromAutomakerDialog({
open,
onOpenChange,
project,
onConfirm,
}: RemoveFromAutomakerDialogProps) {
const handleConfirm = () => {
if (project) {
onConfirm(project.id);
}
};
return (
<ConfirmDialog
open={open}
onOpenChange={onOpenChange}
onConfirm={handleConfirm}
title="Remove from Automaker"
description="Remove this project from Automaker? The folder will remain on disk and can be re-added later."
icon={LogOut}
iconClassName="text-muted-foreground"
confirmText="Remove from Automaker"
confirmVariant="secondary"
>
{project && (
<div className="flex items-center gap-3 p-4 rounded-lg bg-sidebar-accent/10 border border-sidebar-border">
<div className="w-10 h-10 rounded-lg bg-sidebar-accent/20 border border-sidebar-border flex items-center justify-center shrink-0">
<Folder className="w-5 h-5 text-brand-500" />
</div>
<div className="min-w-0">
<p className="font-medium text-foreground truncate">{project.name}</p>
<p className="text-xs text-muted-foreground truncate">{project.path}</p>
</div>
</div>
)}
</ConfirmDialog>
);
}

View File

@@ -1,14 +1,19 @@
import { Button } from '@/components/ui/button';
import { Trash2, Folder, AlertTriangle } from 'lucide-react';
import { Trash2, Folder, AlertTriangle, LogOut } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { Project } from '../shared/types';
interface DangerZoneSectionProps {
project: Project | null;
onDeleteClick: () => void;
onRemoveFromAutomakerClick?: () => void;
}
export function DangerZoneSection({ project, onDeleteClick }: DangerZoneSectionProps) {
export function DangerZoneSection({
project,
onDeleteClick,
onRemoveFromAutomakerClick,
}: DangerZoneSectionProps) {
return (
<div
className={cn(
@@ -28,33 +33,57 @@ export function DangerZoneSection({ project, onDeleteClick }: DangerZoneSectionP
<p className="text-sm text-muted-foreground/80 ml-12">Destructive project actions.</p>
</div>
<div className="p-6 space-y-4">
{/* Project Delete */}
{project ? (
<div className="flex items-center justify-between gap-4 p-4 rounded-xl bg-destructive/5 border border-destructive/10">
<div className="flex items-center gap-3.5 min-w-0">
<div className="w-11 h-11 rounded-xl bg-gradient-to-br from-brand-500/15 to-brand-600/10 border border-brand-500/20 flex items-center justify-center shrink-0">
<Folder className="w-5 h-5 text-brand-500" />
<>
{/* Remove from Automaker */}
{onRemoveFromAutomakerClick && (
<div className="flex items-start justify-between gap-4 p-4 rounded-xl bg-muted/30 border border-border">
<div className="min-w-0">
<p className="font-medium text-foreground">Remove from Automaker</p>
<p className="text-xs text-muted-foreground mt-0.5">
Remove this project from Automaker without deleting any files from disk. You can
re-add it later by opening the folder.
</p>
</div>
<Button
variant="secondary"
onClick={onRemoveFromAutomakerClick}
data-testid="remove-from-automaker-button"
className="shrink-0 transition-all duration-200 ease-out hover:scale-[1.02] active:scale-[0.98]"
>
<LogOut className="w-4 h-4 mr-2" />
Remove
</Button>
</div>
<div className="min-w-0">
<p className="font-medium text-foreground truncate">{project.name}</p>
<p className="text-xs text-muted-foreground/70 truncate mt-0.5">{project.path}</p>
)}
{/* Project Delete / Move to Trash */}
<div className="flex items-center justify-between gap-4 p-4 rounded-xl bg-destructive/5 border border-destructive/10">
<div className="flex items-center gap-3.5 min-w-0">
<div className="w-11 h-11 rounded-xl bg-gradient-to-br from-brand-500/15 to-brand-600/10 border border-brand-500/20 flex items-center justify-center shrink-0">
<Folder className="w-5 h-5 text-brand-500" />
</div>
<div className="min-w-0">
<p className="font-medium text-foreground truncate">{project.name}</p>
<p className="text-xs text-muted-foreground/70 truncate mt-0.5">{project.path}</p>
</div>
</div>
<Button
variant="destructive"
onClick={onDeleteClick}
data-testid="delete-project-button"
className={cn(
'shrink-0',
'shadow-md shadow-destructive/20 hover:shadow-lg hover:shadow-destructive/25',
'transition-all duration-200 ease-out',
'hover:scale-[1.02] active:scale-[0.98]'
)}
>
<Trash2 className="w-4 h-4 mr-2" />
Move to Trash
</Button>
</div>
<Button
variant="destructive"
onClick={onDeleteClick}
data-testid="delete-project-button"
className={cn(
'shrink-0',
'shadow-md shadow-destructive/20 hover:shadow-lg hover:shadow-destructive/25',
'transition-all duration-200 ease-out',
'hover:scale-[1.02] active:scale-[0.98]'
)}
>
<Trash2 className="w-4 h-4 mr-2" />
Delete Project
</Button>
</div>
</>
) : (
<p className="text-sm text-muted-foreground/60 text-center py-4">No project selected.</p>
)}

View File

@@ -88,6 +88,9 @@ export function MobileTerminalShortcuts({
/** Handles arrow key press with long-press repeat support. */
const handleArrowPress = useCallback(
(data: string) => {
// Cancel any in-flight timeout/interval before starting a new one
// to prevent timer leaks when multiple touches occur.
clearRepeat();
sendKey(data);
// Start repeat after 400ms hold, then every 80ms
repeatTimeoutRef.current = setTimeout(() => {
@@ -96,7 +99,7 @@ export function MobileTerminalShortcuts({
}, 80);
}, 400);
},
[sendKey]
[clearRepeat, sendKey]
);
const handleArrowRelease = useCallback(() => {

View File

@@ -9,6 +9,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import { getElectronAPI } from '@/lib/electron';
import { queryKeys } from '@/lib/query-keys';
import { toast } from 'sonner';
import type { Feature } from '@/store/app-store';
/**
* Start running a feature in auto mode
@@ -159,9 +160,26 @@ export function useVerifyFeature(projectPath: string) {
if (!result.success) {
throw new Error(result.error || 'Failed to verify feature');
}
return result;
return { ...result, featureId };
},
onSuccess: () => {
onSuccess: (data) => {
// If verification passed, optimistically update React Query cache
// to move the feature to 'verified' status immediately
if (data.passes) {
const previousFeatures = queryClient.getQueryData<Feature[]>(
queryKeys.features.all(projectPath)
);
if (previousFeatures) {
queryClient.setQueryData<Feature[]>(
queryKeys.features.all(projectPath),
previousFeatures.map((f) =>
f.id === data.featureId
? { ...f, status: 'verified' as const, justFinishedAt: undefined }
: f
)
);
}
}
queryClient.invalidateQueries({ queryKey: queryKeys.features.all(projectPath) });
},
onError: (error: Error) => {

View File

@@ -126,10 +126,18 @@ export function usePushWorktree() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ worktreePath, force }: { worktreePath: string; force?: boolean }) => {
mutationFn: async ({
worktreePath,
force,
remote,
}: {
worktreePath: string;
force?: boolean;
remote?: string;
}) => {
const api = getElectronAPI();
if (!api.worktree) throw new Error('Worktree API not available');
const result = await api.worktree.push(worktreePath, force);
const result = await api.worktree.push(worktreePath, force, remote);
if (!result.success) {
throw new Error(result.error || 'Failed to push changes');
}
@@ -156,10 +164,10 @@ export function usePullWorktree() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (worktreePath: string) => {
mutationFn: async ({ worktreePath, remote }: { worktreePath: string; remote?: string }) => {
const api = getElectronAPI();
if (!api.worktree) throw new Error('Worktree API not available');
const result = await api.worktree.pull(worktreePath);
const result = await api.worktree.pull(worktreePath, remote);
if (!result.success) {
throw new Error(result.error || 'Failed to pull changes');
}
@@ -283,17 +291,6 @@ export function useMergeWorktree(projectPath: string) {
});
}
/**
* Result from the switch branch API call
*/
interface SwitchBranchResult {
previousBranch: string;
currentBranch: string;
message: string;
hasConflicts?: boolean;
stashedChanges?: boolean;
}
/**
* Switch to a different branch
*
@@ -316,14 +313,17 @@ export function useSwitchBranch(options?: {
}: {
worktreePath: string;
branchName: string;
}): Promise<SwitchBranchResult> => {
}) => {
const api = getElectronAPI();
if (!api.worktree) throw new Error('Worktree API not available');
const result = await api.worktree.switchBranch(worktreePath, branchName);
if (!result.success) {
throw new Error(result.error || 'Failed to switch branch');
}
return result.result as SwitchBranchResult;
if (!result.result) {
throw new Error('Switch branch returned no result');
}
return result.result;
},
onSuccess: (data, variables) => {
queryClient.invalidateQueries({ queryKey: ['worktrees'] });
@@ -388,6 +388,36 @@ export function useCheckoutBranch() {
});
}
/**
* Generate a PR title and description from branch diff
*
* @returns Mutation for generating a PR description
*/
export function useGeneratePRDescription() {
return useMutation({
mutationFn: async ({
worktreePath,
baseBranch,
}: {
worktreePath: string;
baseBranch?: string;
}) => {
const api = getElectronAPI();
if (!api.worktree) throw new Error('Worktree API not available');
const result = await api.worktree.generatePRDescription(worktreePath, baseBranch);
if (!result.success) {
throw new Error(result.error || 'Failed to generate PR description');
}
return { title: result.title ?? '', body: result.body ?? '' };
},
onError: (error: Error) => {
toast.error('Failed to generate PR description', {
description: error.message,
});
},
});
}
/**
* Generate a commit message from git diff
*

View File

@@ -144,8 +144,10 @@ export function useGeminiUsage(enabled = true) {
throw new Error('Gemini API bridge unavailable');
}
const result = await api.gemini.getUsage();
// Server always returns a response with 'authenticated' field, even on error
// So we can safely cast to GeminiUsage
// Check if result is an error-only response (no 'authenticated' field means it's the error variant)
if (!('authenticated' in result) && 'error' in result) {
throw new Error(result.message || result.error);
}
return result as GeminiUsage;
},
enabled,

View File

@@ -86,6 +86,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
getMaxConcurrencyForWorktree,
setMaxConcurrencyForWorktree,
isPrimaryWorktreeBranch,
globalMaxConcurrency,
} = useAppStore(
useShallow((state) => ({
autoModeByWorktree: state.autoModeByWorktree,
@@ -100,6 +101,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
getMaxConcurrencyForWorktree: state.getMaxConcurrencyForWorktree,
setMaxConcurrencyForWorktree: state.setMaxConcurrencyForWorktree,
isPrimaryWorktreeBranch: state.isPrimaryWorktreeBranch,
globalMaxConcurrency: state.maxConcurrency,
}))
);
@@ -143,11 +145,13 @@ export function useAutoMode(worktree?: WorktreeInfo) {
const isAutoModeRunning = worktreeAutoModeState.isRunning;
const runningAutoTasks = worktreeAutoModeState.runningTasks;
// Use getMaxConcurrencyForWorktree which properly falls back to the global
// maxConcurrency setting, instead of DEFAULT_MAX_CONCURRENCY (1) which would
// incorrectly block agents when the user has set a higher global limit
// Use the subscribed worktreeAutoModeState.maxConcurrency (from the reactive
// autoModeByWorktree store slice) so canStartNewTask stays reactive when
// refreshStatus updates worktree state or when the global setting changes.
// Falls back to the subscribed globalMaxConcurrency (also reactive) when no
// per-worktree value is set, and to DEFAULT_MAX_CONCURRENCY when no project.
const maxConcurrency = projectId
? getMaxConcurrencyForWorktree(projectId, branchName)
? (worktreeAutoModeState.maxConcurrency ?? globalMaxConcurrency)
: DEFAULT_MAX_CONCURRENCY;
// Check if we can start a new task based on concurrency limit

View File

@@ -25,6 +25,7 @@ export function useProjectSettingsLoader() {
const setAutoDismissInitScriptIndicator = useAppStore(
(state) => state.setAutoDismissInitScriptIndicator
);
const setWorktreeCopyFiles = useAppStore((state) => state.setWorktreeCopyFiles);
const setCurrentProject = useAppStore((state) => state.setCurrentProject);
const appliedProjectRef = useRef<{ path: string; dataUpdatedAt: number } | null>(null);
@@ -95,6 +96,11 @@ export function useProjectSettingsLoader() {
setAutoDismissInitScriptIndicator(projectPath, settings.autoDismissInitScriptIndicator);
}
// Apply worktreeCopyFiles if present
if (settings.worktreeCopyFiles !== undefined) {
setWorktreeCopyFiles(projectPath, settings.worktreeCopyFiles);
}
// Apply activeClaudeApiProfileId and phaseModelOverrides if present
// These are stored directly on the project, so we need to update both
// currentProject AND the projects array to keep them in sync
@@ -152,6 +158,7 @@ export function useProjectSettingsLoader() {
setShowInitScriptIndicator,
setDefaultDeleteBranch,
setAutoDismissInitScriptIndicator,
setWorktreeCopyFiles,
setCurrentProject,
]);
}

View File

@@ -4,8 +4,8 @@ import {
type ClaudeAuthMethod,
type CodexAuthMethod,
type ZaiAuthMethod,
type GeminiAuthMethod,
} from '@/store/setup-store';
import type { GeminiAuthStatus } from '@automaker/types';
import { getHttpApiClient } from '@/lib/http-api-client';
import { createLogger } from '@automaker/utils/logger';
@@ -159,11 +159,16 @@ export function useProviderAuthInit() {
// Set Auth status - always set a status to mark initialization as complete
if (result.auth) {
const auth = result.auth;
const validMethods: GeminiAuthMethod[] = ['cli_login', 'api_key_env', 'api_key', 'none'];
const validMethods: GeminiAuthStatus['method'][] = [
'google_login',
'api_key',
'vertex_ai',
'none',
];
const method = validMethods.includes(auth.method as GeminiAuthMethod)
? (auth.method as GeminiAuthMethod)
: ((auth.authenticated ? 'cli_login' : 'none') as GeminiAuthMethod);
const method = validMethods.includes(auth.method as GeminiAuthStatus['method'])
? (auth.method as GeminiAuthStatus['method'])
: ((auth.authenticated ? 'google_login' : 'none') as GeminiAuthStatus['method']);
setGeminiAuthStatus({
authenticated: auth.authenticated,

View File

@@ -202,6 +202,7 @@ export interface CreatePROptions {
prBody?: string;
baseBranch?: string;
draft?: boolean;
remote?: string;
}
// Re-export types from electron.d.ts for external use
@@ -2195,6 +2196,15 @@ function createMockWorktreeAPI(): WorktreeAPI {
};
},
generatePRDescription: async (worktreePath: string, baseBranch?: string) => {
console.log('[Mock] Generating PR description for:', { worktreePath, baseBranch });
return {
success: true,
title: 'Add new feature implementation',
body: '## Summary\n- Added new feature\n\n## Changes\n- Implementation details here',
};
},
push: async (worktreePath: string, force?: boolean, remote?: string) => {
const targetRemote = remote || 'origin';
console.log('[Mock] Pushing worktree:', { worktreePath, force, remote: targetRemote });
@@ -2249,22 +2259,24 @@ function createMockWorktreeAPI(): WorktreeAPI {
};
},
pull: async (worktreePath: string) => {
console.log('[Mock] Pulling latest changes for:', worktreePath);
pull: async (worktreePath: string, remote?: string) => {
const targetRemote = remote || 'origin';
console.log('[Mock] Pulling latest changes for:', { worktreePath, remote: targetRemote });
return {
success: true,
result: {
branch: 'main',
pulled: true,
message: 'Pulled latest changes',
message: `Pulled latest changes from ${targetRemote}`,
},
};
},
checkoutBranch: async (worktreePath: string, branchName: string) => {
checkoutBranch: async (worktreePath: string, branchName: string, baseBranch?: string) => {
console.log('[Mock] Creating and checking out branch:', {
worktreePath,
branchName,
baseBranch,
});
return {
success: true,
@@ -2303,6 +2315,8 @@ function createMockWorktreeAPI(): WorktreeAPI {
previousBranch: 'main',
currentBranch: branchName,
message: `Switched to branch '${branchName}'`,
hasConflicts: false,
stashedChanges: false,
},
};
},
@@ -2631,6 +2645,101 @@ function createMockWorktreeAPI(): WorktreeAPI {
console.log('[Mock] Unsubscribing from test runner events');
};
},
getCommitLog: async (worktreePath: string, limit?: number) => {
console.log('[Mock] Getting commit log:', { worktreePath, limit });
return {
success: true,
result: {
branch: 'main',
commits: [
{
hash: 'abc1234567890',
shortHash: 'abc1234',
author: 'Mock User',
authorEmail: 'mock@example.com',
date: new Date().toISOString(),
subject: 'Mock commit message',
body: '',
files: ['src/index.ts', 'package.json'],
},
],
total: 1,
},
};
},
stashPush: async (worktreePath: string, message?: string, files?: string[]) => {
console.log('[Mock] Stash push:', { worktreePath, message, files });
return {
success: true,
result: {
stashed: true,
branch: 'main',
message: message || 'WIP on main',
},
};
},
stashList: async (worktreePath: string) => {
console.log('[Mock] Stash list:', { worktreePath });
return {
success: true,
result: {
stashes: [],
total: 0,
},
};
},
stashApply: async (worktreePath: string, stashIndex: number, pop?: boolean) => {
console.log('[Mock] Stash apply:', { worktreePath, stashIndex, pop });
return {
success: true,
result: {
applied: true,
hasConflicts: false,
operation: pop ? ('pop' as const) : ('apply' as const),
stashIndex,
message: `Stash ${pop ? 'popped' : 'applied'} successfully`,
},
};
},
stashDrop: async (worktreePath: string, stashIndex: number) => {
console.log('[Mock] Stash drop:', { worktreePath, stashIndex });
return {
success: true,
result: {
dropped: true,
stashIndex,
message: `Stash stash@{${stashIndex}} dropped successfully`,
},
};
},
cherryPick: async (
worktreePath: string,
commitHashes: string[],
options?: { noCommit?: boolean }
) => {
console.log('[Mock] Cherry-pick:', { worktreePath, commitHashes, options });
return {
success: true,
result: {
cherryPicked: true,
commitHashes,
branch: 'main',
message: `Cherry-picked ${commitHashes.length} commit(s) successfully`,
},
};
},
getBranchCommitLog: async (worktreePath: string, branchName?: string, limit?: number) => {
console.log('[Mock] Get branch commit log:', { worktreePath, branchName, limit });
return {
success: true,
result: {
branch: branchName || 'main',
commits: [],
total: 0,
},
};
},
};
}

View File

@@ -2121,6 +2121,8 @@ export class HttpApiClient implements ElectronAPI {
this.post('/api/worktree/commit', { worktreePath, message, files }),
generateCommitMessage: (worktreePath: string) =>
this.post('/api/worktree/generate-commit-message', { worktreePath }),
generatePRDescription: (worktreePath: string, baseBranch?: string) =>
this.post('/api/worktree/generate-pr-description', { worktreePath, baseBranch }),
push: (worktreePath: string, force?: boolean, remote?: string) =>
this.post('/api/worktree/push', { worktreePath, force, remote }),
createPR: (worktreePath: string, options?: CreatePROptions) =>
@@ -2133,9 +2135,10 @@ export class HttpApiClient implements ElectronAPI {
featureId,
filePath,
}),
pull: (worktreePath: string) => this.post('/api/worktree/pull', { worktreePath }),
checkoutBranch: (worktreePath: string, branchName: string) =>
this.post('/api/worktree/checkout-branch', { worktreePath, branchName }),
pull: (worktreePath: string, remote?: string) =>
this.post('/api/worktree/pull', { worktreePath, remote }),
checkoutBranch: (worktreePath: string, branchName: string, baseBranch?: string) =>
this.post('/api/worktree/checkout-branch', { worktreePath, branchName, baseBranch }),
listBranches: (worktreePath: string, includeRemote?: boolean) =>
this.post('/api/worktree/list-branches', { worktreePath, includeRemote }),
switchBranch: (worktreePath: string, branchName: string) =>
@@ -2216,6 +2219,19 @@ export class HttpApiClient implements ElectronAPI {
startTests: (worktreePath: string, options?: { projectPath?: string; testFile?: string }) =>
this.post('/api/worktree/start-tests', { worktreePath, ...options }),
stopTests: (sessionId: string) => this.post('/api/worktree/stop-tests', { sessionId }),
getCommitLog: (worktreePath: string, limit?: number) =>
this.post('/api/worktree/commit-log', { worktreePath, limit }),
stashPush: (worktreePath: string, message?: string, files?: string[]) =>
this.post('/api/worktree/stash-push', { worktreePath, message, files }),
stashList: (worktreePath: string) => this.post('/api/worktree/stash-list', { worktreePath }),
stashApply: (worktreePath: string, stashIndex: number, pop?: boolean) =>
this.post('/api/worktree/stash-apply', { worktreePath, stashIndex, pop }),
stashDrop: (worktreePath: string, stashIndex: number) =>
this.post('/api/worktree/stash-drop', { worktreePath, stashIndex }),
cherryPick: (worktreePath: string, commitHashes: string[], options?: { noCommit?: boolean }) =>
this.post('/api/worktree/cherry-pick', { worktreePath, commitHashes, options }),
getBranchCommitLog: (worktreePath: string, branchName?: string, limit?: number) =>
this.post('/api/worktree/branch-commit-log', { worktreePath, branchName, limit }),
getTestLogs: (worktreePath?: string, sessionId?: string): Promise<TestLogsResponse> => {
const params = new URLSearchParams();
if (worktreePath) params.append('worktreePath', worktreePath);
@@ -2582,6 +2598,7 @@ export class HttpApiClient implements ElectronAPI {
showInitScriptIndicator?: boolean;
defaultDeleteBranchWithWorktree?: boolean;
autoDismissInitScriptIndicator?: boolean;
worktreeCopyFiles?: string[];
lastSelectedSessionId?: string;
testCommand?: string;
};

View File

@@ -335,6 +335,7 @@ const initialState: AppState = {
defaultDeleteBranchByProject: {},
autoDismissInitScriptIndicatorByProject: {},
useWorktreesByProject: {},
worktreeCopyFilesByProject: {},
worktreePanelCollapsed: false,
lastProjectDir: '',
recentFolders: [],
@@ -359,10 +360,15 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
}
},
removeProject: (projectId) =>
removeProject: (projectId: string) => {
set((state) => ({
projects: state.projects.filter((p) => p.id !== projectId),
})),
currentProject: state.currentProject?.id === projectId ? null : state.currentProject,
}));
// Persist to storage
saveProjects(get().projects);
},
moveProjectToTrash: (projectId: string) => {
const project = get().projects.find((p) => p.id === projectId);
@@ -2394,6 +2400,16 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
return projectOverride !== undefined ? projectOverride : get().useWorktrees;
},
// Worktree Copy Files actions
setWorktreeCopyFiles: (projectPath, files) =>
set((state) => ({
worktreeCopyFilesByProject: {
...state.worktreeCopyFilesByProject,
[projectPath]: files,
},
})),
getWorktreeCopyFiles: (projectPath) => get().worktreeCopyFilesByProject[projectPath] ?? [],
// UI State actions
setWorktreePanelCollapsed: (collapsed) => set({ worktreePanelCollapsed: collapsed }),
setLastProjectDir: (dir) => set({ lastProjectDir: dir }),

View File

@@ -1,4 +1,5 @@
import { create } from 'zustand';
import type { GeminiAuthStatus } from '@automaker/types';
// Note: persist middleware removed - settings now sync via API (use-settings-sync.ts)
// CLI Installation Status
@@ -127,21 +128,8 @@ export interface ZaiAuthStatus {
error?: string;
}
// Gemini Auth Method
export type GeminiAuthMethod =
| 'cli_login' // Gemini CLI is installed and authenticated
| 'api_key_env' // GOOGLE_API_KEY or GEMINI_API_KEY environment variable
| 'api_key' // Manually stored API key
| 'none';
// Gemini Auth Status
export interface GeminiAuthStatus {
authenticated: boolean;
method: GeminiAuthMethod;
hasApiKey?: boolean;
hasEnvApiKey?: boolean;
error?: string;
}
// GeminiAuthStatus is imported from @automaker/types (method: 'google_login' | 'api_key' | 'vertex_ai' | 'none')
export type { GeminiAuthStatus };
// Claude Auth Method - all possible authentication sources
export type ClaudeAuthMethod =

View File

@@ -341,6 +341,10 @@ export interface AppState {
// undefined = use global setting, true/false = project-specific override
useWorktreesByProject: Record<string, boolean | undefined>;
// Worktree Copy Files (per-project, keyed by project path)
// List of relative file paths to copy from project root into new worktrees
worktreeCopyFilesByProject: Record<string, string[]>;
// UI State (previously in localStorage, now synced via API)
/** Whether worktree panel is collapsed in board view */
worktreePanelCollapsed: boolean;
@@ -756,6 +760,10 @@ export interface AppActions {
getProjectUseWorktrees: (projectPath: string) => boolean | undefined; // undefined = using global
getEffectiveUseWorktrees: (projectPath: string) => boolean; // Returns actual value (project or global fallback)
// Worktree Copy Files actions (per-project)
setWorktreeCopyFiles: (projectPath: string, files: string[]) => void;
getWorktreeCopyFiles: (projectPath: string) => string[];
// UI State actions (previously in localStorage, now synced via API)
setWorktreePanelCollapsed: (collapsed: boolean) => void;
setLastProjectDir: (dir: string) => void;

View File

@@ -22,6 +22,7 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { useAppStore } from '@/store/app-store';
interface UICacheState {
/** ID of the currently selected project */
@@ -82,13 +83,27 @@ export function syncUICache(appState: {
worktreePanelCollapsed?: boolean;
collapsedNavSections?: Record<string, boolean>;
}): void {
useUICacheStore.getState().updateFromAppStore({
cachedProjectId: appState.currentProject?.id ?? null,
cachedSidebarOpen: appState.sidebarOpen ?? true,
cachedSidebarStyle: appState.sidebarStyle ?? 'unified',
cachedWorktreePanelCollapsed: appState.worktreePanelCollapsed ?? false,
cachedCollapsedNavSections: appState.collapsedNavSections ?? {},
});
const update: Partial<UICacheState> = {};
if ('currentProject' in appState) {
update.cachedProjectId = appState.currentProject?.id ?? null;
}
if ('sidebarOpen' in appState) {
update.cachedSidebarOpen = appState.sidebarOpen;
}
if ('sidebarStyle' in appState) {
update.cachedSidebarStyle = appState.sidebarStyle;
}
if ('worktreePanelCollapsed' in appState) {
update.cachedWorktreePanelCollapsed = appState.worktreePanelCollapsed;
}
if ('collapsedNavSections' in appState) {
update.cachedCollapsedNavSections = appState.collapsedNavSections;
}
if (Object.keys(update).length > 0) {
useUICacheStore.getState().updateFromAppStore(update);
}
}
/**
@@ -100,7 +115,7 @@ export function syncUICache(appState: {
* This is reconciled later when hydrateStoreFromSettings() overwrites
* the app store with authoritative server data.
*
* @param appStoreSetState - The setState function from the app store (avoids circular import)
* @param appStoreSetState - The setState function from the app store
*/
export function restoreFromUICache(
appStoreSetState: (state: Record<string, unknown>) => void
@@ -112,12 +127,29 @@ export function restoreFromUICache(
return false;
}
appStoreSetState({
// Attempt to resolve the cached project ID to a full project object.
// At early startup the projects array may be empty (server data not yet loaded),
// but if projects are already in the store (e.g. optimistic hydration has run)
// this will restore the project context immediately so tab-discard recovery
// does not lose the selected project when cached settings are missing.
const existingProjects = useAppStore.getState().projects;
const cachedProject = existingProjects.find((p) => p.id === cache.cachedProjectId) ?? null;
const stateUpdate: Record<string, unknown> = {
sidebarOpen: cache.cachedSidebarOpen,
sidebarStyle: cache.cachedSidebarStyle,
worktreePanelCollapsed: cache.cachedWorktreePanelCollapsed,
collapsedNavSections: cache.cachedCollapsedNavSections,
});
};
// Restore the project context when the project object is available.
// When projects are not yet loaded (empty array), currentProject remains
// null and will be properly set later by hydrateStoreFromSettings().
if (cachedProject !== null) {
stateUpdate.currentProject = cachedProject;
}
appStoreSetState(stateUpdate);
return true;
}

View File

@@ -396,30 +396,19 @@
/* iOS Safari: position:fixed on body prevents pull-to-refresh and overscroll bounce.
Scoped to touch devices only to avoid breaking desktop browser behaviours. */
`@media` (hover: none) and (pointer: coarse) {
@media (hover: none) and (pointer: coarse) {
html,
body {
position: fixed;
}
}
/* App container: full viewport, no scroll, safe-area insets for notched devices */
#app {
height: 100%;
height: 100dvh;
overflow: hidden;
overscroll-behavior: none;
}
/* Prevent pull-to-refresh and rubber-band scrolling on mobile */
@supports (-webkit-touch-callout: none) {
body {
/* iOS Safari specific: prevent overscroll bounce */
-webkit-touch-callout: none;
}
}
/* Safe area insets for devices with notches/home indicators (viewport-fit=cover) */
#app {
padding-top: env(safe-area-inset-top, 0px);
padding-bottom: env(safe-area-inset-bottom, 0px);
padding-left: env(safe-area-inset-left, 0px);
@@ -559,6 +548,11 @@
@apply backdrop-blur-md border-white/10;
}
/* Disable iOS long-press context menu - apply only to non-interactive chrome elements */
.no-touch-callout {
-webkit-touch-callout: none;
}
.glass-subtle {
@apply backdrop-blur-sm border-white/5;
}

View File

@@ -2,7 +2,12 @@
* Electron API type definitions
*/
import type { ClaudeUsageResponse, CodexUsageResponse } from '@/store/app-store';
import type {
ClaudeUsageResponse,
CodexUsageResponse,
ZaiUsageResponse,
GeminiUsageResponse,
} from '@/store/app-store';
import type { ParsedTask, FeatureStatusWithPipeline } from '@automaker/types';
export interface ImageAttachment {
@@ -710,6 +715,16 @@ export interface ElectronAPI {
getUsage: () => Promise<CodexUsageResponse>;
};
// z.ai Usage API
zai: {
getUsage: () => Promise<ZaiUsageResponse>;
};
// Gemini Usage API
gemini: {
getUsage: () => Promise<GeminiUsageResponse>;
};
// Worktree Management APIs
worktree: WorktreeAPI;
@@ -884,6 +899,17 @@ export interface WorktreeAPI {
error?: string;
}>;
// Generate an AI PR title and description from branch diff
generatePRDescription: (
worktreePath: string,
baseBranch?: string
) => Promise<{
success: boolean;
title?: string;
body?: string;
error?: string;
}>;
// Push a worktree branch to remote
push: (
worktreePath: string,
@@ -910,6 +936,7 @@ export interface WorktreeAPI {
prBody?: string;
baseBranch?: string;
draft?: boolean;
remote?: string;
}
) => Promise<{
success: boolean;
@@ -940,7 +967,10 @@ export interface WorktreeAPI {
) => Promise<FileDiffResult>;
// Pull latest changes from remote
pull: (worktreePath: string) => Promise<{
pull: (
worktreePath: string,
remote?: string
) => Promise<{
success: boolean;
result?: {
branch: string;
@@ -954,7 +984,8 @@ export interface WorktreeAPI {
// Create and checkout a new branch
checkoutBranch: (
worktreePath: string,
branchName: string
branchName: string,
baseBranch?: string
) => Promise<{
success: boolean;
result?: {
@@ -998,6 +1029,8 @@ export interface WorktreeAPI {
previousBranch: string;
currentBranch: string;
message: string;
hasConflicts: boolean;
stashedChanges: boolean;
};
error?: string;
code?: 'NOT_GIT_REPO' | 'NO_COMMITS' | 'UNCOMMITTED_CHANGES';
@@ -1388,6 +1421,134 @@ export interface WorktreeAPI {
}
) => void
) => () => void;
// Get recent commit history for a worktree
getCommitLog: (
worktreePath: string,
limit?: number
) => Promise<{
success: boolean;
result?: {
branch: string;
commits: Array<{
hash: string;
shortHash: string;
author: string;
authorEmail: string;
date: string;
subject: string;
body: string;
files: string[];
}>;
total: number;
};
error?: string;
}>;
// Stash changes in a worktree (with optional message and optional file selection)
stashPush: (
worktreePath: string,
message?: string,
files?: string[]
) => Promise<{
success: boolean;
result?: {
stashed: boolean;
branch?: string;
message?: string;
};
error?: string;
}>;
// List all stashes in a worktree
stashList: (worktreePath: string) => Promise<{
success: boolean;
result?: {
stashes: Array<{
index: number;
message: string;
branch: string;
date: string;
files: string[];
}>;
total: number;
};
error?: string;
}>;
// Apply or pop a stash entry
stashApply: (
worktreePath: string,
stashIndex: number,
pop?: boolean
) => Promise<{
success: boolean;
result?: {
applied: boolean;
hasConflicts: boolean;
operation: 'apply' | 'pop';
stashIndex: number;
message: string;
};
error?: string;
}>;
// Drop (delete) a stash entry
stashDrop: (
worktreePath: string,
stashIndex: number
) => Promise<{
success: boolean;
result?: {
dropped: boolean;
stashIndex: number;
message: string;
};
error?: string;
}>;
// Cherry-pick one or more commits into the current branch
cherryPick: (
worktreePath: string,
commitHashes: string[],
options?: {
noCommit?: boolean;
}
) => Promise<{
success: boolean;
result?: {
cherryPicked: boolean;
commitHashes: string[];
branch: string;
message: string;
};
error?: string;
hasConflicts?: boolean;
}>;
// Get commit log for a specific branch (not just the current one)
getBranchCommitLog: (
worktreePath: string,
branchName?: string,
limit?: number
) => Promise<{
success: boolean;
result?: {
branch: string;
commits: Array<{
hash: string;
shortHash: string;
author: string;
authorEmail: string;
date: string;
subject: string;
body: string;
files: string[];
}>;
total: number;
};
error?: string;
}>;
}
// Test runner status type

View File

@@ -1383,6 +1383,12 @@ export interface ProjectSettings {
defaultDeleteBranchWithWorktree?: boolean;
/** Auto-dismiss init script indicator after completion (default: true) */
autoDismissInitScriptIndicator?: boolean;
/**
* List of file/directory paths (relative to project root) to copy into new worktrees.
* Useful for files not tracked by git, like .env, local config files, etc.
* Each entry is a relative path from the project root (e.g., ".env", ".env.local", "config/local.json").
*/
worktreeCopyFiles?: string[];
// Session Tracking
/** Last chat session selected in this project */