diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index eda71d44..6cf55bb7 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -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)); diff --git a/apps/server/src/providers/codex-provider.ts b/apps/server/src/providers/codex-provider.ts index f499db41..aae89c87 100644 --- a/apps/server/src/providers/codex-provider.ts +++ b/apps/server/src/providers/codex-provider.ts @@ -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 ]; diff --git a/apps/server/src/providers/cursor-provider.ts b/apps/server/src/providers/cursor-provider.ts index 7d965db4..8684417a 100644 --- a/apps/server/src/providers/cursor-provider.ts +++ b/apps/server/src/providers/cursor-provider.ts @@ -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 = diff --git a/apps/server/src/routes/backlog-plan/routes/apply.ts b/apps/server/src/routes/backlog-plan/routes/apply.ts index dacd156a..e0fb7122 100644 --- a/apps/server/src/routes/backlog-plan/routes/apply.ts +++ b/apps/server/src/routes/backlog-plan/routes/apply.ts @@ -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}` ); diff --git a/apps/server/src/routes/fs/index.ts b/apps/server/src/routes/fs/index.ts index 58732b3a..9991c346 100644 --- a/apps/server/src/routes/fs/index.ts +++ b/apps/server/src/routes/fs/index.ts @@ -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; } diff --git a/apps/server/src/routes/fs/routes/browse-project-files.ts b/apps/server/src/routes/fs/routes/browse-project-files.ts new file mode 100644 index 00000000..5e0ee22e --- /dev/null +++ b/apps/server/src/routes/fs/routes/browse-project-files.ts @@ -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 => { + 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) }); + } + }; +} diff --git a/apps/server/src/routes/gemini/index.ts b/apps/server/src/routes/gemini/index.ts index b3d70cdb..f49ef634 100644 --- a/apps/server/src/routes/gemini/index.ts +++ b/apps/server/src/routes/gemini/index.ts @@ -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); diff --git a/apps/server/src/routes/setup/routes/verify-claude-auth.ts b/apps/server/src/routes/setup/routes/verify-claude-auth.ts index 6e934dab..041e534e 100644 --- a/apps/server/src/routes/setup/routes/verify-claude-auth.ts +++ b/apps/server/src/routes/setup/routes/verify-claude-auth.ts @@ -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); } diff --git a/apps/server/src/routes/worktree/index.ts b/apps/server/src/routes/worktree/index.ts index a7df37bb..8acae439 100644 --- a/apps/server/src/routes/worktree/index.ts +++ b/apps/server/src/routes/worktree/index.ts @@ -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; } diff --git a/apps/server/src/routes/worktree/routes/branch-commit-log.ts b/apps/server/src/routes/worktree/routes/branch-commit-log.ts new file mode 100644 index 00000000..ff7f2cd2 --- /dev/null +++ b/apps/server/src/routes/worktree/routes/branch-commit-log.ts @@ -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 => { + 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) }); + } + }; +} diff --git a/apps/server/src/routes/worktree/routes/checkout-branch.ts b/apps/server/src/routes/worktree/routes/checkout-branch.ts index 23963480..578db282 100644 --- a/apps/server/src/routes/worktree/routes/checkout-branch.ts +++ b/apps/server/src/routes/worktree/routes/checkout-branch.ts @@ -15,9 +15,10 @@ import { getErrorMessage, logError, isValidBranchName, execGitCommand } from '.. export function createCheckoutBranchHandler() { return async (req: Request, res: Response): Promise => { 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, diff --git a/apps/server/src/routes/worktree/routes/cherry-pick.ts b/apps/server/src/routes/worktree/routes/cherry-pick.ts new file mode 100644 index 00000000..828d3785 --- /dev/null +++ b/apps/server/src/routes/worktree/routes/cherry-pick.ts @@ -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 => { + 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) }); + } + }; +} diff --git a/apps/server/src/routes/worktree/routes/commit-log.ts b/apps/server/src/routes/worktree/routes/commit-log.ts new file mode 100644 index 00000000..85207b53 --- /dev/null +++ b/apps/server/src/routes/worktree/routes/commit-log.ts @@ -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 => { + 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) }); + } + }; +} diff --git a/apps/server/src/routes/worktree/routes/commit.ts b/apps/server/src/routes/worktree/routes/commit.ts index 7571fd91..1bfbfd58 100644 --- a/apps/server/src/routes/worktree/routes/commit.ts +++ b/apps/server/src/routes/worktree/routes/commit.ts @@ -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 => { @@ -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, }); diff --git a/apps/server/src/routes/worktree/routes/create-pr.ts b/apps/server/src/routes/worktree/routes/create-pr.ts index d8a395b7..e2d0886f 100644 --- a/apps/server/src/routes/worktree/routes/create-pr.ts +++ b/apps/server/src/routes/worktree/routes/create-pr.ts @@ -20,16 +20,25 @@ const logger = createLogger('CreatePR'); export function createCreatePRHandler() { return async (req: Request, res: Response): Promise => { 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, }); diff --git a/apps/server/src/routes/worktree/routes/create.ts b/apps/server/src/routes/worktree/routes/create.ts index 061fa801..1af08850 100644 --- a/apps/server/src/routes/worktree/routes/create.ts +++ b/apps/server/src/routes/worktree/routes/create.ts @@ -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 { + 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 => { 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, diff --git a/apps/server/src/routes/worktree/routes/discard-changes.ts b/apps/server/src/routes/worktree/routes/discard-changes.ts index 514fae7e..a0933149 100644 --- a/apps/server/src/routes/worktree/routes/discard-changes.ts +++ b/apps/server/src/routes/worktree/routes/discard-changes.ts @@ -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); } } diff --git a/apps/server/src/routes/worktree/routes/generate-pr-description.ts b/apps/server/src/routes/worktree/routes/generate-pr-description.ts new file mode 100644 index 00000000..10f9e147 --- /dev/null +++ b/apps/server/src/routes/worktree/routes/generate-pr-description.ts @@ -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--- + +---BODY--- +## Summary +<1-3 bullet points describing the key changes> + +## Changes + + +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( + generator: AsyncIterable, + timeoutMs: number +): AsyncGenerator { + const timeoutPromise = new Promise((_, 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 { + return async (req: Request, res: Response): Promise => { + 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); + } + }; +} diff --git a/apps/server/src/routes/worktree/routes/list-branches.ts b/apps/server/src/routes/worktree/routes/list-branches.ts index 30fdcb1d..68e0bca8 100644 --- a/apps/server/src/routes/worktree/routes/list-branches.ts +++ b/apps/server/src/routes/worktree/routes/list-branches.ts @@ -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; diff --git a/apps/server/src/routes/worktree/routes/pull.ts b/apps/server/src/routes/worktree/routes/pull.ts index 7b922994..c3a764fb 100644 --- a/apps/server/src/routes/worktree/routes/pull.ts +++ b/apps/server/src/routes/worktree/routes/pull.ts @@ -15,8 +15,9 @@ const execAsync = promisify(exec); export function createPullHandler() { return async (req: Request, res: Response): Promise => { 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; } diff --git a/apps/server/src/routes/worktree/routes/stash-apply.ts b/apps/server/src/routes/worktree/routes/stash-apply.ts new file mode 100644 index 00000000..12c80306 --- /dev/null +++ b/apps/server/src/routes/worktree/routes/stash-apply.ts @@ -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 => { + 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) }); + } + }; +} diff --git a/apps/server/src/routes/worktree/routes/stash-drop.ts b/apps/server/src/routes/worktree/routes/stash-drop.ts new file mode 100644 index 00000000..235ade91 --- /dev/null +++ b/apps/server/src/routes/worktree/routes/stash-drop.ts @@ -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 => { + 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) }); + } + }; +} diff --git a/apps/server/src/routes/worktree/routes/stash-list.ts b/apps/server/src/routes/worktree/routes/stash-list.ts new file mode 100644 index 00000000..096dd25d --- /dev/null +++ b/apps/server/src/routes/worktree/routes/stash-list.ts @@ -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 => { + 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) }); + } + }; +} diff --git a/apps/server/src/routes/worktree/routes/stash-push.ts b/apps/server/src/routes/worktree/routes/stash-push.ts new file mode 100644 index 00000000..bb191ed5 --- /dev/null +++ b/apps/server/src/routes/worktree/routes/stash-push.ts @@ -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 => { + 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) }); + } + }; +} diff --git a/apps/server/src/routes/worktree/routes/switch-branch.ts b/apps/server/src/routes/worktree/routes/switch-branch.ts index beb380ad..9fe25eda 100644 --- a/apps/server/src/routes/worktree/routes/switch-branch.ts +++ b/apps/server/src/routes/worktree/routes/switch-branch.ts @@ -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 { - 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 { 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 { async function stashChanges(cwd: string, message: string): Promise { 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 { 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 { 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 */ async function localBranchExists(cwd: string, branchName: string): Promise { 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 diff --git a/apps/server/src/routes/zai/index.ts b/apps/server/src/routes/zai/index.ts index c66fc2f6..4e5b874c 100644 --- a/apps/server/src/routes/zai/index.ts +++ b/apps/server/src/routes/zai/index.ts @@ -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'; diff --git a/apps/server/src/services/auto-loop-coordinator.ts b/apps/server/src/services/auto-loop-coordinator.ts index 64a3bd2f..cc307ede 100644 --- a/apps/server/src/services/auto-loop-coordinator.ts +++ b/apps/server/src/services/auto-loop-coordinator.ts @@ -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; export type ResetStuckFeaturesFn = (projectPath: string) => Promise; export type IsFeatureFinishedFn = (feature: Feature) => boolean; +export type LoadAllFeaturesFn = (projectPath: string) => Promise; export class AutoLoopCoordinator { private autoLoopsByProject = new Map(); @@ -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( diff --git a/apps/server/src/services/auto-mode/facade.ts b/apps/server/src/services/auto-mode/facade.ts index e31543b4..c7cb64eb 100644 --- a/apps/server/src/services/auto-mode/facade.ts +++ b/apps/server/src/services/auto-mode/facade.ts @@ -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 diff --git a/apps/server/src/services/settings-service.ts b/apps/server/src/services/settings-service.ts index 80e8987f..6a3d804e 100644 --- a/apps/server/src/services/settings-service.ts +++ b/apps/server/src/services/settings-service.ts @@ -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), + }, }; } diff --git a/apps/server/src/services/zai-usage-service.ts b/apps/server/src/services/zai-usage-service.ts index e779c5c3..5a9d4dd8 100644 --- a/apps/server/src/services/zai-usage-service.ts +++ b/apps/server/src/services/zai-usage-service.ts @@ -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}`); diff --git a/apps/server/src/tests/cli-integration.test.ts b/apps/server/src/tests/cli-integration.test.ts index 87269ac0..695e8ea0 100644 --- a/apps/server/src/tests/cli-integration.test.ts +++ b/apps/server/src/tests/cli-integration.test.ts @@ -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[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[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[0]) + ).rejects.toThrow(); }); it('should handle malformed error objects', () => { diff --git a/apps/server/tests/unit/routes/worktree/switch-branch.test.ts b/apps/server/tests/unit/routes/worktree/switch-branch.test.ts index 49ef3bf5..bd79d64f 100644 --- a/apps/server/tests/unit/routes/worktree/switch-branch.test.ts +++ b/apps/server/tests/unit/routes/worktree/switch-branch.test.ts @@ -6,7 +6,7 @@ vi.mock('child_process', async (importOriginal) => { const actual = await importOriginal(); 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') { diff --git a/apps/server/tests/unit/services/auto-loop-coordinator.test.ts b/apps/server/tests/unit/services/auto-loop-coordinator.test.ts index 31a117fe..543d7825 100644 --- a/apps/server/tests/unit/services/auto-loop-coordinator.test.ts +++ b/apps/server/tests/unit/services/auto-loop-coordinator.test.ts @@ -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); diff --git a/apps/ui/src/components/dialogs/project-file-selector-dialog.tsx b/apps/ui/src/components/dialogs/project-file-selector-dialog.tsx new file mode 100644 index 00000000..e72c47f1 --- /dev/null +++ b/apps/ui/src/components/dialogs/project-file-selector-dialog.tsx @@ -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(null); + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [warning, setWarning] = useState(''); + const [selectedPaths, setSelectedPaths] = useState>(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('/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 ( + + + + + + {title} + + + {description} + + + +
+ {/* Navigation bar */} +
+ + + {/* Breadcrumb path */} +
+ + {breadcrumbs.map((crumb) => ( + + + + + ))} +
+
+ + {/* Search filter */} +
+ + setSearchQuery(e.target.value)} + placeholder="Filter files and directories..." + className="h-8 text-xs pl-8 pr-8" + disabled={loading} + /> + {searchQuery && ( + + )} +
+ + {/* Selected items indicator */} + {selectedCount > 0 && ( +
+ + + {selectedCount} {selectedCount === 1 ? 'item' : 'items'} selected + + +
+ )} + + {/* File/directory list */} +
+ {loading && ( +
+
Loading...
+
+ )} + + {error && ( +
+
{error}
+
+ )} + + {warning && ( +
+
{warning}
+
+ )} + + {!loading && !error && filteredEntries.length === 0 && ( +
+
+ {searchQuery ? 'No matching files or directories' : 'This directory is empty'} +
+
+ )} + + {!loading && !error && filteredEntries.length > 0 && ( +
+ {filteredEntries.map((entry) => { + const isSelected = selectedPaths.has(entry.relativePath); + const isConfigured = isAlreadyConfigured(entry.relativePath); + + return ( +
+ {/* Checkbox for selection */} + handleToggleSelect(entry)} + disabled={isConfigured} + className="shrink-0" + aria-label={`Select ${entry.name}`} + /> + + {/* Icon */} + {entry.isDirectory ? ( + + ) : ( + + )} + + {/* File/directory name */} + { + if (!isConfigured) { + handleToggleSelect(entry); + } + }} + > + {entry.name} + + + {/* Already configured badge */} + {isConfigured && ( + + Already added + + )} + + {/* Navigate into directory button */} + {entry.isDirectory && ( + + )} +
+ ); + })} +
+ )} +
+ +
+ Select files or directories to copy into new worktrees. Directories are copied + recursively. Click the arrow to browse into a directory. +
+
+ + + + + +
+
+ ); +} diff --git a/apps/ui/src/components/dialogs/sandbox-risk-dialog.tsx b/apps/ui/src/components/dialogs/sandbox-risk-dialog.tsx index 7b597c8c..88b11d55 100644 --- a/apps/ui/src/components/dialogs/sandbox-risk-dialog.tsx +++ b/apps/ui/src/components/dialogs/sandbox-risk-dialog.tsx @@ -37,18 +37,21 @@ export function SandboxRiskDialog({ open, onConfirm, onDeny }: SandboxRiskDialog return ( {}}> e.preventDefault()} onEscapeKeyDown={(e) => e.preventDefault()} showCloseButton={false} > - + - + Sandbox Environment Not Detected + + +
-
+

Warning: 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

- +
- +
(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(null); const closeTimeoutRef = useRef | 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 && (
- Remove Project + Move to Trash + + +
@@ -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" /> + + ); } diff --git a/apps/ui/src/components/layout/sidebar/components/project-selector-with-options.tsx b/apps/ui/src/components/layout/sidebar/components/project-selector-with-options.tsx index 966ead87..28af95c3 100644 --- a/apps/ui/src/components/layout/sidebar/components/project-selector-with-options.tsx +++ b/apps/ui/src/components/layout/sidebar/components/project-selector-with-options.tsx @@ -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 */} + setShowRemoveFromAutomakerDialog(true)} + className="text-muted-foreground focus:text-foreground" + data-testid="remove-from-automaker" + > + + Remove from Automaker + setShowDeleteProjectDialog(true)} className="text-destructive focus:text-destructive focus:bg-destructive/10" diff --git a/apps/ui/src/components/layout/sidebar/sidebar.tsx b/apps/ui/src/components/layout/sidebar/sidebar.tsx index 9345b81b..93b3532a 100644 --- a/apps/ui/src/components/layout/sidebar/sidebar.tsx +++ b/apps/ui/src/components/layout/sidebar/sidebar.tsx @@ -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 */} + + {/* New Project Modal */} , - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( + React.ComponentPropsWithoutRef & { + /** Content to display below the item text in the dropdown only (not shown in trigger). */ + description?: React.ReactNode; + } +>(({ className, children, description, ...props }, ref) => ( - {children} + {description ? ( +
+ {children} + {description} +
+ ) : ( + {children} + )}
)); SelectItem.displayName = SelectPrimitive.Item.displayName; diff --git a/apps/ui/src/components/usage-popover.tsx b/apps/ui/src/components/usage-popover.tsx index 6122f24c..5292ba1e 100644 --- a/apps/ui/src/components/usage-popover.tsx +++ b/apps/ui/src/components/usage-popover.tsx @@ -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' })}`; diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index 591166d1..94449f8f 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -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(null); + // State for duplicate as child multiple times dialog + const [duplicateMultipleFeature, setDuplicateMultipleFeature] = useState(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( 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 */} + { + if (!open) setDuplicateMultipleFeature(null); + }} + onConfirm={async (count) => { + if (duplicateMultipleFeature) { + await handleDuplicateAsChildMultiple(duplicateMultipleFeature, count); + setDuplicateMultipleFeature(null); + } + }} + featureTitle={duplicateMultipleFeature?.title || duplicateMultipleFeature?.description} + /> + {/* Archive All Verified Dialog */} - {/* Pull & Resolve Conflicts Dialog */} - diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx index 2d215252..e992c8f6 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/agent-info-panel.tsx @@ -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, { diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx index ac80d7ed..5207856c 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx @@ -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 ( - - - Duplicate - - +
{ e.stopPropagation(); onDuplicate(); }} - className="text-xs" + className="flex-1 pr-0 rounded-r-none text-xs" > Duplicate + +
+ { e.stopPropagation(); @@ -84,6 +85,18 @@ function DuplicateMenuItems({ Duplicate as Child + {onDuplicateAsChildMultiple && ( + { + e.stopPropagation(); + onDuplicateAsChildMultiple(); + }} + className="text-xs" + > + + Duplicate as Child ×N + + )}
); @@ -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({ {/* Model info in dropdown */} {(() => { @@ -251,6 +267,7 @@ export const CardHeaderSection = memo(function CardHeaderSection({ @@ -343,6 +360,7 @@ export const CardHeaderSection = memo(function CardHeaderSection({ @@ -417,6 +435,7 @@ export const CardHeaderSection = memo(function CardHeaderSection({ {/* Model info in dropdown */} {(() => { diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx index b54c5fcc..a4ac8dfa 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/kanban-card.tsx @@ -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 */} diff --git a/apps/ui/src/components/views/board-view/components/list-view/list-view.tsx b/apps/ui/src/components/views/board-view/components/list-view/list-view.tsx index cb68f4d7..814bd0e3 100644 --- a/apps/ui/src/components/views/board-view/components/list-view/list-view.tsx +++ b/apps/ui/src/components/views/board-view/components/list-view/list-view.tsx @@ -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] diff --git a/apps/ui/src/components/views/board-view/components/list-view/row-actions.tsx b/apps/ui/src/components/views/board-view/components/list-view/row-actions.tsx index 89462563..f66b95cf 100644 --- a/apps/ui/src/components/views/board-view/components/list-view/row-actions.tsx +++ b/apps/ui/src/components/views/board-view/components/list-view/row-actions.tsx @@ -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 && ( + + )} )} @@ -565,6 +574,13 @@ export const RowActions = memo(function RowActions({ label="Duplicate as Child" onClick={withClose(handlers.onDuplicateAsChild)} /> + {handlers.onDuplicateAsChildMultiple && ( + + )} )} @@ -636,6 +652,13 @@ export const RowActions = memo(function RowActions({ label="Duplicate as Child" onClick={withClose(handlers.onDuplicateAsChild)} /> + {handlers.onDuplicateAsChildMultiple && ( + + )} )} @@ -712,6 +735,13 @@ export const RowActions = memo(function RowActions({ label="Duplicate as Child" onClick={withClose(handlers.onDuplicateAsChild)} /> + {handlers.onDuplicateAsChildMultiple && ( + + )} )} @@ -764,6 +794,13 @@ export const RowActions = memo(function RowActions({ label="Duplicate as Child" onClick={withClose(handlers.onDuplicateAsChild)} /> + {handlers.onDuplicateAsChildMultiple && ( + + )} )} @@ -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, }; } diff --git a/apps/ui/src/components/views/board-view/dialogs/cherry-pick-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/cherry-pick-dialog.tsx new file mode 100644 index 00000000..5129734a --- /dev/null +++ b/apps/ui/src/components/views/board-view/dialogs/cherry-pick-dialog.tsx @@ -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 ( + + ); +} + +type Step = 'select-branch' | 'select-commits' | 'conflict'; + +export function CherryPickDialog({ + open, + onOpenChange, + worktree, + onCherryPicked, + onCreateConflictResolutionFeature, +}: CherryPickDialogProps) { + // Step management + const [step, setStep] = useState('select-branch'); + + // Branch selection state + const [remotes, setRemotes] = useState([]); + const [localBranches, setLocalBranches] = useState([]); + const [selectedRemote, setSelectedRemote] = useState(''); + const [selectedBranch, setSelectedBranch] = useState(''); + const [loadingBranches, setLoadingBranches] = useState(false); + + // Commits state + const [commits, setCommits] = useState([]); + const [selectedCommitHashes, setSelectedCommitHashes] = useState>(new Set()); + const [expandedCommits, setExpandedCommits] = useState>(new Set()); + const [loadingCommits, setLoadingCommits] = useState(false); + const [loadingMoreCommits, setLoadingMoreCommits] = useState(false); + const [commitsError, setCommitsError] = useState(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(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 ( + + + + + + Cherry-Pick Conflicts Detected + + +
+ + There are conflicts when cherry-picking commits from{' '} + {selectedBranch} into{' '} + + {conflictInfo.targetBranch} + + . + + +
+ + + The cherry-pick could not be completed automatically. You can create a feature + task to resolve the conflicts in the{' '} + + {conflictInfo.targetBranch} + {' '} + branch. + +
+ +
+

+ This will create a high-priority feature task that will: +

+
    +
  • + Cherry-pick the selected commit(s) from{' '} + {selectedBranch} +
  • +
  • Resolve any merge conflicts
  • +
  • Ensure the code compiles and tests pass
  • +
+
+
+
+
+ + + + + + +
+
+ ); + } + + // Step 2: Select commits + if (step === 'select-commits') { + return ( + + + + + + Cherry Pick Commits + + + Select commits from{' '} + {selectedBranch} to apply to{' '} + {worktree.branch} + + + +
+
+ {loadingCommits && ( +
+ + Loading commits... +
+ )} + + {commitsError && ( +
+

{commitsError}

+
+ )} + + {!loadingCommits && !commitsError && commits.length === 0 && ( +
+

No commits found on this branch

+
+ )} + + {!loadingCommits && !commitsError && commits.length > 0 && ( +
+ {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 ( +
+
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 */} +
+ toggleCommitSelection(commit.hash)} + onClick={(e) => e.stopPropagation()} + className="mt-0.5" + /> +
+ + {/* Commit content */} +
+
+

+ {commit.subject} +

+ +
+ {commit.body && ( +

+ {commit.body} +

+ )} +
+ + + {commit.author} + + + + + + {hasFiles && ( + + )} +
+
+
+ + {/* Expanded file list */} + {isExpanded && hasFiles && ( +
+
+ {commit.files.map((file) => ( +
+ + {file} +
+ ))} +
+
+ )} +
+ ); + })} + + {/* Load More button */} + {hasMoreCommits && commitLimit < 100 && ( +
+ +
+ )} +
+ )} +
+
+ + + + + + +
+
+ ); + } + + // Step 1: Select branch (and optionally remote) + return ( + + + + + + Cherry Pick + + +
+ + Select a branch to cherry-pick commits from into{' '} + {worktree.branch} + + + {loadingBranches ? ( +
+ + Loading branches... +
+ ) : ( + <> + {/* Remote selector - only show if there are remotes */} + {remotes.length > 0 && ( +
+ + +
+ )} + + {/* Branch selector */} +
+ + {branchOptions.length === 0 ? ( +

No other branches available

+ ) : ( + + )} +
+ + )} +
+
+
+ + + + + +
+
+ ); +} diff --git a/apps/ui/src/components/views/board-view/dialogs/commit-worktree-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/commit-worktree-dialog.tsx index eca00901..6aa60a24 100644 --- a/apps/ui/src/components/views/board-view/dialogs/commit-worktree-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/commit-worktree-dialog.tsx @@ -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]); diff --git a/apps/ui/src/components/views/board-view/dialogs/create-branch-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/create-branch-dialog.tsx index 47153f2e..ca7a398a 100644 --- a/apps/ui/src/components/views/board-view/dialogs/create-branch-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/create-branch-dialog.tsx @@ -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([]); + const [isLoadingBranches, setIsLoadingBranches] = useState(false); const [isCreating, setIsCreating] = useState(false); const [error, setError] = useState(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 ( @@ -96,12 +152,7 @@ export function CreateBranchDialog({ Create New Branch - - Create a new branch from{' '} - - {worktree?.branch || 'current branch'} - - + Create a new branch from a base branch
@@ -123,8 +174,74 @@ export function CreateBranchDialog({ disabled={isCreating} autoFocus /> - {error &&

{error}

}
+ +
+
+ + +
+ {isLoadingBranches && branches.length === 0 ? ( +
+ + Loading branches... +
+ ) : ( + + )} +
+ + {error &&

{error}

} diff --git a/apps/ui/src/components/views/board-view/dialogs/create-pr-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/create-pr-dialog.tsx index 8cb44be8..96a71620 100644 --- a/apps/ui/src/components/views/board-view/dialogs/create-pr-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/create-pr-dialog.tsx @@ -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([]); + const [selectedRemote, setSelectedRemote] = useState(''); + 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({ )}
- +
+ + +
+ {/* Remote selector - only show if multiple remotes are available */} + {remotes.length > 1 && ( +
+
+ + +
+ +
+ )} +
f.path))); + // No files selected by default + setSelectedFiles(new Set()); } } } catch (err) { diff --git a/apps/ui/src/components/views/board-view/dialogs/duplicate-count-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/duplicate-count-dialog.tsx new file mode 100644 index 00000000..5c1d3954 --- /dev/null +++ b/apps/ui/src/components/views/board-view/dialogs/duplicate-count-dialog.tsx @@ -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 ( + + + + + + Duplicate as Child ×N + + + Creates a chain of duplicates where each is a child of the previous, so they execute + sequentially. + {featureTitle && ( + + Source: {featureTitle} + + )} + + + +
+ + { + 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 + /> +

Enter a number between 1 and 50

+
+ + + + 50} + > + + Create {count} {count === 1 ? 'Copy' : 'Copies'} + + +
+
+ ); +} diff --git a/apps/ui/src/components/views/board-view/dialogs/index.ts b/apps/ui/src/components/views/board-view/dialogs/index.ts index 3b2c9694..5b4950c0 100644 --- a/apps/ui/src/components/views/board-view/dialogs/index.ts +++ b/apps/ui/src/components/views/board-view/dialogs/index.ts @@ -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'; diff --git a/apps/ui/src/components/views/board-view/dialogs/pull-resolve-conflicts-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/merge-rebase-dialog.tsx similarity index 73% rename from apps/ui/src/components/views/board-view/dialogs/pull-resolve-conflicts-dialog.tsx rename to apps/ui/src/components/views/board-view/dialogs/merge-rebase-dialog.tsx index cabffaed..921f1a9b 100644 --- a/apps/ui/src/components/views/board-view/dialogs/pull-resolve-conflicts-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/merge-rebase-dialog.tsx @@ -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; + onConfirm: ( + worktree: WorktreeInfo, + remoteBranch: string, + strategy: PullStrategy + ) => void | Promise; } -export function PullResolveConflictsDialog({ +export function MergeRebaseDialog({ open, onOpenChange, worktree, onConfirm, -}: PullResolveConflictsDialogProps) { +}: MergeRebaseDialogProps) { const [remotes, setRemotes] = useState([]); const [selectedRemote, setSelectedRemote] = useState(''); const [selectedBranch, setSelectedBranch] = useState(''); + const [selectedStrategy, setSelectedStrategy] = useState('merge'); const [isLoading, setIsLoading] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false); const [error, setError] = useState(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({ - Pull & Resolve Conflicts + Merge & Rebase - Select a remote branch to pull from and resolve conflicts with{' '} + Select a remote branch to merge or rebase with{' '} {worktree?.branch || 'current branch'} @@ -225,13 +233,16 @@ export function PullResolveConflictsDialog({ {remotes.map((remote) => ( - -
- {remote.name} + {remote.url} -
+ } + > + {remote.name}
))}
@@ -264,13 +275,62 @@ export function PullResolveConflictsDialog({ )}
+
+ + +
+ {selectedBranch && (

- This will create a feature task to pull from{' '} - {selectedBranch} into{' '} - {worktree?.branch} and resolve - any merge conflicts. + This will create a feature task to{' '} + {selectedStrategy === 'rebase' ? ( + <> + rebase {worktree?.branch}{' '} + onto {selectedBranch} + + ) : ( + <> + merge {selectedBranch} into{' '} + {worktree?.branch} + + )}{' '} + and resolve any conflicts.

)} @@ -287,7 +347,7 @@ export function PullResolveConflictsDialog({ className="bg-purple-600 hover:bg-purple-700 text-white" > - Pull & Resolve + Merge & Rebase diff --git a/apps/ui/src/components/views/board-view/dialogs/push-to-remote-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/push-to-remote-dialog.tsx index 0871d267..712d9757 100644 --- a/apps/ui/src/components/views/board-view/dialogs/push-to-remote-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/push-to-remote-dialog.tsx @@ -306,13 +306,16 @@ export function PushToRemoteDialog({ {remotes.map((remote) => ( - -
- {remote.name} + {remote.url} -
+ } + > + {remote.name}
))}
diff --git a/apps/ui/src/components/views/board-view/dialogs/select-remote-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/select-remote-dialog.tsx new file mode 100644 index 00000000..ef1d976e --- /dev/null +++ b/apps/ui/src/components/views/board-view/dialogs/select-remote-dialog.tsx @@ -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([]); + const [selectedRemote, setSelectedRemote] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [isRefreshing, setIsRefreshing] = useState(false); + const [error, setError] = useState(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{' '} + {worktree?.branch || 'current branch'} + + ) : ( + <> + Select a remote to push{' '} + {worktree?.branch || 'current branch'} to + + ); + + return ( + + + + + + {title} + + {description} + + + {isLoading ? ( +
+ +
+ ) : error ? ( +
+
+ + {error} +
+ +
+ ) : ( +
+
+
+ + +
+ +
+ + {selectedRemote && ( +
+

+ {isPull ? ( + <> + This will pull changes from{' '} + + {selectedRemote}/{worktree?.branch} + {' '} + into your local branch. + + ) : ( + <> + This will push your local changes to{' '} + + {selectedRemote}/{worktree?.branch} + + . + + )} +

+
+ )} +
+ )} + + + + + +
+
+ ); +} diff --git a/apps/ui/src/components/views/board-view/dialogs/stash-changes-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/stash-changes-dialog.tsx new file mode 100644 index 00000000..14b53578 --- /dev/null +++ b/apps/ui/src/components/views/board-view/dialogs/stash-changes-dialog.tsx @@ -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 ; + case 'D': + return ; + case 'M': + case 'U': + return ; + case 'R': + case 'C': + return ; + default: + return ; + } +}; + +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 ( +
+ {content} +
+ ); + } + + return ( +
+ + {lineNumber?.old ?? ''} + + + {lineNumber?.new ?? ''} + + + {prefix[type]} + + + {content || '\u00A0'} + +
+ ); +} + +export function StashChangesDialog({ + open, + onOpenChange, + worktree, + onStashed, +}: StashChangesDialogProps) { + const [message, setMessage] = useState(''); + const [isStashing, setIsStashing] = useState(false); + + // File selection state + const [files, setFiles] = useState([]); + const [diffContent, setDiffContent] = useState(''); + const [selectedFiles, setSelectedFiles] = useState>(new Set()); + const [expandedFile, setExpandedFile] = useState(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(); + 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 ( + { + if (!isOpen) { + setMessage(''); + } + onOpenChange(isOpen); + }} + > + + + + + Stash Changes + + + Stash uncommitted changes on{' '} + {worktree.branch} + + + +
+ {/* File Selection */} +
+
+ + {files.length > 0 && ( + + )} +
+ + {isLoadingDiffs ? ( +
+ + Loading changes... +
+ ) : files.length === 0 ? ( +
+ No changes detected +
+ ) : ( +
+ {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 ( +
+
+ {/* Checkbox */} + handleToggleFile(file.path)} + className="flex-shrink-0" + /> + + {/* Clickable file row to show diff */} + +
+ + {/* Expanded diff view */} + {isExpanded && fileDiff && ( +
+ {fileDiff.hunks.map((hunk, hunkIndex) => ( +
+ {hunk.lines.map((line, lineIndex) => ( + + ))} +
+ ))} +
+ )} + {isExpanded && !fileDiff && ( +
+ {file.status === '?' ? ( + New file - diff preview not available + ) : file.status === 'D' ? ( + File deleted + ) : ( + Diff content not available + )} +
+ )} +
+ ); + })} +
+ )} +
+ + {/* Stash Message */} +
+ + setMessage(e.target.value)} + disabled={isStashing} + autoFocus + /> +

+ A descriptive message helps identify this stash later. Press{' '} + + {navigator.platform.includes('Mac') ? '⌘' : 'Ctrl'}+Enter + {' '} + to stash. +

+
+
+ + + + + +
+
+ ); +} diff --git a/apps/ui/src/components/views/board-view/dialogs/view-commits-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/view-commits-dialog.tsx new file mode 100644 index 00000000..7125130e --- /dev/null +++ b/apps/ui/src/components/views/board-view/dialogs/view-commits-dialog.tsx @@ -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 ( + + ); +} + +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 ( +
+
+ {/* Timeline dot and line */} +
+
+ {!isLast &&
} +
+ + {/* Commit content */} +
+
+

{commit.subject}

+ +
+ {commit.body && ( +

+ {commit.body} +

+ )} +
+ + + {commit.author} + + + + + + {hasFiles && ( + + )} +
+
+
+ + {/* Expanded file list */} + {expanded && hasFiles && ( +
+
+ {commit.files.map((file) => ( +
+ + {file} +
+ ))} +
+
+ )} +
+ ); +} + +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([]); + const [isLoading, setIsLoading] = useState(false); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const [error, setError] = useState(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 ( + + + + + + Commit History + + + Recent commits on{' '} + {worktree.branch} + + + +
+
+ {isLoading && ( +
+ + Loading commits... +
+ )} + + {error && ( +
+

{error}

+
+ )} + + {!isLoading && !error && commits.length === 0 && ( +
+

No commits found

+
+ )} + + {!isLoading && !error && commits.length > 0 && ( +
+ {commits.map((commit, index) => ( + + ))} + {hasMore && ( +
+ +
+ )} +
+ )} +
+
+
+
+ ); +} diff --git a/apps/ui/src/components/views/board-view/dialogs/view-stashes-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/view-stashes-dialog.tsx new file mode 100644 index 00000000..0b119ed7 --- /dev/null +++ b/apps/ui/src/components/views/board-view/dialogs/view-stashes-dialog.tsx @@ -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 ( +
+ {/* Header */} +
+ {/* Expand toggle & stash icon */} + + + {/* Content */} +
+
+
+

{displayMessage}

+
+ + stash@{'{' + stash.index + '}'} + + {stash.branch && ( + + + {stash.branch} + + )} + + + + + {stash.files.length > 0 && ( + + + {stash.files.length} file{stash.files.length !== 1 ? 's' : ''} + + )} +
+
+ + {/* Action buttons */} +
+ + + +
+
+
+
+ + {/* Expanded file list */} + {expanded && stash.files.length > 0 && ( +
+
+ {stash.files.map((file) => ( +
+ + {file} +
+ ))} +
+
+ )} +
+ ); +} + +export function ViewStashesDialog({ + open, + onOpenChange, + worktree, + onStashApplied, +}: ViewStashesDialogProps) { + const [stashes, setStashes] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [applyingIndex, setApplyingIndex] = useState(null); + const [droppingIndex, setDroppingIndex] = useState(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 ( + + + + + + Stashes + + + Stashed changes in{' '} + {worktree.branch} + + + +
+
+ {isLoading && ( +
+ + Loading stashes... +
+ )} + + {error && ( +
+

{error}

+
+ )} + + {!isLoading && !error && stashes.length === 0 && ( +
+ +

No stashes found

+

+ Use "Stash Changes" to save your uncommitted changes +

+
+ )} + + {!isLoading && !error && stashes.length > 0 && ( +
+ {stashes.map((stash) => ( + + ))} +
+ )} +
+
+
+
+ ); +} diff --git a/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts b/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts index 40b4247a..326d64e4 100644 --- a/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts +++ b/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts @@ -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, }; } diff --git a/apps/ui/src/components/views/board-view/hooks/use-board-persistence.ts b/apps/ui/src/components/views/board-view/hooks/use-board-persistence.ts index 2e7ff09e..3bbe0a15 100644 --- a/apps/ui/src/components/views/board-view/hooks/use-board-persistence.ts +++ b/apps/ui/src/components/views/board-view/hooks/use-board-persistence.ts @@ -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] diff --git a/apps/ui/src/components/views/board-view/kanban-board.tsx b/apps/ui/src/components/views/board-view/kanban-board.tsx index 1ca92a33..60272c3f 100644 --- a/apps/ui/src/components/views/board-view/kanban-board.tsx +++ b/apps/ui/src/components/views/board-view/kanban-board.tsx @@ -48,6 +48,7 @@ interface KanbanBoardProps { onSpawnTask?: (feature: Feature) => void; onDuplicate?: (feature: Feature) => void; onDuplicateAsChild?: (feature: Feature) => void; + onDuplicateAsChildMultiple?: (feature: Feature) => void; featuresWithContext: Set; 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} diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx index 7b1d96df..2600d57d 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx @@ -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({ - {/* Warning label when git operations are not available */} - {!canPerformGitOps && ( + {/* Loading indicator while git status is being determined */} + {isLoadingGitStatus && ( + <> + + + Checking git status... + + + + )} + {/* Warning label when git operations are not available (only show once loaded) */} + {!isLoadingGitStatus && !canPerformGitOps && ( <> @@ -387,10 +415,90 @@ export function WorktreeActionsDropdown({ )} > - Pull & Resolve Conflicts + Merge & Rebase {!canPerformGitOps && } + + canPerformGitOps && onViewCommits(worktree)} + disabled={!canPerformGitOps} + className={cn('text-xs', !canPerformGitOps && 'opacity-50 cursor-not-allowed')} + > + + View Commits + {!canPerformGitOps && } + + + {/* Cherry-pick commits from another branch */} + {onCherryPick && ( + + canPerformGitOps && onCherryPick(worktree)} + disabled={!canPerformGitOps} + className={cn('text-xs', !canPerformGitOps && 'opacity-50 cursor-not-allowed')} + > + + Cherry Pick + {!canPerformGitOps && ( + + )} + + + )} + {/* Stash operations - combined submenu */} + {(onStashChanges || onViewStashes) && ( + + +
+ {/* Main clickable area - stash changes (primary action) */} + { + 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' + )} + > + + {worktree.hasChanges && onStashChanges ? 'Stash Changes' : 'Stashes'} + {!gitRepoStatus.isGitRepo && ( + + )} + + {/* Chevron trigger for submenu with stash options */} + +
+ + {onViewStashes && ( + onViewStashes(worktree)} className="text-xs"> + + View Stashes + + )} + +
+
+ )} {/* Open in editor - split button: click main area for default, chevron for other options */} {effectiveDefaultEditor && ( diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-dropdown.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-dropdown.tsx index fd1c2ba3..dcc38d69 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-dropdown.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-dropdown.tsx @@ -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} /> )} diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx index 9d508ed3..03d9585e 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-tab.tsx @@ -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} />
diff --git a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktree-actions.ts b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktree-actions.ts index ad9b4e0d..34f54a85 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktree-actions.ts +++ b/apps/ui/src/components/views/board-view/worktree-panel/hooks/use-worktree-actions.ts @@ -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] diff --git a/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx b/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx index 174a4533..c941b35d 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx @@ -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(null); + // View commits dialog state + const [viewCommitsDialogOpen, setViewCommitsDialogOpen] = useState(false); + const [viewCommitsWorktree, setViewCommitsWorktree] = useState(null); + // Discard changes confirmation dialog state const [discardChangesDialogOpen, setDiscardChangesDialogOpen] = useState(false); const [discardChangesWorktree, setDiscardChangesWorktree] = useState(null); @@ -396,6 +406,21 @@ export function WorktreePanel({ const [mergeDialogOpen, setMergeDialogOpen] = useState(false); const [mergeWorktree, setMergeWorktree] = useState(null); + // Select remote dialog state (for pull/push with multiple remotes) + const [selectRemoteDialogOpen, setSelectRemoteDialogOpen] = useState(false); + const [selectRemoteWorktree, setSelectRemoteWorktree] = useState(null); + const [selectRemoteOperation, setSelectRemoteOperation] = useState('pull'); + + // Stash dialog states + const [stashChangesDialogOpen, setStashChangesDialogOpen] = useState(false); + const [stashChangesWorktree, setStashChangesWorktree] = useState(null); + const [viewStashesDialogOpen, setViewStashesDialogOpen] = useState(false); + const [viewStashesWorktree, setViewStashesWorktree] = useState(null); + + // Cherry-pick dialog states + const [cherryPickDialogOpen, setCherryPickDialogOpen] = useState(false); + const [cherryPickWorktree, setCherryPickWorktree] = useState(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 */} + + {/* Discard Changes Dialog */} + {/* Stash Changes Dialog */} + + + {/* View Stashes Dialog */} + + + {/* Cherry Pick Dialog */} + + {/* Dev Server Logs Panel */} + {/* Select Remote Dialog (for pull/push with multiple remotes) */} + + {/* Merge Branch Dialog */} {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 */} + + {/* Discard Changes Dialog */} + {/* Select Remote Dialog (for pull/push with multiple remotes) */} + + {/* Merge Branch Dialog */} handleStopTests(testLogsPanelWorktree) : undefined } /> + + {/* Stash Changes Dialog */} + + + {/* View Stashes Dialog */} + + + {/* Cherry Pick Dialog */} +
); } diff --git a/apps/ui/src/components/views/login-view.tsx b/apps/ui/src/components/views/login-view.tsx index bc580213..d64de3f9 100644 --- a/apps/ui/src/components/views/login-view.tsx +++ b/apps/ui/src/components/views/login-view.tsx @@ -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 ( diff --git a/apps/ui/src/components/views/project-settings-view/project-settings-view.tsx b/apps/ui/src/components/views/project-settings-view/project-settings-view.tsx index b57f3b8f..0c194350 100644 --- a/apps/ui/src/components/views/project-settings-view/project-settings-view.tsx +++ b/apps/ui/src/components/views/project-settings-view/project-settings-view.tsx @@ -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() { setShowDeleteDialog(true)} + onRemoveFromAutomakerClick={() => setShowRemoveFromAutomakerDialog(true)} /> ); default: @@ -178,6 +181,14 @@ export function ProjectSettingsView() { project={currentProject} onConfirm={moveProjectToTrash} /> + + {/* Remove from Automaker Confirmation Dialog */} +
); } diff --git a/apps/ui/src/components/views/project-settings-view/worktree-preferences-section.tsx b/apps/ui/src/components/views/project-settings-view/worktree-preferences-section.tsx index d6d0c247..813b2ff5 100644 --- a/apps/ui/src/components/views/project-settings-view/worktree-preferences-section.tsx +++ b/apps/ui/src/components/views/project-settings-view/worktree-preferences-section.tsx @@ -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 (
+ {/* Copy Files Section */} +
+
+ + +
+

+ Specify files or directories (relative to project root) to automatically copy into new + worktrees. Useful for untracked files like{' '} + .env,{' '} + .env.local, or local config files + that aren't committed to git. +

+ + {/* Current file list */} + {copyFiles.length > 0 && ( +
+ {copyFiles.map((filePath) => ( +
+ + + {filePath} + + +
+ ))} +
+ )} + + {/* Add new file input */} +
+ 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" + /> + + +
+ + {/* File selector dialog */} + +
+ + {/* Separator */} +
+ {/* Init Script Section */}
diff --git a/apps/ui/src/components/views/settings-view/components/index.ts b/apps/ui/src/components/views/settings-view/components/index.ts index de388fad..154026ae 100644 --- a/apps/ui/src/components/views/settings-view/components/index.ts +++ b/apps/ui/src/components/views/settings-view/components/index.ts @@ -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'; diff --git a/apps/ui/src/components/views/settings-view/components/remove-from-automaker-dialog.tsx b/apps/ui/src/components/views/settings-view/components/remove-from-automaker-dialog.tsx new file mode 100644 index 00000000..6303cad9 --- /dev/null +++ b/apps/ui/src/components/views/settings-view/components/remove-from-automaker-dialog.tsx @@ -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 ( + + {project && ( +
+
+ +
+
+

{project.name}

+

{project.path}

+
+
+ )} +
+ ); +} diff --git a/apps/ui/src/components/views/settings-view/danger-zone/danger-zone-section.tsx b/apps/ui/src/components/views/settings-view/danger-zone/danger-zone-section.tsx index 020c7770..3b1de8b5 100644 --- a/apps/ui/src/components/views/settings-view/danger-zone/danger-zone-section.tsx +++ b/apps/ui/src/components/views/settings-view/danger-zone/danger-zone-section.tsx @@ -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 (
Destructive project actions.

- {/* Project Delete */} {project ? ( -
-
-
- + <> + {/* Remove from Automaker */} + {onRemoveFromAutomakerClick && ( +
+
+

Remove from Automaker

+

+ Remove this project from Automaker without deleting any files from disk. You can + re-add it later by opening the folder. +

+
+
-
-

{project.name}

-

{project.path}

+ )} + + {/* Project Delete / Move to Trash */} +
+
+
+ +
+
+

{project.name}

+

{project.path}

+
+
- -
+ ) : (

No project selected.

)} diff --git a/apps/ui/src/components/views/terminal-view/mobile-terminal-shortcuts.tsx b/apps/ui/src/components/views/terminal-view/mobile-terminal-shortcuts.tsx index 09e0112c..13ee9b9d 100644 --- a/apps/ui/src/components/views/terminal-view/mobile-terminal-shortcuts.tsx +++ b/apps/ui/src/components/views/terminal-view/mobile-terminal-shortcuts.tsx @@ -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(() => { diff --git a/apps/ui/src/hooks/mutations/use-auto-mode-mutations.ts b/apps/ui/src/hooks/mutations/use-auto-mode-mutations.ts index 1151de4b..efa83546 100644 --- a/apps/ui/src/hooks/mutations/use-auto-mode-mutations.ts +++ b/apps/ui/src/hooks/mutations/use-auto-mode-mutations.ts @@ -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( + queryKeys.features.all(projectPath) + ); + if (previousFeatures) { + queryClient.setQueryData( + 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) => { diff --git a/apps/ui/src/hooks/mutations/use-worktree-mutations.ts b/apps/ui/src/hooks/mutations/use-worktree-mutations.ts index 9ca91935..f9e6efc3 100644 --- a/apps/ui/src/hooks/mutations/use-worktree-mutations.ts +++ b/apps/ui/src/hooks/mutations/use-worktree-mutations.ts @@ -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 => { + }) => { 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 * diff --git a/apps/ui/src/hooks/queries/use-usage.ts b/apps/ui/src/hooks/queries/use-usage.ts index 18fedfa7..42d3946e 100644 --- a/apps/ui/src/hooks/queries/use-usage.ts +++ b/apps/ui/src/hooks/queries/use-usage.ts @@ -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, diff --git a/apps/ui/src/hooks/use-auto-mode.ts b/apps/ui/src/hooks/use-auto-mode.ts index ea5b6884..04bd218e 100644 --- a/apps/ui/src/hooks/use-auto-mode.ts +++ b/apps/ui/src/hooks/use-auto-mode.ts @@ -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 diff --git a/apps/ui/src/hooks/use-project-settings-loader.ts b/apps/ui/src/hooks/use-project-settings-loader.ts index da3f4a0e..10df7dec 100644 --- a/apps/ui/src/hooks/use-project-settings-loader.ts +++ b/apps/ui/src/hooks/use-project-settings-loader.ts @@ -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, ]); } diff --git a/apps/ui/src/hooks/use-provider-auth-init.ts b/apps/ui/src/hooks/use-provider-auth-init.ts index 02ae801e..c95ce9ea 100644 --- a/apps/ui/src/hooks/use-provider-auth-init.ts +++ b/apps/ui/src/hooks/use-provider-auth-init.ts @@ -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, diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index 933b09c8..335f3f5b 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -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, + }, + }; + }, }; } diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index f3513fbe..1db42d5d 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -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 => { 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; }; diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index 484bc142..65d1b61b 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -335,6 +335,7 @@ const initialState: AppState = { defaultDeleteBranchByProject: {}, autoDismissInitScriptIndicatorByProject: {}, useWorktreesByProject: {}, + worktreeCopyFilesByProject: {}, worktreePanelCollapsed: false, lastProjectDir: '', recentFolders: [], @@ -359,10 +360,15 @@ export const useAppStore = create()((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()((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 }), diff --git a/apps/ui/src/store/setup-store.ts b/apps/ui/src/store/setup-store.ts index aae357ea..23043779 100644 --- a/apps/ui/src/store/setup-store.ts +++ b/apps/ui/src/store/setup-store.ts @@ -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 = diff --git a/apps/ui/src/store/types/state-types.ts b/apps/ui/src/store/types/state-types.ts index 25bc3dfa..259e0fe3 100644 --- a/apps/ui/src/store/types/state-types.ts +++ b/apps/ui/src/store/types/state-types.ts @@ -341,6 +341,10 @@ export interface AppState { // undefined = use global setting, true/false = project-specific override useWorktreesByProject: Record; + // Worktree Copy Files (per-project, keyed by project path) + // List of relative file paths to copy from project root into new worktrees + worktreeCopyFilesByProject: Record; + // 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; diff --git a/apps/ui/src/store/ui-cache-store.ts b/apps/ui/src/store/ui-cache-store.ts index e47065ba..bc3659ac 100644 --- a/apps/ui/src/store/ui-cache-store.ts +++ b/apps/ui/src/store/ui-cache-store.ts @@ -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; }): 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 = {}; + + 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) => 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 = { 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; } diff --git a/apps/ui/src/styles/global.css b/apps/ui/src/styles/global.css index 6775d2b9..a2e8cbee 100644 --- a/apps/ui/src/styles/global.css +++ b/apps/ui/src/styles/global.css @@ -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; } diff --git a/apps/ui/src/types/electron.d.ts b/apps/ui/src/types/electron.d.ts index 9cd7f4a9..3c2bb6fe 100644 --- a/apps/ui/src/types/electron.d.ts +++ b/apps/ui/src/types/electron.d.ts @@ -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; }; + // z.ai Usage API + zai: { + getUsage: () => Promise; + }; + + // Gemini Usage API + gemini: { + getUsage: () => Promise; + }; + // 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; // 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 diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts index a7e76bd6..f9ce4282 100644 --- a/libs/types/src/settings.ts +++ b/libs/types/src/settings.ts @@ -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 */