Fix concurrency limits and remote branch fetching issues (#788)

* Changes from fix/bug-fixes

* feat: Refactor worktree iteration and improve error logging across services

* feat: Extract URL/port patterns to module level and fix abort condition

* fix: Improve IPv6 loopback handling, select component layout, and terminal UI

* feat: Add thinking level defaults and adjust list row padding

* Update apps/ui/src/store/app-store.ts

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* feat: Add worktree-aware terminal creation and split options, fix npm security issues from audit

* feat: Add tracked remote detection to pull dialog flow

* feat: Add merge state tracking to git operations

* feat: Improve merge detection and add post-merge action preferences

* Update apps/ui/src/components/views/board-view/dialogs/git-pull-dialog.tsx

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Update apps/ui/src/components/views/board-view/dialogs/git-pull-dialog.tsx

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* fix: Pass merge detection info to stash reapplication and handle merge state consistently

* fix: Call onPulled callback in merge handlers and add validation checks

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
gsxdsm
2026-02-20 13:48:22 -08:00
committed by GitHub
parent 7df2182818
commit 0a5540c9a2
70 changed files with 4525 additions and 857 deletions

View File

@@ -26,23 +26,9 @@ export function createRunFeatureHandler(autoModeService: AutoModeServiceCompat)
return; return;
} }
// Check per-worktree capacity before starting // Note: No concurrency limit check here. Manual feature starts always run
const capacity = await autoModeService.checkWorktreeCapacity(projectPath, featureId); // immediately and bypass the concurrency limit. Their presence IS counted
if (!capacity.hasCapacity) { // by the auto-loop coordinator when deciding whether to dispatch new auto-mode tasks.
const worktreeDesc = capacity.branchName
? `worktree "${capacity.branchName}"`
: 'main worktree';
res.status(429).json({
success: false,
error: `Agent limit reached for ${worktreeDesc} (${capacity.currentAgents}/${capacity.maxAgents}). Wait for running tasks to complete or increase the limit.`,
details: {
currentAgents: capacity.currentAgents,
maxAgents: capacity.maxAgents,
branchName: capacity.branchName,
},
});
return;
}
// Start execution in background // Start execution in background
// executeFeature derives workDir from feature.branchName // executeFeature derives workDir from feature.branchName

View File

@@ -23,6 +23,7 @@ export function createDiffsHandler() {
diff: result.diff, diff: result.diff,
files: result.files, files: result.files,
hasChanges: result.hasChanges, hasChanges: result.hasChanges,
...(result.mergeState ? { mergeState: result.mergeState } : {}),
}); });
} catch (innerError) { } catch (innerError) {
logError(innerError, 'Git diff failed'); logError(innerError, 'Git diff failed');

View File

@@ -22,6 +22,36 @@ import { getErrorMessage, logError, isValidBranchName } from '../common.js';
import { execGitCommand } from '../../../lib/git.js'; import { execGitCommand } from '../../../lib/git.js';
import type { EventEmitter } from '../../../lib/events.js'; import type { EventEmitter } from '../../../lib/events.js';
import { performCheckoutBranch } from '../../../services/checkout-branch-service.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<void> {
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) { export function createCheckoutBranchHandler(events?: EventEmitter) {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
@@ -127,6 +157,10 @@ export function createCheckoutBranchHandler(events?: EventEmitter) {
} }
// Original simple flow (no stash handling) // 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( const currentBranchOutput = await execGitCommand(
['rev-parse', '--abbrev-ref', 'HEAD'], ['rev-parse', '--abbrev-ref', 'HEAD'],
resolvedPath resolvedPath

View File

@@ -30,6 +30,9 @@ import { runInitScript } from '../../../services/init-script-service.js';
const logger = createLogger('Worktree'); const logger = createLogger('Worktree');
/** Timeout for git fetch operations (30 seconds) */
const FETCH_TIMEOUT_MS = 30_000;
const execAsync = promisify(exec); 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) { export function createCreateHandler(events: EventEmitter, settingsService?: SettingsService) {
const worktreeService = new WorktreeService(); const worktreeService = new WorktreeService();
@@ -206,26 +174,23 @@ export function createCreateHandler(events: EventEmitter, settingsService?: Sett
// Create worktrees directory if it doesn't exist // Create worktrees directory if it doesn't exist
await secureFs.mkdir(worktreesDir, { recursive: true }); await secureFs.mkdir(worktreesDir, { recursive: true });
// If a base branch is specified and it's a remote branch, fetch from that remote first // Fetch latest from all remotes before creating the worktree.
// This ensures we have the latest refs before creating the worktree // This ensures remote refs are up-to-date for:
if (baseBranch && baseBranch !== 'HEAD') { // - Remote base branches (e.g. "origin/main")
const remoteBranchInfo = await detectRemoteBranch(projectPath, baseBranch); // - Existing remote branches being checked out as worktrees
if (remoteBranchInfo) { // - Branch existence checks against fresh remote state
logger.info( logger.info('Fetching from all remotes before creating worktree');
`Fetching from remote "${remoteBranchInfo.remote}" before creating worktree (base: ${baseBranch})` try {
); const controller = new AbortController();
try { const timerId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
await execGitCommand( try {
['fetch', remoteBranchInfo.remote, remoteBranchInfo.branch], await execGitCommand(['fetch', '--all', '--quiet'], projectPath, undefined, controller);
projectPath } finally {
); clearTimeout(timerId);
} 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)}`
);
}
} }
} 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) // Check if branch exists (using array arguments to prevent injection)

View File

@@ -34,6 +34,7 @@ export function createDiffsHandler() {
diff: result.diff, diff: result.diff,
files: result.files, files: result.files,
hasChanges: result.hasChanges, hasChanges: result.hasChanges,
...(result.mergeState ? { mergeState: result.mergeState } : {}),
}); });
return; return;
} }
@@ -55,6 +56,7 @@ export function createDiffsHandler() {
diff: result.diff, diff: result.diff,
files: result.files, files: result.files,
hasChanges: result.hasChanges, hasChanges: result.hasChanges,
...(result.mergeState ? { mergeState: result.mergeState } : {}),
}); });
} catch (innerError) { } catch (innerError) {
// Worktree doesn't exist - fallback to main project path // Worktree doesn't exist - fallback to main project path
@@ -71,6 +73,7 @@ export function createDiffsHandler() {
diff: result.diff, diff: result.diff,
files: result.files, files: result.files,
hasChanges: result.hasChanges, hasChanges: result.hasChanges,
...(result.mergeState ? { mergeState: result.mergeState } : {}),
}); });
} catch (fallbackError) { } catch (fallbackError) {
logError(fallbackError, 'Fallback to main project also failed'); logError(fallbackError, 'Fallback to main project also failed');

View File

@@ -83,6 +83,9 @@ function mapResultToResponse(res: Response, result: PullResult): void {
stashed: result.stashed, stashed: result.stashed,
stashRestored: result.stashRestored, stashRestored: result.stashRestored,
message: result.message, message: result.message,
isMerge: result.isMerge,
isFastForward: result.isFastForward,
mergeAffectedFiles: result.mergeAffectedFiles,
}, },
}); });
} }

View File

@@ -9,7 +9,7 @@
* For remote branches (e.g., "origin/feature"), automatically creates a * For remote branches (e.g., "origin/feature"), automatically creates a
* local tracking branch and checks it out. * 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. * Git business logic is delegated to worktree-branch-service.ts.
* Events are emitted at key lifecycle points for WebSocket subscribers. * Events are emitted at key lifecycle points for WebSocket subscribers.

View File

@@ -163,6 +163,10 @@ export class AutoLoopCoordinator {
const { projectPath, branchName } = projectState.config; const { projectPath, branchName } = projectState.config;
while (projectState.isRunning && !projectState.abortController.signal.aborted) { while (projectState.isRunning && !projectState.abortController.signal.aborted) {
try { 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); const runningCount = await this.getRunningCountForWorktree(projectPath, branchName);
if (runningCount >= projectState.config.maxConcurrency) { if (runningCount >= projectState.config.maxConcurrency) {
await this.sleep(5000, projectState.abortController.signal); await this.sleep(5000, projectState.abortController.signal);
@@ -298,11 +302,17 @@ export class AutoLoopCoordinator {
return Array.from(activeProjects); 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( async getRunningCountForWorktree(
projectPath: string, projectPath: string,
branchName: string | null branchName: string | null,
options?: { autoModeOnly?: boolean }
): Promise<number> { ): Promise<number> {
return this.concurrencyManager.getRunningCountForWorktree(projectPath, branchName); return this.concurrencyManager.getRunningCountForWorktree(projectPath, branchName, options);
} }
trackFailureAndCheckPauseForProject( trackFailureAndCheckPauseForProject(

View File

@@ -334,6 +334,23 @@ export class AutoModeServiceFacade {
async (pPath) => featureLoader.getAll(pPath) 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 // ExecutionService - runAgentFn delegates to AgentExecutor via shared helper
const executionService = new ExecutionService( const executionService = new ExecutionService(
eventBus, eventBus,
@@ -357,11 +374,36 @@ export class AutoModeServiceFacade {
(pPath, featureId) => getFacade().contextExists(featureId), (pPath, featureId) => getFacade().contextExists(featureId),
(pPath, featureId, useWorktrees, _calledInternally) => (pPath, featureId, useWorktrees, _calledInternally) =>
getFacade().resumeFeature(featureId, useWorktrees, _calledInternally), getFacade().resumeFeature(featureId, useWorktrees, _calledInternally),
(errorInfo) => (errorInfo) => {
autoLoopCoordinator.trackFailureAndCheckPauseForProject(projectPath, null, errorInfo), // Track failure against ALL active worktrees for this project.
(errorInfo) => autoLoopCoordinator.signalShouldPauseForProject(projectPath, null, errorInfo), // 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(), (_pPath) => getFacade().saveExecutionState(),
loadContextFiles loadContextFiles

View File

@@ -10,6 +10,7 @@
* Follows the same pattern as worktree-branch-service.ts (performSwitchBranch). * Follows the same pattern as worktree-branch-service.ts (performSwitchBranch).
* *
* The workflow: * The workflow:
* 0. Fetch latest from all remotes (ensures remote refs are up-to-date)
* 1. Validate inputs (branch name, base branch) * 1. Validate inputs (branch name, base branch)
* 2. Get current branch name * 2. Get current branch name
* 3. Check if target branch already exists * 3. Check if target branch already exists
@@ -19,11 +20,51 @@
* 7. Handle error recovery (restore stash if checkout fails) * 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 { execGitCommand } from '../lib/git.js';
import type { EventEmitter } from '../lib/events.js'; import type { EventEmitter } from '../lib/events.js';
import { hasAnyChanges, stashChanges, popStash, localBranchExists } from './branch-utils.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<void> {
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 // Types
// ============================================================================ // ============================================================================
@@ -78,6 +119,11 @@ export async function performCheckoutBranch(
// Emit start event // Emit start event
events?.emit('switch:start', { worktreePath, branchName, operation: 'checkout' }); 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 // 1. Get current branch
let previousBranch: string; let previousBranch: string;
try { try {

View File

@@ -170,17 +170,28 @@ export class ConcurrencyManager {
* @param projectPath - The project path * @param projectPath - The project path
* @param branchName - The branch name, or null for main worktree * @param branchName - The branch name, or null for main worktree
* (features without branchName or matching primary branch) * (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 * @returns Number of running features for the worktree
*/ */
async getRunningCountForWorktree( async getRunningCountForWorktree(
projectPath: string, projectPath: string,
branchName: string | null branchName: string | null,
options?: { autoModeOnly?: boolean }
): Promise<number> { ): Promise<number> {
// Get the actual primary branch name for the project // Get the actual primary branch name for the project
const primaryBranch = await this.getCurrentBranch(projectPath); const primaryBranch = await this.getCurrentBranch(projectPath);
let count = 0; let count = 0;
for (const [, feature] of this.runningFeatures) { 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 // Filter by project path AND branchName to get accurate worktree-specific count
const featureBranch = feature.branchName ?? null; const featureBranch = feature.branchName ?? null;
if (branchName === null) { if (branchName === null) {

View File

@@ -19,6 +19,69 @@ const logger = createLogger('DevServerService');
// Maximum scrollback buffer size (characters) - matches TerminalService pattern // Maximum scrollback buffer size (characters) - matches TerminalService pattern
const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per dev server 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 // Throttle output to prevent overwhelming WebSocket under heavy load
const OUTPUT_THROTTLE_MS = 4; // ~250fps max update rate for responsive feedback const OUTPUT_THROTTLE_MS = 4; // ~250fps max update rate for responsive feedback
const OUTPUT_BATCH_SIZE = 4096; // Smaller batches for lower latency 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 * 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 { private detectUrlFromOutput(server: DevServerInfo, content: string): void {
// Skip if URL already detected // Skip if URL already detected
@@ -115,39 +221,95 @@ class DevServerService {
return; return;
} }
// Common URL patterns from various dev servers: // Strip ANSI escape codes to prevent color codes from breaking regex matching
// - Vite: "Local: http://localhost:5173/" const cleanContent = this.stripAnsi(content);
// - 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) { // Phase 1: Try to detect a full URL from output
const match = content.match(pattern); // 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]) { if (match && match[1]) {
const detectedUrl = match[1].trim(); let detectedUrl = match[1].trim();
// Validate it looks like a reasonable URL // Remove trailing punctuation that might have been captured
detectedUrl = detectedUrl.replace(/[.,;:!?)\]}>]+$/, '');
if (detectedUrl.startsWith('http://') || detectedUrl.startsWith('https://')) { 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.url = detectedUrl;
server.urlDetected = true; 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 // Emit URL update event
if (this.emitter) { if (this.emitter) {
this.emitter.emit('dev-server:url-detected', { this.emitter.emit('dev-server:url-detected', {
worktreePath: server.worktreePath, worktreePath: server.worktreePath,
url: detectedUrl, url: detectedUrl,
port: server.port,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}); });
} }
break; 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(),
});
}
return;
} }
} }
} }
@@ -673,6 +835,7 @@ class DevServerService {
worktreePath: string; worktreePath: string;
port: number; port: number;
url: string; url: string;
urlDetected: boolean;
}>; }>;
}; };
} { } {
@@ -680,6 +843,7 @@ class DevServerService {
worktreePath: s.worktreePath, worktreePath: s.worktreePath,
port: s.port, port: s.port,
url: s.url, url: s.url,
urlDetected: s.urlDetected,
})); }));
return { return {

View File

@@ -46,6 +46,12 @@ export interface PullResult {
conflictSource?: 'pull' | 'stash'; conflictSource?: 'pull' | 'stash';
conflictFiles?: string[]; conflictFiles?: string[];
message?: 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'); 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<boolean> {
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. * 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]; const pullArgs = upstreamStatus === 'tracking' ? ['pull'] : ['pull', targetRemote, branchName];
let pullConflict = false; let pullConflict = false;
let pullConflictFiles: string[] = []; 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 { try {
const pullOutput = await execGitCommand(pullArgs, worktreePath); const pullOutput = await execGitCommand(pullArgs, worktreePath);
const alreadyUpToDate = pullOutput.includes('Already up to date'); 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 no stash to reapply, return success
if (!didStash) { if (!didStash) {
@@ -317,6 +377,8 @@ export async function performPull(
stashed: false, stashed: false,
stashRestored: false, stashRestored: false,
message: alreadyUpToDate ? 'Already up to date' : 'Pulled latest changes', message: alreadyUpToDate ? 'Already up to date' : 'Pulled latest changes',
...(isMerge ? { isMerge: true, mergeAffectedFiles } : {}),
...(isFastForward ? { isFastForward: true } : {}),
}; };
} }
} catch (pullError: unknown) { } catch (pullError: unknown) {
@@ -374,7 +436,11 @@ export async function performPull(
// 10. Pull succeeded, now try to reapply stash // 10. Pull succeeded, now try to reapply stash
if (didStash) { if (didStash) {
return await reapplyStash(worktreePath, branchName); return await reapplyStash(worktreePath, branchName, {
isMerge,
isFastForward,
mergeAffectedFiles,
});
} }
// Shouldn't reach here, but return a safe default // 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 worktreePath - Path to the git worktree
* @param branchName - Current branch name * @param branchName - Current branch name
* @param mergeInfo - Merge/fast-forward detection info from the pull step
* @returns PullResult reflecting stash reapplication status * @returns PullResult reflecting stash reapplication status
*/ */
async function reapplyStash(worktreePath: string, branchName: string): Promise<PullResult> { async function reapplyStash(
worktreePath: string,
branchName: string,
mergeInfo: { isMerge: boolean; isFastForward: boolean; mergeAffectedFiles: string[] }
): Promise<PullResult> {
const mergeFields: Partial<PullResult> = {
...(mergeInfo.isMerge
? { isMerge: true, mergeAffectedFiles: mergeInfo.mergeAffectedFiles }
: {}),
...(mergeInfo.isFastForward ? { isFastForward: true } : {}),
};
try { try {
await popStash(worktreePath); await popStash(worktreePath);
@@ -406,6 +484,7 @@ async function reapplyStash(worktreePath: string, branchName: string): Promise<P
hasConflicts: false, hasConflicts: false,
stashed: true, stashed: true,
stashRestored: true, stashRestored: true,
...mergeFields,
message: 'Pulled latest changes and restored your stashed changes.', message: 'Pulled latest changes and restored your stashed changes.',
}; };
} catch (stashPopError: unknown) { } catch (stashPopError: unknown) {
@@ -431,6 +510,7 @@ async function reapplyStash(worktreePath: string, branchName: string): Promise<P
conflictFiles: stashConflictFiles, conflictFiles: stashConflictFiles,
stashed: true, stashed: true,
stashRestored: false, stashRestored: false,
...mergeFields,
message: 'Pull succeeded but reapplying your stashed changes resulted in merge conflicts.', message: 'Pull succeeded but reapplying your stashed changes resulted in merge conflicts.',
}; };
} }
@@ -445,6 +525,7 @@ async function reapplyStash(worktreePath: string, branchName: string): Promise<P
hasConflicts: false, hasConflicts: false,
stashed: true, stashed: true,
stashRestored: false, stashRestored: false,
...mergeFields,
message: message:
'Pull succeeded but failed to reapply stashed changes. Your changes are still in the stash list.', 'Pull succeeded but failed to reapply stashed changes. Your changes are still in the stash list.',
}; };

View File

@@ -9,7 +9,8 @@
* For remote branches (e.g., "origin/feature"), automatically creates a * For remote branches (e.g., "origin/feature"), automatically creates a
* local tracking branch and checks it out. * local tracking branch and checks it out.
* *
* Also fetches the latest remote refs after switching. * Fetches the latest remote refs before switching to ensure remote branch
* references are up-to-date for accurate detection and checkout.
* *
* Extracted from the worktree switch-branch route to improve organization * Extracted from the worktree switch-branch route to improve organization
* and testability. Follows the same pattern as pull-service.ts and * and testability. Follows the same pattern as pull-service.ts and
@@ -57,7 +58,8 @@ const FETCH_TIMEOUT_MS = 30_000;
* slow or unresponsive remote does not block the branch-switch flow * slow or unresponsive remote does not block the branch-switch flow
* indefinitely. Timeout errors are logged and treated as non-fatal * indefinitely. Timeout errors are logged and treated as non-fatal
* (the same as network-unavailable errors) so the rest of the workflow * (the same as network-unavailable errors) so the rest of the workflow
* continues normally. * continues normally. This is called before the branch switch to
* ensure remote refs are up-to-date for branch detection and checkout.
*/ */
async function fetchRemotes(cwd: string): Promise<void> { async function fetchRemotes(cwd: string): Promise<void> {
const controller = new AbortController(); const controller = new AbortController();
@@ -66,15 +68,15 @@ async function fetchRemotes(cwd: string): Promise<void> {
try { try {
await execGitCommand(['fetch', '--all', '--quiet'], cwd, undefined, controller); await execGitCommand(['fetch', '--all', '--quiet'], cwd, undefined, controller);
} catch (error) { } 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 // Fetch timed out - log and continue; callers should not be blocked by a slow remote
logger.warn( logger.warn(
`fetchRemotes timed out after ${FETCH_TIMEOUT_MS}ms - continuing without latest remote refs` `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 // Non-fatal: continue with locally available refs regardless of failure type
// remote may be temporarily unavailable. The branch switch itself has
// already succeeded at this point.
} finally { } finally {
clearTimeout(timerId); clearTimeout(timerId);
} }
@@ -126,13 +128,13 @@ async function isRemoteBranch(cwd: string, branchName: string): Promise<boolean>
* Perform a full branch switch workflow on the given worktree. * Perform a full branch switch workflow on the given worktree.
* *
* The workflow: * The workflow:
* 1. Get current branch name * 1. Fetch latest from all remotes (ensures remote refs are up-to-date)
* 2. Detect remote vs local branch and determine target * 2. Get current branch name
* 3. Return early if already on target branch * 3. Detect remote vs local branch and determine target
* 4. Validate branch existence * 4. Return early if already on target branch
* 5. Stash local changes if any * 5. Validate branch existence
* 6. Checkout the target branch * 6. Stash local changes if any
* 7. Fetch latest from remotes * 7. Checkout the target branch
* 8. Reapply stashed changes (detect conflicts) * 8. Reapply stashed changes (detect conflicts)
* 9. Handle error recovery (restore stash if checkout fails) * 9. Handle error recovery (restore stash if checkout fails)
* *
@@ -149,14 +151,20 @@ export async function performSwitchBranch(
// Emit start event // Emit start event
events?.emit('switch:start', { worktreePath, branchName }); 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( const currentBranchOutput = await execGitCommand(
['rev-parse', '--abbrev-ref', 'HEAD'], ['rev-parse', '--abbrev-ref', 'HEAD'],
worktreePath worktreePath
); );
const previousBranch = currentBranchOutput.trim(); 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 targetBranch = branchName;
let isRemote = false; 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) { if (previousBranch === targetBranch) {
events?.emit('switch:done', { events?.emit('switch:done', {
worktreePath, 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 (!isRemote) {
if (!(await localBranchExists(worktreePath, branchName))) { if (!(await localBranchExists(worktreePath, branchName))) {
events?.emit('switch:error', { 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 }); const hadChanges = await hasAnyChanges(worktreePath, { excludeWorktreePaths: true });
let didStash = false; let didStash = false;
@@ -242,7 +250,7 @@ export async function performSwitchBranch(
} }
try { try {
// 6. Switch to the target branch // 7. Switch to the target branch
events?.emit('switch:checkout', { events?.emit('switch:checkout', {
worktreePath, worktreePath,
targetBranch, targetBranch,
@@ -265,9 +273,6 @@ export async function performSwitchBranch(
await execGitCommand(['checkout', targetBranch], worktreePath); await execGitCommand(['checkout', targetBranch], worktreePath);
} }
// 7. Fetch latest from remotes after switching
await fetchRemotes(worktreePath);
// 8. Reapply stashed changes if we stashed earlier // 8. Reapply stashed changes if we stashed earlier
let hasConflicts = false; let hasConflicts = false;
let conflictMessage = ''; let conflictMessage = '';
@@ -347,7 +352,7 @@ export async function performSwitchBranch(
}; };
} }
} catch (checkoutError) { } 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) { if (didStash) {
const popResult = await popStash(worktreePath); const popResult = await popStash(worktreePath);
if (popResult.hasConflicts) { if (popResult.hasConflicts) {

View File

@@ -328,6 +328,86 @@ describe('auto-loop-coordinator.ts', () => {
// Should not have executed features because at capacity // Should not have executed features because at capacity
expect(mockExecuteFeature).not.toHaveBeenCalled(); 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', () => { describe('priority-based feature selection', () => {
@@ -788,7 +868,23 @@ describe('auto-loop-coordinator.ts', () => {
expect(count).toBe(3); expect(count).toBe(3);
expect(mockConcurrencyManager.getRunningCountForWorktree).toHaveBeenCalledWith( expect(mockConcurrencyManager.getRunningCountForWorktree).toHaveBeenCalledWith(
'/test/project', '/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 }
); );
}); });
}); });

View File

@@ -416,6 +416,90 @@ describe('ConcurrencyManager', () => {
expect(mainCount).toBe(2); 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 () => { it('should filter by both projectPath and branchName', async () => {
manager.acquire({ manager.acquire({
featureId: 'feature-1', featureId: 'feature-1',

View File

@@ -486,7 +486,7 @@ describe('dev-server-service.ts', () => {
await service.startDevServer(testDir, testDir); await service.startDevServer(testDir, testDir);
// Simulate HTTPS dev server // 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)); 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(firstUrl);
expect(serverInfo?.url).toBe('http://localhost:5173/'); 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('<i> [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.stderr = new EventEmitter();
mockProcess.kill = vi.fn(); mockProcess.kill = vi.fn();
mockProcess.killed = false; mockProcess.killed = false;
mockProcess.pid = 12345;
// Don't exit immediately - let the test control the lifecycle // Don't exit immediately - let the test control the lifecycle
return mockProcess; return mockProcess;

View File

@@ -10,6 +10,7 @@ import {
ChevronRight, ChevronRight,
RefreshCw, RefreshCw,
GitBranch, GitBranch,
GitMerge,
AlertCircle, AlertCircle,
Plus, Plus,
Minus, Minus,
@@ -20,7 +21,7 @@ import { Button } from './button';
import { useWorktreeDiffs, useGitDiffs } from '@/hooks/queries'; import { useWorktreeDiffs, useGitDiffs } from '@/hooks/queries';
import { getElectronAPI } from '@/lib/electron'; import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner'; import { toast } from 'sonner';
import type { FileStatus } from '@/types/electron'; import type { FileStatus, MergeStateInfo } from '@/types/electron';
interface GitDiffPanelProps { interface GitDiffPanelProps {
projectPath: string; 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 (
<span className="text-[10px] px-1.5 py-0.5 rounded border font-medium bg-purple-500/15 text-purple-400 border-purple-500/30 inline-flex items-center gap-1">
<GitMerge className="w-2.5 h-2.5" />
{label}
</span>
);
}
function MergeStateBanner({ mergeState }: { mergeState: MergeStateInfo }) {
// Completed merge commit (HEAD is a merge)
if (mergeState.isMergeCommit && !mergeState.isMerging) {
return (
<div className="mx-4 mt-3 flex items-start gap-2 p-3 rounded-md bg-purple-500/10 border border-purple-500/20">
<GitMerge className="w-4 h-4 text-purple-500 mt-0.5 flex-shrink-0" />
<div className="text-sm">
<span className="font-medium text-purple-400">Merge commit</span>
<span className="text-purple-400/80 ml-1">
&mdash; {mergeState.mergeAffectedFiles.length} file
{mergeState.mergeAffectedFiles.length !== 1 ? 's' : ''} changed in merge
</span>
</div>
</div>
);
}
// In-progress merge/rebase/cherry-pick
const operationLabel =
mergeState.mergeOperationType === 'cherry-pick'
? 'Cherry-pick'
: mergeState.mergeOperationType === 'rebase'
? 'Rebase'
: 'Merge';
return (
<div className="mx-4 mt-3 flex items-start gap-2 p-3 rounded-md bg-purple-500/10 border border-purple-500/20">
<GitMerge className="w-4 h-4 text-purple-500 mt-0.5 flex-shrink-0" />
<div className="text-sm">
<span className="font-medium text-purple-400">{operationLabel} in progress</span>
{mergeState.conflictFiles.length > 0 ? (
<span className="text-purple-400/80 ml-1">
&mdash; {mergeState.conflictFiles.length} file
{mergeState.conflictFiles.length !== 1 ? 's' : ''} with conflicts
</span>
) : mergeState.isCleanMerge ? (
<span className="text-purple-400/80 ml-1">
&mdash; Clean merge, {mergeState.mergeAffectedFiles.length} file
{mergeState.mergeAffectedFiles.length !== 1 ? 's' : ''} affected
</span>
) : null}
</div>
</div>
);
}
function FileDiffSection({ function FileDiffSection({
fileDiff, fileDiff,
isExpanded, isExpanded,
@@ -348,9 +429,21 @@ function FileDiffSection({
const stagingState = fileStatus ? getStagingState(fileStatus) : undefined; const stagingState = fileStatus ? getStagingState(fileStatus) : undefined;
const isMergeFile = fileStatus?.isMergeAffected;
return ( return (
<div className="border border-border rounded-lg overflow-hidden"> <div
<div className="w-full px-3 py-2 flex flex-col gap-1 text-left bg-card hover:bg-accent/50 transition-colors sm:flex-row sm:items-center sm:gap-2"> className={cn(
'border rounded-lg overflow-hidden',
isMergeFile ? 'border-purple-500/40' : 'border-border'
)}
>
<div
className={cn(
'w-full px-3 py-2 flex flex-col gap-1 text-left transition-colors sm:flex-row sm:items-center sm:gap-2',
isMergeFile ? 'bg-purple-500/5 hover:bg-purple-500/10' : 'bg-card hover:bg-accent/50'
)}
>
{/* File name row */} {/* File name row */}
<button onClick={onToggle} className="flex items-center gap-2 flex-1 min-w-0 text-left"> <button onClick={onToggle} className="flex items-center gap-2 flex-1 min-w-0 text-left">
{isExpanded ? ( {isExpanded ? (
@@ -358,7 +451,9 @@ function FileDiffSection({
) : ( ) : (
<ChevronRight className="w-4 h-4 text-muted-foreground flex-shrink-0" /> <ChevronRight className="w-4 h-4 text-muted-foreground flex-shrink-0" />
)} )}
{fileStatus ? ( {isMergeFile ? (
<GitMerge className="w-4 h-4 text-purple-500 flex-shrink-0" />
) : fileStatus ? (
getFileIcon(fileStatus.status) getFileIcon(fileStatus.status)
) : ( ) : (
<FileText className="w-4 h-4 text-muted-foreground flex-shrink-0" /> <FileText className="w-4 h-4 text-muted-foreground flex-shrink-0" />
@@ -370,6 +465,7 @@ function FileDiffSection({
</button> </button>
{/* Indicators & staging row */} {/* Indicators & staging row */}
<div className="flex items-center gap-2 flex-shrink-0 pl-6 sm:pl-0"> <div className="flex items-center gap-2 flex-shrink-0 pl-6 sm:pl-0">
{fileStatus?.isMergeAffected && <MergeBadge mergeType={fileStatus.mergeType} />}
{enableStaging && stagingState && <StagingBadge state={stagingState} />} {enableStaging && stagingState && <StagingBadge state={stagingState} />}
{fileDiff.isNew && ( {fileDiff.isNew && (
<span className="text-xs px-1.5 py-0.5 rounded bg-green-500/20 text-green-400"> <span className="text-xs px-1.5 py-0.5 rounded bg-green-500/20 text-green-400">
@@ -483,9 +579,10 @@ export function GitDiffPanel({
const isLoading = useWorktrees ? isLoadingWorktree : isLoadingGit; const isLoading = useWorktrees ? isLoadingWorktree : isLoadingGit;
const queryError = useWorktrees ? worktreeError : gitError; 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 files: FileStatus[] = diffsData?.files ?? [];
const diffContent = diffsData?.diff ?? ''; const diffContent = diffsData?.diff ?? '';
const mergeState: MergeStateInfo | undefined = diffsData?.mergeState;
const error = queryError const error = queryError
? queryError instanceof Error ? queryError instanceof Error
? queryError.message ? queryError.message
@@ -495,8 +592,6 @@ export function GitDiffPanel({
// Refetch function // Refetch function
const loadDiffs = useWorktrees ? refetchWorktree : refetchGit; const loadDiffs = useWorktrees ? refetchWorktree : refetchGit;
const parsedDiffs = useMemo(() => parseDiff(diffContent), [diffContent]);
// Build a map from file path to FileStatus for quick lookup // Build a map from file path to FileStatus for quick lookup
const fileStatusMap = useMemo(() => { const fileStatusMap = useMemo(() => {
const map = new Map<string, FileStatus>(); const map = new Map<string, FileStatus>();
@@ -506,6 +601,24 @@ export function GitDiffPanel({
return map; return map;
}, [files]); }, [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) => { const toggleFile = (filePath: string) => {
setExpandedFiles((prev) => { setExpandedFiles((prev) => {
const next = new Set(prev); const next = new Set(prev);
@@ -682,6 +795,18 @@ export function GitDiffPanel({
); );
}, [worktreePath, projectPath, useWorktrees, enableStaging, files, executeStagingAction]); }, [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 // Compute staging summary
const stagingSummary = useMemo(() => { const stagingSummary = useMemo(() => {
if (!enableStaging) return null; if (!enableStaging) return null;
@@ -776,6 +901,11 @@ export function GitDiffPanel({
</div> </div>
) : ( ) : (
<div> <div>
{/* Merge state banner */}
{(mergeState?.isMerging || mergeState?.isMergeCommit) && (
<MergeStateBanner mergeState={mergeState} />
)}
{/* Summary bar */} {/* Summary bar */}
<div className="p-4 pb-2 border-b border-border-glass"> <div className="p-4 pb-2 border-b border-border-glass">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
@@ -799,7 +929,7 @@ export function GitDiffPanel({
{} as Record<string, { count: number; statusText: string; files: string[] }> {} as Record<string, { count: number; statusText: string; files: string[] }>
); );
return Object.entries(statusGroups).map(([status, group]) => ( const groups = Object.entries(statusGroups).map(([status, group]) => (
<div <div
key={status} key={status}
className="flex items-center gap-1.5" className="flex items-center gap-1.5"
@@ -817,6 +947,24 @@ export function GitDiffPanel({
</span> </span>
</div> </div>
)); ));
// Add merge group indicator if merge files exist
if (mergeSummary) {
groups.unshift(
<div
key="merge"
className="flex items-center gap-1.5"
data-testid="git-status-group-merge"
>
<GitMerge className="w-4 h-4 text-purple-500" />
<span className="text-xs px-1.5 py-0.5 rounded border font-medium bg-purple-500/20 text-purple-400 border-purple-500/30">
{mergeSummary.total} Merge
</span>
</div>
);
}
return groups;
})()} })()}
</div> </div>
<div className="flex items-center gap-1 flex-wrap"> <div className="flex items-center gap-1 flex-wrap">
@@ -907,7 +1055,7 @@ export function GitDiffPanel({
fileDiff={fileDiff} fileDiff={fileDiff}
isExpanded={expandedFiles.has(fileDiff.filePath)} isExpanded={expandedFiles.has(fileDiff.filePath)}
onToggle={() => toggleFile(fileDiff.filePath)} onToggle={() => toggleFile(fileDiff.filePath)}
fileStatus={enableStaging ? fileStatusMap.get(fileDiff.filePath) : undefined} fileStatus={fileStatusMap.get(fileDiff.filePath)}
enableStaging={enableStaging} enableStaging={enableStaging}
onStage={enableStaging ? handleStageFile : undefined} onStage={enableStaging ? handleStageFile : undefined}
onUnstage={enableStaging ? handleUnstageFile : undefined} onUnstage={enableStaging ? handleUnstageFile : undefined}
@@ -919,15 +1067,28 @@ export function GitDiffPanel({
<div className="space-y-2"> <div className="space-y-2">
{files.map((file) => { {files.map((file) => {
const stagingState = getStagingState(file); const stagingState = getStagingState(file);
const isFileMerge = file.isMergeAffected;
return ( return (
<div <div
key={file.path} key={file.path}
className="border border-border rounded-lg overflow-hidden" className={cn(
'border rounded-lg overflow-hidden',
isFileMerge ? 'border-purple-500/40' : 'border-border'
)}
> >
<div className="w-full px-3 py-2 flex flex-col gap-1 text-left bg-card sm:flex-row sm:items-center sm:gap-2"> <div
className={cn(
'w-full px-3 py-2 flex flex-col gap-1 text-left sm:flex-row sm:items-center sm:gap-2',
isFileMerge ? 'bg-purple-500/5 hover:bg-purple-500/10' : 'bg-card'
)}
>
{/* File name row */} {/* File name row */}
<div className="flex items-center gap-2 flex-1 min-w-0"> <div className="flex items-center gap-2 flex-1 min-w-0">
{getFileIcon(file.status)} {isFileMerge ? (
<GitMerge className="w-4 h-4 text-purple-500 flex-shrink-0" />
) : (
getFileIcon(file.status)
)}
<TruncatedFilePath <TruncatedFilePath
path={file.path} path={file.path}
className="flex-1 text-sm font-mono text-foreground" className="flex-1 text-sm font-mono text-foreground"
@@ -935,6 +1096,7 @@ export function GitDiffPanel({
</div> </div>
{/* Indicators & staging row */} {/* Indicators & staging row */}
<div className="flex items-center gap-2 flex-shrink-0 pl-6 sm:pl-0"> <div className="flex items-center gap-2 flex-shrink-0 pl-6 sm:pl-0">
{isFileMerge && <MergeBadge mergeType={file.mergeType} />}
{enableStaging && <StagingBadge state={stagingState} />} {enableStaging && <StagingBadge state={stagingState} />}
<span <span
className={cn( className={cn(

View File

@@ -126,7 +126,7 @@ const SelectItem = React.forwardRef<
</span> </span>
{description ? ( {description ? (
<div className="flex flex-col items-start"> <div className="flex flex-col items-start w-full min-w-0">
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
{description} {description}
</div> </div>

View File

@@ -215,7 +215,7 @@ function TestLogsPanelContent({
return ( return (
<> <>
{/* Header */} {/* Header */}
<DialogHeader className="shrink-0 px-4 py-3 border-b border-border/50 pr-12"> <DialogHeader className="shrink-0 px-4 py-3 border-b border-border/50 pr-12 dialog-compact-header-mobile">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<DialogTitle className="flex items-center gap-2 text-base"> <DialogTitle className="flex items-center gap-2 text-base">
<FlaskConical className="w-4 h-4 text-primary" /> <FlaskConical className="w-4 h-4 text-primary" />

View File

@@ -375,10 +375,20 @@ export function BoardView() {
return specificTargetCollisions; return specificTargetCollisions;
} }
// Priority 2: Columns // Priority 2: Columns (including column headers and pipeline columns)
const columnCollisions = pointerCollisions.filter((collision: Collision) => const columnCollisions = pointerCollisions.filter((collision: Collision) => {
COLUMNS.some((col) => col.id === collision.id) 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 we found a column collision, use that
if (columnCollisions.length > 0) { 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) { if (autoMode.isRunning) {
// Restart auto mode with new concurrency (backend will handle this) autoMode.restartWithConcurrency().catch((error) => {
autoMode.stop().then(() => { logger.error('[AutoMode] Failed to restart with new concurrency:', error);
autoMode.start().catch((error) => {
logger.error('[AutoMode] Failed to restart with new concurrency:', error);
});
}); });
} }
} }

View File

@@ -17,6 +17,8 @@ import {
interface CardActionsProps { interface CardActionsProps {
feature: Feature; feature: Feature;
isCurrentAutoTask: boolean; 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; hasContext?: boolean;
shortcutKey?: string; shortcutKey?: string;
isSelectionMode?: boolean; isSelectionMode?: boolean;
@@ -36,6 +38,7 @@ interface CardActionsProps {
export const CardActions = memo(function CardActions({ export const CardActions = memo(function CardActions({
feature, feature,
isCurrentAutoTask, isCurrentAutoTask,
isRunningTask = false,
hasContext: _hasContext, hasContext: _hasContext,
shortcutKey, shortcutKey,
isSelectionMode = false, isSelectionMode = false,
@@ -340,7 +343,57 @@ export const CardActions = memo(function CardActions({
) : null} ) : 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 && {!isCurrentAutoTask &&
isRunningTask &&
(feature.status === 'backlog' ||
feature.status === 'interrupted' ||
feature.status === 'ready') && (
<>
{onViewOutput && (
<Button
variant="secondary"
size="sm"
className="flex-1 h-7 text-[11px]"
onClick={(e) => {
e.stopPropagation();
onViewOutput();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`view-output-${feature.id}`}
>
<FileText className="w-3 h-3 mr-1 shrink-0" />
<span className="truncate">Logs</span>
{shortcutKey && (
<span
className="ml-1.5 px-1 py-0.5 text-[9px] font-mono rounded bg-foreground/10"
data-testid={`shortcut-key-${feature.id}`}
>
{shortcutKey}
</span>
)}
</Button>
)}
{onForceStop && (
<Button
variant="destructive"
size="sm"
className="h-7 text-[11px] px-2 shrink-0"
onClick={(e) => {
e.stopPropagation();
onForceStop();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`force-stop-${feature.id}`}
>
<StopCircle className="w-3 h-3" />
</Button>
)}
</>
)}
{!isCurrentAutoTask &&
!isRunningTask &&
(feature.status === 'backlog' || (feature.status === 'backlog' ||
feature.status === 'interrupted' || feature.status === 'interrupted' ||
feature.status === 'ready') && ( feature.status === 'ready') && (

View File

@@ -114,15 +114,27 @@ export const KanbanCard = memo(function KanbanCard({
currentProject: state.currentProject, currentProject: state.currentProject,
})) }))
); );
// A card should only display as "actively running" if it's both in the // A card should display as "actively running" if it's in the runningAutoTasks list
// runningAutoTasks list AND in an execution-compatible status. Cards in resting // AND in an execution-compatible status. However, there's a race window where a feature
// states (backlog, ready, waiting_approval, verified, completed) should never // is tracked as running (in runningAutoTasks) but its disk/UI status hasn't caught up yet
// show running controls, even if they appear in runningAutoTasks due to stale // (still 'backlog', 'ready', or 'interrupted'). In this case, we still want to show
// state (e.g., after a server restart that reconciled features back to backlog). // running controls (Logs/Stop) and animated border, but not the full "actively running"
// state that gates all UI behavior.
const isInExecutionState = const isInExecutionState =
feature.status === 'in_progress' || feature.status === 'in_progress' ||
(typeof feature.status === 'string' && feature.status.startsWith('pipeline_')); (typeof feature.status === 'string' && feature.status.startsWith('pipeline_'));
const isActivelyRunning = !!isCurrentAutoTask && isInExecutionState; 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); const [isLifted, setIsLifted] = useState(false);
useLayoutEffect(() => { useLayoutEffect(() => {
@@ -135,6 +147,7 @@ export const KanbanCard = memo(function KanbanCard({
const isDraggable = const isDraggable =
!isSelectionMode && !isSelectionMode &&
!isRunningWithStaleStatus &&
(feature.status === 'backlog' || (feature.status === 'backlog' ||
feature.status === 'interrupted' || feature.status === 'interrupted' ||
feature.status === 'ready' || feature.status === 'ready' ||
@@ -198,13 +211,13 @@ export const KanbanCard = memo(function KanbanCard({
'kanban-card-content h-full relative', 'kanban-card-content h-full relative',
reduceEffects ? 'shadow-none' : 'shadow-sm', reduceEffects ? 'shadow-none' : 'shadow-sm',
'transition-all duration-200 ease-out', '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 && isInteractive &&
!reduceEffects && !reduceEffects &&
!isActivelyRunning && !showRunningVisuals &&
'hover:-translate-y-0.5 hover:shadow-md hover:shadow-black/10 bg-transparent', 'hover:-translate-y-0.5 hover:shadow-md hover:shadow-black/10 bg-transparent',
!glassmorphism && 'backdrop-blur-[0px]!', !glassmorphism && 'backdrop-blur-[0px]!',
!isActivelyRunning && !showRunningVisuals &&
cardBorderEnabled && cardBorderEnabled &&
(cardBorderOpacity === 100 ? 'border-border/50' : 'border'), (cardBorderOpacity === 100 ? 'border-border/50' : 'border'),
hasError && 'border-[var(--status-error)] border-2 shadow-[var(--status-error-bg)] shadow-lg', 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 = () => ( const renderCardContent = () => (
<Card <Card
style={isActivelyRunning ? undefined : cardStyle} style={showRunningVisuals ? undefined : cardStyle}
className={innerCardClasses} className={innerCardClasses}
onDoubleClick={isSelectionMode ? undefined : onEdit} onDoubleClick={isSelectionMode ? undefined : onEdit}
onClick={handleCardClick} onClick={handleCardClick}
@@ -290,6 +303,7 @@ export const KanbanCard = memo(function KanbanCard({
<CardActions <CardActions
feature={feature} feature={feature}
isCurrentAutoTask={isActivelyRunning} isCurrentAutoTask={isActivelyRunning}
isRunningTask={!!isCurrentAutoTask}
hasContext={hasContext} hasContext={hasContext}
shortcutKey={shortcutKey} shortcutKey={shortcutKey}
isSelectionMode={isSelectionMode} isSelectionMode={isSelectionMode}
@@ -316,7 +330,7 @@ export const KanbanCard = memo(function KanbanCard({
className={wrapperClasses} className={wrapperClasses}
data-testid={`kanban-card-${feature.id}`} data-testid={`kanban-card-${feature.id}`}
> >
{isActivelyRunning ? ( {showRunningVisuals ? (
<div className="animated-border-wrapper">{renderCardContent()}</div> <div className="animated-border-wrapper">{renderCardContent()}</div>
) : ( ) : (
renderCardContent() renderCardContent()

View File

@@ -42,7 +42,12 @@ export const KanbanColumn = memo(function KanbanColumn({
contentStyle, contentStyle,
disableItemSpacing = false, disableItemSpacing = false,
}: KanbanColumnProps) { }: 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 // Use inline style for width if provided, otherwise use default w-72
const widthStyle = width ? { width: `${width}px`, flexShrink: 0 } : undefined; const widthStyle = width ? { width: `${width}px`, flexShrink: 0 } : undefined;
@@ -70,8 +75,9 @@ export const KanbanColumn = memo(function KanbanColumn({
style={{ opacity: opacity / 100 }} style={{ opacity: opacity / 100 }}
/> />
{/* Column Header */} {/* Column Header - also registered as a drop target so dragging to the header area works */}
<div <div
ref={setHeaderDropRef}
className={cn( className={cn(
'relative z-10 flex items-center gap-3 px-3 py-2.5', 'relative z-10 flex items-center gap-3 px-3 py-2.5',
showBorder && 'border-b border-border/40' showBorder && 'border-b border-border/40'

View File

@@ -209,15 +209,22 @@ export const ListRow = memo(function ListRow({
blockingDependencies = [], blockingDependencies = [],
className, className,
}: ListRowProps) { }: ListRowProps) {
// A row should only display as "actively running" if it's both in the // A row should display as "actively running" if it's in the runningAutoTasks list
// runningAutoTasks list AND in an execution-compatible status. Features in resting // AND in an execution-compatible status. However, there's a race window where a feature
// states (backlog, ready, waiting_approval, verified, completed) should never // is tracked as running but its status hasn't caught up yet (still 'backlog', 'ready',
// show running controls, even if they appear in runningAutoTasks due to stale // or 'interrupted'). We handle this with isRunningWithStaleStatus.
// state (e.g., after a server restart that reconciled features back to backlog).
const isInExecutionState = const isInExecutionState =
feature.status === 'in_progress' || feature.status === 'in_progress' ||
(typeof feature.status === 'string' && feature.status.startsWith('pipeline_')); (typeof feature.status === 'string' && feature.status.startsWith('pipeline_'));
const isActivelyRunning = isCurrentAutoTask && isInExecutionState; const isActivelyRunning = isCurrentAutoTask && isInExecutionState;
// Feature is tracked as running but status hasn't updated yet - show running UI
const isRunningWithStaleStatus =
isCurrentAutoTask &&
!isInExecutionState &&
(feature.status === 'backlog' ||
feature.status === 'ready' ||
feature.status === 'interrupted');
const showRunningVisuals = isActivelyRunning || isRunningWithStaleStatus;
const handleRowClick = useCallback( const handleRowClick = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
@@ -268,7 +275,7 @@ export const ListRow = memo(function ListRow({
> >
{/* Checkbox column */} {/* Checkbox column */}
{showCheckbox && ( {showCheckbox && (
<div role="cell" className="flex items-center justify-center w-10 px-2 py-3 shrink-0"> <div role="cell" className="flex items-center justify-center w-10 px-2 py-2 shrink-0">
<input <input
type="checkbox" type="checkbox"
checked={isSelected} checked={isSelected}
@@ -287,7 +294,7 @@ export const ListRow = memo(function ListRow({
<div <div
role="cell" role="cell"
className={cn( className={cn(
'flex items-center pl-3 pr-0 py-3 gap-0', 'flex items-center pl-3 pr-0 py-2 gap-0',
getColumnWidth('title'), getColumnWidth('title'),
getColumnAlign('title') getColumnAlign('title')
)} )}
@@ -296,7 +303,7 @@ export const ListRow = memo(function ListRow({
<div className="flex items-center"> <div className="flex items-center">
<span <span
className={cn( className={cn(
'font-medium truncate', 'text-sm font-medium truncate',
feature.titleGenerating && !feature.title && 'animate-pulse text-muted-foreground' feature.titleGenerating && !feature.title && 'animate-pulse text-muted-foreground'
)} )}
title={feature.title || feature.description} title={feature.title || feature.description}
@@ -325,7 +332,7 @@ export const ListRow = memo(function ListRow({
<div <div
role="cell" role="cell"
className={cn( className={cn(
'flex items-center pl-0 pr-3 py-3 shrink-0', 'flex items-center pl-0 pr-3 py-2 shrink-0',
getColumnWidth('priority'), getColumnWidth('priority'),
getColumnAlign('priority') getColumnAlign('priority')
)} )}
@@ -358,14 +365,19 @@ export const ListRow = memo(function ListRow({
</div> </div>
{/* Actions column */} {/* Actions column */}
<div role="cell" className="flex items-center justify-end px-3 py-3 w-[80px] shrink-0"> <div role="cell" className="flex items-center justify-end px-3 py-2 w-[80px] shrink-0">
<RowActions feature={feature} handlers={handlers} isCurrentAutoTask={isActivelyRunning} /> <RowActions
feature={feature}
handlers={handlers}
isCurrentAutoTask={isActivelyRunning}
isRunningTask={!!isCurrentAutoTask}
/>
</div> </div>
</div> </div>
); );
// Wrap with animated border for currently running auto task // Wrap with animated border for currently running auto task (including stale status)
if (isActivelyRunning) { if (showRunningVisuals) {
return <div className="animated-border-wrapper-row">{rowContent}</div>; return <div className="animated-border-wrapper-row">{rowContent}</div>;
} }

View File

@@ -60,6 +60,8 @@ export interface RowActionsProps {
handlers: RowActionHandlers; handlers: RowActionHandlers;
/** Whether this feature is the current auto task (agent is running) */ /** Whether this feature is the current auto task (agent is running) */
isCurrentAutoTask?: boolean; 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 */ /** Whether the dropdown menu is open */
isOpen?: boolean; isOpen?: boolean;
/** Callback when the dropdown open state changes */ /** Callback when the dropdown open state changes */
@@ -115,7 +117,8 @@ const MenuItem = memo(function MenuItem({
function getPrimaryAction( function getPrimaryAction(
feature: Feature, feature: Feature,
handlers: RowActionHandlers, handlers: RowActionHandlers,
isCurrentAutoTask: boolean isCurrentAutoTask: boolean,
isRunningTask: boolean = false
): { ): {
icon: React.ComponentType<{ className?: string }>; icon: React.ComponentType<{ className?: string }>;
label: string; label: string;
@@ -135,6 +138,24 @@ function getPrimaryAction(
return null; 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 // Backlog - implement is primary
if (feature.status === 'backlog' && handlers.onImplement) { if (feature.status === 'backlog' && handlers.onImplement) {
return { return {
@@ -263,6 +284,7 @@ export const RowActions = memo(function RowActions({
feature, feature,
handlers, handlers,
isCurrentAutoTask = false, isCurrentAutoTask = false,
isRunningTask = false,
isOpen, isOpen,
onOpenChange, onOpenChange,
className, className,
@@ -286,7 +308,7 @@ export const RowActions = memo(function RowActions({
[setOpen] [setOpen]
); );
const primaryAction = getPrimaryAction(feature, handlers, isCurrentAutoTask); const primaryAction = getPrimaryAction(feature, handlers, isCurrentAutoTask, isRunningTask);
const secondaryActions = getSecondaryActions(feature, handlers); const secondaryActions = getSecondaryActions(feature, handlers);
// Helper to close menu after action // Helper to close menu after action
@@ -403,7 +425,7 @@ export const RowActions = memo(function RowActions({
)} )}
{/* Backlog actions */} {/* Backlog actions */}
{!isCurrentAutoTask && feature.status === 'backlog' && ( {!isCurrentAutoTask && !isRunningTask && feature.status === 'backlog' && (
<> <>
<MenuItem icon={Edit} label="Edit" onClick={withClose(handlers.onEdit)} /> <MenuItem icon={Edit} label="Edit" onClick={withClose(handlers.onEdit)} />
{feature.planSpec?.content && handlers.onViewPlan && ( {feature.planSpec?.content && handlers.onViewPlan && (

View File

@@ -493,7 +493,7 @@ export function CherryPickDialog({
if (step === 'select-commits') { if (step === 'select-commits') {
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[640px] sm:max-h-[100dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col dialog-fullscreen-mobile"> <DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[640px] sm:max-h-[85dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col dialog-fullscreen-mobile">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<Cherry className="w-5 h-5 text-foreground" /> <Cherry className="w-5 h-5 text-foreground" />

View File

@@ -20,6 +20,7 @@ import {
} from '@/components/ui/select'; } from '@/components/ui/select';
import { import {
GitCommit, GitCommit,
GitMerge,
Sparkles, Sparkles,
FilePlus, FilePlus,
FileX, FileX,
@@ -36,7 +37,7 @@ import { toast } from 'sonner';
import { useAppStore } from '@/store/app-store'; import { useAppStore } from '@/store/app-store';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { TruncatedFilePath } from '@/components/ui/truncated-file-path'; 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'; import { parseDiff, type ParsedFileDiff } from '@/lib/diff-utils';
interface RemoteInfo { 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({ function DiffLine({
type, type,
content, content,
@@ -190,6 +212,7 @@ export function CommitWorktreeDialog({
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set()); const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
const [expandedFile, setExpandedFile] = useState<string | null>(null); const [expandedFile, setExpandedFile] = useState<string | null>(null);
const [isLoadingDiffs, setIsLoadingDiffs] = useState(false); const [isLoadingDiffs, setIsLoadingDiffs] = useState(false);
const [mergeState, setMergeState] = useState<MergeStateInfo | undefined>(undefined);
// Push after commit state // Push after commit state
const [pushAfterCommit, setPushAfterCommit] = useState(false); const [pushAfterCommit, setPushAfterCommit] = useState(false);
@@ -274,6 +297,7 @@ export function CommitWorktreeDialog({
setDiffContent(''); setDiffContent('');
setSelectedFiles(new Set()); setSelectedFiles(new Set());
setExpandedFile(null); setExpandedFile(null);
setMergeState(undefined);
// Reset push state // Reset push state
setPushAfterCommit(false); setPushAfterCommit(false);
setRemotes([]); setRemotes([]);
@@ -292,8 +316,20 @@ export function CommitWorktreeDialog({
const result = await api.git.getDiffs(worktree.path); const result = await api.git.getDiffs(worktree.path);
if (result.success) { if (result.success) {
const fileList = result.files ?? []; 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) setFiles(fileList);
if (!cancelled) setDiffContent(result.diff ?? ''); if (!cancelled) setDiffContent(result.diff ?? '');
if (!cancelled) setMergeState(result.mergeState);
// If any files are already staged, pre-select only staged files // If any files are already staged, pre-select only staged files
// Otherwise select all files by default // Otherwise select all files by default
const stagedFiles = fileList.filter((f) => { const stagedFiles = fileList.filter((f) => {
@@ -579,6 +615,34 @@ export function CommitWorktreeDialog({
</DialogHeader> </DialogHeader>
<div className="flex flex-col gap-3 py-2 min-h-0 flex-1 overflow-hidden"> <div className="flex flex-col gap-3 py-2 min-h-0 flex-1 overflow-hidden">
{/* Merge state banner */}
{mergeState?.isMerging && (
<div className="flex items-start gap-2 p-3 rounded-md bg-purple-500/10 border border-purple-500/20">
<GitMerge className="w-4 h-4 text-purple-500 mt-0.5 flex-shrink-0" />
<div className="text-sm">
<span className="font-medium text-purple-400">
{mergeState.mergeOperationType === 'cherry-pick'
? 'Cherry-pick'
: mergeState.mergeOperationType === 'rebase'
? 'Rebase'
: 'Merge'}{' '}
in progress
</span>
{mergeState.conflictFiles.length > 0 ? (
<span className="text-purple-400/80 ml-1">
&mdash; {mergeState.conflictFiles.length} file
{mergeState.conflictFiles.length !== 1 ? 's' : ''} with conflicts
</span>
) : mergeState.isCleanMerge ? (
<span className="text-purple-400/80 ml-1">
&mdash; Clean merge, {mergeState.mergeAffectedFiles.length} file
{mergeState.mergeAffectedFiles.length !== 1 ? 's' : ''} affected
</span>
) : null}
</div>
</div>
)}
{/* File Selection */} {/* File Selection */}
<div className="flex flex-col min-h-0"> <div className="flex flex-col min-h-0">
<div className="flex items-center justify-between mb-1.5"> <div className="flex items-center justify-between mb-1.5">
@@ -625,13 +689,25 @@ export function CommitWorktreeDialog({
const isStaged = idx !== ' ' && idx !== '?'; const isStaged = idx !== ' ' && idx !== '?';
const isUnstaged = wt !== ' ' && wt !== '?'; const isUnstaged = wt !== ' ' && wt !== '?';
const isUntracked = idx === '?' && wt === '?'; const isUntracked = idx === '?' && wt === '?';
const isMergeFile =
file.isMergeAffected ||
(mergeState?.mergeAffectedFiles?.includes(file.path) ?? false);
return ( return (
<div key={file.path} className="border-b border-border last:border-b-0"> <div
key={file.path}
className={cn(
'border-b last:border-b-0',
isMergeFile ? 'border-purple-500/30' : 'border-border'
)}
>
<div <div
className={cn( className={cn(
'flex items-center gap-2 px-3 py-1.5 hover:bg-accent/50 transition-colors group', 'flex items-center gap-2 px-3 py-1.5 transition-colors group',
isExpanded && 'bg-accent/30' isMergeFile
? 'bg-purple-500/5 hover:bg-purple-500/10'
: 'hover:bg-accent/50',
isExpanded && (isMergeFile ? 'bg-purple-500/10' : 'bg-accent/30')
)} )}
> >
{/* Checkbox */} {/* Checkbox */}
@@ -651,11 +727,21 @@ export function CommitWorktreeDialog({
) : ( ) : (
<ChevronRight className="w-3 h-3 text-muted-foreground flex-shrink-0" /> <ChevronRight className="w-3 h-3 text-muted-foreground flex-shrink-0" />
)} )}
{getFileIcon(file.status)} {isMergeFile ? (
<GitMerge className="w-3.5 h-3.5 text-purple-500 flex-shrink-0" />
) : (
getFileIcon(file.status)
)}
<TruncatedFilePath <TruncatedFilePath
path={file.path} path={file.path}
className="text-xs font-mono flex-1 text-foreground" className="text-xs font-mono flex-1 text-foreground"
/> />
{isMergeFile && (
<span className="text-[10px] px-1.5 py-0.5 rounded border font-medium flex-shrink-0 bg-purple-500/15 text-purple-400 border-purple-500/30 inline-flex items-center gap-0.5">
<GitMerge className="w-2.5 h-2.5" />
{getMergeTypeLabel(file.mergeType)}
</span>
)}
<span <span
className={cn( className={cn(
'text-[10px] px-1.5 py-0.5 rounded border font-medium flex-shrink-0', 'text-[10px] px-1.5 py-0.5 rounded border font-medium flex-shrink-0',
@@ -810,11 +896,16 @@ export function CommitWorktreeDialog({
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{remotes.map((remote) => ( {remotes.map((remote) => (
<SelectItem key={remote.name} value={remote.name}> <SelectItem
key={remote.name}
value={remote.name}
description={
<span className="text-xs text-muted-foreground truncate w-full block">
{remote.url}
</span>
}
>
<span className="font-medium">{remote.name}</span> <span className="font-medium">{remote.name}</span>
<span className="ml-2 text-muted-foreground text-xs inline-block truncate max-w-[200px] align-bottom">
{remote.url}
</span>
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -17,11 +17,17 @@ import {
FileWarning, FileWarning,
Wrench, Wrench,
Sparkles, Sparkles,
GitMerge,
GitCommitHorizontal,
FileText,
Settings,
} from 'lucide-react'; } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner'; import { Spinner } from '@/components/ui/spinner';
import { Checkbox } from '@/components/ui/checkbox';
import { getElectronAPI } from '@/lib/electron'; import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useAppStore } from '@/store/app-store';
import type { MergeConflictInfo } from '../worktree-panel/types'; import type { MergeConflictInfo } from '../worktree-panel/types';
interface WorktreeInfo { interface WorktreeInfo {
@@ -37,6 +43,7 @@ type PullPhase =
| 'local-changes' // Local changes detected, asking user what to do | 'local-changes' // Local changes detected, asking user what to do
| 'pulling' // Actively pulling (with or without stash) | 'pulling' // Actively pulling (with or without stash)
| 'success' // Pull completed successfully | 'success' // Pull completed successfully
| 'merge-complete' // Pull resulted in a merge (not fast-forward, no conflicts)
| 'conflict' // Merge conflicts detected | 'conflict' // Merge conflicts detected
| 'error'; // Something went wrong | 'error'; // Something went wrong
@@ -53,6 +60,9 @@ interface PullResult {
stashed?: boolean; stashed?: boolean;
stashRestored?: boolean; stashRestored?: boolean;
stashRecoveryFailed?: boolean; stashRecoveryFailed?: boolean;
isMerge?: boolean;
isFastForward?: boolean;
mergeAffectedFiles?: string[];
} }
interface GitPullDialogProps { interface GitPullDialogProps {
@@ -62,6 +72,8 @@ interface GitPullDialogProps {
remote?: string; remote?: string;
onPulled?: () => void; onPulled?: () => void;
onCreateConflictResolutionFeature?: (conflictInfo: MergeConflictInfo) => 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({ export function GitPullDialog({
@@ -71,10 +83,54 @@ export function GitPullDialog({
remote, remote,
onPulled, onPulled,
onCreateConflictResolutionFeature, onCreateConflictResolutionFeature,
onCommitMerge,
}: GitPullDialogProps) { }: GitPullDialogProps) {
const [phase, setPhase] = useState<PullPhase>('checking'); const [phase, setPhase] = useState<PullPhase>('checking');
const [pullResult, setPullResult] = useState<PullResult | null>(null); const [pullResult, setPullResult] = useState<PullResult | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(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 () => { const checkForLocalChanges = useCallback(async () => {
if (!worktree) return; if (!worktree) return;
@@ -103,9 +159,7 @@ export function GitPullDialog({
setPhase('local-changes'); setPhase('local-changes');
} else if (result.result?.pulled !== undefined) { } else if (result.result?.pulled !== undefined) {
// No local changes, pull went through (or already up to date) // No local changes, pull went through (or already up to date)
setPullResult(result.result); handleSuccessfulPull(result.result);
setPhase('success');
onPulled?.();
} else { } else {
// Unexpected response: success but no recognizable fields // Unexpected response: success but no recognizable fields
setPullResult(result.result ?? null); setPullResult(result.result ?? null);
@@ -116,18 +170,33 @@ export function GitPullDialog({
setErrorMessage(err instanceof Error ? err.message : 'Failed to check for changes'); setErrorMessage(err instanceof Error ? err.message : 'Failed to check for changes');
setPhase('error'); setPhase('error');
} }
}, [worktree, remote, onPulled]); }, [worktree, remote, handleSuccessfulPull]);
// Reset state when dialog opens // 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 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(() => { useEffect(() => {
if (open && worktree) { if (open && worktree) {
setPhase('checking'); setPhase('checking');
setPullResult(null); setPullResult(null);
setErrorMessage(null); setErrorMessage(null);
// Start the initial check setRememberChoice(false);
checkForLocalChanges(); 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 () => { const handlePullWithStash = useCallback(async () => {
if (!worktree) return; if (!worktree) return;
@@ -155,8 +224,7 @@ export function GitPullDialog({
if (result.result?.hasConflicts) { if (result.result?.hasConflicts) {
setPhase('conflict'); setPhase('conflict');
} else if (result.result?.pulled) { } else if (result.result?.pulled) {
setPhase('success'); handleSuccessfulPull(result.result);
onPulled?.();
} else { } else {
// Unrecognized response: no pulled flag and no conflicts // Unrecognized response: no pulled flag and no conflicts
console.warn('handlePullWithStash: unrecognized response', result.result); console.warn('handlePullWithStash: unrecognized response', result.result);
@@ -167,7 +235,7 @@ export function GitPullDialog({
setErrorMessage(err instanceof Error ? err.message : 'Failed to pull'); setErrorMessage(err instanceof Error ? err.message : 'Failed to pull');
setPhase('error'); setPhase('error');
} }
}, [worktree, remote, onPulled]); }, [worktree, remote, handleSuccessfulPull]);
const handleResolveWithAI = useCallback(() => { const handleResolveWithAI = useCallback(() => {
if (!worktree || !pullResult || !onCreateConflictResolutionFeature) return; if (!worktree || !pullResult || !onCreateConflictResolutionFeature) return;
@@ -186,6 +254,35 @@ export function GitPullDialog({
onOpenChange(false); onOpenChange(false);
}, [worktree, pullResult, remote, onCreateConflictResolutionFeature, onOpenChange]); }, [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(() => { const handleClose = useCallback(() => {
onOpenChange(false); onOpenChange(false);
}, [onOpenChange]); }, [onOpenChange]);
@@ -336,6 +433,137 @@ export function GitPullDialog({
</> </>
)} )}
{/* Merge Complete Phase — post-merge prompt */}
{phase === 'merge-complete' && (
<>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<GitMerge className="w-5 h-5 text-purple-500" />
Merge Complete
</DialogTitle>
<DialogDescription asChild>
<div className="space-y-3">
<span className="block">
Pull resulted in a merge on{' '}
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>
{pullResult?.mergeAffectedFiles && pullResult.mergeAffectedFiles.length > 0 && (
<span>
{' '}
affecting {pullResult.mergeAffectedFiles.length} file
{pullResult.mergeAffectedFiles.length !== 1 ? 's' : ''}
</span>
)}
. How would you like to proceed?
</span>
{pullResult?.mergeAffectedFiles && pullResult.mergeAffectedFiles.length > 0 && (
<div>
<button
onClick={() => setShowMergeFiles(!showMergeFiles)}
className="text-xs text-muted-foreground hover:text-foreground transition-colors inline-flex items-center gap-1"
>
<FileText className="w-3 h-3" />
{showMergeFiles ? 'Hide' : 'Show'} affected files (
{pullResult.mergeAffectedFiles.length})
</button>
{showMergeFiles && (
<div className="mt-1.5 border border-border rounded-lg overflow-hidden max-h-[150px] overflow-y-auto scrollbar-visible">
{pullResult.mergeAffectedFiles.map((file) => (
<div
key={file}
className="flex items-center gap-2 px-3 py-1.5 text-xs font-mono border-b border-border last:border-b-0 hover:bg-accent/30"
>
<GitMerge className="w-3 h-3 text-purple-500 flex-shrink-0" />
<span className="truncate">{file}</span>
</div>
))}
</div>
)}
</div>
)}
{pullResult?.stashed &&
pullResult?.stashRestored &&
!pullResult?.stashRecoveryFailed && (
<div className="flex items-start gap-2 p-3 rounded-md bg-green-500/10 border border-green-500/20">
<Archive className="w-4 h-4 text-green-500 mt-0.5 flex-shrink-0" />
<span className="text-green-600 dark:text-green-400 text-sm">
Your stashed changes have been restored successfully.
</span>
</div>
)}
<div className="mt-2 p-3 rounded-md bg-muted/50 border border-border">
<p className="text-sm text-muted-foreground font-medium mb-2">
Choose how to proceed:
</p>
<ul className="text-sm text-muted-foreground list-disc list-inside space-y-1">
<li>
<strong>Commit Merge</strong> &mdash; Open the commit dialog with a merge
commit message
</li>
<li>
<strong>Review Manually</strong> &mdash; Leave the working tree as-is for
manual review
</li>
</ul>
</div>
</div>
</DialogDescription>
</DialogHeader>
{/* Remember choice option */}
<div className="flex items-center gap-2 px-1">
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<Checkbox
checked={rememberChoice}
onCheckedChange={(checked) => setRememberChoice(checked === true)}
className="rounded border-border"
/>
<Settings className="w-3 h-3" />
Remember my choice for future merges
</label>
{(rememberChoice || mergePostAction) && (
<span className="text-xs text-muted-foreground ml-auto flex items-center gap-2">
<span className="opacity-70">
Current:{' '}
{mergePostAction === 'commit'
? 'auto-commit'
: mergePostAction === 'manual'
? 'manual review'
: 'ask every time'}
</span>
<button
onClick={() => {
setMergePostAction(null);
setRememberChoice(false);
}}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Reset preference
</button>
</span>
)}
</div>
<DialogFooter className={cn('flex-col sm:flex-row gap-2')}>
<Button variant="outline" onClick={handleMergeManually} className="w-full sm:w-auto">
<FileText className="w-4 h-4 mr-2" />
Review Manually
</Button>
{worktree && onCommitMerge && (
<Button
onClick={handleCommitMerge}
className="w-full sm:w-auto bg-purple-600 hover:bg-purple-700 text-white"
>
<GitCommitHorizontal className="w-4 h-4 mr-2" />
Commit Merge
</Button>
)}
</DialogFooter>
</>
)}
{/* Conflict Phase */} {/* Conflict Phase */}
{phase === 'conflict' && ( {phase === 'conflict' && (
<> <>

View File

@@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[520px] w-full max-w-full sm:rounded-xl rounded-none dialog-fullscreen-mobile">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<GitMerge className="w-5 h-5 text-purple-500" />
Merge Complete
</DialogTitle>
<DialogDescription asChild>
<div className="space-y-3">
<span className="block">
A merge was successfully completed on{' '}
<code className="font-mono bg-muted px-1 rounded">{branchName}</code>
{mergeFileCount > 0 && (
<span>
{' '}
affecting {mergeFileCount} file{mergeFileCount !== 1 ? 's' : ''}
</span>
)}
. How would you like to proceed?
</span>
{mergeAffectedFiles && mergeAffectedFiles.length > 0 && (
<div>
<button
onClick={() => setShowFiles(!showFiles)}
className="text-xs text-muted-foreground hover:text-foreground transition-colors inline-flex items-center gap-1"
>
<FileText className="w-3 h-3" />
{showFiles ? 'Hide' : 'Show'} affected files ({mergeAffectedFiles.length})
</button>
{showFiles && (
<div className="mt-1.5 border border-border rounded-lg overflow-hidden max-h-[150px] overflow-y-auto scrollbar-visible">
{mergeAffectedFiles.map((file) => (
<div
key={file}
className="flex items-center gap-2 px-3 py-1.5 text-xs font-mono border-b border-border last:border-b-0 hover:bg-accent/30"
>
<GitMerge className="w-3 h-3 text-purple-500 flex-shrink-0" />
<span className="truncate">{file}</span>
</div>
))}
</div>
)}
</div>
)}
<div className="mt-2 p-3 rounded-md bg-muted/50 border border-border">
<p className="text-sm text-muted-foreground font-medium mb-2">
Choose how to proceed:
</p>
<ul className="text-sm text-muted-foreground list-disc list-inside space-y-1">
<li>
<strong>Commit Merge</strong> &mdash; Stage all merge files and open the commit
dialog with a pre-populated merge commit message
</li>
<li>
<strong>Review Manually</strong> &mdash; Leave the working tree as-is so you can
review changes and commit at your own pace
</li>
</ul>
</div>
</div>
</DialogDescription>
</DialogHeader>
{/* Remember choice option */}
{onSavePreference && (
<div className="flex items-center gap-2 px-1">
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<Checkbox
checked={rememberChoice}
onCheckedChange={(checked) => setRememberChoice(checked)}
className="rounded border-border"
/>
<Settings className="w-3 h-3" />
Remember my choice for future merges
</label>
{savedPreference && (
<button
onClick={() => onSavePreference(null)}
className="text-xs text-muted-foreground hover:text-foreground transition-colors ml-auto"
>
Reset preference
</button>
)}
</div>
)}
<DialogFooter className={cn('flex-col sm:flex-row gap-2')}>
<Button variant="outline" onClick={handleMergeManually} className="w-full sm:w-auto">
<FileText className="w-4 h-4 mr-2" />
Review Manually
</Button>
<Button
onClick={handleCommitMerge}
className="w-full sm:w-auto bg-purple-600 hover:bg-purple-700 text-white"
>
<GitCommitHorizontal className="w-4 h-4 mr-2" />
Commit Merge
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -251,7 +251,7 @@ export function ViewCommitsDialog({ open, onOpenChange, worktree }: ViewCommitsD
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[640px] sm:max-h-[100dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col dialog-fullscreen-mobile"> <DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[640px] sm:max-h-[85dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col dialog-fullscreen-mobile">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<GitCommit className="w-5 h-5" /> <GitCommit className="w-5 h-5" />
@@ -263,7 +263,7 @@ export function ViewCommitsDialog({ open, onOpenChange, worktree }: ViewCommitsD
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex-1 sm:min-h-[400px] sm:max-h-[60vh] overflow-y-auto scrollbar-visible -mx-6 -mb-6"> <div className="flex-1 min-h-0 sm:min-h-[400px] sm:max-h-[60vh] overflow-y-auto scrollbar-visible -mx-6 -mb-6">
<div className="h-full px-6 pb-6"> <div className="h-full px-6 pb-6">
{isLoading && ( {isLoading && (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">

View File

@@ -367,7 +367,7 @@ export function ViewStashesDialog({
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[640px] sm:max-h-[100dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col dialog-fullscreen-mobile"> <DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[640px] sm:max-h-[85dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col dialog-fullscreen-mobile">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<Archive className="w-5 h-5" /> <Archive className="w-5 h-5" />

View File

@@ -33,7 +33,7 @@ export function ViewWorktreeChangesDialog({
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[900px] sm:max-h-[100dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col dialog-fullscreen-mobile"> <DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[900px] sm:max-h-[85dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col dialog-fullscreen-mobile">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<FileText className="w-5 h-5" /> <FileText className="w-5 h-5" />
@@ -54,7 +54,7 @@ export function ViewWorktreeChangesDialog({
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex-1 sm:min-h-[600px] sm:max-h-[60vh] overflow-y-auto scrollbar-visible -mx-6 -mb-6"> <div className="flex-1 min-h-0 overflow-y-auto scrollbar-visible -mx-6 -mb-6">
<div className="h-full px-6 pb-6"> <div className="h-full px-6 pb-6">
<GitDiffPanel <GitDiffPanel
projectPath={projectPath} projectPath={projectPath}

View File

@@ -94,8 +94,6 @@ export function useBoardActions({
skipVerificationInAutoMode, skipVerificationInAutoMode,
isPrimaryWorktreeBranch, isPrimaryWorktreeBranch,
getPrimaryWorktreeBranch, getPrimaryWorktreeBranch,
getAutoModeState,
getMaxConcurrencyForWorktree,
} = useAppStore(); } = useAppStore();
const autoMode = useAutoMode(); const autoMode = useAutoMode();
@@ -561,38 +559,9 @@ export function useBoardActions({
const handleStartImplementation = useCallback( const handleStartImplementation = useCallback(
async (feature: Feature) => { async (feature: Feature) => {
// Check capacity for the feature's specific worktree, not the current view // Note: No concurrency limit check here. Manual feature starts should never
// Normalize the branch name: if the feature's branch is the primary worktree branch, // be blocked by the auto mode concurrency limit. The concurrency limit only
// treat it as null (main worktree) to match how running tasks are stored // governs how many features the auto-loop picks up automatically.
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;
}
// Check for blocking dependencies and show warning if enabled // Check for blocking dependencies and show warning if enabled
if (enableDependencyBlocking) { if (enableDependencyBlocking) {
@@ -681,18 +650,7 @@ export function useBoardActions({
return false; return false;
} }
}, },
[ [enableDependencyBlocking, features, updateFeature, persistFeatureUpdate, handleRunFeature]
autoMode,
enableDependencyBlocking,
features,
updateFeature,
persistFeatureUpdate,
handleRunFeature,
currentProject,
getAutoModeState,
getMaxConcurrencyForWorktree,
isPrimaryWorktreeBranch,
]
); );
const handleVerifyFeature = useCallback( const handleVerifyFeature = useCallback(

View File

@@ -163,13 +163,22 @@ export function useBoardDragDrop({
let targetStatus: ColumnId | null = null; 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 // 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) { if (column) {
targetStatus = column.id; targetStatus = column.id;
} else if (effectiveOverId.startsWith('pipeline_')) {
// Pipeline step column (not in static COLUMNS list)
targetStatus = effectiveOverId as ColumnId;
} else { } else {
// Dropped on another feature - find its column // 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) { if (overFeature) {
targetStatus = overFeature.status; targetStatus = overFeature.status;
} }

View File

@@ -136,7 +136,7 @@ export function DevServerLogsPanel({
compact compact
> >
{/* Compact Header */} {/* Compact Header */}
<DialogHeader className="shrink-0 px-4 py-3 border-b border-border/50 pr-12"> <DialogHeader className="shrink-0 px-4 py-3 border-b border-border/50 pr-12 dialog-compact-header-mobile">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<DialogTitle className="flex items-center gap-2 text-base"> <DialogTitle className="flex items-center gap-2 text-base">
<Terminal className="w-4 h-4 text-primary" /> <Terminal className="w-4 h-4 text-primary" />

View File

@@ -354,12 +354,19 @@ export function WorktreeActionsDropdown({
<> <>
<DropdownMenuLabel className="text-xs flex items-center gap-2"> <DropdownMenuLabel className="text-xs flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" /> <span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
Dev Server Running (:{devServerInfo?.port}) {devServerInfo?.urlDetected === false
? 'Dev Server Starting...'
: `Dev Server Running (:${devServerInfo?.port})`}
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuItem <DropdownMenuItem
onClick={() => onOpenDevServerUrl(worktree)} onClick={() => onOpenDevServerUrl(worktree)}
className="text-xs" 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`
}
> >
<Globe className="w-3.5 h-3.5 mr-2" aria-hidden="true" /> <Globe className="w-3.5 h-3.5 mr-2" aria-hidden="true" />
Open in Browser Open in Browser

View File

@@ -308,7 +308,11 @@ export function WorktreeDropdown({
{selectedStatus.devServerRunning && ( {selectedStatus.devServerRunning && (
<span <span
className="inline-flex items-center justify-center h-4 w-4 text-green-500 shrink-0" className="inline-flex items-center justify-center h-4 w-4 text-green-500 shrink-0"
title={`Dev server running on port ${selectedStatus.devServerInfo?.port}`} title={
selectedStatus.devServerInfo?.urlDetected === false
? 'Dev server starting...'
: `Dev server running on port ${selectedStatus.devServerInfo?.port}`
}
> >
<Globe className="w-3 h-3" /> <Globe className="w-3 h-3" />
</span> </span>

View File

@@ -206,6 +206,16 @@ export function useDevServerLogs({ worktreePath, autoSubscribe = true }: UseDevS
})); }));
break; break;
} }
case 'dev-server:url-detected': {
const { payload } = event;
logger.info('Dev server URL detected:', payload);
setState((prev) => ({
...prev,
url: payload.url,
port: payload.port,
}));
break;
}
} }
}); });

View File

@@ -25,7 +25,10 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
if (result.success && result.result?.servers) { if (result.success && result.result?.servers) {
const serversMap = new Map<string, DevServerInfo>(); const serversMap = new Map<string, DevServerInfo>();
for (const server of result.result.servers) { for (const server of result.result.servers) {
serversMap.set(server.worktreePath, server); serversMap.set(normalizePath(server.worktreePath), {
...server,
urlDetected: server.urlDetected ?? true,
});
} }
setRunningDevServers(serversMap); setRunningDevServers(serversMap);
} }
@@ -38,6 +41,39 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
fetchDevServers(); fetchDevServers();
}, [fetchDevServers]); }, [fetchDevServers]);
// Subscribe to url-detected events to update port/url when the actual dev server port is detected
useEffect(() => {
const api = getElectronAPI();
if (!api?.worktree?.onDevServerLogEvent) return;
const unsubscribe = api.worktree.onDevServerLogEvent((event) => {
if (event.type === 'dev-server:url-detected') {
const { worktreePath, url, port } = event.payload;
const key = normalizePath(worktreePath);
let didUpdate = false;
setRunningDevServers((prev) => {
const existing = prev.get(key);
if (!existing) return prev;
const next = new Map(prev);
next.set(key, {
...existing,
url,
port,
urlDetected: true,
});
didUpdate = true;
return next;
});
if (didUpdate) {
logger.info(`Dev server URL detected for ${worktreePath}: ${url} (port ${port})`);
toast.success(`Dev server running on port ${port}`);
}
}
});
return unsubscribe;
}, []);
const getWorktreeKey = useCallback( const getWorktreeKey = useCallback(
(worktree: WorktreeInfo) => { (worktree: WorktreeInfo) => {
const path = worktree.isMain ? projectPath : worktree.path; const path = worktree.isMain ? projectPath : worktree.path;
@@ -68,10 +104,11 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
worktreePath: result.result!.worktreePath, worktreePath: result.result!.worktreePath,
port: result.result!.port, port: result.result!.port,
url: result.result!.url, url: result.result!.url,
urlDetected: false,
}); });
return next; return next;
}); });
toast.success(`Dev server started on port ${result.result.port}`); toast.success('Dev server started, detecting port...');
} else { } else {
toast.error(result.error || 'Failed to start dev server'); toast.error(result.error || 'Failed to start dev server');
} }

View File

@@ -34,6 +34,8 @@ export interface DevServerInfo {
worktreePath: string; worktreePath: string;
port: number; port: number;
url: string; url: string;
/** Whether the actual URL/port has been detected from server output */
urlDetected?: boolean;
} }
export interface TestSessionInfo { export interface TestSessionInfo {

View File

@@ -646,57 +646,101 @@ export function WorktreePanel({
setPushToRemoteDialogOpen(true); setPushToRemoteDialogOpen(true);
}, []); }, []);
// Keep a ref to pullDialogWorktree so handlePullCompleted can access the current
// value without including it in the dependency array. If pullDialogWorktree were
// a dep of handlePullCompleted, changing it would recreate the callback, which
// would propagate into GitPullDialog's onPulled prop and ultimately re-trigger
// the pull-check effect inside the dialog (causing the flow to run twice).
const pullDialogWorktreeRef = useRef(pullDialogWorktree);
useEffect(() => {
pullDialogWorktreeRef.current = pullDialogWorktree;
}, [pullDialogWorktree]);
// Handle pull completed - refresh branches and worktrees // Handle pull completed - refresh branches and worktrees
const handlePullCompleted = useCallback(() => { const handlePullCompleted = useCallback(() => {
// Refresh branch data (ahead/behind counts, tracking) and worktree list // Refresh branch data (ahead/behind counts, tracking) and worktree list
// after GitPullDialog completes the pull operation // after GitPullDialog completes the pull operation
if (pullDialogWorktree) { if (pullDialogWorktreeRef.current) {
fetchBranches(pullDialogWorktree.path); fetchBranches(pullDialogWorktreeRef.current.path);
} }
fetchWorktrees({ silent: true }); fetchWorktrees({ silent: true });
}, [fetchWorktrees, fetchBranches, pullDialogWorktree]); }, [fetchWorktrees, fetchBranches]);
// Wrapper for onCommit that works with the pull dialog's simpler WorktreeInfo.
// Uses the full pullDialogWorktree when available (via ref to avoid making it
// a dep that would cascade into handleSuccessfulPull → checkForLocalChanges recreations).
const handleCommitMerge = useCallback(
(_simpleWorktree: { path: string; branch: string; isMain: boolean }) => {
// Prefer the full worktree object we already have (from ref)
if (pullDialogWorktreeRef.current) {
onCommit(pullDialogWorktreeRef.current);
}
},
[onCommit]
);
// Handle pull with remote selection when multiple remotes exist // Handle pull with remote selection when multiple remotes exist
// Now opens the pull dialog which handles stash management and conflict resolution // Now opens the pull dialog which handles stash management and conflict resolution
const handlePullWithRemoteSelection = useCallback(async (worktree: WorktreeInfo) => { // If the branch has a tracked remote, pull from it directly (skip the remote selection dialog)
try { const handlePullWithRemoteSelection = useCallback(
const api = getHttpApiClient();
const result = await api.worktree.listRemotes(worktree.path);
if (result.success && result.result && result.result.remotes.length > 1) {
// Multiple remotes - show selection dialog first
setSelectRemoteWorktree(worktree);
setSelectRemoteOperation('pull');
setSelectRemoteDialogOpen(true);
} else if (result.success && result.result && result.result.remotes.length === 1) {
// Exactly one remote - open pull dialog directly with that remote
const remoteName = result.result.remotes[0].name;
setPullDialogRemote(remoteName);
setPullDialogWorktree(worktree);
setPullDialogOpen(true);
} else {
// No remotes - open pull dialog with default
setPullDialogRemote(undefined);
setPullDialogWorktree(worktree);
setPullDialogOpen(true);
}
} catch {
// If listing remotes fails, open pull dialog with default
setPullDialogRemote(undefined);
setPullDialogWorktree(worktree);
setPullDialogOpen(true);
}
}, []);
// Handle push with remote selection when multiple remotes exist
const handlePushWithRemoteSelection = useCallback(
async (worktree: WorktreeInfo) => { async (worktree: WorktreeInfo) => {
// If the branch already tracks a remote, pull from it directly — no dialog needed
const tracked = getTrackingRemote(worktree.path);
if (tracked) {
setPullDialogRemote(tracked);
setPullDialogWorktree(worktree);
setPullDialogOpen(true);
return;
}
try { try {
const api = getHttpApiClient(); const api = getHttpApiClient();
const result = await api.worktree.listRemotes(worktree.path); const result = await api.worktree.listRemotes(worktree.path);
if (result.success && result.result && result.result.remotes.length > 1) { if (result.success && result.result && result.result.remotes.length > 1) {
// Multiple remotes - show selection dialog // Multiple remotes and no tracking remote - show selection dialog
setSelectRemoteWorktree(worktree);
setSelectRemoteOperation('pull');
setSelectRemoteDialogOpen(true);
} else if (result.success && result.result && result.result.remotes.length === 1) {
// Exactly one remote - open pull dialog directly with that remote
const remoteName = result.result.remotes[0].name;
setPullDialogRemote(remoteName);
setPullDialogWorktree(worktree);
setPullDialogOpen(true);
} else {
// No remotes - open pull dialog with default
setPullDialogRemote(undefined);
setPullDialogWorktree(worktree);
setPullDialogOpen(true);
}
} catch {
// If listing remotes fails, open pull dialog with default
setPullDialogRemote(undefined);
setPullDialogWorktree(worktree);
setPullDialogOpen(true);
}
},
[getTrackingRemote]
);
// Handle push with remote selection when multiple remotes exist
// If the branch has a tracked remote, push to it directly (skip the remote selection dialog)
const handlePushWithRemoteSelection = useCallback(
async (worktree: WorktreeInfo) => {
// If the branch already tracks a remote, push to it directly — no dialog needed
const tracked = getTrackingRemote(worktree.path);
if (tracked) {
handlePush(worktree, tracked);
return;
}
try {
const api = getHttpApiClient();
const result = await api.worktree.listRemotes(worktree.path);
if (result.success && result.result && result.result.remotes.length > 1) {
// Multiple remotes and no tracking remote - show selection dialog
setSelectRemoteWorktree(worktree); setSelectRemoteWorktree(worktree);
setSelectRemoteOperation('push'); setSelectRemoteOperation('push');
setSelectRemoteDialogOpen(true); setSelectRemoteDialogOpen(true);
@@ -713,7 +757,7 @@ export function WorktreePanel({
handlePush(worktree); handlePush(worktree);
} }
}, },
[handlePush] [handlePush, getTrackingRemote]
); );
// Handle confirming remote selection for pull/push // Handle confirming remote selection for pull/push
@@ -992,6 +1036,7 @@ export function WorktreePanel({
remote={pullDialogRemote} remote={pullDialogRemote}
onPulled={handlePullCompleted} onPulled={handlePullCompleted}
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature} onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
onCommitMerge={handleCommitMerge}
/> />
{/* Dev Server Logs Panel */} {/* Dev Server Logs Panel */}
@@ -1445,6 +1490,7 @@ export function WorktreePanel({
onOpenChange={setViewStashesDialogOpen} onOpenChange={setViewStashesDialogOpen}
worktree={viewStashesWorktree} worktree={viewStashesWorktree}
onStashApplied={handleStashApplied} onStashApplied={handleStashApplied}
onStashApplyConflict={onStashApplyConflict}
/> />
{/* Cherry Pick Dialog */} {/* Cherry Pick Dialog */}
@@ -1463,6 +1509,7 @@ export function WorktreePanel({
worktree={pullDialogWorktree} worktree={pullDialogWorktree}
remote={pullDialogRemote} remote={pullDialogRemote}
onPulled={handlePullCompleted} onPulled={handlePullCompleted}
onCommitMerge={handleCommitMerge}
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature} onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
/> />
</div> </div>

View File

@@ -25,6 +25,9 @@ import { getHttpApiClient } from '@/lib/http-api-client';
import type { Project } from '@/lib/electron'; import type { Project } from '@/lib/electron';
import { ProjectFileSelectorDialog } from '@/components/dialogs/project-file-selector-dialog'; 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 { interface WorktreePreferencesSectionProps {
project: Project; project: Project;
} }
@@ -38,20 +41,30 @@ interface InitScriptResponse {
} }
export function WorktreePreferencesSection({ project }: WorktreePreferencesSectionProps) { 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 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 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 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 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 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); const setWorktreeCopyFiles = useAppStore((s) => s.setWorktreeCopyFiles);
// Get effective worktrees setting (project override or global fallback) // Get effective worktrees setting (project override or global fallback)
const projectUseWorktrees = getProjectUseWorktrees(project.path);
const effectiveUseWorktrees = projectUseWorktrees ?? globalUseWorktrees; const effectiveUseWorktrees = projectUseWorktrees ?? globalUseWorktrees;
const [scriptContent, setScriptContent] = useState(''); const [scriptContent, setScriptContent] = useState('');
@@ -65,11 +78,6 @@ export function WorktreePreferencesSection({ project }: WorktreePreferencesSecti
const [newCopyFilePath, setNewCopyFilePath] = useState(''); const [newCopyFilePath, setNewCopyFilePath] = useState('');
const [fileSelectorOpen, setFileSelectorOpen] = useState(false); 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 // Check if there are unsaved changes
const hasChanges = scriptContent !== originalContent; const hasChanges = scriptContent !== originalContent;

View File

@@ -1,12 +1,16 @@
import { useState } from 'react'; 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 { cn } from '@/lib/utils';
import { useAppStore } from '@/store/app-store'; import { useAppStore } from '@/store/app-store';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { PhaseModelSelector } from './phase-model-selector'; import { PhaseModelSelector } from './phase-model-selector';
import { BulkReplaceDialog } from './bulk-replace-dialog'; import { BulkReplaceDialog } from './bulk-replace-dialog';
import type { PhaseModelKey, PhaseModelEntry } from '@automaker/types'; import type { PhaseModelKey, PhaseModelEntry, ThinkingLevel } from '@automaker/types';
import { DEFAULT_PHASE_MODELS, DEFAULT_GLOBAL_SETTINGS } from '@automaker/types'; import {
DEFAULT_PHASE_MODELS,
DEFAULT_GLOBAL_SETTINGS,
REASONING_EFFORT_LEVELS,
} from '@automaker/types';
interface PhaseConfig { interface PhaseConfig {
key: PhaseModelKey; 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 (
<div className="space-y-4">
<div>
<h3 className="text-sm font-medium text-foreground">Quick-Select Defaults</h3>
<p className="text-xs text-muted-foreground">
Thinking/reasoning level applied when quick-selecting a model from the dropdown. You can
always fine-tune per model via the expand arrow.
</p>
</div>
<div className="space-y-3">
{/* Default Thinking Level (Claude models) */}
<div
className={cn(
'flex items-center justify-between p-4 rounded-xl',
'bg-accent/20 border border-border/30',
'hover:bg-accent/30 transition-colors'
)}
>
<div className="flex items-center gap-3 flex-1 pr-4">
<div className="w-8 h-8 rounded-lg bg-purple-500/10 flex items-center justify-center">
<Brain className="w-4 h-4 text-purple-500" />
</div>
<div>
<h4 className="text-sm font-medium text-foreground">Default Thinking Level</h4>
<p className="text-xs text-muted-foreground">
Applied to Claude models when quick-selected
</p>
</div>
</div>
<div className="flex items-center gap-1.5 flex-wrap justify-end">
{THINKING_LEVEL_OPTIONS.map((option) => (
<button
key={option.id}
onClick={() => setDefaultThinkingLevel(option.id)}
className={cn(
'px-2.5 py-1.5 rounded-lg text-xs font-medium transition-all',
'border',
defaultThinkingLevel === option.id
? 'bg-primary text-primary-foreground border-primary shadow-sm'
: 'bg-background border-border/50 text-muted-foreground hover:bg-accent hover:text-foreground'
)}
title={option.description}
>
{option.label}
</button>
))}
</div>
</div>
{/* Default Reasoning Effort (Codex models) */}
<div
className={cn(
'flex items-center justify-between p-4 rounded-xl',
'bg-accent/20 border border-border/30',
'hover:bg-accent/30 transition-colors'
)}
>
<div className="flex items-center gap-3 flex-1 pr-4">
<div className="w-8 h-8 rounded-lg bg-blue-500/10 flex items-center justify-center">
<Brain className="w-4 h-4 text-blue-500" />
</div>
<div>
<h4 className="text-sm font-medium text-foreground">Default Reasoning Effort</h4>
<p className="text-xs text-muted-foreground">
Applied to Codex/OpenAI models when quick-selected
</p>
</div>
</div>
<div className="flex items-center gap-1.5 flex-wrap justify-end">
{REASONING_EFFORT_LEVELS.map((option) => (
<button
key={option.id}
onClick={() => setDefaultReasoningEffort(option.id)}
className={cn(
'px-2.5 py-1.5 rounded-lg text-xs font-medium transition-all',
'border',
defaultReasoningEffort === option.id
? 'bg-primary text-primary-foreground border-primary shadow-sm'
: 'bg-background border-border/50 text-muted-foreground hover:bg-accent hover:text-foreground'
)}
title={option.description}
>
{option.label}
</button>
))}
</div>
</div>
</div>
</div>
);
}
export function ModelDefaultsSection() { export function ModelDefaultsSection() {
const { resetPhaseModels, claudeCompatibleProviders } = useAppStore(); const { resetPhaseModels, claudeCompatibleProviders } = useAppStore();
const [showBulkReplace, setShowBulkReplace] = useState(false); const [showBulkReplace, setShowBulkReplace] = useState(false);
@@ -222,6 +341,9 @@ export function ModelDefaultsSection() {
{/* Feature Defaults */} {/* Feature Defaults */}
<FeatureDefaultModelSection /> <FeatureDefaultModelSection />
{/* Default Thinking Level / Reasoning Effort */}
<DefaultThinkingLevelSection />
{/* Quick Tasks */} {/* Quick Tasks */}
<PhaseGroup <PhaseGroup
title="Quick Tasks" title="Quick Tasks"

View File

@@ -13,6 +13,9 @@ import {
X, X,
SquarePlus, SquarePlus,
Settings, Settings,
GitBranch,
ChevronDown,
FolderGit,
} from 'lucide-react'; } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner'; import { Spinner } from '@/components/ui/spinner';
import { getServerUrlSync } from '@/lib/http-api-client'; import { getServerUrlSync } from '@/lib/http-api-client';
@@ -28,6 +31,17 @@ import { Label } from '@/components/ui/label';
import { Slider } from '@/components/ui/slider'; import { Slider } from '@/components/ui/slider';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -255,6 +269,8 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
setTerminalScrollbackLines, setTerminalScrollbackLines,
setTerminalScreenReaderMode, setTerminalScreenReaderMode,
updateTerminalPanelSizes, updateTerminalPanelSizes,
currentWorktreeByProject,
worktreesByProject,
} = useAppStore(); } = useAppStore();
const navigate = useNavigate(); 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 // Create a new terminal session
// targetSessionId: the terminal to split (if splitting an existing terminal) // targetSessionId: the terminal to split (if splitting an existing terminal)
// customCwd: optional working directory to use instead of the current project path // 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 ( const createTerminal = async (
direction?: 'horizontal' | 'vertical', direction?: 'horizontal' | 'vertical',
targetSessionId?: string, targetSessionId?: string,
customCwd?: string customCwd?: string,
branchName?: string
) => { ) => {
if (!canCreateTerminal('[Terminal] Debounced terminal creation')) { if (!canCreateTerminal('[Terminal] Debounced terminal creation')) {
return; return;
@@ -971,7 +1024,7 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
const data = await response.json(); const data = await response.json();
if (data.success) { 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 // Mark this session as new for running initial command
if (defaultRunScript) { if (defaultRunScript) {
setNewSessionIds((prev) => new Set(prev).add(data.data.id)); 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 // 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')) { if (!canCreateTerminal('[Terminal] Debounced terminal tab creation')) {
return; 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(); const tabId = addTerminalTab();
try { try {
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
@@ -1018,14 +1078,14 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
const response = await apiFetch('/api/terminal/sessions', 'POST', { const response = await apiFetch('/api/terminal/sessions', 'POST', {
headers, 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(); const data = await response.json();
if (data.success) { 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(); 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 // Mark this session as new for running initial command
if (defaultRunScript) { if (defaultRunScript) {
setNewSessionIds((prev) => new Set(prev).add(data.data.id)); 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} isActive={terminalState.activeSessionId === content.sessionId}
onFocus={() => setActiveTerminalSession(content.sessionId)} onFocus={() => setActiveTerminalSession(content.sessionId)}
onClose={() => killTerminal(content.sessionId)} onClose={() => killTerminal(content.sessionId)}
onSplitHorizontal={() => createTerminal('horizontal', content.sessionId)} onSplitHorizontal={() => {
onSplitVertical={() => createTerminal('vertical', content.sessionId)} const { cwd, branchName } = getActiveSessionWorktreeInfo();
createTerminal('horizontal', content.sessionId, cwd, branchName);
}}
onSplitVertical={() => {
const { cwd, branchName } = getActiveSessionWorktreeInfo();
createTerminal('vertical', content.sessionId, cwd, branchName);
}}
onNewTab={createTerminalInNewTab} onNewTab={createTerminalInNewTab}
onNavigateUp={() => navigateToTerminal('up')} onNavigateUp={() => navigateToTerminal('up')}
onNavigateDown={() => navigateToTerminal('down')} onNavigateDown={() => navigateToTerminal('down')}
@@ -1502,6 +1568,15 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
// No terminals yet - show welcome screen // No terminals yet - show welcome screen
if (terminalState.tabs.length === 0) { 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 ( return (
<div className="flex-1 flex flex-col items-center justify-center text-center p-6"> <div className="flex-1 flex flex-col items-center justify-center text-center p-6">
<div className="p-4 rounded-full bg-brand-500/10 mb-4"> <div className="p-4 rounded-full bg-brand-500/10 mb-4">
@@ -1518,10 +1593,40 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
)} )}
</p> </p>
<Button onClick={() => createTerminal()}> <div className="flex flex-col items-center gap-3 w-full max-w-xs">
<Plus className="h-4 w-4 mr-2" /> {currentWorktreePath && (
New Terminal <Button
</Button> className="w-full flex-col h-auto py-2"
onClick={() =>
createTerminal(
undefined,
undefined,
currentWorktreePath,
currentWorktreeBranch ?? undefined
)
}
>
<span className="flex items-center">
<GitBranch className="h-4 w-4 mr-2 shrink-0" />
Open Terminal in Worktree
</span>
{currentWorktreeBranch && (
<span className="text-xs opacity-70 truncate max-w-full px-2">
{currentWorktreeBranch}
</span>
)}
</Button>
)}
<Button
className="w-full"
variant={currentWorktreePath ? 'outline' : 'default'}
onClick={() => createTerminal()}
>
<Plus className="h-4 w-4 mr-2" />
New Terminal
</Button>
</div>
{status?.platform && ( {status?.platform && (
<p className="text-xs text-muted-foreground mt-6"> <p className="text-xs text-muted-foreground mt-6">
@@ -1564,14 +1669,94 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
{(activeDragId || activeDragTabId) && <NewTabDropZone isDropTarget={true} />} {(activeDragId || activeDragTabId) && <NewTabDropZone isDropTarget={true} />}
{/* New tab button */} {/* New tab split button */}
<button <div className="flex items-center">
className="flex items-center justify-center p-1.5 rounded hover:bg-accent text-muted-foreground hover:text-foreground" <button
onClick={createTerminalInNewTab} className="flex items-center justify-center p-1.5 rounded-l hover:bg-accent text-muted-foreground hover:text-foreground"
title="New Tab" onClick={() => createTerminalInNewTab()}
> title="New Tab"
<Plus className="h-4 w-4" /> >
</button> <Plus className="h-4 w-4" />
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="flex items-center justify-center px-0.5 py-1.5 rounded-r hover:bg-accent text-muted-foreground hover:text-foreground border-l border-border"
title="New Terminal Options"
>
<ChevronDown className="h-3 w-3" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" side="bottom" className="w-56">
<DropdownMenuItem onClick={() => createTerminalInNewTab()} className="gap-2">
<Plus className="h-4 w-4" />
New Tab
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
const { cwd, branchName } = getActiveSessionWorktreeInfo();
createTerminal('horizontal', undefined, cwd, branchName);
}}
className="gap-2"
>
<SplitSquareHorizontal className="h-4 w-4" />
Split Right
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
const { cwd, branchName } = getActiveSessionWorktreeInfo();
createTerminal('vertical', undefined, cwd, branchName);
}}
className="gap-2"
>
<SplitSquareVertical className="h-4 w-4" />
Split Down
</DropdownMenuItem>
{/* 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 (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs text-muted-foreground">
Open in Worktree
</DropdownMenuLabel>
{mainWorktree && (
<DropdownMenuItem
onClick={() =>
createTerminalInNewTab(mainWorktree.path, mainWorktree.branch)
}
className="gap-2"
>
<FolderGit className="h-4 w-4" />
<span className="truncate">{mainWorktree.branch}</span>
<span className="ml-auto text-[10px] text-muted-foreground shrink-0">
main
</span>
</DropdownMenuItem>
)}
{featureWorktrees.map((wt) => (
<DropdownMenuItem
key={wt.path}
onClick={() => createTerminalInNewTab(wt.path, wt.branch)}
className="gap-2"
>
<GitBranch className="h-4 w-4" />
<span className="truncate">{wt.branch}</span>
</DropdownMenuItem>
))}
</>
);
})()}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div> </div>
{/* Toolbar buttons */} {/* Toolbar buttons */}
@@ -1580,7 +1765,10 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-7 px-2 text-muted-foreground hover:text-foreground" 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" title="Split Right"
> >
<SplitSquareHorizontal className="h-4 w-4" /> <SplitSquareHorizontal className="h-4 w-4" />
@@ -1589,7 +1777,10 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-7 px-2 text-muted-foreground hover:text-foreground" 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" title="Split Down"
> >
<SplitSquareVertical className="h-4 w-4" /> <SplitSquareVertical className="h-4 w-4" />
@@ -1771,12 +1962,14 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
isActive={true} isActive={true}
onFocus={() => setActiveTerminalSession(terminalState.maximizedSessionId!)} onFocus={() => setActiveTerminalSession(terminalState.maximizedSessionId!)}
onClose={() => killTerminal(terminalState.maximizedSessionId!)} onClose={() => killTerminal(terminalState.maximizedSessionId!)}
onSplitHorizontal={() => onSplitHorizontal={() => {
createTerminal('horizontal', terminalState.maximizedSessionId!) const { cwd, branchName } = getActiveSessionWorktreeInfo();
} createTerminal('horizontal', terminalState.maximizedSessionId!, cwd, branchName);
onSplitVertical={() => }}
createTerminal('vertical', terminalState.maximizedSessionId!) onSplitVertical={() => {
} const { cwd, branchName } = getActiveSessionWorktreeInfo();
createTerminal('vertical', terminalState.maximizedSessionId!, cwd, branchName);
}}
onNewTab={createTerminalInNewTab} onNewTab={createTerminalInNewTab}
onSessionInvalid={() => { onSessionInvalid={() => {
const sessionId = terminalState.maximizedSessionId!; const sessionId = terminalState.maximizedSessionId!;

View File

@@ -1,6 +1,18 @@
import { useCallback, useRef, useEffect, useState } from 'react'; 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 { cn } from '@/lib/utils';
import { StickyModifierKeys, type StickyModifier } from './sticky-modifier-keys';
/** /**
* ANSI escape sequences for special keys. * ANSI escape sequences for special keys.
@@ -37,6 +49,20 @@ interface MobileTerminalShortcutsProps {
onSendInput: (data: string) => void; onSendInput: (data: string) => void;
/** Whether the terminal is connected and ready */ /** Whether the terminal is connected and ready */
isConnected: boolean; 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({ export function MobileTerminalShortcuts({
onSendInput, onSendInput,
isConnected, isConnected,
activeModifier,
onModifierChange,
onCopy,
onPaste,
onSelectAll,
onToggleSelectMode,
isSelectMode,
}: MobileTerminalShortcutsProps) { }: MobileTerminalShortcutsProps) {
const [isCollapsed, setIsCollapsed] = useState(false); const [isCollapsed, setIsCollapsed] = useState(false);
@@ -135,6 +168,54 @@ export function MobileTerminalShortcuts({
{/* Separator */} {/* Separator */}
<div className="w-px h-6 bg-border shrink-0" /> <div className="w-px h-6 bg-border shrink-0" />
{/* Sticky modifier keys (Ctrl, Alt) - at the beginning of the bar */}
<StickyModifierKeys
activeModifier={activeModifier}
onModifierChange={onModifierChange}
isConnected={isConnected}
/>
{/* Separator */}
<div className="w-px h-6 bg-border shrink-0" />
{/* Clipboard actions */}
{onToggleSelectMode && (
<IconShortcutButton
icon={TextSelect}
title={isSelectMode ? 'Exit select mode' : 'Select text'}
onPress={onToggleSelectMode}
disabled={!isConnected}
active={isSelectMode}
/>
)}
{onSelectAll && (
<IconShortcutButton
icon={CheckSquare}
title="Select all"
onPress={onSelectAll}
disabled={!isConnected}
/>
)}
{onCopy && (
<IconShortcutButton
icon={Copy}
title="Copy selection"
onPress={onCopy}
disabled={!isConnected}
/>
)}
{onPaste && (
<IconShortcutButton
icon={ClipboardPaste}
title="Paste from clipboard"
onPress={onPaste}
disabled={!isConnected}
/>
)}
{/* Separator */}
<div className="w-px h-6 bg-border shrink-0" />
{/* Special keys */} {/* Special keys */}
<ShortcutButton <ShortcutButton
label="Esc" label="Esc"
@@ -300,3 +381,42 @@ function ArrowButton({
</button> </button>
); );
} }
/**
* 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 (
<button
className={cn(
'p-2 rounded-md shrink-0 select-none transition-colors min-w-[36px] min-h-[36px] flex items-center justify-center',
'active:scale-95 touch-manipulation',
active
? 'bg-brand-500/20 text-brand-500 ring-1 ring-brand-500/40'
: 'bg-muted/80 text-foreground hover:bg-accent',
disabled && 'opacity-40 pointer-events-none'
)}
onPointerDown={(e) => {
e.preventDefault(); // Prevent focus stealing from terminal
onPress();
}}
title={title}
disabled={disabled}
>
<Icon className="h-4 w-4" />
</button>
);
}

View File

@@ -51,14 +51,11 @@ import { DEFAULT_FONT_VALUE } from '@/config/ui-font-options';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { getElectronAPI } from '@/lib/electron'; import { getElectronAPI } from '@/lib/electron';
import { getApiKey, getSessionToken, getServerUrlSync } from '@/lib/http-api-client'; import { getApiKey, getSessionToken, getServerUrlSync } from '@/lib/http-api-client';
import { writeToClipboard, readFromClipboard } from '@/lib/clipboard-utils';
import { useIsMobile } from '@/hooks/use-media-query'; import { useIsMobile } from '@/hooks/use-media-query';
import { useVirtualKeyboardResize } from '@/hooks/use-virtual-keyboard-resize'; import { useVirtualKeyboardResize } from '@/hooks/use-virtual-keyboard-resize';
import { MobileTerminalShortcuts } from './mobile-terminal-shortcuts'; import { MobileTerminalShortcuts } from './mobile-terminal-shortcuts';
import { import { applyStickyModifier, type StickyModifier } from './sticky-modifier-keys';
StickyModifierKeys,
applyStickyModifier,
type StickyModifier,
} from './sticky-modifier-keys';
import { TerminalScriptsDropdown } from './terminal-scripts-dropdown'; import { TerminalScriptsDropdown } from './terminal-scripts-dropdown';
const logger = createLogger('Terminal'); 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_SIZE = 8 * 1024; // 8KB chunks for large pastes
const PASTE_CHUNK_DELAY_MS = 10; // Small delay between chunks to prevent overwhelming WebSocket 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 { interface TerminalPanelProps {
sessionId: string; sessionId: string;
authToken: string | null; authToken: string | null;
@@ -157,6 +157,9 @@ export function TerminalPanel({
const [isImageDragOver, setIsImageDragOver] = useState(false); const [isImageDragOver, setIsImageDragOver] = useState(false);
const [isProcessingImage, setIsProcessingImage] = useState(false); const [isProcessingImage, setIsProcessingImage] = useState(false);
const hasRunInitialCommandRef = useRef(false); const hasRunInitialCommandRef = useRef(false);
// Long-press timer for mobile context menu
const longPressTimerRef = useRef<NodeJS.Timeout | null>(null);
const longPressTouchStartRef = useRef<{ x: number; y: number } | null>(null);
// Tracks whether the connected shell is a Windows shell (PowerShell, cmd, etc.). // 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 // 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 // causing unnecessary re-renders or stale closure issues. Set inside ws.onmessage
@@ -169,6 +172,10 @@ export function TerminalPanel({
const showSearchRef = useRef(false); const showSearchRef = useRef(false);
const [isAtBottom, setIsAtBottom] = useState(true); 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 // Sticky modifier key state (Ctrl or Alt) for the terminal toolbar
const [stickyModifier, setStickyModifier] = useState<StickyModifier>(null); const [stickyModifier, setStickyModifier] = useState<StickyModifier>(null);
const stickyModifierRef = useRef<StickyModifier>(null); const stickyModifierRef = useRef<StickyModifier>(null);
@@ -330,9 +337,16 @@ export function TerminalPanel({
try { try {
// Strip any ANSI escape codes that might be in the selection // Strip any ANSI escape codes that might be in the selection
const cleanText = stripAnsi(selection); const cleanText = stripAnsi(selection);
await navigator.clipboard.writeText(cleanText); const success = await writeToClipboard(cleanText);
toast.success('Copied to clipboard'); if (success) {
return true; toast.success('Copied to clipboard');
return true;
} else {
toast.error('Copy failed', {
description: 'Could not access clipboard',
});
return false;
}
} catch (err) { } catch (err) {
logger.error('Copy failed:', err); logger.error('Copy failed:', err);
const errorMessage = err instanceof Error ? err.message : 'Unknown error'; const errorMessage = err instanceof Error ? err.message : 'Unknown error';
@@ -399,7 +413,7 @@ export function TerminalPanel({
if (!terminal || !wsRef.current) return; if (!terminal || !wsRef.current) return;
try { try {
const text = await navigator.clipboard.readText(); const text = await readFromClipboard();
if (!text) { if (!text) {
toast.error('Nothing to paste', { toast.error('Nothing to paste', {
description: 'Clipboard is empty', description: 'Clipboard is empty',
@@ -428,7 +442,9 @@ export function TerminalPanel({
toast.error('Paste failed', { toast.error('Paste failed', {
description: errorMessage.includes('permission') description: errorMessage.includes('permission')
? 'Clipboard permission denied' ? 'Clipboard permission denied'
: 'Could not read from clipboard', : errorMessage.includes('not supported')
? errorMessage
: 'Could not read from clipboard',
}); });
} }
}, [sendTextInChunks]); }, [sendTextInChunks]);
@@ -439,6 +455,45 @@ export function TerminalPanel({
xtermRef.current?.selectAll(); 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 // Clear terminal
const clearTerminal = useCallback(() => { const clearTerminal = useCallback(() => {
xtermRef.current?.clear(); xtermRef.current?.clear();
@@ -944,17 +999,17 @@ export function TerminalPanel({
const otherModKey = isMacRef.current ? event.ctrlKey : event.metaKey; const otherModKey = isMacRef.current ? event.ctrlKey : event.metaKey;
// Ctrl+Shift+C / Cmd+Shift+C - Always copy (Linux terminal convention) // 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') { if (modKey && !otherModKey && event.shiftKey && !event.altKey && code === 'KeyC') {
event.preventDefault();
copySelectionRef.current(); copySelectionRef.current();
return false; return false;
} }
// Ctrl+C / Cmd+C - Copy if text is selected, otherwise send SIGINT // 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') { if (modKey && !otherModKey && !event.shiftKey && !event.altKey && code === 'KeyC') {
const hasSelection = terminal.hasSelection(); const hasSelection = terminal.hasSelection();
if (hasSelection) { if (hasSelection) {
event.preventDefault();
copySelectionRef.current(); copySelectionRef.current();
terminal.clearSelection(); terminal.clearSelection();
return false; return false;
@@ -964,9 +1019,11 @@ export function TerminalPanel({
} }
// Ctrl+V / Cmd+V or Ctrl+Shift+V / Cmd+Shift+V - Paste // 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') { if (modKey && !otherModKey && !event.altKey && code === 'KeyV') {
event.preventDefault();
pasteFromClipboardRef.current();
return false; return false;
} }
@@ -1014,6 +1071,12 @@ export function TerminalPanel({
resizeDebounceRef.current = null; 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 // Clear search decorations before disposing to prevent visual artifacts
if (searchAddonRef.current) { if (searchAddonRef.current) {
searchAddonRef.current.clearDecorations(); searchAddonRef.current.clearDecorations();
@@ -1571,6 +1634,17 @@ export function TerminalPanel({
buttons[focusedMenuIndex]?.focus(); buttons[focusedMenuIndex]?.focus();
}, [focusedMenuIndex, contextMenu]); }, [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 // Handle right-click context menu with boundary checking
const handleContextMenu = useCallback((e: React.MouseEvent) => { const handleContextMenu = useCallback((e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
@@ -1602,6 +1676,77 @@ export function TerminalPanel({
setContextMenu({ x, y }); 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 // Convert file to base64
const fileToBase64 = useCallback((file: File): Promise<string> => { const fileToBase64 = useCallback((file: File): Promise<string> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -2092,15 +2237,6 @@ export function TerminalPanel({
<div className="w-px h-3 mx-0.5 bg-border" /> <div className="w-px h-3 mx-0.5 bg-border" />
{/* Sticky modifier keys (Ctrl, Alt) */}
<StickyModifierKeys
activeModifier={stickyModifier}
onModifierChange={handleStickyModifierChange}
isConnected={connectionStatus === 'connected'}
/>
<div className="w-px h-3 mx-0.5 bg-border" />
{/* Split/close buttons */} {/* Split/close buttons */}
<Button <Button
variant="ghost" variant="ghost"
@@ -2221,24 +2357,116 @@ export function TerminalPanel({
</div> </div>
)} )}
{/* Mobile shortcuts bar - special keys and arrow keys for touch devices */} {/* Mobile shortcuts bar - special keys, clipboard, and arrow keys for touch devices */}
{isMobile && ( {isMobile && (
<MobileTerminalShortcuts <MobileTerminalShortcuts
onSendInput={sendTerminalInput} onSendInput={sendTerminalInput}
isConnected={connectionStatus === 'connected'} isConnected={connectionStatus === 'connected'}
activeModifier={stickyModifier}
onModifierChange={handleStickyModifierChange}
onSelectAll={selectAll}
onCopy={() => {
// On mobile, if nothing is selected, auto-select all before copying.
// This provides a convenient "tap to copy all" experience since
// touch-based text selection in xterm.js canvas is not possible.
const terminal = xtermRef.current;
if (terminal && !terminal.hasSelection()) {
terminal.selectAll();
}
copySelectionRef.current();
}}
onPaste={() => pasteFromClipboardRef.current()}
onToggleSelectMode={toggleSelectMode}
isSelectMode={isSelectMode}
/> />
)} )}
{/* Terminal container - uses terminal theme */} {/* Terminal area wrapper - relative container for the terminal and selection overlay */}
<div <div className="flex-1 overflow-hidden relative">
ref={terminalRef} {/* Terminal container - xterm.js mounts here */}
className="flex-1 overflow-hidden relative" <div
style={{ backgroundColor: currentTerminalTheme.background }} ref={terminalRef}
onContextMenu={handleContextMenu} className="absolute inset-0"
onDragOver={handleImageDragOver} style={{ backgroundColor: currentTerminalTheme.background }}
onDragLeave={handleImageDragLeave} onContextMenu={handleContextMenu}
onDrop={handleImageDrop} onTouchStart={handleTouchStart}
/> onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onTouchCancel={handleTouchEnd}
onDragOver={handleImageDragOver}
onDragLeave={handleImageDragLeave}
onDrop={handleImageDrop}
/>
{/* Mobile text selection overlay - renders terminal buffer as native selectable text.
Overlays the canvas so users can use native touch selection on real DOM text.
xterm.js renders to a <canvas>, which prevents native text selection on mobile.
This overlay shows the same content as real DOM text that supports touch selection. */}
{isSelectMode && isMobile && (
<div className="absolute inset-0 z-30 flex flex-col">
{/* Header bar with copy/done actions */}
<div className="flex items-center justify-between px-3 py-2 bg-brand-500/95 backdrop-blur-sm text-white shrink-0">
<span className="text-xs font-medium">Touch &amp; hold to select text</span>
<div className="flex items-center gap-2">
<button
className="px-3 py-1.5 text-xs font-medium rounded-md bg-white/20 hover:bg-white/30 active:scale-95 transition-all touch-manipulation"
onClick={async () => {
const selection = window.getSelection();
const selectedText = selection?.toString();
if (selectedText) {
const success = await writeToClipboard(selectedText);
if (success) {
toast.success('Copied to clipboard');
} else {
toast.error('Copy failed');
}
} else {
const success = await writeToClipboard(selectModeText);
if (success) {
toast.success('Copied all text to clipboard');
} else {
toast.error('Copy failed');
}
}
}}
>
Copy
</button>
<button
className="px-3 py-1.5 text-xs font-medium rounded-md bg-white/20 hover:bg-white/30 active:scale-95 transition-all touch-manipulation"
onClick={() => {
setIsSelectMode(false);
setSelectModeText('');
}}
>
Done
</button>
</div>
</div>
{/* Scrollable text content matching terminal appearance */}
<div
className="flex-1 overflow-auto"
style={
{
backgroundColor: currentTerminalTheme.background,
color: currentTerminalTheme.foreground,
fontFamily: getTerminalFontFamily(fontFamily),
fontSize: `${fontSize}px`,
lineHeight: `${lineHeight || 1.0}`,
padding: '12px 16px',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
userSelect: 'text',
WebkitUserSelect: 'text',
touchAction: 'auto',
} as React.CSSProperties
}
>
{selectModeText || 'No terminal content to select.'}
</div>
</div>
)}
</div>
{/* Jump to bottom button - shown when scrolled up */} {/* Jump to bottom button - shown when scrolled up */}
{!isAtBottom && ( {!isAtBottom && (

View File

@@ -32,6 +32,7 @@ export function useGitDiffs(projectPath: string | undefined, enabled = true) {
return { return {
files: result.files ?? [], files: result.files ?? [],
diff: result.diff ?? '', diff: result.diff ?? '',
...(result.mergeState ? { mergeState: result.mergeState } : {}),
}; };
}, },
enabled: !!projectPath && enabled, enabled: !!projectPath && enabled,

View File

@@ -160,6 +160,7 @@ export function useWorktreeDiffs(projectPath: string | undefined, featureId: str
return { return {
files: result.files ?? [], files: result.files ?? [],
diff: result.diff ?? '', diff: result.diff ?? '',
...(result.mergeState ? { mergeState: result.mergeState } : {}),
}; };
}, },
enabled: !!projectPath && !!featureId, enabled: !!projectPath && !!featureId,

View File

@@ -157,8 +157,40 @@ export function useAutoMode(worktree?: WorktreeInfo) {
// Check if we can start a new task based on concurrency limit // Check if we can start a new task based on concurrency limit
const canStartNewTask = runningAutoTasks.length < maxConcurrency; 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); 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<ReturnType<typeof setTimeout> | 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 () => { const refreshStatus = useCallback(async () => {
if (!currentProject) return; if (!currentProject) return;
@@ -175,20 +207,25 @@ export function useAutoMode(worktree?: WorktreeInfo) {
if (result.success && result.isAutoLoopRunning !== undefined) { if (result.success && result.isAutoLoopRunning !== undefined) {
const backendIsRunning = result.isAutoLoopRunning; const backendIsRunning = result.isAutoLoopRunning;
const backendRunningFeatures = result.runningFeatures ?? []; const backendRunningFeatures = result.runningFeatures ?? [];
// Read latest state from refs to avoid stale closure values
const currentIsRunning = isAutoModeRunningRef.current;
const currentRunningTasks = runningAutoTasksRef.current;
const needsSync = const needsSync =
backendIsRunning !== isAutoModeRunning || backendIsRunning !== currentIsRunning ||
// Also sync when backend has runningFeatures we're missing (handles missed WebSocket events) // Also sync when backend has runningFeatures we're missing (handles missed WebSocket events)
(backendIsRunning && (backendIsRunning &&
Array.isArray(backendRunningFeatures) && Array.isArray(backendRunningFeatures) &&
backendRunningFeatures.length > 0 && backendRunningFeatures.length > 0 &&
!arraysEqual(backendRunningFeatures, runningAutoTasks)) || !arraysEqual(backendRunningFeatures, currentRunningTasks)) ||
// Also sync when UI has stale running tasks but backend has none // Also sync when UI has stale running tasks but backend has none
// (handles server restart where features were reconciled to backlog/ready) // (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) { if (needsSync) {
const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree'; const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
if (backendIsRunning !== isAutoModeRunning) { if (backendIsRunning !== currentIsRunning) {
logger.info( logger.info(
`[AutoMode] Syncing UI state with backend for ${worktreeDesc} in ${currentProject.path}: ${backendIsRunning ? 'ON' : 'OFF'}` `[AutoMode] Syncing UI state with backend for ${worktreeDesc} in ${currentProject.path}: ${backendIsRunning ? 'ON' : 'OFF'}`
); );
@@ -206,7 +243,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
} catch (error) { } catch (error) {
logger.error('Error syncing auto mode state with backend:', 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. // 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. // 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' 'maxConcurrency' in event && typeof event.maxConcurrency === 'number'
? event.maxConcurrency ? event.maxConcurrency
: getMaxConcurrencyForWorktree(eventProjectId, eventBranchName); : getMaxConcurrencyForWorktree(eventProjectId, eventBranchName);
// Always apply start events even during transitions - this confirms the optimistic state
setAutoModeRunning(eventProjectId, eventBranchName, true, eventMaxConcurrency); 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; break;
@@ -307,12 +359,23 @@ export function useAutoMode(worktree?: WorktreeInfo) {
break; break;
case 'auto_mode_stopped': 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'; const worktreeDesc = eventBranchName ? `worktree ${eventBranchName}` : 'main worktree';
logger.info(`[AutoMode] Backend stopped auto loop for ${worktreeDesc}`); if (eventBranchName === branchName && isTransitioningRef.current) {
if (eventProjectId) { logger.info(
setAutoModeRunning(eventProjectId, eventBranchName, false); `[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; break;
@@ -574,6 +637,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
return unsubscribe; return unsubscribe;
}, [ }, [
projectId, projectId,
branchName,
addRunningTask, addRunningTask,
removeRunningTask, removeRunningTask,
addAutoModeActivity, addAutoModeActivity,
@@ -582,7 +646,6 @@ export function useAutoMode(worktree?: WorktreeInfo) {
setAutoModeRunning, setAutoModeRunning,
currentProject?.path, currentProject?.path,
getMaxConcurrencyForWorktree, getMaxConcurrencyForWorktree,
setMaxConcurrencyForWorktree,
isPrimaryWorktreeBranch, isPrimaryWorktreeBranch,
]); ]);
@@ -624,8 +687,10 @@ export function useAutoMode(worktree?: WorktreeInfo) {
} }
logger.debug(`[AutoMode] Started successfully for ${worktreeDesc}`); logger.debug(`[AutoMode] Started successfully for ${worktreeDesc}`);
// Sync with backend after success (gets runningFeatures if events were delayed) // Sync with backend after a short delay to get runningFeatures if events were delayed.
queueMicrotask(() => void refreshStatus()); // 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) { } catch (error) {
// Revert UI state on error // Revert UI state on error
setAutoModeSessionForWorktree(currentProject.path, branchName, false); setAutoModeSessionForWorktree(currentProject.path, branchName, false);
@@ -635,7 +700,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
} finally { } finally {
isTransitioningRef.current = false; 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 // Stop auto mode - calls backend to stop the auto loop for this worktree
const stop = useCallback(async () => { const stop = useCallback(async () => {
@@ -672,8 +737,8 @@ export function useAutoMode(worktree?: WorktreeInfo) {
// NOTE: Running tasks will continue until natural completion. // NOTE: Running tasks will continue until natural completion.
// The backend stops picking up new features but doesn't abort running ones. // The backend stops picking up new features but doesn't abort running ones.
logger.info(`Stopped ${worktreeDesc} - running tasks will continue`); logger.info(`Stopped ${worktreeDesc} - running tasks will continue`);
// Sync with backend after success // Sync with backend after a short delay to confirm stopped state
queueMicrotask(() => void refreshStatus()); setTimeout(() => void refreshStatus(), 500);
} catch (error) { } catch (error) {
// Revert UI state on error // Revert UI state on error
setAutoModeSessionForWorktree(currentProject.path, branchName, true); setAutoModeSessionForWorktree(currentProject.path, branchName, true);
@@ -683,7 +748,95 @@ export function useAutoMode(worktree?: WorktreeInfo) {
} finally { } finally {
isTransitioningRef.current = false; 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 // Stop a specific feature
const stopFeature = useCallback( const stopFeature = useCallback(
@@ -731,6 +884,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
start, start,
stop, stop,
stopFeature, stopFeature,
restartWithConcurrency,
refreshStatus, refreshStatus,
}; };
} }

View File

@@ -166,6 +166,7 @@ export function parseLocalStorageSettings(): Partial<GlobalSettings> | null {
defaultSkipTests: state.defaultSkipTests as boolean, defaultSkipTests: state.defaultSkipTests as boolean,
enableDependencyBlocking: state.enableDependencyBlocking as boolean, enableDependencyBlocking: state.enableDependencyBlocking as boolean,
skipVerificationInAutoMode: state.skipVerificationInAutoMode as boolean, skipVerificationInAutoMode: state.skipVerificationInAutoMode as boolean,
mergePostAction: (state.mergePostAction as 'commit' | 'manual' | null) ?? null,
useWorktrees: state.useWorktrees as boolean, useWorktrees: state.useWorktrees as boolean,
defaultPlanningMode: state.defaultPlanningMode as GlobalSettings['defaultPlanningMode'], defaultPlanningMode: state.defaultPlanningMode as GlobalSettings['defaultPlanningMode'],
defaultRequirePlanApproval: state.defaultRequirePlanApproval as boolean, defaultRequirePlanApproval: state.defaultRequirePlanApproval as boolean,
@@ -704,6 +705,7 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
defaultSkipTests: settings.defaultSkipTests ?? true, defaultSkipTests: settings.defaultSkipTests ?? true,
enableDependencyBlocking: settings.enableDependencyBlocking ?? true, enableDependencyBlocking: settings.enableDependencyBlocking ?? true,
skipVerificationInAutoMode: settings.skipVerificationInAutoMode ?? false, skipVerificationInAutoMode: settings.skipVerificationInAutoMode ?? false,
mergePostAction: settings.mergePostAction ?? null,
useWorktrees: settings.useWorktrees ?? true, useWorktrees: settings.useWorktrees ?? true,
defaultPlanningMode: settings.defaultPlanningMode ?? 'skip', defaultPlanningMode: settings.defaultPlanningMode ?? 'skip',
defaultRequirePlanApproval: settings.defaultRequirePlanApproval ?? false, defaultRequirePlanApproval: settings.defaultRequirePlanApproval ?? false,
@@ -718,6 +720,8 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
enhancementModel: settings.enhancementModel ?? 'claude-sonnet', enhancementModel: settings.enhancementModel ?? 'claude-sonnet',
validationModel: settings.validationModel ?? 'claude-opus', validationModel: settings.validationModel ?? 'claude-opus',
phaseModels: settings.phaseModels ?? current.phaseModels, phaseModels: settings.phaseModels ?? current.phaseModels,
defaultThinkingLevel: settings.defaultThinkingLevel ?? 'none',
defaultReasoningEffort: settings.defaultReasoningEffort ?? 'none',
enabledCursorModels: allCursorModels, // Always use ALL cursor models enabledCursorModels: allCursorModels, // Always use ALL cursor models
cursorDefaultModel: sanitizedCursorDefaultModel, cursorDefaultModel: sanitizedCursorDefaultModel,
enabledOpencodeModels: sanitizedEnabledOpencodeModels, enabledOpencodeModels: sanitizedEnabledOpencodeModels,
@@ -749,6 +753,7 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
projectHistory: settings.projectHistory ?? [], projectHistory: settings.projectHistory ?? [],
projectHistoryIndex: settings.projectHistoryIndex ?? -1, projectHistoryIndex: settings.projectHistoryIndex ?? -1,
lastSelectedSessionByProject: settings.lastSelectedSessionByProject ?? {}, lastSelectedSessionByProject: settings.lastSelectedSessionByProject ?? {},
currentWorktreeByProject: settings.currentWorktreeByProject ?? {},
// UI State // UI State
worktreePanelCollapsed: settings.worktreePanelCollapsed ?? false, worktreePanelCollapsed: settings.worktreePanelCollapsed ?? false,
lastProjectDir: settings.lastProjectDir ?? '', lastProjectDir: settings.lastProjectDir ?? '',
@@ -802,6 +807,7 @@ function buildSettingsUpdateFromStore(): Record<string, unknown> {
defaultSkipTests: state.defaultSkipTests, defaultSkipTests: state.defaultSkipTests,
enableDependencyBlocking: state.enableDependencyBlocking, enableDependencyBlocking: state.enableDependencyBlocking,
skipVerificationInAutoMode: state.skipVerificationInAutoMode, skipVerificationInAutoMode: state.skipVerificationInAutoMode,
mergePostAction: state.mergePostAction,
useWorktrees: state.useWorktrees, useWorktrees: state.useWorktrees,
defaultPlanningMode: state.defaultPlanningMode, defaultPlanningMode: state.defaultPlanningMode,
defaultRequirePlanApproval: state.defaultRequirePlanApproval, defaultRequirePlanApproval: state.defaultRequirePlanApproval,
@@ -812,6 +818,8 @@ function buildSettingsUpdateFromStore(): Record<string, unknown> {
enhancementModel: state.enhancementModel, enhancementModel: state.enhancementModel,
validationModel: state.validationModel, validationModel: state.validationModel,
phaseModels: state.phaseModels, phaseModels: state.phaseModels,
defaultThinkingLevel: state.defaultThinkingLevel,
defaultReasoningEffort: state.defaultReasoningEffort,
enabledDynamicModelIds: state.enabledDynamicModelIds, enabledDynamicModelIds: state.enabledDynamicModelIds,
disabledProviders: state.disabledProviders, disabledProviders: state.disabledProviders,
autoLoadClaudeMd: state.autoLoadClaudeMd, autoLoadClaudeMd: state.autoLoadClaudeMd,
@@ -836,6 +844,7 @@ function buildSettingsUpdateFromStore(): Record<string, unknown> {
projectHistory: state.projectHistory, projectHistory: state.projectHistory,
projectHistoryIndex: state.projectHistoryIndex, projectHistoryIndex: state.projectHistoryIndex,
lastSelectedSessionByProject: state.lastSelectedSessionByProject, lastSelectedSessionByProject: state.lastSelectedSessionByProject,
currentWorktreeByProject: state.currentWorktreeByProject,
worktreePanelCollapsed: state.worktreePanelCollapsed, worktreePanelCollapsed: state.worktreePanelCollapsed,
lastProjectDir: state.lastProjectDir, lastProjectDir: state.lastProjectDir,
recentFolders: state.recentFolders, recentFolders: state.recentFolders,

View File

@@ -58,6 +58,7 @@ const SETTINGS_FIELDS_TO_SYNC = [
'defaultSkipTests', 'defaultSkipTests',
'enableDependencyBlocking', 'enableDependencyBlocking',
'skipVerificationInAutoMode', 'skipVerificationInAutoMode',
'mergePostAction',
'useWorktrees', 'useWorktrees',
'defaultPlanningMode', 'defaultPlanningMode',
'defaultRequirePlanApproval', 'defaultRequirePlanApproval',
@@ -717,6 +718,7 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
defaultSkipTests: serverSettings.defaultSkipTests, defaultSkipTests: serverSettings.defaultSkipTests,
enableDependencyBlocking: serverSettings.enableDependencyBlocking, enableDependencyBlocking: serverSettings.enableDependencyBlocking,
skipVerificationInAutoMode: serverSettings.skipVerificationInAutoMode, skipVerificationInAutoMode: serverSettings.skipVerificationInAutoMode,
mergePostAction: serverSettings.mergePostAction ?? null,
useWorktrees: serverSettings.useWorktrees, useWorktrees: serverSettings.useWorktrees,
defaultPlanningMode: serverSettings.defaultPlanningMode, defaultPlanningMode: serverSettings.defaultPlanningMode,
defaultRequirePlanApproval: serverSettings.defaultRequirePlanApproval, defaultRequirePlanApproval: serverSettings.defaultRequirePlanApproval,

View File

@@ -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<boolean> {
// 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<string> {
// 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);
}
}

View File

@@ -568,6 +568,7 @@ type EventType =
| 'dev-server:started' | 'dev-server:started'
| 'dev-server:output' | 'dev-server:output'
| 'dev-server:stopped' | 'dev-server:stopped'
| 'dev-server:url-detected'
| 'test-runner:started' | 'test-runner:started'
| 'test-runner:output' | 'test-runner:output'
| 'test-runner:completed' | 'test-runner:completed'
@@ -576,13 +577,17 @@ type EventType =
/** /**
* Dev server log event payloads for WebSocket streaming * 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; worktreePath: string;
port: number;
url: string; url: string;
port: number;
timestamp: string; timestamp: string;
} }
export type DevServerStartedEvent = DevServerUrlEvent;
export interface DevServerOutputEvent { export interface DevServerOutputEvent {
worktreePath: string; worktreePath: string;
content: string; content: string;
@@ -597,10 +602,13 @@ export interface DevServerStoppedEvent {
timestamp: string; timestamp: string;
} }
export type DevServerUrlDetectedEvent = DevServerUrlEvent;
export type DevServerLogEvent = export type DevServerLogEvent =
| { type: 'dev-server:started'; payload: DevServerStartedEvent } | { type: 'dev-server:started'; payload: DevServerStartedEvent }
| { type: 'dev-server:output'; payload: DevServerOutputEvent } | { 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 * Test runner event payloads for WebSocket streaming
@@ -2204,10 +2212,14 @@ export class HttpApiClient implements ElectronAPI {
const unsub3 = this.subscribeToEvent('dev-server:stopped', (payload) => const unsub3 = this.subscribeToEvent('dev-server:stopped', (payload) =>
callback({ type: 'dev-server:stopped', payload: payload as DevServerStoppedEvent }) 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 () => { return () => {
unsub1(); unsub1();
unsub2(); unsub2();
unsub3(); unsub3();
unsub4();
}; };
}, },
getPRInfo: (worktreePath: string, branchName: string) => getPRInfo: (worktreePath: string, branchName: string) =>

View File

@@ -305,6 +305,7 @@ function RootLayoutContent() {
sidebarStyle: state.sidebarStyle, sidebarStyle: state.sidebarStyle,
worktreePanelCollapsed: state.worktreePanelCollapsed, worktreePanelCollapsed: state.worktreePanelCollapsed,
collapsedNavSections: state.collapsedNavSections, collapsedNavSections: state.collapsedNavSections,
currentWorktreeByProject: state.currentWorktreeByProject,
}); });
}); });
return unsubscribe; return unsubscribe;

View File

@@ -298,6 +298,7 @@ const initialState: AppState = {
enableDependencyBlocking: true, enableDependencyBlocking: true,
skipVerificationInAutoMode: false, skipVerificationInAutoMode: false,
enableAiCommitMessages: true, enableAiCommitMessages: true,
mergePostAction: null,
planUseSelectedWorktreeBranch: true, planUseSelectedWorktreeBranch: true,
addFeatureUseSelectedWorktreeBranch: false, addFeatureUseSelectedWorktreeBranch: false,
useWorktrees: true, useWorktrees: true,
@@ -362,6 +363,8 @@ const initialState: AppState = {
defaultPlanningMode: 'skip' as PlanningMode, defaultPlanningMode: 'skip' as PlanningMode,
defaultRequirePlanApproval: false, defaultRequirePlanApproval: false,
defaultFeatureModel: DEFAULT_GLOBAL_SETTINGS.defaultFeatureModel, defaultFeatureModel: DEFAULT_GLOBAL_SETTINGS.defaultFeatureModel,
defaultThinkingLevel: DEFAULT_GLOBAL_SETTINGS.defaultThinkingLevel ?? 'none',
defaultReasoningEffort: DEFAULT_GLOBAL_SETTINGS.defaultReasoningEffort ?? 'none',
pendingPlanApproval: null, pendingPlanApproval: null,
claudeRefreshInterval: 60, claudeRefreshInterval: 60,
claudeUsage: null, claudeUsage: null,
@@ -1117,6 +1120,16 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
logger.error('Failed to sync enableAiCommitMessages:', error); 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) => { setPlanUseSelectedWorktreeBranch: async (enabled) => {
set({ planUseSelectedWorktreeBranch: enabled }); set({ planUseSelectedWorktreeBranch: enabled });
// Sync to server // Sync to server
@@ -2313,6 +2326,28 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
setDefaultRequirePlanApproval: (require) => set({ defaultRequirePlanApproval: require }), setDefaultRequirePlanApproval: (require) => set({ defaultRequirePlanApproval: require }),
setDefaultFeatureModel: (entry) => set({ defaultFeatureModel: entry }), 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 // Plan Approval actions
setPendingPlanApproval: (approval) => set({ pendingPlanApproval: approval }), setPendingPlanApproval: (approval) => set({ pendingPlanApproval: approval }),

View File

@@ -21,6 +21,8 @@ import type {
ClaudeApiProfile, ClaudeApiProfile,
ClaudeCompatibleProvider, ClaudeCompatibleProvider,
SidebarStyle, SidebarStyle,
ThinkingLevel,
ReasoningEffort,
} from '@automaker/types'; } from '@automaker/types';
import type { 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) 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) 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 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 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 addFeatureUseSelectedWorktreeBranch: boolean; // When true, Add Feature dialog defaults to custom mode with selected worktree branch
@@ -175,6 +178,10 @@ export interface AppState {
phaseModels: PhaseModelConfig; phaseModels: PhaseModelConfig;
favoriteModels: string[]; favoriteModels: string[];
// Default thinking/reasoning levels for two-stage model selector primary button
defaultThinkingLevel: ThinkingLevel;
defaultReasoningEffort: ReasoningEffort;
// Cursor CLI Settings (global) // Cursor CLI Settings (global)
enabledCursorModels: CursorModelId[]; // Which Cursor models are available in feature modal enabledCursorModels: CursorModelId[]; // Which Cursor models are available in feature modal
cursorDefaultModel: CursorModelId; // Default Cursor model selection cursorDefaultModel: CursorModelId; // Default Cursor model selection
@@ -488,6 +495,7 @@ export interface AppActions {
setEnableDependencyBlocking: (enabled: boolean) => void; setEnableDependencyBlocking: (enabled: boolean) => void;
setSkipVerificationInAutoMode: (enabled: boolean) => Promise<void>; setSkipVerificationInAutoMode: (enabled: boolean) => Promise<void>;
setEnableAiCommitMessages: (enabled: boolean) => Promise<void>; setEnableAiCommitMessages: (enabled: boolean) => Promise<void>;
setMergePostAction: (action: 'commit' | 'manual' | null) => Promise<void>;
setPlanUseSelectedWorktreeBranch: (enabled: boolean) => Promise<void>; setPlanUseSelectedWorktreeBranch: (enabled: boolean) => Promise<void>;
setAddFeatureUseSelectedWorktreeBranch: (enabled: boolean) => Promise<void>; setAddFeatureUseSelectedWorktreeBranch: (enabled: boolean) => Promise<void>;
@@ -548,6 +556,8 @@ export interface AppActions {
setPhaseModels: (models: Partial<PhaseModelConfig>) => Promise<void>; setPhaseModels: (models: Partial<PhaseModelConfig>) => Promise<void>;
resetPhaseModels: () => Promise<void>; resetPhaseModels: () => Promise<void>;
toggleFavoriteModel: (modelId: string) => void; toggleFavoriteModel: (modelId: string) => void;
setDefaultThinkingLevel: (level: ThinkingLevel) => void;
setDefaultReasoningEffort: (effort: ReasoningEffort) => void;
// Cursor CLI Settings actions // Cursor CLI Settings actions
setEnabledCursorModels: (models: CursorModelId[]) => void; setEnabledCursorModels: (models: CursorModelId[]) => void;

View File

@@ -35,6 +35,8 @@ interface UICacheState {
cachedWorktreePanelCollapsed: boolean; cachedWorktreePanelCollapsed: boolean;
/** Collapsed nav sections */ /** Collapsed nav sections */
cachedCollapsedNavSections: Record<string, boolean>; cachedCollapsedNavSections: Record<string, boolean>;
/** Selected worktree per project (path + branch) for instant restore on PWA reload */
cachedCurrentWorktreeByProject: Record<string, { path: string | null; branch: string }>;
} }
interface UICacheActions { interface UICacheActions {
@@ -52,19 +54,29 @@ export const useUICacheStore = create<UICacheState & UICacheActions>()(
cachedSidebarStyle: 'unified', cachedSidebarStyle: 'unified',
cachedWorktreePanelCollapsed: false, cachedWorktreePanelCollapsed: false,
cachedCollapsedNavSections: {}, cachedCollapsedNavSections: {},
cachedCurrentWorktreeByProject: {},
updateFromAppStore: (state) => set(state), updateFromAppStore: (state) => set(state),
}), }),
{ {
name: STORE_NAME, name: STORE_NAME,
version: 1, version: 2,
partialize: (state) => ({ partialize: (state) => ({
cachedProjectId: state.cachedProjectId, cachedProjectId: state.cachedProjectId,
cachedSidebarOpen: state.cachedSidebarOpen, cachedSidebarOpen: state.cachedSidebarOpen,
cachedSidebarStyle: state.cachedSidebarStyle, cachedSidebarStyle: state.cachedSidebarStyle,
cachedWorktreePanelCollapsed: state.cachedWorktreePanelCollapsed, cachedWorktreePanelCollapsed: state.cachedWorktreePanelCollapsed,
cachedCollapsedNavSections: state.cachedCollapsedNavSections, cachedCollapsedNavSections: state.cachedCollapsedNavSections,
cachedCurrentWorktreeByProject: state.cachedCurrentWorktreeByProject,
}), }),
migrate: (persistedState: unknown, version: number) => {
const state = persistedState as Record<string, unknown>;
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'; sidebarStyle?: 'unified' | 'discord';
worktreePanelCollapsed?: boolean; worktreePanelCollapsed?: boolean;
collapsedNavSections?: Record<string, boolean>; collapsedNavSections?: Record<string, boolean>;
currentWorktreeByProject?: Record<string, { path: string | null; branch: string }>;
}): void { }): void {
const update: Partial<UICacheState> = {}; const update: Partial<UICacheState> = {};
@@ -100,6 +113,9 @@ export function syncUICache(appState: {
if ('collapsedNavSections' in appState) { if ('collapsedNavSections' in appState) {
update.cachedCollapsedNavSections = appState.collapsedNavSections; update.cachedCollapsedNavSections = appState.collapsedNavSections;
} }
if ('currentWorktreeByProject' in appState) {
update.cachedCurrentWorktreeByProject = appState.currentWorktreeByProject;
}
if (Object.keys(update).length > 0) { if (Object.keys(update).length > 0) {
useUICacheStore.getState().updateFromAppStore(update); useUICacheStore.getState().updateFromAppStore(update);
@@ -142,6 +158,15 @@ export function restoreFromUICache(
collapsedNavSections: cache.cachedCollapsedNavSections, 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. // Restore the project context when the project object is available.
// When projects are not yet loaded (empty array), currentProject remains // When projects are not yet loaded (empty array), currentProject remains
// null and will be properly set later by hydrateStoreFromSettings(). // null and will be properly set later by hydrateStoreFromSettings().

View File

@@ -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 { .glass-subtle {
@apply backdrop-blur-sm border-white/5; @apply backdrop-blur-sm border-white/5;
} }

View File

@@ -8,7 +8,7 @@ import type {
ZaiUsageResponse, ZaiUsageResponse,
GeminiUsageResponse, GeminiUsageResponse,
} from '@/store/app-store'; } from '@/store/app-store';
import type { ParsedTask, FeatureStatusWithPipeline } from '@automaker/types'; import type { ParsedTask, FeatureStatusWithPipeline, MergeStateInfo } from '@automaker/types';
export interface ImageAttachment { export interface ImageAttachment {
id?: string; // Optional - may not be present in messages loaded from server id?: string; // Optional - may not be present in messages loaded from server
@@ -759,6 +759,10 @@ export interface FileStatus {
indexStatus?: string; indexStatus?: string;
/** Raw working tree status character from git porcelain format */ /** Raw working tree status character from git porcelain format */
workTreeStatus?: string; 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 { export interface FileDiffsResult {
@@ -767,6 +771,8 @@ export interface FileDiffsResult {
files?: FileStatus[]; files?: FileStatus[];
hasChanges?: boolean; hasChanges?: boolean;
error?: string; error?: string;
/** Merge state info, present when a merge/rebase/cherry-pick is in progress */
mergeState?: MergeStateInfo;
} }
export interface FileDiffResult { export interface FileDiffResult {
@@ -1286,6 +1292,7 @@ export interface WorktreeAPI {
worktreePath: string; worktreePath: string;
port: number; port: number;
url: string; url: string;
urlDetected: boolean;
}>; }>;
}; };
error?: string; error?: string;
@@ -1304,7 +1311,7 @@ export interface WorktreeAPI {
error?: string; error?: string;
}>; }>;
// Subscribe to dev server log events (started, output, stopped) // Subscribe to dev server log events (started, output, stopped, url-detected)
onDevServerLogEvent: ( onDevServerLogEvent: (
callback: ( callback: (
event: event:
@@ -1326,6 +1333,15 @@ export interface WorktreeAPI {
timestamp: string; timestamp: string;
}; };
} }
| {
type: 'dev-server:url-detected';
payload: {
worktreePath: string;
url: string;
port: number;
timestamp: string;
};
}
) => void ) => void
) => () => void; ) => () => void;

View File

@@ -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 });
});
});

View File

@@ -7,8 +7,8 @@ import { secureFs } from '@automaker/platform';
import path from 'path'; import path from 'path';
import { exec } from 'child_process'; import { exec } from 'child_process';
import { promisify } from 'util'; import { promisify } from 'util';
import { BINARY_EXTENSIONS, type FileStatus } from './types.js'; import { BINARY_EXTENSIONS, type FileStatus, type MergeStateInfo } from './types.js';
import { isGitRepo, parseGitStatus } from './status.js'; import { isGitRepo, parseGitStatus, detectMergeState, detectMergeCommit } from './status.js';
const execAsync = promisify(exec); const execAsync = promisify(exec);
const logger = createLogger('GitUtils'); const logger = createLogger('GitUtils');
@@ -243,11 +243,15 @@ export async function generateDiffsForNonGitDirectory(
/** /**
* Get git repository diffs for a given path * 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( export async function getGitRepositoryDiffs(repoPath: string): Promise<{
repoPath: string diff: string;
): Promise<{ diff: string; files: FileStatus[]; hasChanges: boolean }> { files: FileStatus[];
hasChanges: boolean;
mergeState?: MergeStateInfo;
}> {
// Check if it's a git repository // Check if it's a git repository
const isRepo = await isGitRepo(repoPath); const isRepo = await isGitRepo(repoPath);
@@ -273,11 +277,133 @@ export async function getGitRepositoryDiffs(
const files = parseGitStatus(status); const files = parseGitStatus(status);
// Generate synthetic diffs for untracked (new) files // 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<string>();
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 { return {
diff: combinedDiff, diff: combinedDiff,
files, files,
hasChanges: files.length > 0, hasChanges: files.length > 0,
...(mergeState.isMerging ? { mergeState } : {}),
}; };
} }

View File

@@ -7,10 +7,15 @@
export { execGitCommand } from './exec.js'; export { execGitCommand } from './exec.js';
// Export types and constants // 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 status utilities
export { isGitRepo, parseGitStatus } from './status.js'; export { isGitRepo, parseGitStatus, detectMergeState, detectMergeCommit } from './status.js';
// Export diff utilities // Export diff utilities
export { export {

View File

@@ -4,7 +4,9 @@
import { exec } from 'child_process'; import { exec } from 'child_process';
import { promisify } from 'util'; 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); const execAsync = promisify(exec);
@@ -95,12 +97,161 @@ export function parseGitStatus(statusOutput: string): FileStatus[] {
primaryStatus = workTreeStatus; // Working tree change 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 { return {
status: primaryStatus, status: primaryStatus,
path: filePath, path: filePath,
statusText: getStatusText(indexStatus, workTreeStatus), statusText: getStatusText(indexStatus, workTreeStatus),
indexStatus, indexStatus,
workTreeStatus, 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<MergeStateInfo> {
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;
}
}

View File

@@ -2,6 +2,9 @@
* Git utilities types and constants * 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 // Binary file extensions to skip
export const BINARY_EXTENSIONS = new Set([ export const BINARY_EXTENSIONS = new Set([
'.png', '.png',
@@ -74,4 +77,8 @@ export interface FileStatus {
indexStatus?: string; indexStatus?: string;
/** Raw working tree status character from git porcelain format */ /** Raw working tree status character from git porcelain format */
workTreeStatus?: string; 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;
} }

View File

@@ -200,6 +200,7 @@ export {
getThinkingTokenBudget, getThinkingTokenBudget,
isAdaptiveThinkingModel, isAdaptiveThinkingModel,
getThinkingLevelsForModel, getThinkingLevelsForModel,
getDefaultThinkingLevel,
// Event hook constants // Event hook constants
EVENT_HOOK_TRIGGER_LABELS, EVENT_HOOK_TRIGGER_LABELS,
// Claude-compatible provider templates (new) // Claude-compatible provider templates (new)
@@ -359,6 +360,7 @@ export type {
AddRemoteResult, AddRemoteResult,
AddRemoteResponse, AddRemoteResponse,
AddRemoteErrorResponse, AddRemoteErrorResponse,
MergeStateInfo,
} from './worktree.js'; } from './worktree.js';
export { PR_STATES, validatePRState } from './worktree.js'; export { PR_STATES, validatePRState } from './worktree.js';

View File

@@ -268,6 +268,16 @@ export function getThinkingLevelsForModel(model: string): ThinkingLevel[] {
return ['none', 'low', 'medium', 'high', 'ultrathink']; 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 */ /** ModelProvider - AI model provider for credentials and API key management */
export type ModelProvider = 'claude' | 'cursor' | 'codex' | 'opencode' | 'gemini' | 'copilot'; export type ModelProvider = 'claude' | 'cursor' | 'codex' | 'opencode' | 'gemini' | 'copilot';
@@ -1051,6 +1061,8 @@ export interface GlobalSettings {
enableDependencyBlocking: boolean; enableDependencyBlocking: boolean;
/** Skip verification requirement in auto-mode (treat 'completed' same as 'verified') */ /** Skip verification requirement in auto-mode (treat 'completed' same as 'verified') */
skipVerificationInAutoMode: boolean; 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 */ /** Default: use git worktrees for feature branches */
useWorktrees: boolean; useWorktrees: boolean;
/** Default: planning approach (skip/lite/spec/full) */ /** Default: planning approach (skip/lite/spec/full) */
@@ -1086,6 +1098,15 @@ export interface GlobalSettings {
/** Phase-specific AI model configuration */ /** Phase-specific AI model configuration */
phaseModels: PhaseModelConfig; 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) // Legacy AI Model Selection (deprecated - use phaseModels instead)
/** @deprecated Use phaseModels.enhancementModel instead */ /** @deprecated Use phaseModels.enhancementModel instead */
enhancementModel: ModelAlias; enhancementModel: ModelAlias;
@@ -1150,6 +1171,10 @@ export interface GlobalSettings {
/** Maps project path -> last selected session ID in that project */ /** Maps project path -> last selected session ID in that project */
lastSelectedSessionByProject: Record<string, string>; lastSelectedSessionByProject: Record<string, string>;
// Worktree Selection Tracking
/** Maps project path -> last selected worktree (path + branch) for restoring on PWA reload */
currentWorktreeByProject?: Record<string, { path: string | null; branch: string }>;
// Window State (Electron only) // Window State (Electron only)
/** Persisted window bounds for restoring position/size across sessions */ /** Persisted window bounds for restoring position/size across sessions */
windowBounds?: WindowBounds; windowBounds?: WindowBounds;
@@ -1574,6 +1599,7 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
defaultSkipTests: true, defaultSkipTests: true,
enableDependencyBlocking: true, enableDependencyBlocking: true,
skipVerificationInAutoMode: false, skipVerificationInAutoMode: false,
mergePostAction: null,
useWorktrees: true, useWorktrees: true,
defaultPlanningMode: 'skip', defaultPlanningMode: 'skip',
defaultRequirePlanApproval: false, defaultRequirePlanApproval: false,
@@ -1585,6 +1611,8 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
showQueryDevtools: true, showQueryDevtools: true,
enableAiCommitMessages: true, enableAiCommitMessages: true,
phaseModels: DEFAULT_PHASE_MODELS, phaseModels: DEFAULT_PHASE_MODELS,
defaultThinkingLevel: 'none',
defaultReasoningEffort: 'none',
enhancementModel: 'sonnet', // Legacy alias still supported enhancementModel: 'sonnet', // Legacy alias still supported
validationModel: 'opus', // Legacy alias still supported validationModel: 'opus', // Legacy alias still supported
enabledCursorModels: getAllCursorModelIds(), // Returns prefixed IDs enabledCursorModels: getAllCursorModelIds(), // Returns prefixed IDs
@@ -1607,6 +1635,7 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
recentFolders: [], recentFolders: [],
worktreePanelCollapsed: false, worktreePanelCollapsed: false,
lastSelectedSessionByProject: {}, lastSelectedSessionByProject: {},
currentWorktreeByProject: {},
autoLoadClaudeMd: true, autoLoadClaudeMd: true,
skipSandboxWarning: false, skipSandboxWarning: false,
codexAutoLoadAgents: DEFAULT_CODEX_AUTO_LOAD_AGENTS, codexAutoLoadAgents: DEFAULT_CODEX_AUTO_LOAD_AGENTS,

View File

@@ -74,3 +74,21 @@ export interface AddRemoteErrorResponse {
/** Optional error code for specific error types (e.g., 'REMOTE_EXISTS') */ /** Optional error code for specific error types (e.g., 'REMOTE_EXISTS') */
code?: string; 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;
}

55
package-lock.json generated
View File

@@ -3864,9 +3864,9 @@
} }
}, },
"node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": {
"version": "8.17.1", "version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.3", "fast-deep-equal": "^3.1.3",
@@ -7159,9 +7159,9 @@
} }
}, },
"node_modules/ajv": { "node_modules/ajv": {
"version": "6.12.6", "version": "6.14.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -7193,9 +7193,9 @@
} }
}, },
"node_modules/ajv-formats/node_modules/ajv": { "node_modules/ajv-formats/node_modules/ajv": {
"version": "8.17.1", "version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.3", "fast-deep-equal": "^3.1.3",
@@ -9985,9 +9985,9 @@
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/fast-xml-parser": { "node_modules/fast-xml-parser": {
"version": "5.3.3", "version": "5.3.7",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.3.tgz", "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.7.tgz",
"integrity": "sha512-2O3dkPAAC6JavuMm8+4+pgTk+5hoAs+CjZ+sWcQLkX9+/tHRuTkQh/Oaifr8qDmZ8iEHb771Ea6G8CdwkrgvYA==", "integrity": "sha512-JzVLro9NQv92pOM/jTCR6mHlJh2FGwtomH8ZQjhFj/R29P2Fnj38OgPJVtcvYw6SuKClhgYuwUZf5b3rd8u2mA==",
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -9996,7 +9996,7 @@
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"strnum": "^2.1.0" "strnum": "^2.1.2"
}, },
"bin": { "bin": {
"fxparser": "src/cli/cli.js" "fxparser": "src/cli/cli.js"
@@ -11554,7 +11554,6 @@
"os": [ "os": [
"android" "android"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 12.0.0" "node": ">= 12.0.0"
}, },
@@ -11576,7 +11575,6 @@
"os": [ "os": [
"darwin" "darwin"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 12.0.0" "node": ">= 12.0.0"
}, },
@@ -11598,7 +11596,6 @@
"os": [ "os": [
"darwin" "darwin"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 12.0.0" "node": ">= 12.0.0"
}, },
@@ -11620,7 +11617,6 @@
"os": [ "os": [
"freebsd" "freebsd"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 12.0.0" "node": ">= 12.0.0"
}, },
@@ -11642,7 +11638,6 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 12.0.0" "node": ">= 12.0.0"
}, },
@@ -11664,7 +11659,6 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 12.0.0" "node": ">= 12.0.0"
}, },
@@ -11686,7 +11680,6 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 12.0.0" "node": ">= 12.0.0"
}, },
@@ -11708,7 +11701,6 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 12.0.0" "node": ">= 12.0.0"
}, },
@@ -11730,7 +11722,6 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 12.0.0" "node": ">= 12.0.0"
}, },
@@ -11752,7 +11743,6 @@
"os": [ "os": [
"win32" "win32"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 12.0.0" "node": ">= 12.0.0"
}, },
@@ -11774,7 +11764,6 @@
"os": [ "os": [
"win32" "win32"
], ],
"peer": true,
"engines": { "engines": {
"node": ">= 12.0.0" "node": ">= 12.0.0"
}, },
@@ -12005,9 +11994,9 @@
} }
}, },
"node_modules/lodash": { "node_modules/lodash": {
"version": "4.17.21", "version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash.debounce": { "node_modules/lodash.debounce": {
@@ -14363,9 +14352,9 @@
} }
}, },
"node_modules/qs": { "node_modules/qs": {
"version": "6.14.1", "version": "6.15.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
"side-channel": "^1.1.0" "side-channel": "^1.1.0"
@@ -15005,9 +14994,9 @@
} }
}, },
"node_modules/seroval": { "node_modules/seroval": {
"version": "1.4.0", "version": "1.5.0",
"resolved": "https://registry.npmjs.org/seroval/-/seroval-1.4.0.tgz", "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.0.tgz",
"integrity": "sha512-BdrNXdzlofomLTiRnwJTSEAaGKyHHZkbMXIywOh7zlzp4uZnXErEwl9XZ+N1hJSNpeTtNxWvVwN0wUzAIQ4Hpg==", "integrity": "sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=10" "node": ">=10"