diff --git a/apps/server/src/routes/auto-mode/routes/run-feature.ts b/apps/server/src/routes/auto-mode/routes/run-feature.ts index c59ed7ca0..a61a40641 100644 --- a/apps/server/src/routes/auto-mode/routes/run-feature.ts +++ b/apps/server/src/routes/auto-mode/routes/run-feature.ts @@ -26,23 +26,9 @@ export function createRunFeatureHandler(autoModeService: AutoModeServiceCompat) return; } - // Check per-worktree capacity before starting - const capacity = await autoModeService.checkWorktreeCapacity(projectPath, featureId); - if (!capacity.hasCapacity) { - const worktreeDesc = capacity.branchName - ? `worktree "${capacity.branchName}"` - : 'main worktree'; - res.status(429).json({ - success: false, - error: `Agent limit reached for ${worktreeDesc} (${capacity.currentAgents}/${capacity.maxAgents}). Wait for running tasks to complete or increase the limit.`, - details: { - currentAgents: capacity.currentAgents, - maxAgents: capacity.maxAgents, - branchName: capacity.branchName, - }, - }); - return; - } + // Note: No concurrency limit check here. Manual feature starts always run + // immediately and bypass the concurrency limit. Their presence IS counted + // by the auto-loop coordinator when deciding whether to dispatch new auto-mode tasks. // Start execution in background // executeFeature derives workDir from feature.branchName diff --git a/apps/server/src/routes/git/routes/diffs.ts b/apps/server/src/routes/git/routes/diffs.ts index ca919dcfb..02ce2028c 100644 --- a/apps/server/src/routes/git/routes/diffs.ts +++ b/apps/server/src/routes/git/routes/diffs.ts @@ -23,6 +23,7 @@ export function createDiffsHandler() { diff: result.diff, files: result.files, hasChanges: result.hasChanges, + ...(result.mergeState ? { mergeState: result.mergeState } : {}), }); } catch (innerError) { logError(innerError, 'Git diff failed'); diff --git a/apps/server/src/routes/worktree/routes/checkout-branch.ts b/apps/server/src/routes/worktree/routes/checkout-branch.ts index d8a9d828c..97b4419b5 100644 --- a/apps/server/src/routes/worktree/routes/checkout-branch.ts +++ b/apps/server/src/routes/worktree/routes/checkout-branch.ts @@ -22,6 +22,36 @@ import { getErrorMessage, logError, isValidBranchName } from '../common.js'; import { execGitCommand } from '../../../lib/git.js'; import type { EventEmitter } from '../../../lib/events.js'; import { performCheckoutBranch } from '../../../services/checkout-branch-service.js'; +import { createLogger } from '@automaker/utils'; + +const logger = createLogger('CheckoutBranchRoute'); + +/** Timeout for git fetch operations (30 seconds) */ +const FETCH_TIMEOUT_MS = 30_000; + +/** + * Fetch latest from all remotes (silently, with timeout). + * Non-fatal: fetch errors are logged and swallowed so the workflow continues. + */ +async function fetchRemotes(cwd: string): Promise { + const controller = new AbortController(); + const timerId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + + try { + await execGitCommand(['fetch', '--all', '--quiet'], cwd, undefined, controller); + } catch (error) { + if (error instanceof Error && error.message === 'Process aborted') { + logger.warn( + `fetchRemotes timed out after ${FETCH_TIMEOUT_MS}ms - continuing without latest remote refs` + ); + } else { + logger.warn(`fetchRemotes failed: ${getErrorMessage(error)} - continuing with local refs`); + } + // Non-fatal: continue with locally available refs + } finally { + clearTimeout(timerId); + } +} export function createCheckoutBranchHandler(events?: EventEmitter) { return async (req: Request, res: Response): Promise => { @@ -127,6 +157,10 @@ export function createCheckoutBranchHandler(events?: EventEmitter) { } // Original simple flow (no stash handling) + // Fetch latest remote refs before creating the branch so that + // base branch validation works for remote references like "origin/main" + await fetchRemotes(resolvedPath); + const currentBranchOutput = await execGitCommand( ['rev-parse', '--abbrev-ref', 'HEAD'], resolvedPath diff --git a/apps/server/src/routes/worktree/routes/create.ts b/apps/server/src/routes/worktree/routes/create.ts index 10c4e956a..d64abc678 100644 --- a/apps/server/src/routes/worktree/routes/create.ts +++ b/apps/server/src/routes/worktree/routes/create.ts @@ -30,6 +30,9 @@ import { runInitScript } from '../../../services/init-script-service.js'; const logger = createLogger('Worktree'); +/** Timeout for git fetch operations (30 seconds) */ +const FETCH_TIMEOUT_MS = 30_000; + const execAsync = promisify(exec); /** @@ -83,41 +86,6 @@ async function findExistingWorktreeForBranch( } } -/** - * Detect whether a base branch reference is a remote branch (e.g. "origin/main"). - * Returns the remote name if it matches a known remote, otherwise null. - */ -async function detectRemoteBranch( - projectPath: string, - baseBranch: string -): Promise<{ remote: string; branch: string } | null> { - const slashIndex = baseBranch.indexOf('/'); - if (slashIndex <= 0) return null; - - const possibleRemote = baseBranch.substring(0, slashIndex); - - try { - // Check if this is actually a remote name by listing remotes - const stdout = await execGitCommand(['remote'], projectPath); - const remotes = stdout - .trim() - .split('\n') - .map((r: string) => r.trim()) - .filter(Boolean); - - if (remotes.includes(possibleRemote)) { - return { - remote: possibleRemote, - branch: baseBranch.substring(slashIndex + 1), - }; - } - } catch { - // Not a git repo or no remotes — fall through - } - - return null; -} - export function createCreateHandler(events: EventEmitter, settingsService?: SettingsService) { const worktreeService = new WorktreeService(); @@ -206,26 +174,23 @@ export function createCreateHandler(events: EventEmitter, settingsService?: Sett // Create worktrees directory if it doesn't exist await secureFs.mkdir(worktreesDir, { recursive: true }); - // If a base branch is specified and it's a remote branch, fetch from that remote first - // This ensures we have the latest refs before creating the worktree - if (baseBranch && baseBranch !== 'HEAD') { - const remoteBranchInfo = await detectRemoteBranch(projectPath, baseBranch); - if (remoteBranchInfo) { - logger.info( - `Fetching from remote "${remoteBranchInfo.remote}" before creating worktree (base: ${baseBranch})` - ); - try { - await execGitCommand( - ['fetch', remoteBranchInfo.remote, remoteBranchInfo.branch], - projectPath - ); - } catch (fetchErr) { - // Non-fatal: log but continue — the ref might already be cached locally - logger.warn( - `Failed to fetch from remote "${remoteBranchInfo.remote}": ${getErrorMessage(fetchErr)}` - ); - } + // Fetch latest from all remotes before creating the worktree. + // This ensures remote refs are up-to-date for: + // - Remote base branches (e.g. "origin/main") + // - Existing remote branches being checked out as worktrees + // - Branch existence checks against fresh remote state + logger.info('Fetching from all remotes before creating worktree'); + try { + const controller = new AbortController(); + const timerId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + try { + await execGitCommand(['fetch', '--all', '--quiet'], projectPath, undefined, controller); + } finally { + clearTimeout(timerId); } + } catch (fetchErr) { + // Non-fatal: log but continue — refs might already be cached locally + logger.warn(`Failed to fetch from remotes: ${getErrorMessage(fetchErr)}`); } // Check if branch exists (using array arguments to prevent injection) diff --git a/apps/server/src/routes/worktree/routes/diffs.ts b/apps/server/src/routes/worktree/routes/diffs.ts index 314fa8ce5..1e8586bfa 100644 --- a/apps/server/src/routes/worktree/routes/diffs.ts +++ b/apps/server/src/routes/worktree/routes/diffs.ts @@ -34,6 +34,7 @@ export function createDiffsHandler() { diff: result.diff, files: result.files, hasChanges: result.hasChanges, + ...(result.mergeState ? { mergeState: result.mergeState } : {}), }); return; } @@ -55,6 +56,7 @@ export function createDiffsHandler() { diff: result.diff, files: result.files, hasChanges: result.hasChanges, + ...(result.mergeState ? { mergeState: result.mergeState } : {}), }); } catch (innerError) { // Worktree doesn't exist - fallback to main project path @@ -71,6 +73,7 @@ export function createDiffsHandler() { diff: result.diff, files: result.files, hasChanges: result.hasChanges, + ...(result.mergeState ? { mergeState: result.mergeState } : {}), }); } catch (fallbackError) { logError(fallbackError, 'Fallback to main project also failed'); diff --git a/apps/server/src/routes/worktree/routes/pull.ts b/apps/server/src/routes/worktree/routes/pull.ts index 7f157faf2..3c9f06653 100644 --- a/apps/server/src/routes/worktree/routes/pull.ts +++ b/apps/server/src/routes/worktree/routes/pull.ts @@ -83,6 +83,9 @@ function mapResultToResponse(res: Response, result: PullResult): void { stashed: result.stashed, stashRestored: result.stashRestored, message: result.message, + isMerge: result.isMerge, + isFastForward: result.isFastForward, + mergeAffectedFiles: result.mergeAffectedFiles, }, }); } diff --git a/apps/server/src/routes/worktree/routes/switch-branch.ts b/apps/server/src/routes/worktree/routes/switch-branch.ts index 9e873aacc..abcdfdcd3 100644 --- a/apps/server/src/routes/worktree/routes/switch-branch.ts +++ b/apps/server/src/routes/worktree/routes/switch-branch.ts @@ -9,7 +9,7 @@ * For remote branches (e.g., "origin/feature"), automatically creates a * local tracking branch and checks it out. * - * Also fetches the latest remote refs after switching. + * Also fetches the latest remote refs before switching to ensure accurate branch detection. * * Git business logic is delegated to worktree-branch-service.ts. * Events are emitted at key lifecycle points for WebSocket subscribers. diff --git a/apps/server/src/services/auto-loop-coordinator.ts b/apps/server/src/services/auto-loop-coordinator.ts index 29c365585..6d83e6994 100644 --- a/apps/server/src/services/auto-loop-coordinator.ts +++ b/apps/server/src/services/auto-loop-coordinator.ts @@ -163,6 +163,10 @@ export class AutoLoopCoordinator { const { projectPath, branchName } = projectState.config; while (projectState.isRunning && !projectState.abortController.signal.aborted) { try { + // Count ALL running features (both auto and manual) against the concurrency limit. + // This ensures auto mode is aware of the total system load and does not over-subscribe + // resources. Manual tasks always bypass the limit and run immediately, but their + // presence is accounted for when deciding whether to dispatch new auto-mode tasks. const runningCount = await this.getRunningCountForWorktree(projectPath, branchName); if (runningCount >= projectState.config.maxConcurrency) { await this.sleep(5000, projectState.abortController.signal); @@ -298,11 +302,17 @@ export class AutoLoopCoordinator { return Array.from(activeProjects); } + /** + * Get the number of running features for a worktree. + * By default counts ALL running features (both auto-mode and manual). + * Pass `autoModeOnly: true` to count only auto-mode features. + */ async getRunningCountForWorktree( projectPath: string, - branchName: string | null + branchName: string | null, + options?: { autoModeOnly?: boolean } ): Promise { - return this.concurrencyManager.getRunningCountForWorktree(projectPath, branchName); + return this.concurrencyManager.getRunningCountForWorktree(projectPath, branchName, options); } trackFailureAndCheckPauseForProject( diff --git a/apps/server/src/services/auto-mode/facade.ts b/apps/server/src/services/auto-mode/facade.ts index 2d8e9c9ea..ecaf864fe 100644 --- a/apps/server/src/services/auto-mode/facade.ts +++ b/apps/server/src/services/auto-mode/facade.ts @@ -334,6 +334,23 @@ export class AutoModeServiceFacade { async (pPath) => featureLoader.getAll(pPath) ); + /** + * Iterate all active worktrees for this project, falling back to the + * main worktree (null) when none are active. + */ + const forEachProjectWorktree = (fn: (branchName: string | null) => void): void => { + const projectWorktrees = autoLoopCoordinator + .getActiveWorktrees() + .filter((w) => w.projectPath === projectPath); + if (projectWorktrees.length === 0) { + fn(null); + } else { + for (const w of projectWorktrees) { + fn(w.branchName); + } + } + }; + // ExecutionService - runAgentFn delegates to AgentExecutor via shared helper const executionService = new ExecutionService( eventBus, @@ -357,11 +374,36 @@ export class AutoModeServiceFacade { (pPath, featureId) => getFacade().contextExists(featureId), (pPath, featureId, useWorktrees, _calledInternally) => getFacade().resumeFeature(featureId, useWorktrees, _calledInternally), - (errorInfo) => - autoLoopCoordinator.trackFailureAndCheckPauseForProject(projectPath, null, errorInfo), - (errorInfo) => autoLoopCoordinator.signalShouldPauseForProject(projectPath, null, errorInfo), + (errorInfo) => { + // Track failure against ALL active worktrees for this project. + // The ExecutionService callbacks don't receive branchName, so we + // iterate all active worktrees. Uses a for-of loop (not .some()) to + // ensure every worktree's failure counter is incremented. + let shouldPause = false; + forEachProjectWorktree((branchName) => { + if ( + autoLoopCoordinator.trackFailureAndCheckPauseForProject( + projectPath, + branchName, + errorInfo + ) + ) { + shouldPause = true; + } + }); + return shouldPause; + }, + (errorInfo) => { + forEachProjectWorktree((branchName) => + autoLoopCoordinator.signalShouldPauseForProject(projectPath, branchName, errorInfo) + ); + }, () => { - /* recordSuccess - no-op */ + // Record success to clear failure tracking. This prevents failures + // from accumulating over time and incorrectly pausing auto mode. + forEachProjectWorktree((branchName) => + autoLoopCoordinator.recordSuccessForProject(projectPath, branchName) + ); }, (_pPath) => getFacade().saveExecutionState(), loadContextFiles diff --git a/apps/server/src/services/checkout-branch-service.ts b/apps/server/src/services/checkout-branch-service.ts index 35fa8f21c..922be329b 100644 --- a/apps/server/src/services/checkout-branch-service.ts +++ b/apps/server/src/services/checkout-branch-service.ts @@ -10,6 +10,7 @@ * Follows the same pattern as worktree-branch-service.ts (performSwitchBranch). * * The workflow: + * 0. Fetch latest from all remotes (ensures remote refs are up-to-date) * 1. Validate inputs (branch name, base branch) * 2. Get current branch name * 3. Check if target branch already exists @@ -19,11 +20,51 @@ * 7. Handle error recovery (restore stash if checkout fails) */ -import { getErrorMessage } from '@automaker/utils'; +import { createLogger, getErrorMessage } from '@automaker/utils'; import { execGitCommand } from '../lib/git.js'; import type { EventEmitter } from '../lib/events.js'; import { hasAnyChanges, stashChanges, popStash, localBranchExists } from './branch-utils.js'; +const logger = createLogger('CheckoutBranchService'); + +// ============================================================================ +// Local Helpers +// ============================================================================ + +/** Timeout for git fetch operations (30 seconds) */ +const FETCH_TIMEOUT_MS = 30_000; + +/** + * Fetch latest from all remotes (silently, with timeout). + * + * A process-level timeout is enforced via an AbortController so that a + * slow or unresponsive remote does not block the branch creation flow + * indefinitely. Timeout errors are logged and treated as non-fatal + * (the same as network-unavailable errors) so the rest of the workflow + * continues normally. This is called before creating the new branch to + * ensure remote refs are up-to-date when a remote base branch is used. + */ +async function fetchRemotes(cwd: string): Promise { + const controller = new AbortController(); + const timerId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + + try { + await execGitCommand(['fetch', '--all', '--quiet'], cwd, undefined, controller); + } catch (error) { + if (controller.signal.aborted) { + // Fetch timed out - log and continue; callers should not be blocked by a slow remote + logger.warn( + `fetchRemotes timed out after ${FETCH_TIMEOUT_MS}ms - continuing without latest remote refs` + ); + } else { + logger.warn(`fetchRemotes failed: ${getErrorMessage(error)} - continuing with local refs`); + } + // Non-fatal: continue with locally available refs regardless of failure type + } finally { + clearTimeout(timerId); + } +} + // ============================================================================ // Types // ============================================================================ @@ -78,6 +119,11 @@ export async function performCheckoutBranch( // Emit start event events?.emit('switch:start', { worktreePath, branchName, operation: 'checkout' }); + // 0. Fetch latest from all remotes before creating the branch + // This ensures remote refs are up-to-date so that base branch validation + // works correctly for remote branch references (e.g. "origin/main"). + await fetchRemotes(worktreePath); + // 1. Get current branch let previousBranch: string; try { diff --git a/apps/server/src/services/concurrency-manager.ts b/apps/server/src/services/concurrency-manager.ts index 6c5c0bd03..b64456a17 100644 --- a/apps/server/src/services/concurrency-manager.ts +++ b/apps/server/src/services/concurrency-manager.ts @@ -170,17 +170,28 @@ export class ConcurrencyManager { * @param projectPath - The project path * @param branchName - The branch name, or null for main worktree * (features without branchName or matching primary branch) + * @param options.autoModeOnly - If true, only count features started by auto mode. + * Note: The auto-loop coordinator now counts ALL + * running features (not just auto-mode) to ensure + * total system load is respected. This option is + * retained for other callers that may need filtered counts. * @returns Number of running features for the worktree */ async getRunningCountForWorktree( projectPath: string, - branchName: string | null + branchName: string | null, + options?: { autoModeOnly?: boolean } ): Promise { // Get the actual primary branch name for the project const primaryBranch = await this.getCurrentBranch(projectPath); let count = 0; for (const [, feature] of this.runningFeatures) { + // If autoModeOnly is set, skip manually started features + if (options?.autoModeOnly && !feature.isAutoMode) { + continue; + } + // Filter by project path AND branchName to get accurate worktree-specific count const featureBranch = feature.branchName ?? null; if (branchName === null) { diff --git a/apps/server/src/services/dev-server-service.ts b/apps/server/src/services/dev-server-service.ts index 13281dc1b..781b9757b 100644 --- a/apps/server/src/services/dev-server-service.ts +++ b/apps/server/src/services/dev-server-service.ts @@ -19,6 +19,69 @@ const logger = createLogger('DevServerService'); // Maximum scrollback buffer size (characters) - matches TerminalService pattern const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per dev server +// URL patterns for detecting full URLs from dev server output. +// Defined once at module level to avoid reallocation on every call to detectUrlFromOutput. +// Ordered from most specific (framework-specific) to least specific. +const URL_PATTERNS: Array<{ pattern: RegExp; description: string }> = [ + // Vite / Nuxt / SvelteKit / Astro / Angular CLI format: "Local: http://..." + { + pattern: /(?:Local|Network|External):\s+(https?:\/\/[^\s]+)/i, + description: 'Vite/Nuxt/SvelteKit/Astro/Angular format', + }, + // Next.js format: "ready - started server on 0.0.0.0:3000, url: http://localhost:3000" + // Next.js 14+: "▲ Next.js 14.0.0\n- Local: http://localhost:3000" + { + pattern: /(?:ready|started server).*?(?:url:\s*)?(https?:\/\/[^\s,]+)/i, + description: 'Next.js format', + }, + // Remix format: "started at http://localhost:3000" + // Django format: "Starting development server at http://127.0.0.1:8000/" + // Rails / Puma: "Listening on http://127.0.0.1:3000" + // Generic: "listening at http://...", "available at http://...", "running at http://..." + { + pattern: + /(?:starting|started|listening|running|available|serving|accessible)\s+(?:at|on)\s+(https?:\/\/[^\s,)]+)/i, + description: 'Generic "starting/started/listening at" format', + }, + // PHP built-in server: "Development Server (http://localhost:8000) started" + { + pattern: /(?:server|development server)\s*\(\s*(https?:\/\/[^\s)]+)\s*\)/i, + description: 'PHP server format', + }, + // Webpack Dev Server: "Project is running at http://localhost:8080/" + { + pattern: /(?:project|app|application)\s+(?:is\s+)?running\s+(?:at|on)\s+(https?:\/\/[^\s,]+)/i, + description: 'Webpack/generic "running at" format', + }, + // Go / Rust / generic: "Serving on http://...", "Server on http://..." + { + pattern: /(?:serving|server)\s+(?:on|at)\s+(https?:\/\/[^\s,]+)/i, + description: 'Generic "serving on" format', + }, + // Localhost URL with port (conservative - must be localhost/127.0.0.1/[::]/0.0.0.0) + // This catches anything that looks like a dev server URL + { + pattern: /(https?:\/\/(?:localhost|127\.0\.0\.1|\[::\]|0\.0\.0\.0):\d+\S*)/i, + description: 'Generic localhost URL with port', + }, +]; + +// Port-only patterns for detecting port numbers from dev server output +// when a full URL is not present in the output. +// Defined once at module level to avoid reallocation on every call to detectUrlFromOutput. +const PORT_PATTERNS: Array<{ pattern: RegExp; description: string }> = [ + // "listening on port 3000", "server on port 3000", "started on port 3000" + { + pattern: /(?:listening|running|started|serving|available)\s+on\s+port\s+(\d+)/i, + description: '"listening on port" format', + }, + // "Port: 3000", "port 3000" (at start of line or after whitespace) + { + pattern: /(?:^|\s)port[:\s]+(\d{4,5})(?:\s|$|[.,;])/im, + description: '"port:" format', + }, +]; + // Throttle output to prevent overwhelming WebSocket under heavy load const OUTPUT_THROTTLE_MS = 4; // ~250fps max update rate for responsive feedback const OUTPUT_BATCH_SIZE = 4096; // Smaller batches for lower latency @@ -105,9 +168,52 @@ class DevServerService { } } + /** + * Strip ANSI escape codes from a string + * Dev server output often contains color codes that can interfere with URL detection + */ + private stripAnsi(str: string): string { + // Matches ANSI escape sequences: CSI sequences, OSC sequences, and simple escapes + // eslint-disable-next-line no-control-regex + return str.replace(/\x1B(?:\[[0-9;]*[a-zA-Z]|\].*?(?:\x07|\x1B\\)|\[[?]?[0-9;]*[hl])/g, ''); + } + + /** + * Extract port number from a URL string. + * Returns the explicit port if present, or null if no port is specified. + * Default protocol ports (80/443) are intentionally NOT returned to avoid + * overwriting allocated dev server ports with protocol defaults. + */ + private extractPortFromUrl(url: string): number | null { + try { + const parsed = new URL(url); + if (parsed.port) { + return parseInt(parsed.port, 10); + } + return null; + } catch { + return null; + } + } + /** * Detect actual server URL from output - * Parses stdout/stderr for common URL patterns from dev servers + * Parses stdout/stderr for common URL patterns from dev servers. + * + * Supports detection of URLs from: + * - Vite: "Local: http://localhost:5173/" + * - Next.js: "ready - started server on 0.0.0.0:3000, url: http://localhost:3000" + * - Nuxt: "Local: http://localhost:3000/" + * - Remix: "started at http://localhost:3000" + * - Astro: "Local http://localhost:4321/" + * - SvelteKit: "Local: http://localhost:5173/" + * - CRA/Webpack: "On Your Network: http://192.168.1.1:3000" + * - Angular: "Local: http://localhost:4200/" + * - Express/Fastify/Koa: "Server listening on port 3000" + * - Django: "Starting development server at http://127.0.0.1:8000/" + * - Rails: "Listening on http://127.0.0.1:3000" + * - PHP: "Development Server (http://localhost:8000) started" + * - Generic: Any localhost URL with a port */ private detectUrlFromOutput(server: DevServerInfo, content: string): void { // Skip if URL already detected @@ -115,39 +221,95 @@ class DevServerService { return; } - // Common URL patterns from various dev servers: - // - Vite: "Local: http://localhost:5173/" - // - Next.js: "ready - started server on 0.0.0.0:3000, url: http://localhost:3000" - // - CRA/Webpack: "On Your Network: http://192.168.1.1:3000" - // - Generic: Any http:// or https:// URL - const urlPatterns = [ - /(?:Local|Network):\s+(https?:\/\/[^\s]+)/i, // Vite format - /(?:ready|started server).*?(?:url:\s*)?(https?:\/\/[^\s,]+)/i, // Next.js format - /(https?:\/\/(?:localhost|127\.0\.0\.1|\[::\]):\d+)/i, // Generic localhost URL - /(https?:\/\/[^\s<>"{}|\\^`[\]]+)/i, // Any HTTP(S) URL - ]; - - for (const pattern of urlPatterns) { - const match = content.match(pattern); + // Strip ANSI escape codes to prevent color codes from breaking regex matching + const cleanContent = this.stripAnsi(content); + + // Phase 1: Try to detect a full URL from output + // Patterns are defined at module level (URL_PATTERNS) and reused across calls + for (const { pattern, description } of URL_PATTERNS) { + const match = cleanContent.match(pattern); if (match && match[1]) { - const detectedUrl = match[1].trim(); - // Validate it looks like a reasonable URL + let detectedUrl = match[1].trim(); + // Remove trailing punctuation that might have been captured + detectedUrl = detectedUrl.replace(/[.,;:!?)\]}>]+$/, ''); + if (detectedUrl.startsWith('http://') || detectedUrl.startsWith('https://')) { + // Normalize 0.0.0.0 to localhost for browser accessibility + detectedUrl = detectedUrl.replace( + /\/\/0\.0\.0\.0(:\d+)?/, + (_, port) => `//localhost${port || ''}` + ); + // Normalize [::] to localhost for browser accessibility + detectedUrl = detectedUrl.replace( + /\/\/\[::\](:\d+)?/, + (_, port) => `//localhost${port || ''}` + ); + // Normalize [::1] (IPv6 loopback) to localhost for browser accessibility + detectedUrl = detectedUrl.replace( + /\/\/\[::1\](:\d+)?/, + (_, port) => `//localhost${port || ''}` + ); + server.url = detectedUrl; server.urlDetected = true; - logger.info( - `Detected actual server URL: ${detectedUrl} (allocated port was ${server.port})` - ); + + // Update the port to match the detected URL's actual port + const detectedPort = this.extractPortFromUrl(detectedUrl); + if (detectedPort && detectedPort !== server.port) { + logger.info( + `Port mismatch: allocated ${server.port}, detected ${detectedPort} from ${description}` + ); + server.port = detectedPort; + } + + logger.info(`Detected server URL via ${description}: ${detectedUrl}`); + + // Emit URL update event + if (this.emitter) { + this.emitter.emit('dev-server:url-detected', { + worktreePath: server.worktreePath, + url: detectedUrl, + port: server.port, + timestamp: new Date().toISOString(), + }); + } + return; + } + } + } + + // Phase 2: Try to detect just a port number from output (no full URL) + // Some servers only print "listening on port 3000" without a full URL + // Patterns are defined at module level (PORT_PATTERNS) and reused across calls + for (const { pattern, description } of PORT_PATTERNS) { + const match = cleanContent.match(pattern); + if (match && match[1]) { + const detectedPort = parseInt(match[1], 10); + // Sanity check: port should be in a reasonable range + if (detectedPort > 0 && detectedPort <= 65535) { + const detectedUrl = `http://localhost:${detectedPort}`; + server.url = detectedUrl; + server.urlDetected = true; + + if (detectedPort !== server.port) { + logger.info( + `Port mismatch: allocated ${server.port}, detected ${detectedPort} from ${description}` + ); + server.port = detectedPort; + } + + logger.info(`Detected server port via ${description}: ${detectedPort} → ${detectedUrl}`); // Emit URL update event if (this.emitter) { this.emitter.emit('dev-server:url-detected', { worktreePath: server.worktreePath, url: detectedUrl, + port: server.port, timestamp: new Date().toISOString(), }); } - break; + return; } } } @@ -673,6 +835,7 @@ class DevServerService { worktreePath: string; port: number; url: string; + urlDetected: boolean; }>; }; } { @@ -680,6 +843,7 @@ class DevServerService { worktreePath: s.worktreePath, port: s.port, url: s.url, + urlDetected: s.urlDetected, })); return { diff --git a/apps/server/src/services/pull-service.ts b/apps/server/src/services/pull-service.ts index ab217c2bd..82531423f 100644 --- a/apps/server/src/services/pull-service.ts +++ b/apps/server/src/services/pull-service.ts @@ -46,6 +46,12 @@ export interface PullResult { conflictSource?: 'pull' | 'stash'; conflictFiles?: string[]; message?: string; + /** Whether the pull resulted in a merge commit (not fast-forward) */ + isMerge?: boolean; + /** Whether the pull was a fast-forward (no merge commit needed) */ + isFastForward?: boolean; + /** Files affected by the merge (only present when isMerge is true) */ + mergeAffectedFiles?: string[]; } // ============================================================================ @@ -178,6 +184,31 @@ function isConflictError(errorOutput: string): boolean { return errorOutput.includes('CONFLICT') || errorOutput.includes('Automatic merge failed'); } +/** + * Determine whether the current HEAD commit is a merge commit by checking + * whether it has two or more parent hashes. + * + * Runs `git show -s --pretty=%P HEAD` which prints the parent SHAs separated + * by spaces. A merge commit has at least two parents; a regular commit has one. + * + * @param worktreePath - Path to the git worktree + * @returns true if HEAD is a merge commit, false otherwise + */ +async function isMergeCommit(worktreePath: string): Promise { + try { + const output = await execGitCommand(['show', '-s', '--pretty=%P', 'HEAD'], worktreePath); + // Each parent SHA is separated by a space; two or more means it's a merge + const parents = output + .trim() + .split(/\s+/) + .filter((p) => p.length > 0); + return parents.length >= 2; + } catch { + // If the check fails for any reason, assume it is not a merge commit + return false; + } +} + /** * Check whether an output string indicates a stash conflict. */ @@ -302,10 +333,39 @@ export async function performPull( const pullArgs = upstreamStatus === 'tracking' ? ['pull'] : ['pull', targetRemote, branchName]; let pullConflict = false; let pullConflictFiles: string[] = []; + + // Declare merge detection variables before the try block so they are accessible + // in the stash reapplication path even when didStash is true. + let isMerge = false; + let isFastForward = false; + let mergeAffectedFiles: string[] = []; + try { const pullOutput = await execGitCommand(pullArgs, worktreePath); const alreadyUpToDate = pullOutput.includes('Already up to date'); + // Detect fast-forward from git pull output + isFastForward = pullOutput.includes('Fast-forward') || pullOutput.includes('fast-forward'); + // Detect merge by checking whether the new HEAD has two parents (more reliable + // than string-matching localised pull output which may not contain 'Merge'). + isMerge = !alreadyUpToDate && !isFastForward ? await isMergeCommit(worktreePath) : false; + + // If it was a real merge (not fast-forward), get the affected files + if (isMerge) { + try { + // Get files changed in the merge commit + const diffOutput = await execGitCommand( + ['diff', '--name-only', 'HEAD~1', 'HEAD'], + worktreePath + ); + mergeAffectedFiles = diffOutput + .trim() + .split('\n') + .filter((f: string) => f.trim().length > 0); + } catch { + // Ignore errors - this is best-effort + } + } // If no stash to reapply, return success if (!didStash) { @@ -317,6 +377,8 @@ export async function performPull( stashed: false, stashRestored: false, message: alreadyUpToDate ? 'Already up to date' : 'Pulled latest changes', + ...(isMerge ? { isMerge: true, mergeAffectedFiles } : {}), + ...(isFastForward ? { isFastForward: true } : {}), }; } } catch (pullError: unknown) { @@ -374,7 +436,11 @@ export async function performPull( // 10. Pull succeeded, now try to reapply stash if (didStash) { - return await reapplyStash(worktreePath, branchName); + return await reapplyStash(worktreePath, branchName, { + isMerge, + isFastForward, + mergeAffectedFiles, + }); } // Shouldn't reach here, but return a safe default @@ -392,9 +458,21 @@ export async function performPull( * * @param worktreePath - Path to the git worktree * @param branchName - Current branch name + * @param mergeInfo - Merge/fast-forward detection info from the pull step * @returns PullResult reflecting stash reapplication status */ -async function reapplyStash(worktreePath: string, branchName: string): Promise { +async function reapplyStash( + worktreePath: string, + branchName: string, + mergeInfo: { isMerge: boolean; isFastForward: boolean; mergeAffectedFiles: string[] } +): Promise { + const mergeFields: Partial = { + ...(mergeInfo.isMerge + ? { isMerge: true, mergeAffectedFiles: mergeInfo.mergeAffectedFiles } + : {}), + ...(mergeInfo.isFastForward ? { isFastForward: true } : {}), + }; + try { await popStash(worktreePath); @@ -406,6 +484,7 @@ async function reapplyStash(worktreePath: string, branchName: string): Promise

{ const controller = new AbortController(); @@ -66,15 +68,15 @@ async function fetchRemotes(cwd: string): Promise { try { await execGitCommand(['fetch', '--all', '--quiet'], cwd, undefined, controller); } catch (error) { - if (error instanceof Error && error.message === 'Process aborted') { + if (controller.signal.aborted) { // Fetch timed out - log and continue; callers should not be blocked by a slow remote logger.warn( `fetchRemotes timed out after ${FETCH_TIMEOUT_MS}ms - continuing without latest remote refs` ); + } else { + logger.warn(`fetchRemotes failed: ${getErrorMessage(error)} - continuing with local refs`); } - // Ignore all fetch errors (timeout or otherwise) - we may be offline or the - // remote may be temporarily unavailable. The branch switch itself has - // already succeeded at this point. + // Non-fatal: continue with locally available refs regardless of failure type } finally { clearTimeout(timerId); } @@ -126,13 +128,13 @@ async function isRemoteBranch(cwd: string, branchName: string): Promise * Perform a full branch switch workflow on the given worktree. * * The workflow: - * 1. Get current branch name - * 2. Detect remote vs local branch and determine target - * 3. Return early if already on target branch - * 4. Validate branch existence - * 5. Stash local changes if any - * 6. Checkout the target branch - * 7. Fetch latest from remotes + * 1. Fetch latest from all remotes (ensures remote refs are up-to-date) + * 2. Get current branch name + * 3. Detect remote vs local branch and determine target + * 4. Return early if already on target branch + * 5. Validate branch existence + * 6. Stash local changes if any + * 7. Checkout the target branch * 8. Reapply stashed changes (detect conflicts) * 9. Handle error recovery (restore stash if checkout fails) * @@ -149,14 +151,20 @@ export async function performSwitchBranch( // Emit start event events?.emit('switch:start', { worktreePath, branchName }); - // 1. Get current branch + // 1. Fetch latest from all remotes before switching + // This ensures remote branch refs are up-to-date so that isRemoteBranch() + // can detect newly created remote branches and local tracking branches + // are aware of upstream changes. + await fetchRemotes(worktreePath); + + // 2. Get current branch const currentBranchOutput = await execGitCommand( ['rev-parse', '--abbrev-ref', 'HEAD'], worktreePath ); const previousBranch = currentBranchOutput.trim(); - // 2. Determine the actual target branch name for checkout + // 3. Determine the actual target branch name for checkout let targetBranch = branchName; let isRemote = false; @@ -180,7 +188,7 @@ export async function performSwitchBranch( } } - // 3. Return early if already on the target branch + // 4. Return early if already on the target branch if (previousBranch === targetBranch) { events?.emit('switch:done', { worktreePath, @@ -198,7 +206,7 @@ export async function performSwitchBranch( }; } - // 4. Check if target branch exists as a local branch + // 5. Check if target branch exists as a local branch if (!isRemote) { if (!(await localBranchExists(worktreePath, branchName))) { events?.emit('switch:error', { @@ -213,7 +221,7 @@ export async function performSwitchBranch( } } - // 5. Stash local changes if any exist + // 6. Stash local changes if any exist const hadChanges = await hasAnyChanges(worktreePath, { excludeWorktreePaths: true }); let didStash = false; @@ -242,7 +250,7 @@ export async function performSwitchBranch( } try { - // 6. Switch to the target branch + // 7. Switch to the target branch events?.emit('switch:checkout', { worktreePath, targetBranch, @@ -265,9 +273,6 @@ export async function performSwitchBranch( await execGitCommand(['checkout', targetBranch], worktreePath); } - // 7. Fetch latest from remotes after switching - await fetchRemotes(worktreePath); - // 8. Reapply stashed changes if we stashed earlier let hasConflicts = false; let conflictMessage = ''; @@ -347,7 +352,7 @@ export async function performSwitchBranch( }; } } catch (checkoutError) { - // 9. If checkout failed and we stashed, try to restore the stash + // 9. Error recovery: if checkout failed and we stashed, try to restore the stash if (didStash) { const popResult = await popStash(worktreePath); if (popResult.hasConflicts) { 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 e9d10932d..92239997c 100644 --- a/apps/server/tests/unit/services/auto-loop-coordinator.test.ts +++ b/apps/server/tests/unit/services/auto-loop-coordinator.test.ts @@ -328,6 +328,86 @@ describe('auto-loop-coordinator.ts', () => { // Should not have executed features because at capacity expect(mockExecuteFeature).not.toHaveBeenCalled(); }); + + it('counts all running features (auto + manual) against concurrency limit', async () => { + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([testFeature]); + // 2 manual features running — total count is 2 + vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(2); + + await coordinator.startAutoLoopForProject('/test/project', null, 2); + + await vi.advanceTimersByTimeAsync(6000); + + await coordinator.stopAutoLoopForProject('/test/project', null); + + // Should NOT execute because total running count (2) meets the concurrency limit (2) + expect(mockExecuteFeature).not.toHaveBeenCalled(); + // Verify it was called WITHOUT autoModeOnly (counts all tasks) + // The coordinator's wrapper passes options through as undefined when not specified + expect(mockConcurrencyManager.getRunningCountForWorktree).toHaveBeenCalledWith( + '/test/project', + null, + undefined + ); + }); + + it('allows auto dispatch when manual tasks finish and capacity becomes available', async () => { + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([testFeature]); + // First call: at capacity (2 manual features running) + // Second call: capacity freed (1 feature running) + vi.mocked(mockConcurrencyManager.getRunningCountForWorktree) + .mockResolvedValueOnce(2) // at capacity + .mockResolvedValueOnce(1); // capacity available after manual task completes + + await coordinator.startAutoLoopForProject('/test/project', null, 2); + + // First iteration: at capacity, should wait + await vi.advanceTimersByTimeAsync(5000); + + // Second iteration: capacity available, should execute + await vi.advanceTimersByTimeAsync(6000); + + await coordinator.stopAutoLoopForProject('/test/project', null); + + // Should execute after capacity freed + expect(mockExecuteFeature).toHaveBeenCalledWith('/test/project', 'feature-1', true, true); + }); + + it('waits when manually started tasks already fill concurrency limit at auto mode activation', async () => { + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([testFeature]); + // Manual tasks already fill the limit + vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(3); + + await coordinator.startAutoLoopForProject('/test/project', null, 3); + + await vi.advanceTimersByTimeAsync(6000); + + await coordinator.stopAutoLoopForProject('/test/project', null); + + // Auto mode should remain waiting, not dispatch + expect(mockExecuteFeature).not.toHaveBeenCalled(); + }); + + it('resumes dispatching when all running tasks complete simultaneously', async () => { + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([testFeature]); + // First check: all 3 slots occupied + // Second check: all tasks completed simultaneously + vi.mocked(mockConcurrencyManager.getRunningCountForWorktree) + .mockResolvedValueOnce(3) // all slots full + .mockResolvedValueOnce(0); // all tasks completed at once + + await coordinator.startAutoLoopForProject('/test/project', null, 3); + + // First iteration: at capacity + await vi.advanceTimersByTimeAsync(5000); + // Second iteration: all freed + await vi.advanceTimersByTimeAsync(6000); + + await coordinator.stopAutoLoopForProject('/test/project', null); + + // Should execute after all tasks freed capacity + expect(mockExecuteFeature).toHaveBeenCalledWith('/test/project', 'feature-1', true, true); + }); }); describe('priority-based feature selection', () => { @@ -788,7 +868,23 @@ describe('auto-loop-coordinator.ts', () => { expect(count).toBe(3); expect(mockConcurrencyManager.getRunningCountForWorktree).toHaveBeenCalledWith( '/test/project', - null + null, + undefined + ); + }); + + it('passes autoModeOnly option to ConcurrencyManager', async () => { + vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(1); + + const count = await coordinator.getRunningCountForWorktree('/test/project', null, { + autoModeOnly: true, + }); + + expect(count).toBe(1); + expect(mockConcurrencyManager.getRunningCountForWorktree).toHaveBeenCalledWith( + '/test/project', + null, + { autoModeOnly: true } ); }); }); diff --git a/apps/server/tests/unit/services/concurrency-manager.test.ts b/apps/server/tests/unit/services/concurrency-manager.test.ts index 465964d06..c7cce4823 100644 --- a/apps/server/tests/unit/services/concurrency-manager.test.ts +++ b/apps/server/tests/unit/services/concurrency-manager.test.ts @@ -416,6 +416,90 @@ describe('ConcurrencyManager', () => { expect(mainCount).toBe(2); }); + it('should count only auto-mode features when autoModeOnly is true', async () => { + // Auto-mode feature on main worktree + manager.acquire({ + featureId: 'feature-auto', + projectPath: '/test/project', + isAutoMode: true, + }); + + // Manual feature on main worktree + manager.acquire({ + featureId: 'feature-manual', + projectPath: '/test/project', + isAutoMode: false, + }); + + // Without autoModeOnly: counts both + const totalCount = await manager.getRunningCountForWorktree('/test/project', null); + expect(totalCount).toBe(2); + + // With autoModeOnly: counts only auto-mode features + const autoModeCount = await manager.getRunningCountForWorktree('/test/project', null, { + autoModeOnly: true, + }); + expect(autoModeCount).toBe(1); + }); + + it('should count only auto-mode features on specific worktree when autoModeOnly is true', async () => { + // Auto-mode feature on feature branch + manager.acquire({ + featureId: 'feature-auto', + projectPath: '/test/project', + isAutoMode: true, + }); + manager.updateRunningFeature('feature-auto', { branchName: 'feature-branch' }); + + // Manual feature on same feature branch + manager.acquire({ + featureId: 'feature-manual', + projectPath: '/test/project', + isAutoMode: false, + }); + manager.updateRunningFeature('feature-manual', { branchName: 'feature-branch' }); + + // Another auto-mode feature on different branch (should not be counted) + manager.acquire({ + featureId: 'feature-other', + projectPath: '/test/project', + isAutoMode: true, + }); + manager.updateRunningFeature('feature-other', { branchName: 'other-branch' }); + + const autoModeCount = await manager.getRunningCountForWorktree( + '/test/project', + 'feature-branch', + { autoModeOnly: true } + ); + expect(autoModeCount).toBe(1); + + const totalCount = await manager.getRunningCountForWorktree( + '/test/project', + 'feature-branch' + ); + expect(totalCount).toBe(2); + }); + + it('should return 0 when autoModeOnly is true and only manual features are running', async () => { + manager.acquire({ + featureId: 'feature-manual-1', + projectPath: '/test/project', + isAutoMode: false, + }); + + manager.acquire({ + featureId: 'feature-manual-2', + projectPath: '/test/project', + isAutoMode: false, + }); + + const autoModeCount = await manager.getRunningCountForWorktree('/test/project', null, { + autoModeOnly: true, + }); + expect(autoModeCount).toBe(0); + }); + it('should filter by both projectPath and branchName', async () => { manager.acquire({ featureId: 'feature-1', diff --git a/apps/server/tests/unit/services/dev-server-service.test.ts b/apps/server/tests/unit/services/dev-server-service.test.ts index e95259bc6..3e3164508 100644 --- a/apps/server/tests/unit/services/dev-server-service.test.ts +++ b/apps/server/tests/unit/services/dev-server-service.test.ts @@ -486,7 +486,7 @@ describe('dev-server-service.ts', () => { await service.startDevServer(testDir, testDir); // Simulate HTTPS dev server - mockProcess.stdout.emit('data', Buffer.from('Server at https://localhost:3443\n')); + mockProcess.stdout.emit('data', Buffer.from('Server listening at https://localhost:3443\n')); await new Promise((resolve) => setTimeout(resolve, 50)); @@ -521,6 +521,368 @@ describe('dev-server-service.ts', () => { expect(serverInfo?.url).toBe(firstUrl); expect(serverInfo?.url).toBe('http://localhost:5173/'); }); + + it('should detect Astro format URL', async () => { + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + await service.startDevServer(testDir, testDir); + + // Astro uses the same "Local:" prefix as Vite + mockProcess.stdout.emit('data', Buffer.from(' 🚀 astro v4.0.0 started in 200ms\n')); + mockProcess.stdout.emit('data', Buffer.from(' ┃ Local http://localhost:4321/\n')); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const serverInfo = service.getServerInfo(testDir); + // Astro doesn't use "Local:" with colon, so it should be caught by the localhost URL pattern + expect(serverInfo?.url).toBe('http://localhost:4321/'); + expect(serverInfo?.urlDetected).toBe(true); + }); + + it('should detect Remix format URL', async () => { + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + await service.startDevServer(testDir, testDir); + + mockProcess.stdout.emit( + 'data', + Buffer.from('Remix App Server started at http://localhost:3000\n') + ); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const serverInfo = service.getServerInfo(testDir); + expect(serverInfo?.url).toBe('http://localhost:3000'); + expect(serverInfo?.urlDetected).toBe(true); + }); + + it('should detect Django format URL', async () => { + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + await service.startDevServer(testDir, testDir); + + mockProcess.stdout.emit( + 'data', + Buffer.from('Starting development server at http://127.0.0.1:8000/\n') + ); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const serverInfo = service.getServerInfo(testDir); + expect(serverInfo?.url).toBe('http://127.0.0.1:8000/'); + expect(serverInfo?.urlDetected).toBe(true); + }); + + it('should detect Webpack Dev Server format URL', async () => { + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + await service.startDevServer(testDir, testDir); + + mockProcess.stdout.emit( + 'data', + Buffer.from(' [webpack-dev-server] Project is running at http://localhost:8080/\n') + ); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const serverInfo = service.getServerInfo(testDir); + expect(serverInfo?.url).toBe('http://localhost:8080/'); + expect(serverInfo?.urlDetected).toBe(true); + }); + + it('should detect PHP built-in server format URL', async () => { + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + await service.startDevServer(testDir, testDir); + + mockProcess.stdout.emit( + 'data', + Buffer.from('Development Server (http://localhost:8000) started\n') + ); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const serverInfo = service.getServerInfo(testDir); + expect(serverInfo?.url).toBe('http://localhost:8000'); + expect(serverInfo?.urlDetected).toBe(true); + }); + + it('should detect "listening on port" format (port-only detection)', async () => { + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + await service.startDevServer(testDir, testDir); + + // Some servers only print the port number, not a full URL + mockProcess.stdout.emit('data', Buffer.from('Server listening on port 4000\n')); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const serverInfo = service.getServerInfo(testDir); + expect(serverInfo?.url).toBe('http://localhost:4000'); + expect(serverInfo?.urlDetected).toBe(true); + }); + + it('should detect "running on port" format (port-only detection)', async () => { + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + await service.startDevServer(testDir, testDir); + + mockProcess.stdout.emit('data', Buffer.from('Application running on port 9000\n')); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const serverInfo = service.getServerInfo(testDir); + expect(serverInfo?.url).toBe('http://localhost:9000'); + expect(serverInfo?.urlDetected).toBe(true); + }); + + it('should strip ANSI escape codes before detecting URL', async () => { + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + await service.startDevServer(testDir, testDir); + + // Simulate Vite output with ANSI color codes + mockProcess.stdout.emit( + 'data', + Buffer.from( + ' \x1B[32m➜\x1B[0m \x1B[1mLocal:\x1B[0m \x1B[36mhttp://localhost:5173/\x1B[0m\n' + ) + ); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const serverInfo = service.getServerInfo(testDir); + expect(serverInfo?.url).toBe('http://localhost:5173/'); + expect(serverInfo?.urlDetected).toBe(true); + }); + + it('should normalize 0.0.0.0 to localhost', async () => { + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + await service.startDevServer(testDir, testDir); + + mockProcess.stdout.emit('data', Buffer.from('Server listening at http://0.0.0.0:3000\n')); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const serverInfo = service.getServerInfo(testDir); + expect(serverInfo?.url).toBe('http://localhost:3000'); + expect(serverInfo?.urlDetected).toBe(true); + }); + + it('should normalize [::] to localhost', async () => { + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + await service.startDevServer(testDir, testDir); + + mockProcess.stdout.emit('data', Buffer.from('Local: http://[::]:4000/\n')); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const serverInfo = service.getServerInfo(testDir); + expect(serverInfo?.url).toBe('http://localhost:4000/'); + expect(serverInfo?.urlDetected).toBe(true); + }); + + it('should update port field when detected URL has different port', async () => { + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + const result = await service.startDevServer(testDir, testDir); + const allocatedPort = result.result?.port; + + // Server starts on a completely different port (ignoring PORT env var) + mockProcess.stdout.emit('data', Buffer.from('Local: http://localhost:9999/\n')); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const serverInfo = service.getServerInfo(testDir); + expect(serverInfo?.url).toBe('http://localhost:9999/'); + expect(serverInfo?.port).toBe(9999); + // The port should be different from what was initially allocated + if (allocatedPort !== 9999) { + expect(serverInfo?.port).not.toBe(allocatedPort); + } + }); + + it('should detect URL from stderr output', async () => { + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + await service.startDevServer(testDir, testDir); + + // Some servers output URL info to stderr + mockProcess.stderr.emit('data', Buffer.from('Local: http://localhost:3000/\n')); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const serverInfo = service.getServerInfo(testDir); + expect(serverInfo?.url).toBe('http://localhost:3000/'); + expect(serverInfo?.urlDetected).toBe(true); + }); + + it('should not match URLs without a port (non-dev-server URLs)', async () => { + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + const result = await service.startDevServer(testDir, testDir); + + // CDN/external URLs should not be detected + mockProcess.stdout.emit( + 'data', + Buffer.from('Downloading from https://cdn.example.com/bundle.js\n') + ); + mockProcess.stdout.emit('data', Buffer.from('Fetching https://registry.npmjs.org/package\n')); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const serverInfo = service.getServerInfo(testDir); + // Should keep the initial allocated URL since external URLs don't match + expect(serverInfo?.url).toBe(result.result?.url); + expect(serverInfo?.urlDetected).toBe(false); + }); + + it('should handle URLs with trailing punctuation', async () => { + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + await service.startDevServer(testDir, testDir); + + // URL followed by punctuation + mockProcess.stdout.emit('data', Buffer.from('Server started at http://localhost:3000.\n')); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const serverInfo = service.getServerInfo(testDir); + expect(serverInfo?.url).toBe('http://localhost:3000'); + expect(serverInfo?.urlDetected).toBe(true); + }); + + it('should detect Express/Fastify format URL', async () => { + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + await service.startDevServer(testDir, testDir); + + mockProcess.stdout.emit('data', Buffer.from('Server listening on http://localhost:3000\n')); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const serverInfo = service.getServerInfo(testDir); + expect(serverInfo?.url).toBe('http://localhost:3000'); + expect(serverInfo?.urlDetected).toBe(true); + }); + + it('should detect Angular CLI format URL', async () => { + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + await service.startDevServer(testDir, testDir); + + // Angular CLI output + mockProcess.stderr.emit( + 'data', + Buffer.from( + '** Angular Live Development Server is listening on localhost:4200, open your browser on http://localhost:4200/ **\n' + ) + ); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const serverInfo = service.getServerInfo(testDir); + expect(serverInfo?.url).toBe('http://localhost:4200/'); + expect(serverInfo?.urlDetected).toBe(true); + }); }); }); @@ -531,6 +893,7 @@ function createMockProcess() { mockProcess.stderr = new EventEmitter(); mockProcess.kill = vi.fn(); mockProcess.killed = false; + mockProcess.pid = 12345; // Don't exit immediately - let the test control the lifecycle return mockProcess; diff --git a/apps/ui/src/components/ui/git-diff-panel.tsx b/apps/ui/src/components/ui/git-diff-panel.tsx index 2f7bd5193..cd57cfe75 100644 --- a/apps/ui/src/components/ui/git-diff-panel.tsx +++ b/apps/ui/src/components/ui/git-diff-panel.tsx @@ -10,6 +10,7 @@ import { ChevronRight, RefreshCw, GitBranch, + GitMerge, AlertCircle, Plus, Minus, @@ -20,7 +21,7 @@ import { Button } from './button'; import { useWorktreeDiffs, useGitDiffs } from '@/hooks/queries'; import { getElectronAPI } from '@/lib/electron'; import { toast } from 'sonner'; -import type { FileStatus } from '@/types/electron'; +import type { FileStatus, MergeStateInfo } from '@/types/electron'; interface GitDiffPanelProps { projectPath: string; @@ -318,6 +319,86 @@ function StagingBadge({ state }: { state: 'staged' | 'unstaged' | 'partial' }) { ); } +function MergeBadge({ mergeType }: { mergeType?: string }) { + if (!mergeType) return null; + + const label = (() => { + switch (mergeType) { + case 'both-modified': + return 'Both Modified'; + case 'added-by-us': + return 'Added by Us'; + case 'added-by-them': + return 'Added by Them'; + case 'deleted-by-us': + return 'Deleted by Us'; + case 'deleted-by-them': + return 'Deleted by Them'; + case 'both-added': + return 'Both Added'; + case 'both-deleted': + return 'Both Deleted'; + case 'merged': + return 'Merged'; + default: + return 'Merge'; + } + })(); + + return ( + + + {label} + + ); +} + +function MergeStateBanner({ mergeState }: { mergeState: MergeStateInfo }) { + // Completed merge commit (HEAD is a merge) + if (mergeState.isMergeCommit && !mergeState.isMerging) { + return ( +

+ +
+ Merge commit + + — {mergeState.mergeAffectedFiles.length} file + {mergeState.mergeAffectedFiles.length !== 1 ? 's' : ''} changed in merge + +
+
+ ); + } + + // In-progress merge/rebase/cherry-pick + const operationLabel = + mergeState.mergeOperationType === 'cherry-pick' + ? 'Cherry-pick' + : mergeState.mergeOperationType === 'rebase' + ? 'Rebase' + : 'Merge'; + + return ( +
+ +
+ {operationLabel} in progress + {mergeState.conflictFiles.length > 0 ? ( + + — {mergeState.conflictFiles.length} file + {mergeState.conflictFiles.length !== 1 ? 's' : ''} with conflicts + + ) : mergeState.isCleanMerge ? ( + + — Clean merge, {mergeState.mergeAffectedFiles.length} file + {mergeState.mergeAffectedFiles.length !== 1 ? 's' : ''} affected + + ) : null} +
+
+ ); +} + function FileDiffSection({ fileDiff, isExpanded, @@ -348,9 +429,21 @@ function FileDiffSection({ const stagingState = fileStatus ? getStagingState(fileStatus) : undefined; + const isMergeFile = fileStatus?.isMergeAffected; + return ( -
-
+
+
{/* File name row */} {/* Indicators & staging row */}
+ {fileStatus?.isMergeAffected && } {enableStaging && stagingState && } {fileDiff.isNew && ( @@ -483,9 +579,10 @@ export function GitDiffPanel({ const isLoading = useWorktrees ? isLoadingWorktree : isLoadingGit; const queryError = useWorktrees ? worktreeError : gitError; - // Extract files and diff content from the data + // Extract files, diff content, and merge state from the data const files: FileStatus[] = diffsData?.files ?? []; const diffContent = diffsData?.diff ?? ''; + const mergeState: MergeStateInfo | undefined = diffsData?.mergeState; const error = queryError ? queryError instanceof Error ? queryError.message @@ -495,8 +592,6 @@ export function GitDiffPanel({ // Refetch function const loadDiffs = useWorktrees ? refetchWorktree : refetchGit; - const parsedDiffs = useMemo(() => parseDiff(diffContent), [diffContent]); - // Build a map from file path to FileStatus for quick lookup const fileStatusMap = useMemo(() => { const map = new Map(); @@ -506,6 +601,24 @@ export function GitDiffPanel({ return map; }, [files]); + const parsedDiffs = useMemo(() => { + const diffs = parseDiff(diffContent); + // Sort: merge-affected files first, then preserve original order + if (mergeState?.isMerging || mergeState?.isMergeCommit) { + const mergeSet = new Set(mergeState.mergeAffectedFiles); + diffs.sort((a, b) => { + const aIsMerge = + mergeSet.has(a.filePath) || (fileStatusMap.get(a.filePath)?.isMergeAffected ?? false); + const bIsMerge = + mergeSet.has(b.filePath) || (fileStatusMap.get(b.filePath)?.isMergeAffected ?? false); + if (aIsMerge && !bIsMerge) return -1; + if (!aIsMerge && bIsMerge) return 1; + return 0; + }); + } + return diffs; + }, [diffContent, mergeState, fileStatusMap]); + const toggleFile = (filePath: string) => { setExpandedFiles((prev) => { const next = new Set(prev); @@ -682,6 +795,18 @@ export function GitDiffPanel({ ); }, [worktreePath, projectPath, useWorktrees, enableStaging, files, executeStagingAction]); + // Compute merge summary + const mergeSummary = useMemo(() => { + const mergeFiles = files.filter((f) => f.isMergeAffected); + if (mergeFiles.length === 0) return null; + return { + total: mergeFiles.length, + conflicted: mergeFiles.filter( + (f) => f.mergeType === 'both-modified' || f.mergeType === 'both-added' + ).length, + }; + }, [files]); + // Compute staging summary const stagingSummary = useMemo(() => { if (!enableStaging) return null; @@ -776,6 +901,11 @@ export function GitDiffPanel({
) : (
+ {/* Merge state banner */} + {(mergeState?.isMerging || mergeState?.isMergeCommit) && ( + + )} + {/* Summary bar */}
@@ -799,7 +929,7 @@ export function GitDiffPanel({ {} as Record ); - return Object.entries(statusGroups).map(([status, group]) => ( + const groups = Object.entries(statusGroups).map(([status, group]) => (
)); + + // Add merge group indicator if merge files exist + if (mergeSummary) { + groups.unshift( +
+ + + {mergeSummary.total} Merge + +
+ ); + } + + return groups; })()}
@@ -907,7 +1055,7 @@ export function GitDiffPanel({ fileDiff={fileDiff} isExpanded={expandedFiles.has(fileDiff.filePath)} onToggle={() => toggleFile(fileDiff.filePath)} - fileStatus={enableStaging ? fileStatusMap.get(fileDiff.filePath) : undefined} + fileStatus={fileStatusMap.get(fileDiff.filePath)} enableStaging={enableStaging} onStage={enableStaging ? handleStageFile : undefined} onUnstage={enableStaging ? handleUnstageFile : undefined} @@ -919,15 +1067,28 @@ export function GitDiffPanel({
{files.map((file) => { const stagingState = getStagingState(file); + const isFileMerge = file.isMergeAffected; return (
-
+
{/* File name row */}
- {getFileIcon(file.status)} + {isFileMerge ? ( + + ) : ( + getFileIcon(file.status) + )} {/* Indicators & staging row */}
+ {isFileMerge && } {enableStaging && } {description ? ( -
+
{children} {description}
diff --git a/apps/ui/src/components/ui/test-logs-panel.tsx b/apps/ui/src/components/ui/test-logs-panel.tsx index 2c48f42a5..dd2e4c683 100644 --- a/apps/ui/src/components/ui/test-logs-panel.tsx +++ b/apps/ui/src/components/ui/test-logs-panel.tsx @@ -215,7 +215,7 @@ function TestLogsPanelContent({ return ( <> {/* Header */} - +
diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index bca651c3a..dca23f007 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -375,10 +375,20 @@ export function BoardView() { return specificTargetCollisions; } - // Priority 2: Columns - const columnCollisions = pointerCollisions.filter((collision: Collision) => - COLUMNS.some((col) => col.id === collision.id) - ); + // Priority 2: Columns (including column headers and pipeline columns) + const columnCollisions = pointerCollisions.filter((collision: Collision) => { + const colId = String(collision.id); + // Direct column ID match (e.g. 'backlog', 'in_progress') + if (COLUMNS.some((col) => col.id === colId)) return true; + // Column header droppable (e.g. 'column-header-backlog') + if (colId.startsWith('column-header-')) { + const baseId = colId.replace('column-header-', ''); + return COLUMNS.some((col) => col.id === baseId) || baseId.startsWith('pipeline_'); + } + // Pipeline column IDs (e.g. 'pipeline_tests') + if (colId.startsWith('pipeline_')) return true; + return false; + }); // If we found a column collision, use that if (columnCollisions.length > 0) { @@ -1426,13 +1436,12 @@ export function BoardView() { }, }); - // Also update backend if auto mode is running + // Also update backend if auto mode is running. + // Use restartWithConcurrency to avoid toggle flickering - it restarts + // the backend without toggling isRunning off/on in the UI. if (autoMode.isRunning) { - // Restart auto mode with new concurrency (backend will handle this) - autoMode.stop().then(() => { - autoMode.start().catch((error) => { - logger.error('[AutoMode] Failed to restart with new concurrency:', error); - }); + autoMode.restartWithConcurrency().catch((error) => { + logger.error('[AutoMode] Failed to restart with new concurrency:', error); }); } } diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/card-actions.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/card-actions.tsx index 841a45266..45e0e8be1 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/card-actions.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/card-actions.tsx @@ -17,6 +17,8 @@ import { interface CardActionsProps { feature: Feature; isCurrentAutoTask: boolean; + /** Whether this feature is tracked as a running task (may be true even before status updates to in_progress) */ + isRunningTask?: boolean; hasContext?: boolean; shortcutKey?: string; isSelectionMode?: boolean; @@ -36,6 +38,7 @@ interface CardActionsProps { export const CardActions = memo(function CardActions({ feature, isCurrentAutoTask, + isRunningTask = false, hasContext: _hasContext, shortcutKey, isSelectionMode = false, @@ -340,7 +343,57 @@ export const CardActions = memo(function CardActions({ ) : null} )} + {/* Running task with stale status: feature is tracked as running but status hasn't updated yet. + Show Logs/Stop controls instead of Make to avoid confusing UI. */} {!isCurrentAutoTask && + isRunningTask && + (feature.status === 'backlog' || + feature.status === 'interrupted' || + feature.status === 'ready') && ( + <> + {onViewOutput && ( + + )} + {onForceStop && ( + + )} + + )} + {!isCurrentAutoTask && + !isRunningTask && (feature.status === 'backlog' || feature.status === 'interrupted' || feature.status === 'ready') && ( 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 a4ac8dfa2..63220c877 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 @@ -114,15 +114,27 @@ export const KanbanCard = memo(function KanbanCard({ currentProject: state.currentProject, })) ); - // A card should only display as "actively running" if it's both in the - // runningAutoTasks list AND in an execution-compatible status. Cards in resting - // states (backlog, ready, waiting_approval, verified, completed) should never - // show running controls, even if they appear in runningAutoTasks due to stale - // state (e.g., after a server restart that reconciled features back to backlog). + // A card should display as "actively running" if it's in the runningAutoTasks list + // AND in an execution-compatible status. However, there's a race window where a feature + // is tracked as running (in runningAutoTasks) but its disk/UI status hasn't caught up yet + // (still 'backlog', 'ready', or 'interrupted'). In this case, we still want to show + // running controls (Logs/Stop) and animated border, but not the full "actively running" + // state that gates all UI behavior. const isInExecutionState = feature.status === 'in_progress' || (typeof feature.status === 'string' && feature.status.startsWith('pipeline_')); const isActivelyRunning = !!isCurrentAutoTask && isInExecutionState; + // isRunningWithStaleStatus: feature is tracked as running but status hasn't updated yet. + // This happens during the timing gap between when the server starts a feature and when + // the UI receives the status update. Show running UI to prevent "Make" button flash. + const isRunningWithStaleStatus = + !!isCurrentAutoTask && + !isInExecutionState && + (feature.status === 'backlog' || + feature.status === 'ready' || + feature.status === 'interrupted'); + // Show running visual treatment for both fully confirmed and stale-status running tasks + const showRunningVisuals = isActivelyRunning || isRunningWithStaleStatus; const [isLifted, setIsLifted] = useState(false); useLayoutEffect(() => { @@ -135,6 +147,7 @@ export const KanbanCard = memo(function KanbanCard({ const isDraggable = !isSelectionMode && + !isRunningWithStaleStatus && (feature.status === 'backlog' || feature.status === 'interrupted' || feature.status === 'ready' || @@ -198,13 +211,13 @@ export const KanbanCard = memo(function KanbanCard({ 'kanban-card-content h-full relative', reduceEffects ? 'shadow-none' : 'shadow-sm', 'transition-all duration-200 ease-out', - // Disable hover translate for in-progress cards to prevent gap showing gradient + // Disable hover translate for running cards to prevent gap showing gradient isInteractive && !reduceEffects && - !isActivelyRunning && + !showRunningVisuals && 'hover:-translate-y-0.5 hover:shadow-md hover:shadow-black/10 bg-transparent', !glassmorphism && 'backdrop-blur-[0px]!', - !isActivelyRunning && + !showRunningVisuals && cardBorderEnabled && (cardBorderOpacity === 100 ? 'border-border/50' : 'border'), hasError && 'border-[var(--status-error)] border-2 shadow-[var(--status-error-bg)] shadow-lg', @@ -221,7 +234,7 @@ export const KanbanCard = memo(function KanbanCard({ const renderCardContent = () => ( - {isActivelyRunning ? ( + {showRunningVisuals ? (
{renderCardContent()}
) : ( renderCardContent() diff --git a/apps/ui/src/components/views/board-view/components/kanban-column.tsx b/apps/ui/src/components/views/board-view/components/kanban-column.tsx index 8a3da6a61..5a89b46df 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-column.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-column.tsx @@ -42,7 +42,12 @@ export const KanbanColumn = memo(function KanbanColumn({ contentStyle, disableItemSpacing = false, }: KanbanColumnProps) { - const { setNodeRef, isOver } = useDroppable({ id }); + const { setNodeRef, isOver: isColumnOver } = useDroppable({ id }); + // Also make the header explicitly a drop target so dragging to the top of the column works + const { setNodeRef: setHeaderDropRef, isOver: isHeaderOver } = useDroppable({ + id: `column-header-${id}`, + }); + const isOver = isColumnOver || isHeaderOver; // Use inline style for width if provided, otherwise use default w-72 const widthStyle = width ? { width: `${width}px`, flexShrink: 0 } : undefined; @@ -70,8 +75,9 @@ export const KanbanColumn = memo(function KanbanColumn({ style={{ opacity: opacity / 100 }} /> - {/* Column Header */} + {/* Column Header - also registered as a drop target so dragging to the header area works */}
{ @@ -268,7 +275,7 @@ export const ListRow = memo(function ListRow({ > {/* Checkbox column */} {showCheckbox && ( -
+
{/* Actions column */} -
- +
+
); - // Wrap with animated border for currently running auto task - if (isActivelyRunning) { + // Wrap with animated border for currently running auto task (including stale status) + if (showRunningVisuals) { return
{rowContent}
; } 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 f66b95cf8..7c893ff89 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 @@ -60,6 +60,8 @@ export interface RowActionsProps { handlers: RowActionHandlers; /** Whether this feature is the current auto task (agent is running) */ isCurrentAutoTask?: boolean; + /** Whether this feature is tracked as a running task (may be true even before status updates to in_progress) */ + isRunningTask?: boolean; /** Whether the dropdown menu is open */ isOpen?: boolean; /** Callback when the dropdown open state changes */ @@ -115,7 +117,8 @@ const MenuItem = memo(function MenuItem({ function getPrimaryAction( feature: Feature, handlers: RowActionHandlers, - isCurrentAutoTask: boolean + isCurrentAutoTask: boolean, + isRunningTask: boolean = false ): { icon: React.ComponentType<{ className?: string }>; label: string; @@ -135,6 +138,24 @@ function getPrimaryAction( return null; } + // Running task with stale status - show stop instead of Make + // This handles the race window where the feature is tracked as running + // but status hasn't updated to in_progress yet + if ( + isRunningTask && + (feature.status === 'backlog' || + feature.status === 'ready' || + feature.status === 'interrupted') && + handlers.onForceStop + ) { + return { + icon: StopCircle, + label: 'Stop', + onClick: handlers.onForceStop, + variant: 'destructive', + }; + } + // Backlog - implement is primary if (feature.status === 'backlog' && handlers.onImplement) { return { @@ -263,6 +284,7 @@ export const RowActions = memo(function RowActions({ feature, handlers, isCurrentAutoTask = false, + isRunningTask = false, isOpen, onOpenChange, className, @@ -286,7 +308,7 @@ export const RowActions = memo(function RowActions({ [setOpen] ); - const primaryAction = getPrimaryAction(feature, handlers, isCurrentAutoTask); + const primaryAction = getPrimaryAction(feature, handlers, isCurrentAutoTask, isRunningTask); const secondaryActions = getSecondaryActions(feature, handlers); // Helper to close menu after action @@ -403,7 +425,7 @@ export const RowActions = memo(function RowActions({ )} {/* Backlog actions */} - {!isCurrentAutoTask && feature.status === 'backlog' && ( + {!isCurrentAutoTask && !isRunningTask && feature.status === 'backlog' && ( <> {feature.planSpec?.content && handlers.onViewPlan && ( 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 index e6944d702..8f49bf71e 100644 --- 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 @@ -493,7 +493,7 @@ export function CherryPickDialog({ if (step === 'select-commits') { return ( - + 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 b8ef6d62c..c31ff482d 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 @@ -20,6 +20,7 @@ import { } from '@/components/ui/select'; import { GitCommit, + GitMerge, Sparkles, FilePlus, FileX, @@ -36,7 +37,7 @@ import { toast } from 'sonner'; import { useAppStore } from '@/store/app-store'; import { cn } from '@/lib/utils'; import { TruncatedFilePath } from '@/components/ui/truncated-file-path'; -import type { FileStatus } from '@/types/electron'; +import type { FileStatus, MergeStateInfo } from '@/types/electron'; import { parseDiff, type ParsedFileDiff } from '@/lib/diff-utils'; interface RemoteInfo { @@ -116,6 +117,27 @@ const getStatusBadgeColor = (status: string) => { } }; +const getMergeTypeLabel = (mergeType?: string) => { + switch (mergeType) { + case 'both-modified': + return 'Both Modified'; + case 'added-by-us': + return 'Added by Us'; + case 'added-by-them': + return 'Added by Them'; + case 'deleted-by-us': + return 'Deleted by Us'; + case 'deleted-by-them': + return 'Deleted by Them'; + case 'both-added': + return 'Both Added'; + case 'both-deleted': + return 'Both Deleted'; + default: + return 'Merge'; + } +}; + function DiffLine({ type, content, @@ -190,6 +212,7 @@ export function CommitWorktreeDialog({ const [selectedFiles, setSelectedFiles] = useState>(new Set()); const [expandedFile, setExpandedFile] = useState(null); const [isLoadingDiffs, setIsLoadingDiffs] = useState(false); + const [mergeState, setMergeState] = useState(undefined); // Push after commit state const [pushAfterCommit, setPushAfterCommit] = useState(false); @@ -274,6 +297,7 @@ export function CommitWorktreeDialog({ setDiffContent(''); setSelectedFiles(new Set()); setExpandedFile(null); + setMergeState(undefined); // Reset push state setPushAfterCommit(false); setRemotes([]); @@ -292,8 +316,20 @@ export function CommitWorktreeDialog({ const result = await api.git.getDiffs(worktree.path); if (result.success) { const fileList = result.files ?? []; + // Sort merge-affected files first when a merge is in progress + if (result.mergeState?.isMerging) { + const mergeSet = new Set(result.mergeState.mergeAffectedFiles); + fileList.sort((a, b) => { + const aIsMerge = mergeSet.has(a.path) || (a.isMergeAffected ?? false); + const bIsMerge = mergeSet.has(b.path) || (b.isMergeAffected ?? false); + if (aIsMerge && !bIsMerge) return -1; + if (!aIsMerge && bIsMerge) return 1; + return 0; + }); + } if (!cancelled) setFiles(fileList); if (!cancelled) setDiffContent(result.diff ?? ''); + if (!cancelled) setMergeState(result.mergeState); // If any files are already staged, pre-select only staged files // Otherwise select all files by default const stagedFiles = fileList.filter((f) => { @@ -579,6 +615,34 @@ export function CommitWorktreeDialog({
+ {/* Merge state banner */} + {mergeState?.isMerging && ( +
+ +
+ + {mergeState.mergeOperationType === 'cherry-pick' + ? 'Cherry-pick' + : mergeState.mergeOperationType === 'rebase' + ? 'Rebase' + : 'Merge'}{' '} + in progress + + {mergeState.conflictFiles.length > 0 ? ( + + — {mergeState.conflictFiles.length} file + {mergeState.conflictFiles.length !== 1 ? 's' : ''} with conflicts + + ) : mergeState.isCleanMerge ? ( + + — Clean merge, {mergeState.mergeAffectedFiles.length} file + {mergeState.mergeAffectedFiles.length !== 1 ? 's' : ''} affected + + ) : null} +
+
+ )} + {/* File Selection */}
@@ -625,13 +689,25 @@ export function CommitWorktreeDialog({ const isStaged = idx !== ' ' && idx !== '?'; const isUnstaged = wt !== ' ' && wt !== '?'; const isUntracked = idx === '?' && wt === '?'; + const isMergeFile = + file.isMergeAffected || + (mergeState?.mergeAffectedFiles?.includes(file.path) ?? false); return ( -
+
{/* Checkbox */} @@ -651,11 +727,21 @@ export function CommitWorktreeDialog({ ) : ( )} - {getFileIcon(file.status)} + {isMergeFile ? ( + + ) : ( + getFileIcon(file.status) + )} + {isMergeFile && ( + + + {getMergeTypeLabel(file.mergeType)} + + )} {remotes.map((remote) => ( - + + {remote.url} + + } + > {remote.name} - - {remote.url} - ))} diff --git a/apps/ui/src/components/views/board-view/dialogs/git-pull-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/git-pull-dialog.tsx index aad74048e..edbf4f02a 100644 --- a/apps/ui/src/components/views/board-view/dialogs/git-pull-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/git-pull-dialog.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import { Dialog, DialogContent, @@ -17,11 +17,17 @@ import { FileWarning, Wrench, Sparkles, + GitMerge, + GitCommitHorizontal, + FileText, + Settings, } from 'lucide-react'; import { Spinner } from '@/components/ui/spinner'; +import { Checkbox } from '@/components/ui/checkbox'; import { getElectronAPI } from '@/lib/electron'; import { toast } from 'sonner'; import { cn } from '@/lib/utils'; +import { useAppStore } from '@/store/app-store'; import type { MergeConflictInfo } from '../worktree-panel/types'; interface WorktreeInfo { @@ -37,6 +43,7 @@ type PullPhase = | 'local-changes' // Local changes detected, asking user what to do | 'pulling' // Actively pulling (with or without stash) | 'success' // Pull completed successfully + | 'merge-complete' // Pull resulted in a merge (not fast-forward, no conflicts) | 'conflict' // Merge conflicts detected | 'error'; // Something went wrong @@ -53,6 +60,9 @@ interface PullResult { stashed?: boolean; stashRestored?: boolean; stashRecoveryFailed?: boolean; + isMerge?: boolean; + isFastForward?: boolean; + mergeAffectedFiles?: string[]; } interface GitPullDialogProps { @@ -62,6 +72,8 @@ interface GitPullDialogProps { remote?: string; onPulled?: () => void; onCreateConflictResolutionFeature?: (conflictInfo: MergeConflictInfo) => void; + /** Called when user chooses to commit the merge — opens the commit dialog */ + onCommitMerge?: (worktree: { path: string; branch: string; isMain: boolean }) => void; } export function GitPullDialog({ @@ -71,10 +83,54 @@ export function GitPullDialog({ remote, onPulled, onCreateConflictResolutionFeature, + onCommitMerge, }: GitPullDialogProps) { const [phase, setPhase] = useState('checking'); const [pullResult, setPullResult] = useState(null); const [errorMessage, setErrorMessage] = useState(null); + const [rememberChoice, setRememberChoice] = useState(false); + const [showMergeFiles, setShowMergeFiles] = useState(false); + + const mergePostAction = useAppStore((s) => s.mergePostAction); + const setMergePostAction = useAppStore((s) => s.setMergePostAction); + + /** + * Determine the appropriate phase after a successful pull. + * If the pull resulted in a merge (not fast-forward) and no conflicts, + * check user preference before deciding whether to show merge prompt. + */ + const handleSuccessfulPull = useCallback( + (result: PullResult) => { + setPullResult(result); + + if (result.isMerge && !result.hasConflicts) { + // Merge happened — check user preference + if (mergePostAction === 'commit') { + // User preference: auto-commit + setPhase('success'); + onPulled?.(); + // Auto-trigger commit dialog + if (worktree && onCommitMerge) { + onCommitMerge(worktree); + onOpenChange(false); + } + } else if (mergePostAction === 'manual') { + // User preference: manual review + setPhase('success'); + onPulled?.(); + } else { + // No preference — show merge prompt; onPulled will be called from the + // user-action handlers (handleCommitMerge / handleMergeManually) once + // the user makes their choice, consistent with the conflict phase. + setPhase('merge-complete'); + } + } else { + setPhase('success'); + onPulled?.(); + } + }, + [mergePostAction, worktree, onCommitMerge, onPulled, onOpenChange] + ); const checkForLocalChanges = useCallback(async () => { if (!worktree) return; @@ -103,9 +159,7 @@ export function GitPullDialog({ setPhase('local-changes'); } else if (result.result?.pulled !== undefined) { // No local changes, pull went through (or already up to date) - setPullResult(result.result); - setPhase('success'); - onPulled?.(); + handleSuccessfulPull(result.result); } else { // Unexpected response: success but no recognizable fields setPullResult(result.result ?? null); @@ -116,18 +170,33 @@ export function GitPullDialog({ setErrorMessage(err instanceof Error ? err.message : 'Failed to check for changes'); setPhase('error'); } - }, [worktree, remote, onPulled]); + }, [worktree, remote, handleSuccessfulPull]); + + // Keep a ref to the latest checkForLocalChanges to break the circular dependency + // between the "reset/start" effect and the callback chain. Without this, any + // change in onPulled (passed from the parent) would recreate handleSuccessfulPull + // → checkForLocalChanges → re-trigger the effect while the dialog is already open, + // causing the pull flow to restart unintentionally. + const checkForLocalChangesRef = useRef(checkForLocalChanges); + useEffect(() => { + checkForLocalChangesRef.current = checkForLocalChanges; + }); - // Reset state when dialog opens + // Reset state when dialog opens and start the initial pull check. + // Depends only on `open` and `worktree` — NOT on `checkForLocalChanges` — + // so that parent callback re-creations don't restart the pull flow mid-flight. useEffect(() => { if (open && worktree) { setPhase('checking'); setPullResult(null); setErrorMessage(null); - // Start the initial check - checkForLocalChanges(); + setRememberChoice(false); + setShowMergeFiles(false); + // Start the initial check using the ref so we always call the latest version + // without making it a dependency of this effect. + checkForLocalChangesRef.current(); } - }, [open, worktree, checkForLocalChanges]); + }, [open, worktree]); const handlePullWithStash = useCallback(async () => { if (!worktree) return; @@ -155,8 +224,7 @@ export function GitPullDialog({ if (result.result?.hasConflicts) { setPhase('conflict'); } else if (result.result?.pulled) { - setPhase('success'); - onPulled?.(); + handleSuccessfulPull(result.result); } else { // Unrecognized response: no pulled flag and no conflicts console.warn('handlePullWithStash: unrecognized response', result.result); @@ -167,7 +235,7 @@ export function GitPullDialog({ setErrorMessage(err instanceof Error ? err.message : 'Failed to pull'); setPhase('error'); } - }, [worktree, remote, onPulled]); + }, [worktree, remote, handleSuccessfulPull]); const handleResolveWithAI = useCallback(() => { if (!worktree || !pullResult || !onCreateConflictResolutionFeature) return; @@ -186,6 +254,35 @@ export function GitPullDialog({ onOpenChange(false); }, [worktree, pullResult, remote, onCreateConflictResolutionFeature, onOpenChange]); + const handleCommitMerge = useCallback(() => { + if (!worktree || !onCommitMerge) { + // No handler available — show feedback and bail without persisting preference + toast.error('Commit merge is not available', { + description: 'The commit merge action is not configured for this context.', + duration: 4000, + }); + return; + } + if (rememberChoice) { + setMergePostAction('commit'); + } + onPulled?.(); + onCommitMerge(worktree); + onOpenChange(false); + }, [rememberChoice, setMergePostAction, worktree, onCommitMerge, onPulled, onOpenChange]); + + const handleMergeManually = useCallback(() => { + if (rememberChoice) { + setMergePostAction('manual'); + } + toast.info('Merge left for manual review', { + description: 'Review the merged files and commit when ready.', + duration: 5000, + }); + onPulled?.(); + onOpenChange(false); + }, [rememberChoice, setMergePostAction, onPulled, onOpenChange]); + const handleClose = useCallback(() => { onOpenChange(false); }, [onOpenChange]); @@ -336,6 +433,137 @@ export function GitPullDialog({ )} + {/* Merge Complete Phase — post-merge prompt */} + {phase === 'merge-complete' && ( + <> + + + + Merge Complete + + +
+ + Pull resulted in a merge on{' '} + {worktree.branch} + {pullResult?.mergeAffectedFiles && pullResult.mergeAffectedFiles.length > 0 && ( + + {' '} + affecting {pullResult.mergeAffectedFiles.length} file + {pullResult.mergeAffectedFiles.length !== 1 ? 's' : ''} + + )} + . How would you like to proceed? + + + {pullResult?.mergeAffectedFiles && pullResult.mergeAffectedFiles.length > 0 && ( +
+ + {showMergeFiles && ( +
+ {pullResult.mergeAffectedFiles.map((file) => ( +
+ + {file} +
+ ))} +
+ )} +
+ )} + + {pullResult?.stashed && + pullResult?.stashRestored && + !pullResult?.stashRecoveryFailed && ( +
+ + + Your stashed changes have been restored successfully. + +
+ )} + +
+

+ Choose how to proceed: +

+
    +
  • + Commit Merge — Open the commit dialog with a merge + commit message +
  • +
  • + Review Manually — Leave the working tree as-is for + manual review +
  • +
+
+
+
+
+ + {/* Remember choice option */} +
+ + {(rememberChoice || mergePostAction) && ( + + + Current:{' '} + {mergePostAction === 'commit' + ? 'auto-commit' + : mergePostAction === 'manual' + ? 'manual review' + : 'ask every time'} + + + + )} +
+ + + + {worktree && onCommitMerge && ( + + )} + + + )} + {/* Conflict Phase */} {phase === 'conflict' && ( <> diff --git a/apps/ui/src/components/views/board-view/dialogs/post-merge-prompt-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/post-merge-prompt-dialog.tsx new file mode 100644 index 000000000..141832da6 --- /dev/null +++ b/apps/ui/src/components/views/board-view/dialogs/post-merge-prompt-dialog.tsx @@ -0,0 +1,190 @@ +/** + * Post-Merge Prompt Dialog + * + * Shown after a pull or stash apply results in a clean merge (no conflicts). + * Presents the user with two options: + * 1. Commit the merge — automatically stage all merge-result files and open commit dialog + * 2. Merge manually — leave the working tree as-is for manual review + * + * The user's choice can be persisted as a preference to avoid repeated prompts. + */ + +import { useState, useCallback, useEffect } 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 { GitMerge, GitCommitHorizontal, FileText, Settings } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +export type MergePostAction = 'commit' | 'manual' | null; + +interface PostMergePromptDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + /** Branch name where the merge happened */ + branchName: string; + /** Number of files affected by the merge */ + mergeFileCount: number; + /** List of files affected by the merge */ + mergeAffectedFiles?: string[]; + /** Called when the user chooses to commit the merge */ + onCommitMerge: () => void; + /** Called when the user chooses to handle the merge manually */ + onMergeManually: () => void; + /** Current saved preference (null = ask every time) */ + savedPreference?: MergePostAction; + /** Called when the user changes the preference */ + onSavePreference?: (preference: MergePostAction) => void; +} + +export function PostMergePromptDialog({ + open, + onOpenChange, + branchName, + mergeFileCount, + mergeAffectedFiles, + onCommitMerge, + onMergeManually, + savedPreference, + onSavePreference, +}: PostMergePromptDialogProps) { + const [rememberChoice, setRememberChoice] = useState(false); + const [showFiles, setShowFiles] = useState(false); + + // Reset transient state each time the dialog is opened + useEffect(() => { + if (open) { + setRememberChoice(false); + setShowFiles(false); + } + }, [open]); + + const handleCommitMerge = useCallback(() => { + if (rememberChoice && onSavePreference) { + onSavePreference('commit'); + } + onCommitMerge(); + onOpenChange(false); + }, [rememberChoice, onSavePreference, onCommitMerge, onOpenChange]); + + const handleMergeManually = useCallback(() => { + if (rememberChoice && onSavePreference) { + onSavePreference('manual'); + } + onMergeManually(); + onOpenChange(false); + }, [rememberChoice, onSavePreference, onMergeManually, onOpenChange]); + + return ( + + + + + + Merge Complete + + +
+ + A merge was successfully completed on{' '} + {branchName} + {mergeFileCount > 0 && ( + + {' '} + affecting {mergeFileCount} file{mergeFileCount !== 1 ? 's' : ''} + + )} + . How would you like to proceed? + + + {mergeAffectedFiles && mergeAffectedFiles.length > 0 && ( +
+ + {showFiles && ( +
+ {mergeAffectedFiles.map((file) => ( +
+ + {file} +
+ ))} +
+ )} +
+ )} + +
+

+ Choose how to proceed: +

+
    +
  • + Commit Merge — Stage all merge files and open the commit + dialog with a pre-populated merge commit message +
  • +
  • + Review Manually — Leave the working tree as-is so you can + review changes and commit at your own pace +
  • +
+
+
+
+
+ + {/* Remember choice option */} + {onSavePreference && ( +
+ + {savedPreference && ( + + )} +
+ )} + + + + + +
+
+ ); +} 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 index 5aac5c4d5..b44398708 100644 --- 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 @@ -251,7 +251,7 @@ export function ViewCommitsDialog({ open, onOpenChange, worktree }: ViewCommitsD return ( - + @@ -263,7 +263,7 @@ export function ViewCommitsDialog({ open, onOpenChange, worktree }: ViewCommitsD -
+
{isLoading && (
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 index c3f0d81d1..73dbebee1 100644 --- 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 @@ -367,7 +367,7 @@ export function ViewStashesDialog({ return ( - + diff --git a/apps/ui/src/components/views/board-view/dialogs/view-worktree-changes-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/view-worktree-changes-dialog.tsx index 4ea392710..f57dc8716 100644 --- a/apps/ui/src/components/views/board-view/dialogs/view-worktree-changes-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/view-worktree-changes-dialog.tsx @@ -33,7 +33,7 @@ export function ViewWorktreeChangesDialog({ return ( - + @@ -54,7 +54,7 @@ export function ViewWorktreeChangesDialog({ -
+
{ - // Check capacity for the feature's specific worktree, not the current view - // Normalize the branch name: if the feature's branch is the primary worktree branch, - // treat it as null (main worktree) to match how running tasks are stored - const rawBranchName = feature.branchName ?? null; - const featureBranchName = - currentProject?.path && - rawBranchName && - isPrimaryWorktreeBranch(currentProject.path, rawBranchName) - ? null - : rawBranchName; - const featureWorktreeState = currentProject - ? getAutoModeState(currentProject.id, featureBranchName) - : null; - // Use getMaxConcurrencyForWorktree which correctly falls back to global maxConcurrency - // instead of autoMode.maxConcurrency which only falls back to DEFAULT_MAX_CONCURRENCY (1) - const featureMaxConcurrency = currentProject - ? getMaxConcurrencyForWorktree(currentProject.id, featureBranchName) - : autoMode.maxConcurrency; - const featureRunningCount = featureWorktreeState?.runningTasks?.length ?? 0; - const canStartInWorktree = featureRunningCount < featureMaxConcurrency; - - if (!canStartInWorktree) { - const worktreeDesc = featureBranchName - ? `worktree "${featureBranchName}"` - : 'main worktree'; - toast.error('Concurrency limit reached', { - description: `${worktreeDesc} can only have ${featureMaxConcurrency} task${ - featureMaxConcurrency > 1 ? 's' : '' - } running at a time. Wait for a task to complete or increase the limit.`, - }); - return false; - } + // Note: No concurrency limit check here. Manual feature starts should never + // be blocked by the auto mode concurrency limit. The concurrency limit only + // governs how many features the auto-loop picks up automatically. // Check for blocking dependencies and show warning if enabled if (enableDependencyBlocking) { @@ -681,18 +650,7 @@ export function useBoardActions({ return false; } }, - [ - autoMode, - enableDependencyBlocking, - features, - updateFeature, - persistFeatureUpdate, - handleRunFeature, - currentProject, - getAutoModeState, - getMaxConcurrencyForWorktree, - isPrimaryWorktreeBranch, - ] + [enableDependencyBlocking, features, updateFeature, persistFeatureUpdate, handleRunFeature] ); const handleVerifyFeature = useCallback( diff --git a/apps/ui/src/components/views/board-view/hooks/use-board-drag-drop.ts b/apps/ui/src/components/views/board-view/hooks/use-board-drag-drop.ts index 7b9b5abc4..b313c7629 100644 --- a/apps/ui/src/components/views/board-view/hooks/use-board-drag-drop.ts +++ b/apps/ui/src/components/views/board-view/hooks/use-board-drag-drop.ts @@ -163,13 +163,22 @@ export function useBoardDragDrop({ let targetStatus: ColumnId | null = null; + // Normalize the over ID: strip 'column-header-' prefix if the card was dropped + // directly onto the column header droppable zone (e.g. 'column-header-backlog' → 'backlog') + const effectiveOverId = overId.startsWith('column-header-') + ? overId.replace('column-header-', '') + : overId; + // Check if we dropped on a column - const column = COLUMNS.find((c) => c.id === overId); + const column = COLUMNS.find((c) => c.id === effectiveOverId); if (column) { targetStatus = column.id; + } else if (effectiveOverId.startsWith('pipeline_')) { + // Pipeline step column (not in static COLUMNS list) + targetStatus = effectiveOverId as ColumnId; } else { // Dropped on another feature - find its column - const overFeature = features.find((f) => f.id === overId); + const overFeature = features.find((f) => f.id === effectiveOverId); if (overFeature) { targetStatus = overFeature.status; } diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/dev-server-logs-panel.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/dev-server-logs-panel.tsx index e22dee3de..fd8f70562 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/dev-server-logs-panel.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/dev-server-logs-panel.tsx @@ -136,7 +136,7 @@ export function DevServerLogsPanel({ compact > {/* Compact Header */} - +
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 a7c0c039d..04d5badd0 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 @@ -354,12 +354,19 @@ export function WorktreeActionsDropdown({ <> - Dev Server Running (:{devServerInfo?.port}) + {devServerInfo?.urlDetected === false + ? 'Dev Server Starting...' + : `Dev Server Running (:${devServerInfo?.port})`} onOpenDevServerUrl(worktree)} className="text-xs" - aria-label={`Open dev server on port ${devServerInfo?.port} in browser`} + disabled={devServerInfo?.urlDetected === false} + aria-label={ + devServerInfo?.urlDetected === false + ? 'Open dev server in browser' + : `Open dev server on port ${devServerInfo?.port} in browser` + } >
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 3792185bd..dae2885ee 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 @@ -25,6 +25,9 @@ import { getHttpApiClient } from '@/lib/http-api-client'; import type { Project } from '@/lib/electron'; import { ProjectFileSelectorDialog } from '@/components/dialogs/project-file-selector-dialog'; +// Stable empty array reference to prevent unnecessary re-renders when no copy files are set +const EMPTY_FILES: string[] = []; + interface WorktreePreferencesSectionProps { project: Project; } @@ -38,20 +41,30 @@ interface InitScriptResponse { } export function WorktreePreferencesSection({ project }: WorktreePreferencesSectionProps) { + // Use direct store subscriptions (not getter functions) so the component + // properly re-renders when these values change in the store. const globalUseWorktrees = useAppStore((s) => s.useWorktrees); - const getProjectUseWorktrees = useAppStore((s) => s.getProjectUseWorktrees); + const projectUseWorktrees = useAppStore((s) => s.useWorktreesByProject[project.path]); const setProjectUseWorktrees = useAppStore((s) => s.setProjectUseWorktrees); - const getShowInitScriptIndicator = useAppStore((s) => s.getShowInitScriptIndicator); + const showIndicator = useAppStore( + (s) => s.showInitScriptIndicatorByProject[project.path] ?? true + ); const setShowInitScriptIndicator = useAppStore((s) => s.setShowInitScriptIndicator); - const getDefaultDeleteBranch = useAppStore((s) => s.getDefaultDeleteBranch); + const defaultDeleteBranch = useAppStore( + (s) => s.defaultDeleteBranchByProject[project.path] ?? false + ); const setDefaultDeleteBranch = useAppStore((s) => s.setDefaultDeleteBranch); - const getAutoDismissInitScriptIndicator = useAppStore((s) => s.getAutoDismissInitScriptIndicator); + const autoDismiss = useAppStore( + (s) => s.autoDismissInitScriptIndicatorByProject[project.path] ?? true + ); const setAutoDismissInitScriptIndicator = useAppStore((s) => s.setAutoDismissInitScriptIndicator); - const copyFiles = useAppStore((s) => s.worktreeCopyFilesByProject[project.path] ?? []); + // Use a stable empty array reference to prevent new array on every render when + // worktreeCopyFilesByProject[project.path] is undefined (not yet loaded). + const copyFilesFromStore = useAppStore((s) => s.worktreeCopyFilesByProject[project.path]); + const copyFiles = copyFilesFromStore ?? EMPTY_FILES; const setWorktreeCopyFiles = useAppStore((s) => s.setWorktreeCopyFiles); // Get effective worktrees setting (project override or global fallback) - const projectUseWorktrees = getProjectUseWorktrees(project.path); const effectiveUseWorktrees = projectUseWorktrees ?? globalUseWorktrees; const [scriptContent, setScriptContent] = useState(''); @@ -65,11 +78,6 @@ export function WorktreePreferencesSection({ project }: WorktreePreferencesSecti const [newCopyFilePath, setNewCopyFilePath] = useState(''); const [fileSelectorOpen, setFileSelectorOpen] = useState(false); - // Get the current settings for this project - const showIndicator = getShowInitScriptIndicator(project.path); - const defaultDeleteBranch = getDefaultDeleteBranch(project.path); - const autoDismiss = getAutoDismissInitScriptIndicator(project.path); - // Check if there are unsaved changes const hasChanges = scriptContent !== originalContent; diff --git a/apps/ui/src/components/views/settings-view/model-defaults/model-defaults-section.tsx b/apps/ui/src/components/views/settings-view/model-defaults/model-defaults-section.tsx index 9652f0741..58c46a456 100644 --- a/apps/ui/src/components/views/settings-view/model-defaults/model-defaults-section.tsx +++ b/apps/ui/src/components/views/settings-view/model-defaults/model-defaults-section.tsx @@ -1,12 +1,16 @@ import { useState } from 'react'; -import { Workflow, RotateCcw, Replace, Sparkles } from 'lucide-react'; +import { Workflow, RotateCcw, Replace, Sparkles, Brain } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useAppStore } from '@/store/app-store'; import { Button } from '@/components/ui/button'; import { PhaseModelSelector } from './phase-model-selector'; import { BulkReplaceDialog } from './bulk-replace-dialog'; -import type { PhaseModelKey, PhaseModelEntry } from '@automaker/types'; -import { DEFAULT_PHASE_MODELS, DEFAULT_GLOBAL_SETTINGS } from '@automaker/types'; +import type { PhaseModelKey, PhaseModelEntry, ThinkingLevel } from '@automaker/types'; +import { + DEFAULT_PHASE_MODELS, + DEFAULT_GLOBAL_SETTINGS, + REASONING_EFFORT_LEVELS, +} from '@automaker/types'; interface PhaseConfig { key: PhaseModelKey; @@ -161,6 +165,121 @@ function FeatureDefaultModelSection() { ); } +// Thinking level options with descriptions for the settings UI +const THINKING_LEVEL_OPTIONS: { id: ThinkingLevel; label: string; description: string }[] = [ + { id: 'none', label: 'None', description: 'No extended thinking' }, + { id: 'low', label: 'Low', description: 'Light reasoning (1k tokens)' }, + { id: 'medium', label: 'Medium', description: 'Moderate reasoning (10k tokens)' }, + { id: 'high', label: 'High', description: 'Deep reasoning (16k tokens)' }, + { id: 'ultrathink', label: 'Ultra', description: 'Maximum reasoning (32k tokens)' }, + { id: 'adaptive', label: 'Adaptive', description: 'Model decides reasoning depth' }, +]; + +/** + * Default thinking level / reasoning effort section. + * These defaults are applied when selecting a model via the primary button + * in the two-stage model selector (i.e. clicking the model name directly). + */ +function DefaultThinkingLevelSection() { + const { + defaultThinkingLevel, + setDefaultThinkingLevel, + defaultReasoningEffort, + setDefaultReasoningEffort, + } = useAppStore(); + + return ( +
+
+

Quick-Select Defaults

+

+ Thinking/reasoning level applied when quick-selecting a model from the dropdown. You can + always fine-tune per model via the expand arrow. +

+
+
+ {/* Default Thinking Level (Claude models) */} +
+
+
+ +
+
+

Default Thinking Level

+

+ Applied to Claude models when quick-selected +

+
+
+
+ {THINKING_LEVEL_OPTIONS.map((option) => ( + + ))} +
+
+ + {/* Default Reasoning Effort (Codex models) */} +
+
+
+ +
+
+

Default Reasoning Effort

+

+ Applied to Codex/OpenAI models when quick-selected +

+
+
+
+ {REASONING_EFFORT_LEVELS.map((option) => ( + + ))} +
+
+
+
+ ); +} + export function ModelDefaultsSection() { const { resetPhaseModels, claudeCompatibleProviders } = useAppStore(); const [showBulkReplace, setShowBulkReplace] = useState(false); @@ -222,6 +341,9 @@ export function ModelDefaultsSection() { {/* Feature Defaults */} + {/* Default Thinking Level / Reasoning Effort */} + + {/* Quick Tasks */} m.id === selectedModel); - if (codexModel) return { ...codexModel, icon: OpenAIIcon }; + if (codexModel) { + const reasoningLabel = + selectedReasoningEffort !== 'none' + ? ` (${REASONING_EFFORT_LABELS[selectedReasoningEffort]} Reasoning)` + : ''; + return { + ...codexModel, + label: `${codexModel.label}${reasoningLabel}`, + icon: OpenAIIcon, + }; + } // Check Gemini models // Note: Gemini CLI doesn't support thinking level configuration @@ -492,6 +504,7 @@ export function PhaseModelSelector({ selectedModel, selectedProviderId, selectedThinkingLevel, + selectedReasoningEffort, availableCursorModels, availableGeminiModels, availableCopilotModels, @@ -809,14 +822,25 @@ export function PhaseModelSelector({ ); } - // Model supports reasoning - show popover with reasoning effort options + // Model supports reasoning - two-stage interaction pattern: + // Primary zone: selects model with default reasoning effort (medium) + // Secondary zone (chevron): expands reasoning effort sub-menu + // On mobile, render inline expansion instead of nested popover if (isMobile) { return (
setExpandedCodexModel(isExpanded ? null : (model.id as CodexModelId))} + onSelect={() => { + // Primary action: select model with default reasoning effort + onChange({ + model: model.id as CodexModelId, + reasoningEffort: storeDefaultReasoningEffort, + }); + setExpandedCodexModel(null); + setOpen(false); + }} className="group flex items-center justify-between py-2" >
@@ -856,12 +880,27 @@ export function PhaseModelSelector({ {isSelected && !isExpanded && } - { + e.stopPropagation(); + e.preventDefault(); + setExpandedCodexModel(isExpanded ? null : (model.id as CodexModelId)); + }} className={cn( - 'h-4 w-4 text-muted-foreground transition-transform', - isExpanded && 'rotate-90' + 'flex items-center justify-center h-7 w-7 rounded-sm', + 'hover:bg-accent/80 transition-colors', + isExpanded && 'bg-accent' )} - /> + title="Adjust reasoning effort" + > + +
@@ -871,164 +910,201 @@ export function PhaseModelSelector({
Reasoning Effort
- {REASONING_EFFORT_LEVELS.map((effort) => ( - - ))} + {REASONING_EFFORT_LEVELS.map((effort) => { + const isActiveEffort = isSelected && currentReasoning === effort; + return ( + + ); + })}
)}
); } - // Desktop: Use nested popover + // Desktop: Two-stage pattern with nested popover for reasoning effort return ( setExpandedCodexModel(isExpanded ? null : (model.id as CodexModelId))} + onSelect={() => { + // Primary action: select model with default reasoning effort + onChange({ + model: model.id as CodexModelId, + reasoningEffort: storeDefaultReasoningEffort, + }); + setExpandedCodexModel(null); + setOpen(false); + }} className="p-0 data-[selected=true]:bg-transparent" > - { - if (!isOpen) { - setExpandedCodexModel(null); - } - }} +
- -
+ -
- -
- - {model.label} - - - {isSelected && currentReasoning !== 'none' - ? `Reasoning: ${REASONING_EFFORT_LABELS[currentReasoning]}` - : model.description} - -
-
+ /> +
+ + {model.label} + + + {isSelected && currentReasoning !== 'none' + ? `Reasoning: ${REASONING_EFFORT_LABELS[currentReasoning]}` + : model.description} + +
+
-
- + {isSelected && } + {/* Secondary zone: expand reasoning effort popover */} + { + if (!isOpen) { + setExpandedCodexModel(null); + } + }} + > + + - {isSelected && } - -
-
- - e.preventDefault()} - > -
-
- Reasoning Effort -
- {REASONING_EFFORT_LEVELS.map((effort) => ( - - ))} -
-
-
+ + e.preventDefault()} + > +
+
+ Reasoning Effort +
+ {REASONING_EFFORT_LEVELS.map((effort) => { + const isActiveEffort = isSelected && currentReasoning === effort; + return ( + + ); + })} +
+
+ +
+
); }; @@ -1202,6 +1278,7 @@ export function PhaseModelSelector({ }; // Render ClaudeCompatibleProvider model item with thinking level support + // Two-stage pattern: primary selects model with default thinking, chevron expands thinking level const renderProviderModelItem = ( provider: ClaudeCompatibleProvider, model: ProviderModel, @@ -1215,6 +1292,13 @@ export function PhaseModelSelector({ const displayName = showProviderSuffix ? `${model.displayName} (${provider.name})` : model.displayName; + // Use the user's preferred default, clamped to available levels for this provider model + const providerAvailableLevels = getThinkingLevelsForModel( + model.mapsToClaudeModel === 'opus' ? 'claude-opus' : model.id || '' + ); + const defaultThinking = providerAvailableLevels.includes(storeDefaultThinkingLevel) + ? storeDefaultThinkingLevel + : providerAvailableLevels[0]; // Build description showing all mapped Claude models const modelLabelMap: Record = { @@ -1255,7 +1339,16 @@ export function PhaseModelSelector({
setExpandedProviderModel(isExpanded ? null : expandKey)} + onSelect={() => { + // Primary action: select provider model with default thinking level + onChange({ + providerId: provider.id, + model: model.id, + thinkingLevel: defaultThinking, + }); + setExpandedProviderModel(null); + setOpen(false); + }} className="group flex items-center justify-between py-2" >
@@ -1279,12 +1372,27 @@ export function PhaseModelSelector({
{isSelected && !isExpanded && } - { + e.stopPropagation(); + e.preventDefault(); + setExpandedProviderModel(isExpanded ? null : expandKey); + }} className={cn( - 'h-4 w-4 text-muted-foreground transition-transform', - isExpanded && 'rotate-90' + 'flex items-center justify-center h-7 w-7 rounded-sm', + 'hover:bg-accent/80 transition-colors', + isExpanded && 'bg-accent' )} - /> + title="Adjust thinking level" + > + +
@@ -1296,152 +1404,190 @@ export function PhaseModelSelector({
{getThinkingLevelsForModel( model.mapsToClaudeModel === 'opus' ? 'claude-opus' : model.id || '' - ).map((level) => ( - - ))} + ).map((level) => { + const isActiveLevel = isSelected && currentThinking === level; + return ( + + ); + })}
)}
); } - // Desktop: Use nested popover + // Desktop: Two-stage pattern with nested popover for thinking level return ( setExpandedProviderModel(isExpanded ? null : expandKey)} + onSelect={() => { + // Primary action: select provider model with default thinking level + onChange({ + providerId: provider.id, + model: model.id, + thinkingLevel: defaultThinking, + }); + setExpandedProviderModel(null); + setOpen(false); + }} className="p-0 data-[selected=true]:bg-transparent" > - { - if (!isOpen) { - setExpandedProviderModel(null); - } - }} +
- -
+ -
- -
- - {displayName} - - - {isSelected && currentThinking !== 'none' - ? `Thinking: ${THINKING_LEVEL_LABELS[currentThinking]}` - : `Maps to ${mappedModelLabel}`} - -
-
- -
- {isSelected && } - -
+ /> +
+ + {displayName} + + + {isSelected && currentThinking !== 'none' + ? `Thinking: ${THINKING_LEVEL_LABELS[currentThinking]}` + : `Maps to ${mappedModelLabel}`} +
- - e.preventDefault()} - > -
-
- Thinking Level -
- {getThinkingLevelsForModel( - model.mapsToClaudeModel === 'opus' ? 'claude-opus' : model.id || '' - ).map((level) => ( +
+ +
+ {isSelected && } + {/* Secondary zone: expand thinking level popover */} + { + if (!isOpen) { + setExpandedProviderModel(null); + } + }} + > + - ))} -
-
- + + e.preventDefault()} + > +
+
+ Thinking Level +
+ {getThinkingLevelsForModel( + model.mapsToClaudeModel === 'opus' ? 'claude-opus' : model.id || '' + ).map((level) => { + const isActiveLevel = isSelected && currentThinking === level; + return ( + + ); + })} +
+
+ +
+
); }; @@ -1506,6 +1652,16 @@ export function PhaseModelSelector({ const isFavorite = favoriteModels.includes(model.id); const isExpanded = expandedClaudeModel === model.id; const currentThinking = isSelected ? selectedThinkingLevel : 'none'; + // Use the user's preferred default thinking level from settings. + // For adaptive-only models (Opus 4.6), clamp to a valid level. + const availableLevels = getThinkingLevelsForModel(model.id); + const defaultThinking = availableLevels.includes(storeDefaultThinkingLevel) + ? storeDefaultThinkingLevel + : availableLevels[0]; // Fall back to first available (typically 'none') + + // Two-stage interaction pattern: + // - Primary zone (model icon + name): selects model with default thinking level + // - Secondary zone (chevron arrow): expands thinking level sub-menu // On mobile, render inline expansion instead of nested popover if (isMobile) { @@ -1513,7 +1669,15 @@ export function PhaseModelSelector({
setExpandedClaudeModel(isExpanded ? null : (model.id as ModelAlias))} + onSelect={() => { + // Primary action: select model with its default thinking level + onChange({ + model: model.id as ModelAlias, + thinkingLevel: defaultThinking, + }); + setExpandedClaudeModel(null); + setOpen(false); + }} className="group flex items-center justify-between py-2" >
@@ -1553,12 +1717,27 @@ export function PhaseModelSelector({ {isSelected && !isExpanded && } - { + e.stopPropagation(); + e.preventDefault(); + setExpandedClaudeModel(isExpanded ? null : (model.id as ModelAlias)); + }} className={cn( - 'h-4 w-4 text-muted-foreground transition-transform', - isExpanded && 'rotate-90' + 'flex items-center justify-center h-7 w-7 rounded-sm', + 'hover:bg-accent/80 transition-colors', + isExpanded && 'bg-accent' )} - /> + title="Adjust thinking level" + > + +
@@ -1568,164 +1747,203 @@ export function PhaseModelSelector({
Thinking Level
- {getThinkingLevelsForModel(model.id).map((level) => ( - - ))} + {getThinkingLevelsForModel(model.id).map((level) => { + const isActiveLevel = isSelected && currentThinking === level; + return ( + + ); + })}
)}
); } - // Desktop: Use nested popover + // Desktop: Two-stage pattern with nested popover for thinking level + // Primary zone: clicking model name/icon area selects the model with default thinking + // Secondary zone: clicking chevron arrow opens thinking level popover return ( setExpandedClaudeModel(isExpanded ? null : (model.id as ModelAlias))} + onSelect={() => { + // Primary action: select model with default thinking level + onChange({ + model: model.id as ModelAlias, + thinkingLevel: defaultThinking, + }); + setExpandedClaudeModel(null); + setOpen(false); + }} className="p-0 data-[selected=true]:bg-transparent" > - { - if (!isOpen) { - setExpandedClaudeModel(null); - } - }} +
- -
+ -
- -
- - {model.label} - - - {isSelected && currentThinking !== 'none' - ? `Thinking: ${THINKING_LEVEL_LABELS[currentThinking]}` - : model.description} - -
-
+ /> +
+ + {model.label} + + + {isSelected && currentThinking !== 'none' + ? `Thinking: ${THINKING_LEVEL_LABELS[currentThinking]}` + : model.description} + +
+
-
- + {isSelected && } + {/* Secondary zone: expand thinking level popover */} + { + if (!isOpen) { + setExpandedClaudeModel(null); + } + }} + > + + - {isSelected && } - -
-
- - e.preventDefault()} - > -
-
- Thinking Level -
- {getThinkingLevelsForModel(model.id).map((level) => ( - - ))} -
-
-
+ + e.preventDefault()} + > +
+
+ Thinking Level +
+ {getThinkingLevelsForModel(model.id).map((level) => { + const isActiveLevel = isSelected && currentThinking === level; + return ( + + ); + })} +
+
+ +
+
); }; @@ -1928,6 +2146,31 @@ export function PhaseModelSelector({ ); }; + // Compute a short badge label for the active thinking/reasoning level + const activeLevelBadge = useMemo(() => { + if (selectedThinkingLevel !== 'none') { + return { label: THINKING_LEVEL_LABELS[selectedThinkingLevel], type: 'thinking' as const }; + } + if (selectedReasoningEffort !== 'none') { + return { + label: REASONING_EFFORT_LABELS[selectedReasoningEffort], + type: 'reasoning' as const, + }; + } + return null; + }, [selectedThinkingLevel, selectedReasoningEffort]); + + // Strip the thinking/reasoning parenthetical from the model name for the trigger + // since we show it as a separate badge + const triggerModelName = useMemo(() => { + const label = currentModel?.label || 'Select model...'; + // Remove " (Med Thinking)", " (High Reasoning)" etc. from label since badge shows it + return label.replace( + /\s*\((?:None|Low|Med|Medium|High|Ultra|Adaptive|XHigh|Min)\s+(?:Thinking|Reasoning)\)$/i, + '' + ); + }, [currentModel?.label]); + // Compact trigger button (for agent view etc.) const compactTrigger = ( ); @@ -1957,13 +2210,25 @@ export function PhaseModelSelector({ aria-expanded={open} disabled={disabled} className={cn( - 'w-[260px] justify-between h-9 px-3 bg-background/50 border-border/50 hover:bg-background/80 hover:text-foreground', + 'w-[280px] justify-between h-9 px-3 bg-background/50 border-border/50 hover:bg-background/80 hover:text-foreground', triggerClassName )} >
{currentModel?.icon && } - {currentModel?.label || 'Select model...'} + {triggerModelName} + {activeLevelBadge && ( + + {activeLevelBadge.label} + + )}
diff --git a/apps/ui/src/components/views/terminal-view.tsx b/apps/ui/src/components/views/terminal-view.tsx index fe5c908f8..e350a2545 100644 --- a/apps/ui/src/components/views/terminal-view.tsx +++ b/apps/ui/src/components/views/terminal-view.tsx @@ -13,6 +13,9 @@ import { X, SquarePlus, Settings, + GitBranch, + ChevronDown, + FolderGit, } from 'lucide-react'; import { Spinner } from '@/components/ui/spinner'; import { getServerUrlSync } from '@/lib/http-api-client'; @@ -28,6 +31,17 @@ import { Label } from '@/components/ui/label'; import { Slider } from '@/components/ui/slider'; import { Switch } from '@/components/ui/switch'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; import { Select, SelectContent, @@ -255,6 +269,8 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }: setTerminalScrollbackLines, setTerminalScreenReaderMode, updateTerminalPanelSizes, + currentWorktreeByProject, + worktreesByProject, } = useAppStore(); const navigate = useNavigate(); @@ -946,13 +962,50 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }: } }; + // Helper: find the branchName of the given session ID within a layout tree + const findSessionBranchName = ( + layout: TerminalPanelContent | null, + sessionId: string + ): string | undefined => { + if (!layout) return undefined; + if (layout.type === 'terminal') { + return layout.sessionId === sessionId ? layout.branchName : undefined; + } + if (layout.type === 'split') { + for (const panel of layout.panels) { + const found = findSessionBranchName(panel, sessionId); + if (found !== undefined) return found; + } + } + return undefined; + }; + + // Helper: resolve the worktree cwd and branchName for the currently active terminal session. + // Returns { cwd, branchName } if the active terminal was opened in a worktree, or {} otherwise. + const getActiveSessionWorktreeInfo = (): { cwd?: string; branchName?: string } => { + const activeSessionId = terminalState.activeSessionId; + if (!activeSessionId || !activeTab?.layout || !currentProject) return {}; + + const branchName = findSessionBranchName(activeTab.layout, activeSessionId); + if (!branchName) return {}; + + // Look up the worktree path for this branch in the project's worktree list + const projectWorktrees = worktreesByProject[currentProject.path] ?? []; + const worktree = projectWorktrees.find((wt) => wt.branch === branchName); + if (!worktree) return { branchName }; + + return { cwd: worktree.path, branchName }; + }; + // Create a new terminal session // targetSessionId: the terminal to split (if splitting an existing terminal) // customCwd: optional working directory to use instead of the current project path + // branchName: optional branch name to display in the terminal panel header const createTerminal = async ( direction?: 'horizontal' | 'vertical', targetSessionId?: string, - customCwd?: string + customCwd?: string, + branchName?: string ) => { if (!canCreateTerminal('[Terminal] Debounced terminal creation')) { return; @@ -971,7 +1024,7 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }: const data = await response.json(); if (data.success) { - addTerminalToLayout(data.data.id, direction, targetSessionId); + addTerminalToLayout(data.data.id, direction, targetSessionId, branchName); // Mark this session as new for running initial command if (defaultRunScript) { setNewSessionIds((prev) => new Set(prev).add(data.data.id)); @@ -1004,11 +1057,18 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }: }; // Create terminal in new tab - const createTerminalInNewTab = async () => { + // customCwd: optional working directory (e.g., a specific worktree path) + // branchName: optional branch name to display in the terminal panel header + const createTerminalInNewTab = async (customCwd?: string, branchName?: string) => { if (!canCreateTerminal('[Terminal] Debounced terminal tab creation')) { return; } + // Use provided cwd/branch, or inherit from active session's worktree + const { cwd: worktreeCwd, branchName: worktreeBranch } = customCwd + ? { cwd: customCwd, branchName: branchName } + : getActiveSessionWorktreeInfo(); + const tabId = addTerminalTab(); try { const headers: Record = {}; @@ -1018,14 +1078,14 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }: const response = await apiFetch('/api/terminal/sessions', 'POST', { headers, - body: { cwd: currentProject?.path || undefined, cols: 80, rows: 24 }, + body: { cwd: worktreeCwd || currentProject?.path || undefined, cols: 80, rows: 24 }, }); const data = await response.json(); if (data.success) { - // Add to the newly created tab + // Add to the newly created tab (passing branchName so the panel header shows the branch badge) const { addTerminalToTab } = useAppStore.getState(); - addTerminalToTab(data.data.id, tabId); + addTerminalToTab(data.data.id, tabId, undefined, worktreeBranch); // Mark this session as new for running initial command if (defaultRunScript) { setNewSessionIds((prev) => new Set(prev).add(data.data.id)); @@ -1344,8 +1404,14 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }: isActive={terminalState.activeSessionId === content.sessionId} onFocus={() => setActiveTerminalSession(content.sessionId)} onClose={() => killTerminal(content.sessionId)} - onSplitHorizontal={() => createTerminal('horizontal', content.sessionId)} - onSplitVertical={() => createTerminal('vertical', content.sessionId)} + onSplitHorizontal={() => { + const { cwd, branchName } = getActiveSessionWorktreeInfo(); + createTerminal('horizontal', content.sessionId, cwd, branchName); + }} + onSplitVertical={() => { + const { cwd, branchName } = getActiveSessionWorktreeInfo(); + createTerminal('vertical', content.sessionId, cwd, branchName); + }} onNewTab={createTerminalInNewTab} onNavigateUp={() => navigateToTerminal('up')} onNavigateDown={() => navigateToTerminal('down')} @@ -1502,6 +1568,15 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }: // No terminals yet - show welcome screen if (terminalState.tabs.length === 0) { + // Get the current worktree for this project (if any) + const currentWorktreeInfo = currentProject + ? (currentWorktreeByProject[currentProject.path] ?? null) + : null; + // Only show worktree button when the current worktree has a specific path set + // (non-null path means a worktree is selected, as opposed to the main project) + const currentWorktreePath = currentWorktreeInfo?.path ?? null; + const currentWorktreeBranch = currentWorktreeInfo?.branch ?? null; + return (
@@ -1518,10 +1593,40 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }: )}

- +
+ {currentWorktreePath && ( + + )} + + +
{status?.platform && (

@@ -1564,14 +1669,94 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }: {(activeDragId || activeDragTabId) && } - {/* New tab button */} - + {/* New tab split button */} +

+ + + + + + + createTerminalInNewTab()} className="gap-2"> + + New Tab + + + { + const { cwd, branchName } = getActiveSessionWorktreeInfo(); + createTerminal('horizontal', undefined, cwd, branchName); + }} + className="gap-2" + > + + Split Right + + { + const { cwd, branchName } = getActiveSessionWorktreeInfo(); + createTerminal('vertical', undefined, cwd, branchName); + }} + className="gap-2" + > + + Split Down + + {/* Worktree options - show when project has worktrees */} + {(() => { + const projectWorktrees = currentProject + ? (worktreesByProject[currentProject.path] ?? []) + : []; + if (projectWorktrees.length === 0) return null; + const mainWorktree = projectWorktrees.find((wt) => wt.isMain); + const featureWorktrees = projectWorktrees.filter((wt) => !wt.isMain); + return ( + <> + + + Open in Worktree + + {mainWorktree && ( + + createTerminalInNewTab(mainWorktree.path, mainWorktree.branch) + } + className="gap-2" + > + + {mainWorktree.branch} + + main + + + )} + {featureWorktrees.map((wt) => ( + createTerminalInNewTab(wt.path, wt.branch)} + className="gap-2" + > + + {wt.branch} + + ))} + + ); + })()} + + +
{/* Toolbar buttons */} @@ -1580,7 +1765,10 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }: variant="ghost" size="sm" className="h-7 px-2 text-muted-foreground hover:text-foreground" - onClick={() => createTerminal('horizontal')} + onClick={() => { + const { cwd, branchName } = getActiveSessionWorktreeInfo(); + createTerminal('horizontal', undefined, cwd, branchName); + }} title="Split Right" > @@ -1589,7 +1777,10 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }: variant="ghost" size="sm" className="h-7 px-2 text-muted-foreground hover:text-foreground" - onClick={() => createTerminal('vertical')} + onClick={() => { + const { cwd, branchName } = getActiveSessionWorktreeInfo(); + createTerminal('vertical', undefined, cwd, branchName); + }} title="Split Down" > @@ -1771,12 +1962,14 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }: isActive={true} onFocus={() => setActiveTerminalSession(terminalState.maximizedSessionId!)} onClose={() => killTerminal(terminalState.maximizedSessionId!)} - onSplitHorizontal={() => - createTerminal('horizontal', terminalState.maximizedSessionId!) - } - onSplitVertical={() => - createTerminal('vertical', terminalState.maximizedSessionId!) - } + onSplitHorizontal={() => { + const { cwd, branchName } = getActiveSessionWorktreeInfo(); + createTerminal('horizontal', terminalState.maximizedSessionId!, cwd, branchName); + }} + onSplitVertical={() => { + const { cwd, branchName } = getActiveSessionWorktreeInfo(); + createTerminal('vertical', terminalState.maximizedSessionId!, cwd, branchName); + }} onNewTab={createTerminalInNewTab} onSessionInvalid={() => { const sessionId = terminalState.maximizedSessionId!; 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 13ee9b9d2..206decae1 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 @@ -1,6 +1,18 @@ import { useCallback, useRef, useEffect, useState } from 'react'; -import { ArrowUp, ArrowDown, ArrowLeft, ArrowRight, ChevronUp, ChevronDown } from 'lucide-react'; +import { + ArrowUp, + ArrowDown, + ArrowLeft, + ArrowRight, + ChevronUp, + ChevronDown, + Copy, + ClipboardPaste, + CheckSquare, + TextSelect, +} from 'lucide-react'; import { cn } from '@/lib/utils'; +import { StickyModifierKeys, type StickyModifier } from './sticky-modifier-keys'; /** * ANSI escape sequences for special keys. @@ -37,6 +49,20 @@ interface MobileTerminalShortcutsProps { onSendInput: (data: string) => void; /** Whether the terminal is connected and ready */ isConnected: boolean; + /** Currently active sticky modifier (Ctrl or Alt) */ + activeModifier: StickyModifier; + /** Callback when sticky modifier is toggled */ + onModifierChange: (modifier: StickyModifier) => void; + /** Callback to copy selected text to clipboard */ + onCopy?: () => void; + /** Callback to paste from clipboard into terminal */ + onPaste?: () => void; + /** Callback to select all terminal content */ + onSelectAll?: () => void; + /** Callback to toggle text selection mode (renders selectable text overlay) */ + onToggleSelectMode?: () => void; + /** Whether text selection mode is currently active */ + isSelectMode?: boolean; } /** @@ -50,6 +76,13 @@ interface MobileTerminalShortcutsProps { export function MobileTerminalShortcuts({ onSendInput, isConnected, + activeModifier, + onModifierChange, + onCopy, + onPaste, + onSelectAll, + onToggleSelectMode, + isSelectMode, }: MobileTerminalShortcutsProps) { const [isCollapsed, setIsCollapsed] = useState(false); @@ -135,6 +168,54 @@ export function MobileTerminalShortcuts({ {/* Separator */}
+ {/* Sticky modifier keys (Ctrl, Alt) - at the beginning of the bar */} + + + {/* Separator */} +
+ + {/* Clipboard actions */} + {onToggleSelectMode && ( + + )} + {onSelectAll && ( + + )} + {onCopy && ( + + )} + {onPaste && ( + + )} + + {/* Separator */} +
+ {/* Special keys */} ); } + +/** + * Icon-based shortcut button for clipboard actions. + * Uses a Lucide icon instead of text label for a cleaner mobile UI. + */ +function IconShortcutButton({ + icon: Icon, + title, + onPress, + disabled = false, + active = false, +}: { + icon: React.ComponentType<{ className?: string }>; + title: string; + onPress: () => void; + disabled?: boolean; + active?: boolean; +}) { + return ( + + ); +} diff --git a/apps/ui/src/components/views/terminal-view/terminal-panel.tsx b/apps/ui/src/components/views/terminal-view/terminal-panel.tsx index cea973804..76f0d6254 100644 --- a/apps/ui/src/components/views/terminal-view/terminal-panel.tsx +++ b/apps/ui/src/components/views/terminal-view/terminal-panel.tsx @@ -51,14 +51,11 @@ import { DEFAULT_FONT_VALUE } from '@/config/ui-font-options'; import { toast } from 'sonner'; import { getElectronAPI } from '@/lib/electron'; import { getApiKey, getSessionToken, getServerUrlSync } from '@/lib/http-api-client'; +import { writeToClipboard, readFromClipboard } from '@/lib/clipboard-utils'; import { useIsMobile } from '@/hooks/use-media-query'; import { useVirtualKeyboardResize } from '@/hooks/use-virtual-keyboard-resize'; import { MobileTerminalShortcuts } from './mobile-terminal-shortcuts'; -import { - StickyModifierKeys, - applyStickyModifier, - type StickyModifier, -} from './sticky-modifier-keys'; +import { applyStickyModifier, type StickyModifier } from './sticky-modifier-keys'; import { TerminalScriptsDropdown } from './terminal-scripts-dropdown'; const logger = createLogger('Terminal'); @@ -81,6 +78,9 @@ const LARGE_PASTE_WARNING_THRESHOLD = 1024 * 1024; // 1MB - show warning for pas const PASTE_CHUNK_SIZE = 8 * 1024; // 8KB chunks for large pastes const PASTE_CHUNK_DELAY_MS = 10; // Small delay between chunks to prevent overwhelming WebSocket +// Mobile overlay buffer cap - limit lines read from terminal buffer to avoid DOM blow-up on mobile +const MAX_OVERLAY_LINES = 1000; // Maximum number of lines to read for the mobile select-mode overlay + interface TerminalPanelProps { sessionId: string; authToken: string | null; @@ -157,6 +157,9 @@ export function TerminalPanel({ const [isImageDragOver, setIsImageDragOver] = useState(false); const [isProcessingImage, setIsProcessingImage] = useState(false); const hasRunInitialCommandRef = useRef(false); + // Long-press timer for mobile context menu + const longPressTimerRef = useRef(null); + const longPressTouchStartRef = useRef<{ x: number; y: number } | null>(null); // Tracks whether the connected shell is a Windows shell (PowerShell, cmd, etc.). // Maintained as a ref (not state) so sendCommand can read the current value without // causing unnecessary re-renders or stale closure issues. Set inside ws.onmessage @@ -169,6 +172,10 @@ export function TerminalPanel({ const showSearchRef = useRef(false); const [isAtBottom, setIsAtBottom] = useState(true); + // Mobile text selection mode - renders terminal buffer as selectable DOM text + const [isSelectMode, setIsSelectMode] = useState(false); + const [selectModeText, setSelectModeText] = useState(''); + // Sticky modifier key state (Ctrl or Alt) for the terminal toolbar const [stickyModifier, setStickyModifier] = useState(null); const stickyModifierRef = useRef(null); @@ -330,9 +337,16 @@ export function TerminalPanel({ try { // Strip any ANSI escape codes that might be in the selection const cleanText = stripAnsi(selection); - await navigator.clipboard.writeText(cleanText); - toast.success('Copied to clipboard'); - return true; + const success = await writeToClipboard(cleanText); + if (success) { + toast.success('Copied to clipboard'); + return true; + } else { + toast.error('Copy failed', { + description: 'Could not access clipboard', + }); + return false; + } } catch (err) { logger.error('Copy failed:', err); const errorMessage = err instanceof Error ? err.message : 'Unknown error'; @@ -399,7 +413,7 @@ export function TerminalPanel({ if (!terminal || !wsRef.current) return; try { - const text = await navigator.clipboard.readText(); + const text = await readFromClipboard(); if (!text) { toast.error('Nothing to paste', { description: 'Clipboard is empty', @@ -428,7 +442,9 @@ export function TerminalPanel({ toast.error('Paste failed', { description: errorMessage.includes('permission') ? 'Clipboard permission denied' - : 'Could not read from clipboard', + : errorMessage.includes('not supported') + ? errorMessage + : 'Could not read from clipboard', }); } }, [sendTextInChunks]); @@ -439,6 +455,45 @@ export function TerminalPanel({ xtermRef.current?.selectAll(); }, []); + // Extract terminal buffer text for mobile selection mode overlay + const getTerminalBufferText = useCallback((): string => { + const terminal = xtermRef.current; + if (!terminal) return ''; + + const buffer = terminal.buffer.active; + const lines: string[] = []; + + // Cap the number of lines read to MAX_OVERLAY_LINES to avoid blowing up the DOM on mobile + const startIndex = Math.max(0, buffer.length - MAX_OVERLAY_LINES); + for (let i = startIndex; i < buffer.length; i++) { + const line = buffer.getLine(i); + if (line) { + lines.push(line.translateToString(true)); + } + } + + // Trim trailing empty lines but keep internal structure + while (lines.length > 0 && lines[lines.length - 1].trim() === '') { + lines.pop(); + } + + return lines.join('\n'); + }, []); + + // Toggle mobile text selection mode + const toggleSelectMode = useCallback(() => { + if (isSelectMode) { + setIsSelectMode(false); + setSelectModeText(''); + } else { + const text = getTerminalBufferText(); + // Strip ANSI escape codes for clean display + const cleanText = stripAnsi(text); + setSelectModeText(cleanText); + setIsSelectMode(true); + } + }, [isSelectMode, getTerminalBufferText]); + // Clear terminal const clearTerminal = useCallback(() => { xtermRef.current?.clear(); @@ -944,17 +999,17 @@ export function TerminalPanel({ const otherModKey = isMacRef.current ? event.ctrlKey : event.metaKey; // Ctrl+Shift+C / Cmd+Shift+C - Always copy (Linux terminal convention) + // Don't preventDefault() — allow the native browser copy to work alongside our custom copy if (modKey && !otherModKey && event.shiftKey && !event.altKey && code === 'KeyC') { - event.preventDefault(); copySelectionRef.current(); return false; } // Ctrl+C / Cmd+C - Copy if text is selected, otherwise send SIGINT + // Don't preventDefault() when copying — allow the native browser copy to work alongside our custom copy if (modKey && !otherModKey && !event.shiftKey && !event.altKey && code === 'KeyC') { const hasSelection = terminal.hasSelection(); if (hasSelection) { - event.preventDefault(); copySelectionRef.current(); terminal.clearSelection(); return false; @@ -964,9 +1019,11 @@ export function TerminalPanel({ } // Ctrl+V / Cmd+V or Ctrl+Shift+V / Cmd+Shift+V - Paste + // Don't preventDefault() — allow the native browser paste to work. + // Return false to prevent xterm from sending \x16 (literal next), + // but the browser's native paste event will still fire and xterm will + // receive the pasted text through its onData handler. if (modKey && !otherModKey && !event.altKey && code === 'KeyV') { - event.preventDefault(); - pasteFromClipboardRef.current(); return false; } @@ -1014,6 +1071,12 @@ export function TerminalPanel({ resizeDebounceRef.current = null; } + // Clear long-press timer + if (longPressTimerRef.current) { + clearTimeout(longPressTimerRef.current); + longPressTimerRef.current = null; + } + // Clear search decorations before disposing to prevent visual artifacts if (searchAddonRef.current) { searchAddonRef.current.clearDecorations(); @@ -1571,6 +1634,17 @@ export function TerminalPanel({ buttons[focusedMenuIndex]?.focus(); }, [focusedMenuIndex, contextMenu]); + // Reset select mode when viewport transitions from mobile to non-mobile. + // The select-mode overlay is only rendered when (isSelectMode && isMobile), so if the + // viewport becomes non-mobile while isSelectMode is true the overlay disappears but the + // state is left dirty with no UI to clear it. Resetting here keeps state consistent. + useEffect(() => { + if (!isMobile && isSelectMode) { + setIsSelectMode(false); + setSelectModeText(''); + } + }, [isMobile]); // eslint-disable-line react-hooks/exhaustive-deps + // Handle right-click context menu with boundary checking const handleContextMenu = useCallback((e: React.MouseEvent) => { e.preventDefault(); @@ -1602,6 +1676,77 @@ export function TerminalPanel({ setContextMenu({ x, y }); }, []); + // Long-press handlers for mobile context menu + // On mobile, there's no right-click, so we trigger the context menu on long-press (500ms hold) + const LONG_PRESS_DURATION = 500; // ms + const LONG_PRESS_MOVE_THRESHOLD = 10; // px - cancel if finger moves more than this + + const handleTouchStart = useCallback( + (e: React.TouchEvent) => { + if (!isMobile) return; + const touch = e.touches[0]; + if (!touch) return; + + // Clear any existing timer before creating a new one to avoid orphaned timeouts + if (longPressTimerRef.current) { + clearTimeout(longPressTimerRef.current); + longPressTimerRef.current = null; + } + + // Capture initial touch coordinates into an immutable local snapshot + const startPos = { x: touch.clientX, y: touch.clientY }; + longPressTouchStartRef.current = startPos; + + longPressTimerRef.current = setTimeout(() => { + // Use the locally captured startPos rather than re-reading the ref + // Menu dimensions (approximate) + const menuWidth = 160; + const menuHeight = 152; + const padding = 8; + + let x = startPos.x; + let y = startPos.y; + + // Boundary checks + if (x + menuWidth + padding > window.innerWidth) { + x = window.innerWidth - menuWidth - padding; + } + if (y + menuHeight + padding > window.innerHeight) { + y = window.innerHeight - menuHeight - padding; + } + x = Math.max(padding, x); + y = Math.max(padding, y); + + setContextMenu({ x, y }); + longPressTouchStartRef.current = null; + }, LONG_PRESS_DURATION); + }, + [isMobile] + ); + + const handleTouchMove = useCallback((e: React.TouchEvent) => { + if (!longPressTimerRef.current || !longPressTouchStartRef.current) return; + const touch = e.touches[0]; + if (!touch) return; + + const dx = touch.clientX - longPressTouchStartRef.current.x; + const dy = touch.clientY - longPressTouchStartRef.current.y; + if (Math.sqrt(dx * dx + dy * dy) > LONG_PRESS_MOVE_THRESHOLD) { + // Finger moved too far, cancel long-press + clearTimeout(longPressTimerRef.current); + longPressTimerRef.current = null; + longPressTouchStartRef.current = null; + } + }, []); + + const handleTouchEnd = useCallback(() => { + if (longPressTimerRef.current) { + clearTimeout(longPressTimerRef.current); + longPressTimerRef.current = null; + } + longPressTouchStartRef.current = null; + }, []); + // Convert file to base64 const fileToBase64 = useCallback((file: File): Promise => { return new Promise((resolve, reject) => { @@ -2092,15 +2237,6 @@ export function TerminalPanel({
- {/* Sticky modifier keys (Ctrl, Alt) */} - - -
- {/* Split/close buttons */} + +
+
+ {/* Scrollable text content matching terminal appearance */} +
+ {selectModeText || 'No terminal content to select.'} +
+
+ )} +
{/* Jump to bottom button - shown when scrolled up */} {!isAtBottom && ( diff --git a/apps/ui/src/hooks/queries/use-git.ts b/apps/ui/src/hooks/queries/use-git.ts index 43bfc02fb..666eeab6f 100644 --- a/apps/ui/src/hooks/queries/use-git.ts +++ b/apps/ui/src/hooks/queries/use-git.ts @@ -32,6 +32,7 @@ export function useGitDiffs(projectPath: string | undefined, enabled = true) { return { files: result.files ?? [], diff: result.diff ?? '', + ...(result.mergeState ? { mergeState: result.mergeState } : {}), }; }, enabled: !!projectPath && enabled, diff --git a/apps/ui/src/hooks/queries/use-worktrees.ts b/apps/ui/src/hooks/queries/use-worktrees.ts index bc893cafd..15b697d2c 100644 --- a/apps/ui/src/hooks/queries/use-worktrees.ts +++ b/apps/ui/src/hooks/queries/use-worktrees.ts @@ -160,6 +160,7 @@ export function useWorktreeDiffs(projectPath: string | undefined, featureId: str return { files: result.files ?? [], diff: result.diff ?? '', + ...(result.mergeState ? { mergeState: result.mergeState } : {}), }; }, enabled: !!projectPath && !!featureId, diff --git a/apps/ui/src/hooks/use-auto-mode.ts b/apps/ui/src/hooks/use-auto-mode.ts index 04bd218ed..add6de0df 100644 --- a/apps/ui/src/hooks/use-auto-mode.ts +++ b/apps/ui/src/hooks/use-auto-mode.ts @@ -157,8 +157,40 @@ export function useAutoMode(worktree?: WorktreeInfo) { // Check if we can start a new task based on concurrency limit const canStartNewTask = runningAutoTasks.length < maxConcurrency; - // Ref to prevent refreshStatus from overwriting optimistic state during start/stop + // Ref to prevent refreshStatus and WebSocket handlers from overwriting optimistic state + // during start/stop transitions. const isTransitioningRef = useRef(false); + // Tracks specifically a restart-for-concurrency transition. When true, the + // auto_mode_started WebSocket handler will clear isTransitioningRef, ensuring + // delayed auto_mode_stopped events that arrive after the HTTP calls complete + // (but before the WebSocket events) are still suppressed. + const isRestartTransitionRef = useRef(false); + // Safety timeout ID to clear the transition flag if the auto_mode_started event never arrives + const restartSafetyTimeoutRef = useRef | null>(null); + + // Use refs for mutable state in refreshStatus to avoid unstable callback identity. + // This prevents the useEffect that calls refreshStatus on mount from re-firing + // every time isAutoModeRunning or runningAutoTasks changes, which was a source of + // flickering as refreshStatus would race with WebSocket events and optimistic updates. + const isAutoModeRunningRef = useRef(isAutoModeRunning); + const runningAutoTasksRef = useRef(runningAutoTasks); + useEffect(() => { + isAutoModeRunningRef.current = isAutoModeRunning; + }, [isAutoModeRunning]); + useEffect(() => { + runningAutoTasksRef.current = runningAutoTasks; + }, [runningAutoTasks]); + + // Clean up safety timeout on unmount to prevent timer leaks and misleading log warnings + useEffect(() => { + return () => { + if (restartSafetyTimeoutRef.current) { + clearTimeout(restartSafetyTimeoutRef.current); + restartSafetyTimeoutRef.current = null; + } + isRestartTransitionRef.current = false; + }; + }, []); const refreshStatus = useCallback(async () => { if (!currentProject) return; @@ -175,20 +207,25 @@ export function useAutoMode(worktree?: WorktreeInfo) { if (result.success && result.isAutoLoopRunning !== undefined) { const backendIsRunning = result.isAutoLoopRunning; const backendRunningFeatures = result.runningFeatures ?? []; + // Read latest state from refs to avoid stale closure values + const currentIsRunning = isAutoModeRunningRef.current; + const currentRunningTasks = runningAutoTasksRef.current; const needsSync = - backendIsRunning !== isAutoModeRunning || + backendIsRunning !== currentIsRunning || // Also sync when backend has runningFeatures we're missing (handles missed WebSocket events) (backendIsRunning && Array.isArray(backendRunningFeatures) && backendRunningFeatures.length > 0 && - !arraysEqual(backendRunningFeatures, runningAutoTasks)) || + !arraysEqual(backendRunningFeatures, currentRunningTasks)) || // Also sync when UI has stale running tasks but backend has none // (handles server restart where features were reconciled to backlog/ready) - (!backendIsRunning && runningAutoTasks.length > 0 && backendRunningFeatures.length === 0); + (!backendIsRunning && + currentRunningTasks.length > 0 && + backendRunningFeatures.length === 0); if (needsSync) { const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree'; - if (backendIsRunning !== isAutoModeRunning) { + if (backendIsRunning !== currentIsRunning) { logger.info( `[AutoMode] Syncing UI state with backend for ${worktreeDesc} in ${currentProject.path}: ${backendIsRunning ? 'ON' : 'OFF'}` ); @@ -206,7 +243,7 @@ export function useAutoMode(worktree?: WorktreeInfo) { } catch (error) { logger.error('Error syncing auto mode state with backend:', error); } - }, [branchName, currentProject, isAutoModeRunning, runningAutoTasks, setAutoModeRunning]); + }, [branchName, currentProject, setAutoModeRunning]); // On mount, query backend for current auto loop status and sync UI state. // This handles cases where the backend is still running after a page refresh. @@ -281,8 +318,23 @@ export function useAutoMode(worktree?: WorktreeInfo) { 'maxConcurrency' in event && typeof event.maxConcurrency === 'number' ? event.maxConcurrency : getMaxConcurrencyForWorktree(eventProjectId, eventBranchName); + // Always apply start events even during transitions - this confirms the optimistic state setAutoModeRunning(eventProjectId, eventBranchName, true, eventMaxConcurrency); } + // If we were in a restart transition (concurrency change), the arrival of + // auto_mode_started confirms the restart is complete. Clear the transition + // flags so future auto_mode_stopped events are processed normally. + // Only clear transition refs when the event is for this hook's worktree, + // to avoid events for worktree B incorrectly affecting worktree A's state. + if (isRestartTransitionRef.current && eventBranchName === branchName) { + logger.debug(`[AutoMode] Restart transition complete for ${worktreeDesc}`); + isTransitioningRef.current = false; + isRestartTransitionRef.current = false; + if (restartSafetyTimeoutRef.current) { + clearTimeout(restartSafetyTimeoutRef.current); + restartSafetyTimeoutRef.current = null; + } + } } break; @@ -307,12 +359,23 @@ export function useAutoMode(worktree?: WorktreeInfo) { break; case 'auto_mode_stopped': - // Backend stopped auto loop - update UI state + // Backend stopped auto loop - update UI state. + // Skip during transitions (e.g., restartWithConcurrency) to avoid flickering the toggle + // off between stop and start. The transition handler will set the correct final state. + // Only suppress (and only apply transition guard) when the event is for this hook's + // worktree, to avoid worktree B's stop events being incorrectly suppressed by + // worktree A's transition state. { const worktreeDesc = eventBranchName ? `worktree ${eventBranchName}` : 'main worktree'; - logger.info(`[AutoMode] Backend stopped auto loop for ${worktreeDesc}`); - if (eventProjectId) { - setAutoModeRunning(eventProjectId, eventBranchName, false); + if (eventBranchName === branchName && isTransitioningRef.current) { + logger.info( + `[AutoMode] Backend stopped auto loop for ${worktreeDesc} (ignored during transition)` + ); + } else { + logger.info(`[AutoMode] Backend stopped auto loop for ${worktreeDesc}`); + if (eventProjectId) { + setAutoModeRunning(eventProjectId, eventBranchName, false); + } } } break; @@ -574,6 +637,7 @@ export function useAutoMode(worktree?: WorktreeInfo) { return unsubscribe; }, [ projectId, + branchName, addRunningTask, removeRunningTask, addAutoModeActivity, @@ -582,7 +646,6 @@ export function useAutoMode(worktree?: WorktreeInfo) { setAutoModeRunning, currentProject?.path, getMaxConcurrencyForWorktree, - setMaxConcurrencyForWorktree, isPrimaryWorktreeBranch, ]); @@ -624,8 +687,10 @@ export function useAutoMode(worktree?: WorktreeInfo) { } logger.debug(`[AutoMode] Started successfully for ${worktreeDesc}`); - // Sync with backend after success (gets runningFeatures if events were delayed) - queueMicrotask(() => void refreshStatus()); + // Sync with backend after a short delay to get runningFeatures if events were delayed. + // The delay ensures the backend has fully processed the start before we poll status, + // avoiding a race where status returns stale data and briefly flickers the toggle. + setTimeout(() => void refreshStatus(), 500); } catch (error) { // Revert UI state on error setAutoModeSessionForWorktree(currentProject.path, branchName, false); @@ -635,7 +700,7 @@ export function useAutoMode(worktree?: WorktreeInfo) { } finally { isTransitioningRef.current = false; } - }, [currentProject, branchName, setAutoModeRunning, getMaxConcurrencyForWorktree]); + }, [currentProject, branchName, setAutoModeRunning, getMaxConcurrencyForWorktree, refreshStatus]); // Stop auto mode - calls backend to stop the auto loop for this worktree const stop = useCallback(async () => { @@ -672,8 +737,8 @@ export function useAutoMode(worktree?: WorktreeInfo) { // NOTE: Running tasks will continue until natural completion. // The backend stops picking up new features but doesn't abort running ones. logger.info(`Stopped ${worktreeDesc} - running tasks will continue`); - // Sync with backend after success - queueMicrotask(() => void refreshStatus()); + // Sync with backend after a short delay to confirm stopped state + setTimeout(() => void refreshStatus(), 500); } catch (error) { // Revert UI state on error setAutoModeSessionForWorktree(currentProject.path, branchName, true); @@ -683,7 +748,95 @@ export function useAutoMode(worktree?: WorktreeInfo) { } finally { isTransitioningRef.current = false; } - }, [currentProject, branchName, setAutoModeRunning]); + }, [currentProject, branchName, setAutoModeRunning, refreshStatus]); + + // Restart auto mode with new concurrency without flickering the toggle. + // Unlike stop() + start(), this keeps isRunning=true throughout the transition + // so the toggle switch never visually turns off. + // + // IMPORTANT: isTransitioningRef is NOT cleared in the finally block here. + // Instead, it stays true until the auto_mode_started WebSocket event arrives, + // which confirms the backend restart is complete. This prevents a race condition + // where a delayed auto_mode_stopped WebSocket event (sent by the backend during + // stop()) arrives after the HTTP calls complete but before the WebSocket events, + // which would briefly set isRunning=false and cause a visible toggle flicker. + // A safety timeout ensures the flag is cleared even if the event never arrives. + const restartWithConcurrency = useCallback(async () => { + if (!currentProject) { + logger.error('No project selected'); + return; + } + + // Clear any previous safety timeout + if (restartSafetyTimeoutRef.current) { + clearTimeout(restartSafetyTimeoutRef.current); + restartSafetyTimeoutRef.current = null; + } + + isTransitioningRef.current = true; + isRestartTransitionRef.current = true; + try { + const api = getElectronAPI(); + if (!api?.autoMode?.stop || !api?.autoMode?.start) { + throw new Error('Auto mode API not available'); + } + + const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree'; + logger.info( + `[AutoMode] Restarting with new concurrency for ${worktreeDesc} in ${currentProject.path}` + ); + + // Stop backend without updating UI state (keep isRunning=true) + const stopResult = await api.autoMode.stop(currentProject.path, branchName); + + if (!stopResult.success) { + logger.error('Failed to stop auto mode during restart:', stopResult.error); + // Don't throw - try to start anyway since the goal is to update concurrency + } + + // Start backend with the new concurrency (UI state stays isRunning=true) + const currentMaxConcurrency = getMaxConcurrencyForWorktree(currentProject.id, branchName); + const startResult = await api.autoMode.start( + currentProject.path, + branchName, + currentMaxConcurrency + ); + + if (!startResult.success) { + // If start fails, we need to revert UI state since we're actually stopped now + isTransitioningRef.current = false; + isRestartTransitionRef.current = false; + setAutoModeSessionForWorktree(currentProject.path, branchName, false); + setAutoModeRunning(currentProject.id, branchName, false); + logger.error('Failed to restart auto mode with new concurrency:', startResult.error); + throw new Error(startResult.error || 'Failed to restart auto mode'); + } + + logger.debug(`[AutoMode] Restarted successfully for ${worktreeDesc}`); + + // Don't clear isTransitioningRef here - let the auto_mode_started WebSocket + // event handler clear it. Set a safety timeout in case the event never arrives. + restartSafetyTimeoutRef.current = setTimeout(() => { + if (isRestartTransitionRef.current) { + logger.warn('[AutoMode] Restart transition safety timeout - clearing transition flag'); + isTransitioningRef.current = false; + isRestartTransitionRef.current = false; + restartSafetyTimeoutRef.current = null; + } + }, 5000); + } catch (error) { + // On error, clear the transition flags immediately + isTransitioningRef.current = false; + isRestartTransitionRef.current = false; + // Revert UI state since the backend may be stopped after a partial restart + if (currentProject) { + setAutoModeSessionForWorktree(currentProject.path, branchName, false); + setAutoModeRunning(currentProject.id, branchName, false); + } + logger.error('Error restarting auto mode:', error); + throw error; + } + }, [currentProject, branchName, setAutoModeRunning, getMaxConcurrencyForWorktree]); // Stop a specific feature const stopFeature = useCallback( @@ -731,6 +884,7 @@ export function useAutoMode(worktree?: WorktreeInfo) { start, stop, stopFeature, + restartWithConcurrency, refreshStatus, }; } diff --git a/apps/ui/src/hooks/use-settings-migration.ts b/apps/ui/src/hooks/use-settings-migration.ts index 5b6459c76..0db8e6055 100644 --- a/apps/ui/src/hooks/use-settings-migration.ts +++ b/apps/ui/src/hooks/use-settings-migration.ts @@ -166,6 +166,7 @@ export function parseLocalStorageSettings(): Partial | null { defaultSkipTests: state.defaultSkipTests as boolean, enableDependencyBlocking: state.enableDependencyBlocking as boolean, skipVerificationInAutoMode: state.skipVerificationInAutoMode as boolean, + mergePostAction: (state.mergePostAction as 'commit' | 'manual' | null) ?? null, useWorktrees: state.useWorktrees as boolean, defaultPlanningMode: state.defaultPlanningMode as GlobalSettings['defaultPlanningMode'], defaultRequirePlanApproval: state.defaultRequirePlanApproval as boolean, @@ -704,6 +705,7 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void { defaultSkipTests: settings.defaultSkipTests ?? true, enableDependencyBlocking: settings.enableDependencyBlocking ?? true, skipVerificationInAutoMode: settings.skipVerificationInAutoMode ?? false, + mergePostAction: settings.mergePostAction ?? null, useWorktrees: settings.useWorktrees ?? true, defaultPlanningMode: settings.defaultPlanningMode ?? 'skip', defaultRequirePlanApproval: settings.defaultRequirePlanApproval ?? false, @@ -718,6 +720,8 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void { enhancementModel: settings.enhancementModel ?? 'claude-sonnet', validationModel: settings.validationModel ?? 'claude-opus', phaseModels: settings.phaseModels ?? current.phaseModels, + defaultThinkingLevel: settings.defaultThinkingLevel ?? 'none', + defaultReasoningEffort: settings.defaultReasoningEffort ?? 'none', enabledCursorModels: allCursorModels, // Always use ALL cursor models cursorDefaultModel: sanitizedCursorDefaultModel, enabledOpencodeModels: sanitizedEnabledOpencodeModels, @@ -749,6 +753,7 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void { projectHistory: settings.projectHistory ?? [], projectHistoryIndex: settings.projectHistoryIndex ?? -1, lastSelectedSessionByProject: settings.lastSelectedSessionByProject ?? {}, + currentWorktreeByProject: settings.currentWorktreeByProject ?? {}, // UI State worktreePanelCollapsed: settings.worktreePanelCollapsed ?? false, lastProjectDir: settings.lastProjectDir ?? '', @@ -802,6 +807,7 @@ function buildSettingsUpdateFromStore(): Record { defaultSkipTests: state.defaultSkipTests, enableDependencyBlocking: state.enableDependencyBlocking, skipVerificationInAutoMode: state.skipVerificationInAutoMode, + mergePostAction: state.mergePostAction, useWorktrees: state.useWorktrees, defaultPlanningMode: state.defaultPlanningMode, defaultRequirePlanApproval: state.defaultRequirePlanApproval, @@ -812,6 +818,8 @@ function buildSettingsUpdateFromStore(): Record { enhancementModel: state.enhancementModel, validationModel: state.validationModel, phaseModels: state.phaseModels, + defaultThinkingLevel: state.defaultThinkingLevel, + defaultReasoningEffort: state.defaultReasoningEffort, enabledDynamicModelIds: state.enabledDynamicModelIds, disabledProviders: state.disabledProviders, autoLoadClaudeMd: state.autoLoadClaudeMd, @@ -836,6 +844,7 @@ function buildSettingsUpdateFromStore(): Record { projectHistory: state.projectHistory, projectHistoryIndex: state.projectHistoryIndex, lastSelectedSessionByProject: state.lastSelectedSessionByProject, + currentWorktreeByProject: state.currentWorktreeByProject, worktreePanelCollapsed: state.worktreePanelCollapsed, lastProjectDir: state.lastProjectDir, recentFolders: state.recentFolders, diff --git a/apps/ui/src/hooks/use-settings-sync.ts b/apps/ui/src/hooks/use-settings-sync.ts index 2dfed5346..3bd3ed0f9 100644 --- a/apps/ui/src/hooks/use-settings-sync.ts +++ b/apps/ui/src/hooks/use-settings-sync.ts @@ -58,6 +58,7 @@ const SETTINGS_FIELDS_TO_SYNC = [ 'defaultSkipTests', 'enableDependencyBlocking', 'skipVerificationInAutoMode', + 'mergePostAction', 'useWorktrees', 'defaultPlanningMode', 'defaultRequirePlanApproval', @@ -717,6 +718,7 @@ export async function refreshSettingsFromServer(): Promise { defaultSkipTests: serverSettings.defaultSkipTests, enableDependencyBlocking: serverSettings.enableDependencyBlocking, skipVerificationInAutoMode: serverSettings.skipVerificationInAutoMode, + mergePostAction: serverSettings.mergePostAction ?? null, useWorktrees: serverSettings.useWorktrees, defaultPlanningMode: serverSettings.defaultPlanningMode, defaultRequirePlanApproval: serverSettings.defaultRequirePlanApproval, diff --git a/apps/ui/src/lib/clipboard-utils.ts b/apps/ui/src/lib/clipboard-utils.ts new file mode 100644 index 000000000..6eb05c931 --- /dev/null +++ b/apps/ui/src/lib/clipboard-utils.ts @@ -0,0 +1,145 @@ +/** + * Clipboard utility functions with fallbacks for non-HTTPS (insecure) contexts. + * + * The modern Clipboard API (`navigator.clipboard`) requires a Secure Context (HTTPS). + * When running on HTTP, these APIs are unavailable or throw errors. + * This module provides `writeToClipboard` and `readFromClipboard` that automatically + * fall back to the legacy `document.execCommand` approach using a hidden textarea. + */ + +/** + * Check whether the modern Clipboard API is available. + * It requires a secure context (HTTPS or localhost) and the API to exist. + */ +function isClipboardApiAvailable(): boolean { + return ( + typeof navigator !== 'undefined' && + !!navigator.clipboard && + typeof navigator.clipboard.writeText === 'function' && + typeof navigator.clipboard.readText === 'function' && + typeof window !== 'undefined' && + window.isSecureContext !== false + ); +} + +/** + * Write text to the clipboard using the modern Clipboard API with a + * fallback to `document.execCommand('copy')` for insecure contexts. + * + * @param text - The text to write to the clipboard. + * @returns `true` if the text was successfully copied; `false` otherwise. + */ +export async function writeToClipboard(text: string): Promise { + // Try the modern Clipboard API first + if (isClipboardApiAvailable()) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + // Fall through to legacy approach + } + } + + // Legacy fallback using a hidden textarea + execCommand + return writeToClipboardLegacy(text); +} + +/** + * Read text from the clipboard using the modern Clipboard API with a + * fallback to `document.execCommand('paste')` for insecure contexts. + * + * Note: The legacy fallback for *reading* is limited. `document.execCommand('paste')` + * only works in some browsers (mainly older ones). On modern browsers in insecure + * contexts, reading from the clipboard may not be possible at all. In those cases, + * this function throws an error so the caller can show an appropriate message. + * + * @returns The text from the clipboard. + * @throws If clipboard reading is not supported or permission is denied. + */ +export async function readFromClipboard(): Promise { + // Try the modern Clipboard API first + if (isClipboardApiAvailable()) { + try { + return await navigator.clipboard.readText(); + } catch (err) { + // Check if this is a permission-related error + if (err instanceof Error) { + // Re-throw permission errors so they propagate to the caller + if (err.name === 'NotAllowedError' || err.name === 'NotReadableError') { + throw err; + } + } + // For other errors, fall through to legacy approach + } + } + + // Legacy fallback using a hidden textarea + execCommand + return readFromClipboardLegacy(); +} + +/** + * Legacy clipboard write using a hidden textarea and `document.execCommand('copy')`. + * This works in both secure and insecure contexts in most browsers. + */ +function writeToClipboardLegacy(text: string): boolean { + const textarea = document.createElement('textarea'); + textarea.value = text; + + // Prevent scrolling and make invisible + textarea.style.position = 'fixed'; + textarea.style.left = '-9999px'; + textarea.style.top = '-9999px'; + textarea.style.opacity = '0'; + + document.body.appendChild(textarea); + + try { + textarea.select(); + textarea.setSelectionRange(0, text.length); + const success = document.execCommand('copy'); + return success; + } catch { + return false; + } finally { + document.body.removeChild(textarea); + } +} + +/** + * Legacy clipboard read using a hidden textarea and `document.execCommand('paste')`. + * This has very limited browser support. Most modern browsers block this for security. + * When it fails, we throw an error to let the caller handle it gracefully. + */ +function readFromClipboardLegacy(): string { + const textarea = document.createElement('textarea'); + + // Prevent scrolling and make invisible + textarea.style.position = 'fixed'; + textarea.style.left = '-9999px'; + textarea.style.top = '-9999px'; + textarea.style.opacity = '0'; + + document.body.appendChild(textarea); + textarea.focus(); + + try { + const success = document.execCommand('paste'); + if (success && textarea.value) { + return textarea.value; + } + throw new Error( + 'Clipboard paste is not supported in this browser on non-HTTPS sites. ' + + 'Please use HTTPS or paste manually with keyboard shortcuts.' + ); + } catch (err) { + if (err instanceof Error && err.message.includes('Clipboard paste is not supported')) { + throw err; + } + throw new Error( + 'Clipboard paste is not supported in this browser on non-HTTPS sites. ' + + 'Please use HTTPS or paste manually with keyboard shortcuts.' + ); + } finally { + document.body.removeChild(textarea); + } +} diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index b77812ec1..df9bcffa4 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -568,6 +568,7 @@ type EventType = | 'dev-server:started' | 'dev-server:output' | 'dev-server:stopped' + | 'dev-server:url-detected' | 'test-runner:started' | 'test-runner:output' | 'test-runner:completed' @@ -576,13 +577,17 @@ type EventType = /** * Dev server log event payloads for WebSocket streaming */ -export interface DevServerStartedEvent { + +/** Shared base for dev server events that carry URL/port information */ +interface DevServerUrlEvent { worktreePath: string; - port: number; url: string; + port: number; timestamp: string; } +export type DevServerStartedEvent = DevServerUrlEvent; + export interface DevServerOutputEvent { worktreePath: string; content: string; @@ -597,10 +602,13 @@ export interface DevServerStoppedEvent { timestamp: string; } +export type DevServerUrlDetectedEvent = DevServerUrlEvent; + export type DevServerLogEvent = | { type: 'dev-server:started'; payload: DevServerStartedEvent } | { type: 'dev-server:output'; payload: DevServerOutputEvent } - | { type: 'dev-server:stopped'; payload: DevServerStoppedEvent }; + | { type: 'dev-server:stopped'; payload: DevServerStoppedEvent } + | { type: 'dev-server:url-detected'; payload: DevServerUrlDetectedEvent }; /** * Test runner event payloads for WebSocket streaming @@ -2204,10 +2212,14 @@ export class HttpApiClient implements ElectronAPI { const unsub3 = this.subscribeToEvent('dev-server:stopped', (payload) => callback({ type: 'dev-server:stopped', payload: payload as DevServerStoppedEvent }) ); + const unsub4 = this.subscribeToEvent('dev-server:url-detected', (payload) => + callback({ type: 'dev-server:url-detected', payload: payload as DevServerUrlDetectedEvent }) + ); return () => { unsub1(); unsub2(); unsub3(); + unsub4(); }; }, getPRInfo: (worktreePath: string, branchName: string) => diff --git a/apps/ui/src/routes/__root.tsx b/apps/ui/src/routes/__root.tsx index 4edcc40dd..d7e3f73ee 100644 --- a/apps/ui/src/routes/__root.tsx +++ b/apps/ui/src/routes/__root.tsx @@ -305,6 +305,7 @@ function RootLayoutContent() { sidebarStyle: state.sidebarStyle, worktreePanelCollapsed: state.worktreePanelCollapsed, collapsedNavSections: state.collapsedNavSections, + currentWorktreeByProject: state.currentWorktreeByProject, }); }); return unsubscribe; diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index cb91bcaf3..b9730ecf3 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -298,6 +298,7 @@ const initialState: AppState = { enableDependencyBlocking: true, skipVerificationInAutoMode: false, enableAiCommitMessages: true, + mergePostAction: null, planUseSelectedWorktreeBranch: true, addFeatureUseSelectedWorktreeBranch: false, useWorktrees: true, @@ -362,6 +363,8 @@ const initialState: AppState = { defaultPlanningMode: 'skip' as PlanningMode, defaultRequirePlanApproval: false, defaultFeatureModel: DEFAULT_GLOBAL_SETTINGS.defaultFeatureModel, + defaultThinkingLevel: DEFAULT_GLOBAL_SETTINGS.defaultThinkingLevel ?? 'none', + defaultReasoningEffort: DEFAULT_GLOBAL_SETTINGS.defaultReasoningEffort ?? 'none', pendingPlanApproval: null, claudeRefreshInterval: 60, claudeUsage: null, @@ -1117,6 +1120,16 @@ export const useAppStore = create()((set, get) => ({ logger.error('Failed to sync enableAiCommitMessages:', error); } }, + setMergePostAction: async (action) => { + set({ mergePostAction: action }); + // Sync to server + try { + const httpApi = getHttpApiClient(); + await httpApi.put('/api/settings', { mergePostAction: action }); + } catch (error) { + logger.error('Failed to sync mergePostAction:', error); + } + }, setPlanUseSelectedWorktreeBranch: async (enabled) => { set({ planUseSelectedWorktreeBranch: enabled }); // Sync to server @@ -2313,6 +2326,28 @@ export const useAppStore = create()((set, get) => ({ setDefaultRequirePlanApproval: (require) => set({ defaultRequirePlanApproval: require }), setDefaultFeatureModel: (entry) => set({ defaultFeatureModel: entry }), + setDefaultThinkingLevel: async (level) => { + set({ defaultThinkingLevel: level }); + // Sync to server + try { + const httpApi = getHttpApiClient(); + await httpApi.put('/api/settings', { defaultThinkingLevel: level }); + } catch (error) { + logger.error('Failed to sync defaultThinkingLevel:', error); + } + }, + + setDefaultReasoningEffort: async (effort) => { + set({ defaultReasoningEffort: effort }); + // Sync to server + try { + const httpApi = getHttpApiClient(); + await httpApi.put('/api/settings', { defaultReasoningEffort: effort }); + } catch (error) { + logger.error('Failed to sync defaultReasoningEffort:', error); + } + }, + // Plan Approval actions setPendingPlanApproval: (approval) => set({ pendingPlanApproval: approval }), diff --git a/apps/ui/src/store/types/state-types.ts b/apps/ui/src/store/types/state-types.ts index 224c09180..ec8fcd2b1 100644 --- a/apps/ui/src/store/types/state-types.ts +++ b/apps/ui/src/store/types/state-types.ts @@ -21,6 +21,8 @@ import type { ClaudeApiProfile, ClaudeCompatibleProvider, SidebarStyle, + ThinkingLevel, + ReasoningEffort, } from '@automaker/types'; import type { @@ -127,6 +129,7 @@ export interface AppState { enableDependencyBlocking: boolean; // When true, show blocked badges and warnings for features with incomplete dependencies (default: true) skipVerificationInAutoMode: boolean; // When true, auto-mode grabs features even if dependencies are not verified (only checks they're not running) enableAiCommitMessages: boolean; // When true, auto-generate commit messages using AI when opening commit dialog + mergePostAction: 'commit' | 'manual' | null; // User's preferred action after a clean merge (null = ask every time) planUseSelectedWorktreeBranch: boolean; // When true, Plan dialog creates features on the currently selected worktree branch addFeatureUseSelectedWorktreeBranch: boolean; // When true, Add Feature dialog defaults to custom mode with selected worktree branch @@ -175,6 +178,10 @@ export interface AppState { phaseModels: PhaseModelConfig; favoriteModels: string[]; + // Default thinking/reasoning levels for two-stage model selector primary button + defaultThinkingLevel: ThinkingLevel; + defaultReasoningEffort: ReasoningEffort; + // Cursor CLI Settings (global) enabledCursorModels: CursorModelId[]; // Which Cursor models are available in feature modal cursorDefaultModel: CursorModelId; // Default Cursor model selection @@ -488,6 +495,7 @@ export interface AppActions { setEnableDependencyBlocking: (enabled: boolean) => void; setSkipVerificationInAutoMode: (enabled: boolean) => Promise; setEnableAiCommitMessages: (enabled: boolean) => Promise; + setMergePostAction: (action: 'commit' | 'manual' | null) => Promise; setPlanUseSelectedWorktreeBranch: (enabled: boolean) => Promise; setAddFeatureUseSelectedWorktreeBranch: (enabled: boolean) => Promise; @@ -548,6 +556,8 @@ export interface AppActions { setPhaseModels: (models: Partial) => Promise; resetPhaseModels: () => Promise; toggleFavoriteModel: (modelId: string) => void; + setDefaultThinkingLevel: (level: ThinkingLevel) => void; + setDefaultReasoningEffort: (effort: ReasoningEffort) => void; // Cursor CLI Settings actions setEnabledCursorModels: (models: CursorModelId[]) => void; diff --git a/apps/ui/src/store/ui-cache-store.ts b/apps/ui/src/store/ui-cache-store.ts index bc3659ac0..44eb9ed39 100644 --- a/apps/ui/src/store/ui-cache-store.ts +++ b/apps/ui/src/store/ui-cache-store.ts @@ -35,6 +35,8 @@ interface UICacheState { cachedWorktreePanelCollapsed: boolean; /** Collapsed nav sections */ cachedCollapsedNavSections: Record; + /** Selected worktree per project (path + branch) for instant restore on PWA reload */ + cachedCurrentWorktreeByProject: Record; } interface UICacheActions { @@ -52,19 +54,29 @@ export const useUICacheStore = create()( cachedSidebarStyle: 'unified', cachedWorktreePanelCollapsed: false, cachedCollapsedNavSections: {}, + cachedCurrentWorktreeByProject: {}, updateFromAppStore: (state) => set(state), }), { name: STORE_NAME, - version: 1, + version: 2, partialize: (state) => ({ cachedProjectId: state.cachedProjectId, cachedSidebarOpen: state.cachedSidebarOpen, cachedSidebarStyle: state.cachedSidebarStyle, cachedWorktreePanelCollapsed: state.cachedWorktreePanelCollapsed, cachedCollapsedNavSections: state.cachedCollapsedNavSections, + cachedCurrentWorktreeByProject: state.cachedCurrentWorktreeByProject, }), + migrate: (persistedState: unknown, version: number) => { + const state = persistedState as Record; + if (version < 2) { + // Migration from v1: add cachedCurrentWorktreeByProject + state.cachedCurrentWorktreeByProject = {}; + } + return state as unknown as UICacheState & UICacheActions; + }, } ) ); @@ -82,6 +94,7 @@ export function syncUICache(appState: { sidebarStyle?: 'unified' | 'discord'; worktreePanelCollapsed?: boolean; collapsedNavSections?: Record; + currentWorktreeByProject?: Record; }): void { const update: Partial = {}; @@ -100,6 +113,9 @@ export function syncUICache(appState: { if ('collapsedNavSections' in appState) { update.cachedCollapsedNavSections = appState.collapsedNavSections; } + if ('currentWorktreeByProject' in appState) { + update.cachedCurrentWorktreeByProject = appState.currentWorktreeByProject; + } if (Object.keys(update).length > 0) { useUICacheStore.getState().updateFromAppStore(update); @@ -142,6 +158,15 @@ export function restoreFromUICache( collapsedNavSections: cache.cachedCollapsedNavSections, }; + // Restore last selected worktree per project so the board doesn't + // reset to main branch after PWA memory eviction or tab discard. + if ( + cache.cachedCurrentWorktreeByProject && + Object.keys(cache.cachedCurrentWorktreeByProject).length > 0 + ) { + stateUpdate.currentWorktreeByProject = cache.cachedCurrentWorktreeByProject; + } + // 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(). diff --git a/apps/ui/src/styles/global.css b/apps/ui/src/styles/global.css index 4d3315904..e5394d74e 100644 --- a/apps/ui/src/styles/global.css +++ b/apps/ui/src/styles/global.css @@ -572,6 +572,34 @@ } } + /* Safe-area-aware close button positioning for full-screen mobile dialogs. + On mobile, shift the close button down by the safe-area-inset-top so it + remains reachable and not hidden behind the notch or Dynamic Island. + On sm+ (desktop), use standard top positioning. */ + .dialog-fullscreen-mobile [data-slot='dialog-close'] { + top: calc(env(safe-area-inset-top, 0px) + 0.75rem); + } + + @media (min-width: 640px) { + .dialog-fullscreen-mobile [data-slot='dialog-close'] { + top: 0.75rem; + } + } + + /* Safe-area-aware top padding for compact dialog headers (p-0 dialogs with own header). + Ensures the header content starts below the Dynamic Island / notch on iOS. + Used in dev-server-logs and test-logs panels that use p-0 on DialogContent. + On sm+ (desktop), the dialog is centered so no safe-area adjustment needed. */ + .dialog-compact-header-mobile { + padding-top: calc(env(safe-area-inset-top, 0px) + 0.75rem); + } + + @media (min-width: 640px) { + .dialog-compact-header-mobile { + padding-top: 0.75rem; + } + } + .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 80aa00536..cb0e0b1b3 100644 --- a/apps/ui/src/types/electron.d.ts +++ b/apps/ui/src/types/electron.d.ts @@ -8,7 +8,7 @@ import type { ZaiUsageResponse, GeminiUsageResponse, } from '@/store/app-store'; -import type { ParsedTask, FeatureStatusWithPipeline } from '@automaker/types'; +import type { ParsedTask, FeatureStatusWithPipeline, MergeStateInfo } from '@automaker/types'; export interface ImageAttachment { id?: string; // Optional - may not be present in messages loaded from server @@ -759,6 +759,10 @@ export interface FileStatus { indexStatus?: string; /** Raw working tree status character from git porcelain format */ workTreeStatus?: string; + /** Whether this file is involved in a merge operation */ + isMergeAffected?: boolean; + /** Type of merge involvement (e.g. 'both-modified', 'added-by-us', etc.) */ + mergeType?: string; } export interface FileDiffsResult { @@ -767,6 +771,8 @@ export interface FileDiffsResult { files?: FileStatus[]; hasChanges?: boolean; error?: string; + /** Merge state info, present when a merge/rebase/cherry-pick is in progress */ + mergeState?: MergeStateInfo; } export interface FileDiffResult { @@ -1286,6 +1292,7 @@ export interface WorktreeAPI { worktreePath: string; port: number; url: string; + urlDetected: boolean; }>; }; error?: string; @@ -1304,7 +1311,7 @@ export interface WorktreeAPI { error?: string; }>; - // Subscribe to dev server log events (started, output, stopped) + // Subscribe to dev server log events (started, output, stopped, url-detected) onDevServerLogEvent: ( callback: ( event: @@ -1326,6 +1333,15 @@ export interface WorktreeAPI { timestamp: string; }; } + | { + type: 'dev-server:url-detected'; + payload: { + worktreePath: string; + url: string; + port: number; + timestamp: string; + }; + } ) => void ) => () => void; diff --git a/apps/ui/tests/features/running-task-card-display.spec.ts b/apps/ui/tests/features/running-task-card-display.spec.ts new file mode 100644 index 000000000..ac08c2b91 --- /dev/null +++ b/apps/ui/tests/features/running-task-card-display.spec.ts @@ -0,0 +1,211 @@ +/** + * Running Task Card Display E2E Test + * + * Tests that task cards with a running state display the correct UI controls. + * + * This test verifies that: + * 1. A feature in the in_progress column with status 'in_progress' shows Logs/Stop controls (not Make) + * 2. A feature with status 'backlog' that is tracked as running (stale status race condition) + * shows Logs/Stop controls instead of the Make button when placed in in_progress column + * 3. The Make button only appears for genuinely idle backlog/interrupted/ready features + * 4. Features in backlog that are NOT running show the correct Edit/Make buttons + */ + +import { test, expect } from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; +import { + createTempDirPath, + cleanupTempDir, + setupRealProject, + waitForNetworkIdle, + getKanbanColumn, + authenticateForTests, + handleLoginScreenIfPresent, +} from '../utils'; + +const TEST_TEMP_DIR = createTempDirPath('running-task-display-test'); + +// Generate deterministic projectId once at test module load +const TEST_PROJECT_ID = `project-running-task-${Date.now()}`; + +test.describe('Running Task Card Display', () => { + let projectPath: string; + const projectName = `test-project-${Date.now()}`; + const backlogFeatureId = 'test-feature-backlog'; + const inProgressFeatureId = 'test-feature-in-progress'; + + test.beforeAll(async () => { + if (!fs.existsSync(TEST_TEMP_DIR)) { + fs.mkdirSync(TEST_TEMP_DIR, { recursive: true }); + } + + projectPath = path.join(TEST_TEMP_DIR, projectName); + fs.mkdirSync(projectPath, { recursive: true }); + + fs.writeFileSync( + path.join(projectPath, 'package.json'), + JSON.stringify({ name: projectName, version: '1.0.0' }, null, 2) + ); + + const automakerDir = path.join(projectPath, '.automaker'); + fs.mkdirSync(automakerDir, { recursive: true }); + fs.mkdirSync(path.join(automakerDir, 'features'), { recursive: true }); + fs.mkdirSync(path.join(automakerDir, 'context'), { recursive: true }); + + fs.writeFileSync( + path.join(automakerDir, 'categories.json'), + JSON.stringify({ categories: [] }, null, 2) + ); + + fs.writeFileSync( + path.join(automakerDir, 'app_spec.txt'), + `# ${projectName}\n\nA test project for e2e testing.` + ); + }); + + test.afterAll(async () => { + cleanupTempDir(TEST_TEMP_DIR); + }); + + test('should show Logs/Stop buttons for in_progress features, not Make button', async ({ + page, + }) => { + // Set up the project in localStorage with a deterministic projectId + await setupRealProject(page, projectPath, projectName, { + setAsCurrent: true, + projectId: TEST_PROJECT_ID, + }); + + // Intercept settings API to ensure our test project remains current + await page.route('**/api/settings/global', async (route) => { + const method = route.request().method(); + if (method === 'PUT') { + return route.continue(); + } + const response = await route.fetch(); + const json = await response.json(); + if (json.settings) { + const existingProjects = json.settings.projects || []; + let testProject = existingProjects.find((p: { path: string }) => p.path === projectPath); + if (!testProject) { + testProject = { + id: TEST_PROJECT_ID, + name: projectName, + path: projectPath, + lastOpened: new Date().toISOString(), + }; + json.settings.projects = [testProject, ...existingProjects]; + } + json.settings.currentProjectId = testProject.id; + json.settings.setupComplete = true; + json.settings.isFirstRun = false; + } + await route.fulfill({ response, json }); + }); + + await authenticateForTests(page); + + // Navigate to board + await page.goto('/board'); + await page.waitForLoadState('load'); + await handleLoginScreenIfPresent(page); + await waitForNetworkIdle(page); + await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 10000 }); + + // Create a feature that is already in_progress status (simulates a running task) + const inProgressFeature = { + id: inProgressFeatureId, + description: 'Test feature that is currently running', + category: 'test', + status: 'in_progress', + skipTests: false, + model: 'sonnet', + thinkingLevel: 'none', + createdAt: new Date().toISOString(), + startedAt: new Date().toISOString(), + branchName: '', + priority: 2, + }; + + // Create a feature in backlog status (idle, should show Make button) + const backlogFeature = { + id: backlogFeatureId, + description: 'Test feature in backlog waiting to start', + category: 'test', + status: 'backlog', + skipTests: false, + model: 'sonnet', + thinkingLevel: 'none', + createdAt: new Date().toISOString(), + branchName: '', + priority: 2, + }; + + const API_BASE_URL = process.env.SERVER_URL || 'http://localhost:3008'; + + // Create both features via HTTP API + const createInProgress = await page.request.post(`${API_BASE_URL}/api/features/create`, { + data: { projectPath, feature: inProgressFeature }, + headers: { 'Content-Type': 'application/json' }, + }); + if (!createInProgress.ok()) { + throw new Error(`Failed to create in_progress feature: ${await createInProgress.text()}`); + } + + const createBacklog = await page.request.post(`${API_BASE_URL}/api/features/create`, { + data: { projectPath, feature: backlogFeature }, + headers: { 'Content-Type': 'application/json' }, + }); + if (!createBacklog.ok()) { + throw new Error(`Failed to create backlog feature: ${await createBacklog.text()}`); + } + + // Reload to pick up the new features + await page.reload(); + await page.waitForLoadState('load'); + await handleLoginScreenIfPresent(page); + await waitForNetworkIdle(page); + await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 10000 }); + + // Wait for both feature cards to appear + const inProgressCard = page.locator(`[data-testid="kanban-card-${inProgressFeatureId}"]`); + const backlogCard = page.locator(`[data-testid="kanban-card-${backlogFeatureId}"]`); + await expect(inProgressCard).toBeVisible({ timeout: 20000 }); + await expect(backlogCard).toBeVisible({ timeout: 20000 }); + + // Verify the in_progress feature is in the in_progress column + const inProgressColumn = await getKanbanColumn(page, 'in_progress'); + await expect(inProgressColumn).toBeVisible({ timeout: 5000 }); + const cardInInProgress = inProgressColumn.locator( + `[data-testid="kanban-card-${inProgressFeatureId}"]` + ); + await expect(cardInInProgress).toBeVisible({ timeout: 5000 }); + + // Verify the backlog feature is in the backlog column + const backlogColumn = await getKanbanColumn(page, 'backlog'); + await expect(backlogColumn).toBeVisible({ timeout: 5000 }); + const cardInBacklog = backlogColumn.locator(`[data-testid="kanban-card-${backlogFeatureId}"]`); + await expect(cardInBacklog).toBeVisible({ timeout: 5000 }); + + // CRITICAL: Verify the in_progress feature does NOT show a Make button + // The Make button should only appear on backlog/interrupted/ready features that are NOT running + const makeButtonOnInProgress = page.locator(`[data-testid="make-${inProgressFeatureId}"]`); + await expect(makeButtonOnInProgress).not.toBeVisible({ timeout: 3000 }); + + // Verify the in_progress feature shows appropriate controls + // (view-output/force-stop buttons should be present for in_progress without error) + const viewOutputButton = page.locator(`[data-testid="view-output-${inProgressFeatureId}"]`); + await expect(viewOutputButton).toBeVisible({ timeout: 5000 }); + const forceStopButton = page.locator(`[data-testid="force-stop-${inProgressFeatureId}"]`); + await expect(forceStopButton).toBeVisible({ timeout: 5000 }); + + // Verify the backlog feature DOES show a Make button + const makeButtonOnBacklog = page.locator(`[data-testid="make-${backlogFeatureId}"]`); + await expect(makeButtonOnBacklog).toBeVisible({ timeout: 5000 }); + + // Verify the backlog feature also shows an Edit button + const editButton = page.locator(`[data-testid="edit-backlog-${backlogFeatureId}"]`); + await expect(editButton).toBeVisible({ timeout: 5000 }); + }); +}); diff --git a/libs/git-utils/src/diff.ts b/libs/git-utils/src/diff.ts index aff7a438a..31360eac8 100644 --- a/libs/git-utils/src/diff.ts +++ b/libs/git-utils/src/diff.ts @@ -7,8 +7,8 @@ import { secureFs } from '@automaker/platform'; import path from 'path'; import { exec } from 'child_process'; import { promisify } from 'util'; -import { BINARY_EXTENSIONS, type FileStatus } from './types.js'; -import { isGitRepo, parseGitStatus } from './status.js'; +import { BINARY_EXTENSIONS, type FileStatus, type MergeStateInfo } from './types.js'; +import { isGitRepo, parseGitStatus, detectMergeState, detectMergeCommit } from './status.js'; const execAsync = promisify(exec); const logger = createLogger('GitUtils'); @@ -243,11 +243,15 @@ export async function generateDiffsForNonGitDirectory( /** * Get git repository diffs for a given path - * Handles both git repos and non-git directories + * Handles both git repos and non-git directories. + * Also detects merge state and annotates files accordingly. */ -export async function getGitRepositoryDiffs( - repoPath: string -): Promise<{ diff: string; files: FileStatus[]; hasChanges: boolean }> { +export async function getGitRepositoryDiffs(repoPath: string): Promise<{ + diff: string; + files: FileStatus[]; + hasChanges: boolean; + mergeState?: MergeStateInfo; +}> { // Check if it's a git repository const isRepo = await isGitRepo(repoPath); @@ -273,11 +277,133 @@ export async function getGitRepositoryDiffs( const files = parseGitStatus(status); // Generate synthetic diffs for untracked (new) files - const combinedDiff = await appendUntrackedFileDiffs(repoPath, diff, files); + let combinedDiff = await appendUntrackedFileDiffs(repoPath, diff, files); + + // Detect merge state (in-progress merge/rebase/cherry-pick) + const mergeState = await detectMergeState(repoPath); + + // If no in-progress merge, check if HEAD is a completed merge commit + // and include merge commit changes in the diff and file list + if (!mergeState.isMerging) { + const mergeCommitInfo = await detectMergeCommit(repoPath); + + if (mergeCommitInfo.isMergeCommit && mergeCommitInfo.mergeAffectedFiles.length > 0) { + // Get the diff of the merge commit relative to first parent + try { + const { stdout: mergeDiff } = await execAsync('git diff HEAD~1 HEAD', { + cwd: repoPath, + maxBuffer: 10 * 1024 * 1024, + }); + + // Add merge-affected files to the file list (avoid duplicates with working tree changes) + const fileByPath = new Map(files.map((f) => [f.path, f])); + const existingPaths = new Set(fileByPath.keys()); + for (const filePath of mergeCommitInfo.mergeAffectedFiles) { + if (!existingPaths.has(filePath)) { + const newFile = { + status: 'M', + path: filePath, + statusText: 'Merged', + indexStatus: ' ', + workTreeStatus: ' ', + isMergeAffected: true, + mergeType: 'merged', + }; + files.push(newFile); + fileByPath.set(filePath, newFile); + existingPaths.add(filePath); + } else { + // Mark existing file as also merge-affected + const existing = fileByPath.get(filePath); + if (existing) { + existing.isMergeAffected = true; + existing.mergeType = 'merged'; + } + } + } + + // Prepend merge diff to the combined diff so merge changes appear + // For files that only exist in the merge (not in working tree), we need their diffs + if (mergeDiff.trim()) { + // Parse the existing working tree diff to find which files it covers + const workingTreeDiffPaths = new Set(); + const diffLines = combinedDiff.split('\n'); + for (const line of diffLines) { + if (line.startsWith('diff --git')) { + const match = line.match(/diff --git a\/(.*?) b\/(.*)/); + if (match) { + workingTreeDiffPaths.add(match[2]); + } + } + } + + // Only include merge diff entries for files NOT already in working tree diff + const mergeDiffFiles = mergeDiff.split(/(?=diff --git)/); + const newMergeDiffs: string[] = []; + for (const fileDiff of mergeDiffFiles) { + if (!fileDiff.trim()) continue; + const match = fileDiff.match(/diff --git a\/(.*?) b\/(.*)/); + if (match && !workingTreeDiffPaths.has(match[2])) { + newMergeDiffs.push(fileDiff); + } + } + + if (newMergeDiffs.length > 0) { + combinedDiff = newMergeDiffs.join('') + combinedDiff; + } + } + } catch (mergeError) { + // Best-effort: log and continue without merge diff + logger.error('Failed to get merge commit diff:', mergeError); + + // Ensure files[] is consistent with mergeState.mergeAffectedFiles even when the + // diff command failed. Without this, mergeAffectedFiles would list paths that have + // no corresponding entry in the files array. + const existingPathsAfterError = new Set(files.map((f) => f.path)); + for (const filePath of mergeCommitInfo.mergeAffectedFiles) { + if (!existingPathsAfterError.has(filePath)) { + files.push({ + status: 'M', + path: filePath, + statusText: 'Merged', + indexStatus: ' ', + workTreeStatus: ' ', + isMergeAffected: true, + mergeType: 'merged', + }); + existingPathsAfterError.add(filePath); + } else { + // Mark existing file as also merge-affected + const existing = files.find((f) => f.path === filePath); + if (existing) { + existing.isMergeAffected = true; + existing.mergeType = 'merged'; + } + } + } + } + + // Return with merge commit info in the mergeState + return { + diff: combinedDiff, + files, + hasChanges: files.length > 0, + mergeState: { + isMerging: false, + mergeOperationType: 'merge', + isCleanMerge: true, + mergeAffectedFiles: mergeCommitInfo.mergeAffectedFiles, + conflictFiles: [], + isMergeCommit: true, + }, + }; + } + } return { diff: combinedDiff, files, hasChanges: files.length > 0, + ...(mergeState.isMerging ? { mergeState } : {}), }; } diff --git a/libs/git-utils/src/index.ts b/libs/git-utils/src/index.ts index b9cea86ec..5bee6126e 100644 --- a/libs/git-utils/src/index.ts +++ b/libs/git-utils/src/index.ts @@ -7,10 +7,15 @@ export { execGitCommand } from './exec.js'; // Export types and constants -export { BINARY_EXTENSIONS, GIT_STATUS_MAP, type FileStatus } from './types.js'; +export { + BINARY_EXTENSIONS, + GIT_STATUS_MAP, + type FileStatus, + type MergeStateInfo, +} from './types.js'; // Export status utilities -export { isGitRepo, parseGitStatus } from './status.js'; +export { isGitRepo, parseGitStatus, detectMergeState, detectMergeCommit } from './status.js'; // Export diff utilities export { diff --git a/libs/git-utils/src/status.ts b/libs/git-utils/src/status.ts index 15b2f9bde..22ad93344 100644 --- a/libs/git-utils/src/status.ts +++ b/libs/git-utils/src/status.ts @@ -4,7 +4,9 @@ import { exec } from 'child_process'; import { promisify } from 'util'; -import { GIT_STATUS_MAP, type FileStatus } from './types.js'; +import fs from 'fs/promises'; +import path from 'path'; +import { GIT_STATUS_MAP, type FileStatus, type MergeStateInfo } from './types.js'; const execAsync = promisify(exec); @@ -95,12 +97,161 @@ export function parseGitStatus(statusOutput: string): FileStatus[] { primaryStatus = workTreeStatus; // Working tree change } + // Detect merge-affected files: when both X and Y are 'U', or U appears in either position + // In merge state, git uses 'U' (unmerged) to indicate merge-affected entries + const isMergeAffected = + indexStatus === 'U' || + workTreeStatus === 'U' || + (indexStatus === 'A' && workTreeStatus === 'A') || // both-added + (indexStatus === 'D' && workTreeStatus === 'D'); // both-deleted (during merge) + + let mergeType: string | undefined; + if (isMergeAffected) { + if (indexStatus === 'U' && workTreeStatus === 'U') mergeType = 'both-modified'; + else if (indexStatus === 'A' && workTreeStatus === 'U') mergeType = 'added-by-us'; + else if (indexStatus === 'U' && workTreeStatus === 'A') mergeType = 'added-by-them'; + else if (indexStatus === 'D' && workTreeStatus === 'U') mergeType = 'deleted-by-us'; + else if (indexStatus === 'U' && workTreeStatus === 'D') mergeType = 'deleted-by-them'; + else if (indexStatus === 'A' && workTreeStatus === 'A') mergeType = 'both-added'; + else if (indexStatus === 'D' && workTreeStatus === 'D') mergeType = 'both-deleted'; + else mergeType = 'unmerged'; + } + return { status: primaryStatus, path: filePath, statusText: getStatusText(indexStatus, workTreeStatus), indexStatus, workTreeStatus, + ...(isMergeAffected && { isMergeAffected: true }), + ...(mergeType && { mergeType }), }; }); } + +/** + * Check if the current HEAD commit is a merge commit (has more than one parent). + * This is used to detect completed merge commits so we can show what the merge changed. + * + * @param repoPath - Path to the git repository or worktree + * @returns Object with isMergeCommit flag and the list of files affected by the merge + */ +export async function detectMergeCommit( + repoPath: string +): Promise<{ isMergeCommit: boolean; mergeAffectedFiles: string[] }> { + try { + // Check how many parents HEAD has using rev-parse + // For a merge commit, HEAD^2 exists (second parent); for non-merge commits it doesn't + try { + await execAsync('git rev-parse --verify "HEAD^2"', { cwd: repoPath }); + } catch { + // HEAD^2 doesn't exist — not a merge commit + return { isMergeCommit: false, mergeAffectedFiles: [] }; + } + + // HEAD is a merge commit - get the files it changed relative to first parent + let mergeAffectedFiles: string[] = []; + try { + const { stdout: diffOutput } = await execAsync('git diff --name-only "HEAD~1" "HEAD"', { + cwd: repoPath, + }); + mergeAffectedFiles = diffOutput + .trim() + .split('\n') + .filter((f) => f.trim().length > 0); + } catch { + // Ignore errors getting affected files + } + + return { isMergeCommit: true, mergeAffectedFiles }; + } catch { + return { isMergeCommit: false, mergeAffectedFiles: [] }; + } +} + +/** + * Detect the current merge state of a git repository. + * Checks for .git/MERGE_HEAD, .git/REBASE_HEAD, .git/CHERRY_PICK_HEAD + * to determine if a merge/rebase/cherry-pick is in progress. + * + * @param repoPath - Path to the git repository or worktree + * @returns MergeStateInfo describing the current merge state + */ +export async function detectMergeState(repoPath: string): Promise { + const defaultState: MergeStateInfo = { + isMerging: false, + mergeOperationType: null, + isCleanMerge: false, + mergeAffectedFiles: [], + conflictFiles: [], + }; + + try { + // Find the actual .git directory (handles worktrees with .git file pointing to main repo) + const { stdout: gitDirRaw } = await execAsync('git rev-parse --git-dir', { cwd: repoPath }); + const gitDir = path.resolve(repoPath, gitDirRaw.trim()); + + // Check for merge/rebase/cherry-pick indicators + let mergeOperationType: 'merge' | 'rebase' | 'cherry-pick' | null = null; + + const checks = [ + { file: 'MERGE_HEAD', type: 'merge' as const }, + { file: 'REBASE_HEAD', type: 'rebase' as const }, + { file: 'rebase-merge', type: 'rebase' as const }, + { file: 'rebase-apply', type: 'rebase' as const }, + { file: 'CHERRY_PICK_HEAD', type: 'cherry-pick' as const }, + ]; + + for (const check of checks) { + try { + await fs.access(path.join(gitDir, check.file)); + mergeOperationType = check.type; + break; + } catch { + // File doesn't exist, continue checking + } + } + + if (!mergeOperationType) { + return defaultState; + } + + // Get unmerged files (files with conflicts) + let conflictFiles: string[] = []; + try { + const { stdout: diffOutput } = await execAsync('git diff --name-only --diff-filter=U', { + cwd: repoPath, + }); + conflictFiles = diffOutput + .trim() + .split('\n') + .filter((f) => f.trim().length > 0); + } catch { + // Ignore errors getting conflict files + } + + // Get all files affected by the merge (staged files that came from the merge) + let mergeAffectedFiles: string[] = []; + try { + const { stdout: statusOutput } = await execAsync('git status --porcelain', { + cwd: repoPath, + }); + const files = parseGitStatus(statusOutput); + mergeAffectedFiles = files + .filter((f) => f.isMergeAffected || (f.indexStatus !== ' ' && f.indexStatus !== '?')) + .map((f) => f.path); + } catch { + // Ignore errors + } + + return { + isMerging: true, + mergeOperationType, + isCleanMerge: conflictFiles.length === 0, + mergeAffectedFiles, + conflictFiles, + }; + } catch { + return defaultState; + } +} diff --git a/libs/git-utils/src/types.ts b/libs/git-utils/src/types.ts index b6f16e666..638c10e04 100644 --- a/libs/git-utils/src/types.ts +++ b/libs/git-utils/src/types.ts @@ -2,6 +2,9 @@ * Git utilities types and constants */ +// Re-export MergeStateInfo from the centralized @automaker/types package +export type { MergeStateInfo } from '@automaker/types'; + // Binary file extensions to skip export const BINARY_EXTENSIONS = new Set([ '.png', @@ -74,4 +77,8 @@ export interface FileStatus { indexStatus?: string; /** Raw working tree status character from git porcelain format */ workTreeStatus?: string; + /** Whether this file is involved in a merge operation (both-modified, added-by-us, etc.) */ + isMergeAffected?: boolean; + /** Type of merge involvement: 'both-modified' | 'added-by-us' | 'added-by-them' | 'deleted-by-us' | 'deleted-by-them' | 'both-added' | 'both-deleted' */ + mergeType?: string; } diff --git a/libs/types/src/index.ts b/libs/types/src/index.ts index e0d214704..8c9863ce1 100644 --- a/libs/types/src/index.ts +++ b/libs/types/src/index.ts @@ -200,6 +200,7 @@ export { getThinkingTokenBudget, isAdaptiveThinkingModel, getThinkingLevelsForModel, + getDefaultThinkingLevel, // Event hook constants EVENT_HOOK_TRIGGER_LABELS, // Claude-compatible provider templates (new) @@ -359,6 +360,7 @@ export type { AddRemoteResult, AddRemoteResponse, AddRemoteErrorResponse, + MergeStateInfo, } from './worktree.js'; export { PR_STATES, validatePRState } from './worktree.js'; diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts index 0946e383f..9b0765648 100644 --- a/libs/types/src/settings.ts +++ b/libs/types/src/settings.ts @@ -268,6 +268,16 @@ export function getThinkingLevelsForModel(model: string): ThinkingLevel[] { return ['none', 'low', 'medium', 'high', 'ultrathink']; } +/** + * Get the default thinking level for a given model. + * Used when selecting a model via the primary button in the two-stage selector. + * Always returns 'none' — users can configure their preferred default + * via the defaultThinkingLevel setting in the model defaults page. + */ +export function getDefaultThinkingLevel(_model: string): ThinkingLevel { + return 'none'; +} + /** ModelProvider - AI model provider for credentials and API key management */ export type ModelProvider = 'claude' | 'cursor' | 'codex' | 'opencode' | 'gemini' | 'copilot'; @@ -1051,6 +1061,8 @@ export interface GlobalSettings { enableDependencyBlocking: boolean; /** Skip verification requirement in auto-mode (treat 'completed' same as 'verified') */ skipVerificationInAutoMode: boolean; + /** User's preferred action after a clean merge (null = ask every time) */ + mergePostAction: 'commit' | 'manual' | null; /** Default: use git worktrees for feature branches */ useWorktrees: boolean; /** Default: planning approach (skip/lite/spec/full) */ @@ -1086,6 +1098,15 @@ export interface GlobalSettings { /** Phase-specific AI model configuration */ phaseModels: PhaseModelConfig; + /** Default thinking level applied when selecting a model via the primary button + * in the two-stage model selector. Users can still adjust per-model via the expand arrow. + * Defaults to 'none' (no extended thinking). */ + defaultThinkingLevel?: ThinkingLevel; + + /** Default reasoning effort applied when selecting a Codex model via the primary button + * in the two-stage model selector. Defaults to 'none'. */ + defaultReasoningEffort?: ReasoningEffort; + // Legacy AI Model Selection (deprecated - use phaseModels instead) /** @deprecated Use phaseModels.enhancementModel instead */ enhancementModel: ModelAlias; @@ -1150,6 +1171,10 @@ export interface GlobalSettings { /** Maps project path -> last selected session ID in that project */ lastSelectedSessionByProject: Record; + // Worktree Selection Tracking + /** Maps project path -> last selected worktree (path + branch) for restoring on PWA reload */ + currentWorktreeByProject?: Record; + // Window State (Electron only) /** Persisted window bounds for restoring position/size across sessions */ windowBounds?: WindowBounds; @@ -1574,6 +1599,7 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = { defaultSkipTests: true, enableDependencyBlocking: true, skipVerificationInAutoMode: false, + mergePostAction: null, useWorktrees: true, defaultPlanningMode: 'skip', defaultRequirePlanApproval: false, @@ -1585,6 +1611,8 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = { showQueryDevtools: true, enableAiCommitMessages: true, phaseModels: DEFAULT_PHASE_MODELS, + defaultThinkingLevel: 'none', + defaultReasoningEffort: 'none', enhancementModel: 'sonnet', // Legacy alias still supported validationModel: 'opus', // Legacy alias still supported enabledCursorModels: getAllCursorModelIds(), // Returns prefixed IDs @@ -1607,6 +1635,7 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = { recentFolders: [], worktreePanelCollapsed: false, lastSelectedSessionByProject: {}, + currentWorktreeByProject: {}, autoLoadClaudeMd: true, skipSandboxWarning: false, codexAutoLoadAgents: DEFAULT_CODEX_AUTO_LOAD_AGENTS, diff --git a/libs/types/src/worktree.ts b/libs/types/src/worktree.ts index a3edff4c0..b5032adb5 100644 --- a/libs/types/src/worktree.ts +++ b/libs/types/src/worktree.ts @@ -74,3 +74,21 @@ export interface AddRemoteErrorResponse { /** Optional error code for specific error types (e.g., 'REMOTE_EXISTS') */ code?: string; } + +/** + * Merge state information for a git repository + */ +export interface MergeStateInfo { + /** Whether a merge is currently in progress */ + isMerging: boolean; + /** Type of merge operation: 'merge' | 'rebase' | 'cherry-pick' | null */ + mergeOperationType: 'merge' | 'rebase' | 'cherry-pick' | null; + /** Whether the merge completed cleanly (no conflicts) */ + isCleanMerge: boolean; + /** Files affected by the merge */ + mergeAffectedFiles: string[]; + /** Files with unresolved conflicts */ + conflictFiles: string[]; + /** Whether the current HEAD is a completed merge commit (has multiple parents) */ + isMergeCommit?: boolean; +} diff --git a/package-lock.json b/package-lock.json index f79257a8f..48dc3c7e2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3864,9 +3864,9 @@ } }, "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -7159,9 +7159,9 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "devOptional": true, "license": "MIT", "dependencies": { @@ -7193,9 +7193,9 @@ } }, "node_modules/ajv-formats/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -9985,9 +9985,9 @@ "license": "BSD-3-Clause" }, "node_modules/fast-xml-parser": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.3.tgz", - "integrity": "sha512-2O3dkPAAC6JavuMm8+4+pgTk+5hoAs+CjZ+sWcQLkX9+/tHRuTkQh/Oaifr8qDmZ8iEHb771Ea6G8CdwkrgvYA==", + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.7.tgz", + "integrity": "sha512-JzVLro9NQv92pOM/jTCR6mHlJh2FGwtomH8ZQjhFj/R29P2Fnj38OgPJVtcvYw6SuKClhgYuwUZf5b3rd8u2mA==", "funding": [ { "type": "github", @@ -9996,7 +9996,7 @@ ], "license": "MIT", "dependencies": { - "strnum": "^2.1.0" + "strnum": "^2.1.2" }, "bin": { "fxparser": "src/cli/cli.js" @@ -11554,7 +11554,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11576,7 +11575,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11598,7 +11596,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11620,7 +11617,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11642,7 +11638,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11664,7 +11659,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11686,7 +11680,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11708,7 +11701,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11730,7 +11722,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11752,7 +11743,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11774,7 +11764,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -12005,9 +11994,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "license": "MIT" }, "node_modules/lodash.debounce": { @@ -14363,9 +14352,9 @@ } }, "node_modules/qs": { - "version": "6.14.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", - "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -15005,9 +14994,9 @@ } }, "node_modules/seroval": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.4.0.tgz", - "integrity": "sha512-BdrNXdzlofomLTiRnwJTSEAaGKyHHZkbMXIywOh7zlzp4uZnXErEwl9XZ+N1hJSNpeTtNxWvVwN0wUzAIQ4Hpg==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.0.tgz", + "integrity": "sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw==", "license": "MIT", "engines": { "node": ">=10"