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

View File

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

View File

@@ -22,6 +22,36 @@ import { getErrorMessage, logError, isValidBranchName } from '../common.js';
import { execGitCommand } from '../../../lib/git.js';
import type { EventEmitter } from '../../../lib/events.js';
import { performCheckoutBranch } from '../../../services/checkout-branch-service.js';
import { createLogger } from '@automaker/utils';
const logger = createLogger('CheckoutBranchRoute');
/** Timeout for git fetch operations (30 seconds) */
const FETCH_TIMEOUT_MS = 30_000;
/**
* Fetch latest from all remotes (silently, with timeout).
* Non-fatal: fetch errors are logged and swallowed so the workflow continues.
*/
async function fetchRemotes(cwd: string): Promise<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) {
return async (req: Request, res: Response): Promise<void> => {
@@ -127,6 +157,10 @@ export function createCheckoutBranchHandler(events?: EventEmitter) {
}
// Original simple flow (no stash handling)
// Fetch latest remote refs before creating the branch so that
// base branch validation works for remote references like "origin/main"
await fetchRemotes(resolvedPath);
const currentBranchOutput = await execGitCommand(
['rev-parse', '--abbrev-ref', 'HEAD'],
resolvedPath

View File

@@ -30,6 +30,9 @@ import { runInitScript } from '../../../services/init-script-service.js';
const logger = createLogger('Worktree');
/** Timeout for git fetch operations (30 seconds) */
const FETCH_TIMEOUT_MS = 30_000;
const execAsync = promisify(exec);
/**
@@ -83,41 +86,6 @@ async function findExistingWorktreeForBranch(
}
}
/**
* Detect whether a base branch reference is a remote branch (e.g. "origin/main").
* Returns the remote name if it matches a known remote, otherwise null.
*/
async function detectRemoteBranch(
projectPath: string,
baseBranch: string
): Promise<{ remote: string; branch: string } | null> {
const slashIndex = baseBranch.indexOf('/');
if (slashIndex <= 0) return null;
const possibleRemote = baseBranch.substring(0, slashIndex);
try {
// Check if this is actually a remote name by listing remotes
const stdout = await execGitCommand(['remote'], projectPath);
const remotes = stdout
.trim()
.split('\n')
.map((r: string) => r.trim())
.filter(Boolean);
if (remotes.includes(possibleRemote)) {
return {
remote: possibleRemote,
branch: baseBranch.substring(slashIndex + 1),
};
}
} catch {
// Not a git repo or no remotes — fall through
}
return null;
}
export function createCreateHandler(events: EventEmitter, settingsService?: SettingsService) {
const worktreeService = new WorktreeService();
@@ -206,26 +174,23 @@ export function createCreateHandler(events: EventEmitter, settingsService?: Sett
// Create worktrees directory if it doesn't exist
await secureFs.mkdir(worktreesDir, { recursive: true });
// If a base branch is specified and it's a remote branch, fetch from that remote first
// This ensures we have the latest refs before creating the worktree
if (baseBranch && baseBranch !== 'HEAD') {
const remoteBranchInfo = await detectRemoteBranch(projectPath, baseBranch);
if (remoteBranchInfo) {
logger.info(
`Fetching from remote "${remoteBranchInfo.remote}" before creating worktree (base: ${baseBranch})`
);
try {
await execGitCommand(
['fetch', remoteBranchInfo.remote, remoteBranchInfo.branch],
projectPath
);
} catch (fetchErr) {
// Non-fatal: log but continue — the ref might already be cached locally
logger.warn(
`Failed to fetch from remote "${remoteBranchInfo.remote}": ${getErrorMessage(fetchErr)}`
);
}
// Fetch latest from all remotes before creating the worktree.
// This ensures remote refs are up-to-date for:
// - Remote base branches (e.g. "origin/main")
// - Existing remote branches being checked out as worktrees
// - Branch existence checks against fresh remote state
logger.info('Fetching from all remotes before creating worktree');
try {
const controller = new AbortController();
const timerId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
await execGitCommand(['fetch', '--all', '--quiet'], projectPath, undefined, controller);
} finally {
clearTimeout(timerId);
}
} catch (fetchErr) {
// Non-fatal: log but continue — refs might already be cached locally
logger.warn(`Failed to fetch from remotes: ${getErrorMessage(fetchErr)}`);
}
// Check if branch exists (using array arguments to prevent injection)

View File

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

View File

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

View File

@@ -9,7 +9,7 @@
* For remote branches (e.g., "origin/feature"), automatically creates a
* local tracking branch and checks it out.
*
* Also fetches the latest remote refs after switching.
* Also fetches the latest remote refs before switching to ensure accurate branch detection.
*
* Git business logic is delegated to worktree-branch-service.ts.
* Events are emitted at key lifecycle points for WebSocket subscribers.

View File

@@ -163,6 +163,10 @@ export class AutoLoopCoordinator {
const { projectPath, branchName } = projectState.config;
while (projectState.isRunning && !projectState.abortController.signal.aborted) {
try {
// Count ALL running features (both auto and manual) against the concurrency limit.
// This ensures auto mode is aware of the total system load and does not over-subscribe
// resources. Manual tasks always bypass the limit and run immediately, but their
// presence is accounted for when deciding whether to dispatch new auto-mode tasks.
const runningCount = await this.getRunningCountForWorktree(projectPath, branchName);
if (runningCount >= projectState.config.maxConcurrency) {
await this.sleep(5000, projectState.abortController.signal);
@@ -298,11 +302,17 @@ export class AutoLoopCoordinator {
return Array.from(activeProjects);
}
/**
* Get the number of running features for a worktree.
* By default counts ALL running features (both auto-mode and manual).
* Pass `autoModeOnly: true` to count only auto-mode features.
*/
async getRunningCountForWorktree(
projectPath: string,
branchName: string | null
branchName: string | null,
options?: { autoModeOnly?: boolean }
): Promise<number> {
return this.concurrencyManager.getRunningCountForWorktree(projectPath, branchName);
return this.concurrencyManager.getRunningCountForWorktree(projectPath, branchName, options);
}
trackFailureAndCheckPauseForProject(

View File

@@ -334,6 +334,23 @@ export class AutoModeServiceFacade {
async (pPath) => featureLoader.getAll(pPath)
);
/**
* Iterate all active worktrees for this project, falling back to the
* main worktree (null) when none are active.
*/
const forEachProjectWorktree = (fn: (branchName: string | null) => void): void => {
const projectWorktrees = autoLoopCoordinator
.getActiveWorktrees()
.filter((w) => w.projectPath === projectPath);
if (projectWorktrees.length === 0) {
fn(null);
} else {
for (const w of projectWorktrees) {
fn(w.branchName);
}
}
};
// ExecutionService - runAgentFn delegates to AgentExecutor via shared helper
const executionService = new ExecutionService(
eventBus,
@@ -357,11 +374,36 @@ export class AutoModeServiceFacade {
(pPath, featureId) => getFacade().contextExists(featureId),
(pPath, featureId, useWorktrees, _calledInternally) =>
getFacade().resumeFeature(featureId, useWorktrees, _calledInternally),
(errorInfo) =>
autoLoopCoordinator.trackFailureAndCheckPauseForProject(projectPath, null, errorInfo),
(errorInfo) => autoLoopCoordinator.signalShouldPauseForProject(projectPath, null, errorInfo),
(errorInfo) => {
// Track failure against ALL active worktrees for this project.
// The ExecutionService callbacks don't receive branchName, so we
// iterate all active worktrees. Uses a for-of loop (not .some()) to
// ensure every worktree's failure counter is incremented.
let shouldPause = false;
forEachProjectWorktree((branchName) => {
if (
autoLoopCoordinator.trackFailureAndCheckPauseForProject(
projectPath,
branchName,
errorInfo
)
) {
shouldPause = true;
}
});
return shouldPause;
},
(errorInfo) => {
forEachProjectWorktree((branchName) =>
autoLoopCoordinator.signalShouldPauseForProject(projectPath, branchName, errorInfo)
);
},
() => {
/* recordSuccess - no-op */
// Record success to clear failure tracking. This prevents failures
// from accumulating over time and incorrectly pausing auto mode.
forEachProjectWorktree((branchName) =>
autoLoopCoordinator.recordSuccessForProject(projectPath, branchName)
);
},
(_pPath) => getFacade().saveExecutionState(),
loadContextFiles

View File

@@ -10,6 +10,7 @@
* Follows the same pattern as worktree-branch-service.ts (performSwitchBranch).
*
* The workflow:
* 0. Fetch latest from all remotes (ensures remote refs are up-to-date)
* 1. Validate inputs (branch name, base branch)
* 2. Get current branch name
* 3. Check if target branch already exists
@@ -19,11 +20,51 @@
* 7. Handle error recovery (restore stash if checkout fails)
*/
import { getErrorMessage } from '@automaker/utils';
import { createLogger, getErrorMessage } from '@automaker/utils';
import { execGitCommand } from '../lib/git.js';
import type { EventEmitter } from '../lib/events.js';
import { hasAnyChanges, stashChanges, popStash, localBranchExists } from './branch-utils.js';
const logger = createLogger('CheckoutBranchService');
// ============================================================================
// Local Helpers
// ============================================================================
/** Timeout for git fetch operations (30 seconds) */
const FETCH_TIMEOUT_MS = 30_000;
/**
* Fetch latest from all remotes (silently, with timeout).
*
* A process-level timeout is enforced via an AbortController so that a
* slow or unresponsive remote does not block the branch creation flow
* indefinitely. Timeout errors are logged and treated as non-fatal
* (the same as network-unavailable errors) so the rest of the workflow
* continues normally. This is called before creating the new branch to
* ensure remote refs are up-to-date when a remote base branch is used.
*/
async function fetchRemotes(cwd: string): Promise<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
// ============================================================================
@@ -78,6 +119,11 @@ export async function performCheckoutBranch(
// Emit start event
events?.emit('switch:start', { worktreePath, branchName, operation: 'checkout' });
// 0. Fetch latest from all remotes before creating the branch
// This ensures remote refs are up-to-date so that base branch validation
// works correctly for remote branch references (e.g. "origin/main").
await fetchRemotes(worktreePath);
// 1. Get current branch
let previousBranch: string;
try {

View File

@@ -170,17 +170,28 @@ export class ConcurrencyManager {
* @param projectPath - The project path
* @param branchName - The branch name, or null for main worktree
* (features without branchName or matching primary branch)
* @param options.autoModeOnly - If true, only count features started by auto mode.
* Note: The auto-loop coordinator now counts ALL
* running features (not just auto-mode) to ensure
* total system load is respected. This option is
* retained for other callers that may need filtered counts.
* @returns Number of running features for the worktree
*/
async getRunningCountForWorktree(
projectPath: string,
branchName: string | null
branchName: string | null,
options?: { autoModeOnly?: boolean }
): Promise<number> {
// Get the actual primary branch name for the project
const primaryBranch = await this.getCurrentBranch(projectPath);
let count = 0;
for (const [, feature] of this.runningFeatures) {
// If autoModeOnly is set, skip manually started features
if (options?.autoModeOnly && !feature.isAutoMode) {
continue;
}
// Filter by project path AND branchName to get accurate worktree-specific count
const featureBranch = feature.branchName ?? null;
if (branchName === null) {

View File

@@ -19,6 +19,69 @@ const logger = createLogger('DevServerService');
// Maximum scrollback buffer size (characters) - matches TerminalService pattern
const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per dev server
// URL patterns for detecting full URLs from dev server output.
// Defined once at module level to avoid reallocation on every call to detectUrlFromOutput.
// Ordered from most specific (framework-specific) to least specific.
const URL_PATTERNS: Array<{ pattern: RegExp; description: string }> = [
// Vite / Nuxt / SvelteKit / Astro / Angular CLI format: "Local: http://..."
{
pattern: /(?:Local|Network|External):\s+(https?:\/\/[^\s]+)/i,
description: 'Vite/Nuxt/SvelteKit/Astro/Angular format',
},
// Next.js format: "ready - started server on 0.0.0.0:3000, url: http://localhost:3000"
// Next.js 14+: "▲ Next.js 14.0.0\n- Local: http://localhost:3000"
{
pattern: /(?:ready|started server).*?(?:url:\s*)?(https?:\/\/[^\s,]+)/i,
description: 'Next.js format',
},
// Remix format: "started at http://localhost:3000"
// Django format: "Starting development server at http://127.0.0.1:8000/"
// Rails / Puma: "Listening on http://127.0.0.1:3000"
// Generic: "listening at http://...", "available at http://...", "running at http://..."
{
pattern:
/(?:starting|started|listening|running|available|serving|accessible)\s+(?:at|on)\s+(https?:\/\/[^\s,)]+)/i,
description: 'Generic "starting/started/listening at" format',
},
// PHP built-in server: "Development Server (http://localhost:8000) started"
{
pattern: /(?:server|development server)\s*\(\s*(https?:\/\/[^\s)]+)\s*\)/i,
description: 'PHP server format',
},
// Webpack Dev Server: "Project is running at http://localhost:8080/"
{
pattern: /(?:project|app|application)\s+(?:is\s+)?running\s+(?:at|on)\s+(https?:\/\/[^\s,]+)/i,
description: 'Webpack/generic "running at" format',
},
// Go / Rust / generic: "Serving on http://...", "Server on http://..."
{
pattern: /(?:serving|server)\s+(?:on|at)\s+(https?:\/\/[^\s,]+)/i,
description: 'Generic "serving on" format',
},
// Localhost URL with port (conservative - must be localhost/127.0.0.1/[::]/0.0.0.0)
// This catches anything that looks like a dev server URL
{
pattern: /(https?:\/\/(?:localhost|127\.0\.0\.1|\[::\]|0\.0\.0\.0):\d+\S*)/i,
description: 'Generic localhost URL with port',
},
];
// Port-only patterns for detecting port numbers from dev server output
// when a full URL is not present in the output.
// Defined once at module level to avoid reallocation on every call to detectUrlFromOutput.
const PORT_PATTERNS: Array<{ pattern: RegExp; description: string }> = [
// "listening on port 3000", "server on port 3000", "started on port 3000"
{
pattern: /(?:listening|running|started|serving|available)\s+on\s+port\s+(\d+)/i,
description: '"listening on port" format',
},
// "Port: 3000", "port 3000" (at start of line or after whitespace)
{
pattern: /(?:^|\s)port[:\s]+(\d{4,5})(?:\s|$|[.,;])/im,
description: '"port:" format',
},
];
// Throttle output to prevent overwhelming WebSocket under heavy load
const OUTPUT_THROTTLE_MS = 4; // ~250fps max update rate for responsive feedback
const OUTPUT_BATCH_SIZE = 4096; // Smaller batches for lower latency
@@ -105,9 +168,52 @@ class DevServerService {
}
}
/**
* Strip ANSI escape codes from a string
* Dev server output often contains color codes that can interfere with URL detection
*/
private stripAnsi(str: string): string {
// Matches ANSI escape sequences: CSI sequences, OSC sequences, and simple escapes
// eslint-disable-next-line no-control-regex
return str.replace(/\x1B(?:\[[0-9;]*[a-zA-Z]|\].*?(?:\x07|\x1B\\)|\[[?]?[0-9;]*[hl])/g, '');
}
/**
* Extract port number from a URL string.
* Returns the explicit port if present, or null if no port is specified.
* Default protocol ports (80/443) are intentionally NOT returned to avoid
* overwriting allocated dev server ports with protocol defaults.
*/
private extractPortFromUrl(url: string): number | null {
try {
const parsed = new URL(url);
if (parsed.port) {
return parseInt(parsed.port, 10);
}
return null;
} catch {
return null;
}
}
/**
* Detect actual server URL from output
* Parses stdout/stderr for common URL patterns from dev servers
* Parses stdout/stderr for common URL patterns from dev servers.
*
* Supports detection of URLs from:
* - Vite: "Local: http://localhost:5173/"
* - Next.js: "ready - started server on 0.0.0.0:3000, url: http://localhost:3000"
* - Nuxt: "Local: http://localhost:3000/"
* - Remix: "started at http://localhost:3000"
* - Astro: "Local http://localhost:4321/"
* - SvelteKit: "Local: http://localhost:5173/"
* - CRA/Webpack: "On Your Network: http://192.168.1.1:3000"
* - Angular: "Local: http://localhost:4200/"
* - Express/Fastify/Koa: "Server listening on port 3000"
* - Django: "Starting development server at http://127.0.0.1:8000/"
* - Rails: "Listening on http://127.0.0.1:3000"
* - PHP: "Development Server (http://localhost:8000) started"
* - Generic: Any localhost URL with a port
*/
private detectUrlFromOutput(server: DevServerInfo, content: string): void {
// Skip if URL already detected
@@ -115,39 +221,95 @@ class DevServerService {
return;
}
// Common URL patterns from various dev servers:
// - Vite: "Local: http://localhost:5173/"
// - Next.js: "ready - started server on 0.0.0.0:3000, url: http://localhost:3000"
// - CRA/Webpack: "On Your Network: http://192.168.1.1:3000"
// - Generic: Any http:// or https:// URL
const urlPatterns = [
/(?:Local|Network):\s+(https?:\/\/[^\s]+)/i, // Vite format
/(?:ready|started server).*?(?:url:\s*)?(https?:\/\/[^\s,]+)/i, // Next.js format
/(https?:\/\/(?:localhost|127\.0\.0\.1|\[::\]):\d+)/i, // Generic localhost URL
/(https?:\/\/[^\s<>"{}|\\^`[\]]+)/i, // Any HTTP(S) URL
];
// Strip ANSI escape codes to prevent color codes from breaking regex matching
const cleanContent = this.stripAnsi(content);
for (const pattern of urlPatterns) {
const match = content.match(pattern);
// Phase 1: Try to detect a full URL from output
// Patterns are defined at module level (URL_PATTERNS) and reused across calls
for (const { pattern, description } of URL_PATTERNS) {
const match = cleanContent.match(pattern);
if (match && match[1]) {
const detectedUrl = match[1].trim();
// Validate it looks like a reasonable URL
let detectedUrl = match[1].trim();
// Remove trailing punctuation that might have been captured
detectedUrl = detectedUrl.replace(/[.,;:!?)\]}>]+$/, '');
if (detectedUrl.startsWith('http://') || detectedUrl.startsWith('https://')) {
// Normalize 0.0.0.0 to localhost for browser accessibility
detectedUrl = detectedUrl.replace(
/\/\/0\.0\.0\.0(:\d+)?/,
(_, port) => `//localhost${port || ''}`
);
// Normalize [::] to localhost for browser accessibility
detectedUrl = detectedUrl.replace(
/\/\/\[::\](:\d+)?/,
(_, port) => `//localhost${port || ''}`
);
// Normalize [::1] (IPv6 loopback) to localhost for browser accessibility
detectedUrl = detectedUrl.replace(
/\/\/\[::1\](:\d+)?/,
(_, port) => `//localhost${port || ''}`
);
server.url = detectedUrl;
server.urlDetected = true;
logger.info(
`Detected actual server URL: ${detectedUrl} (allocated port was ${server.port})`
);
// Update the port to match the detected URL's actual port
const detectedPort = this.extractPortFromUrl(detectedUrl);
if (detectedPort && detectedPort !== server.port) {
logger.info(
`Port mismatch: allocated ${server.port}, detected ${detectedPort} from ${description}`
);
server.port = detectedPort;
}
logger.info(`Detected server URL via ${description}: ${detectedUrl}`);
// Emit URL update event
if (this.emitter) {
this.emitter.emit('dev-server:url-detected', {
worktreePath: server.worktreePath,
url: detectedUrl,
port: server.port,
timestamp: new Date().toISOString(),
});
}
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;
port: number;
url: string;
urlDetected: boolean;
}>;
};
} {
@@ -680,6 +843,7 @@ class DevServerService {
worktreePath: s.worktreePath,
port: s.port,
url: s.url,
urlDetected: s.urlDetected,
}));
return {

View File

@@ -46,6 +46,12 @@ export interface PullResult {
conflictSource?: 'pull' | 'stash';
conflictFiles?: string[];
message?: string;
/** Whether the pull resulted in a merge commit (not fast-forward) */
isMerge?: boolean;
/** Whether the pull was a fast-forward (no merge commit needed) */
isFastForward?: boolean;
/** Files affected by the merge (only present when isMerge is true) */
mergeAffectedFiles?: string[];
}
// ============================================================================
@@ -178,6 +184,31 @@ function isConflictError(errorOutput: string): boolean {
return errorOutput.includes('CONFLICT') || errorOutput.includes('Automatic merge failed');
}
/**
* Determine whether the current HEAD commit is a merge commit by checking
* whether it has two or more parent hashes.
*
* Runs `git show -s --pretty=%P HEAD` which prints the parent SHAs separated
* by spaces. A merge commit has at least two parents; a regular commit has one.
*
* @param worktreePath - Path to the git worktree
* @returns true if HEAD is a merge commit, false otherwise
*/
async function isMergeCommit(worktreePath: string): Promise<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.
*/
@@ -302,10 +333,39 @@ export async function performPull(
const pullArgs = upstreamStatus === 'tracking' ? ['pull'] : ['pull', targetRemote, branchName];
let pullConflict = false;
let pullConflictFiles: string[] = [];
// Declare merge detection variables before the try block so they are accessible
// in the stash reapplication path even when didStash is true.
let isMerge = false;
let isFastForward = false;
let mergeAffectedFiles: string[] = [];
try {
const pullOutput = await execGitCommand(pullArgs, worktreePath);
const alreadyUpToDate = pullOutput.includes('Already up to date');
// Detect fast-forward from git pull output
isFastForward = pullOutput.includes('Fast-forward') || pullOutput.includes('fast-forward');
// Detect merge by checking whether the new HEAD has two parents (more reliable
// than string-matching localised pull output which may not contain 'Merge').
isMerge = !alreadyUpToDate && !isFastForward ? await isMergeCommit(worktreePath) : false;
// If it was a real merge (not fast-forward), get the affected files
if (isMerge) {
try {
// Get files changed in the merge commit
const diffOutput = await execGitCommand(
['diff', '--name-only', 'HEAD~1', 'HEAD'],
worktreePath
);
mergeAffectedFiles = diffOutput
.trim()
.split('\n')
.filter((f: string) => f.trim().length > 0);
} catch {
// Ignore errors - this is best-effort
}
}
// If no stash to reapply, return success
if (!didStash) {
@@ -317,6 +377,8 @@ export async function performPull(
stashed: false,
stashRestored: false,
message: alreadyUpToDate ? 'Already up to date' : 'Pulled latest changes',
...(isMerge ? { isMerge: true, mergeAffectedFiles } : {}),
...(isFastForward ? { isFastForward: true } : {}),
};
}
} catch (pullError: unknown) {
@@ -374,7 +436,11 @@ export async function performPull(
// 10. Pull succeeded, now try to reapply stash
if (didStash) {
return await reapplyStash(worktreePath, branchName);
return await reapplyStash(worktreePath, branchName, {
isMerge,
isFastForward,
mergeAffectedFiles,
});
}
// Shouldn't reach here, but return a safe default
@@ -392,9 +458,21 @@ export async function performPull(
*
* @param worktreePath - Path to the git worktree
* @param branchName - Current branch name
* @param mergeInfo - Merge/fast-forward detection info from the pull step
* @returns PullResult reflecting stash reapplication status
*/
async function reapplyStash(worktreePath: string, branchName: string): Promise<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 {
await popStash(worktreePath);
@@ -406,6 +484,7 @@ async function reapplyStash(worktreePath: string, branchName: string): Promise<P
hasConflicts: false,
stashed: true,
stashRestored: true,
...mergeFields,
message: 'Pulled latest changes and restored your stashed changes.',
};
} catch (stashPopError: unknown) {
@@ -431,6 +510,7 @@ async function reapplyStash(worktreePath: string, branchName: string): Promise<P
conflictFiles: stashConflictFiles,
stashed: true,
stashRestored: false,
...mergeFields,
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,
stashed: true,
stashRestored: false,
...mergeFields,
message:
'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
* 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
* 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
* indefinitely. Timeout errors are logged and treated as non-fatal
* (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> {
const controller = new AbortController();
@@ -66,15 +68,15 @@ async function fetchRemotes(cwd: string): Promise<void> {
try {
await execGitCommand(['fetch', '--all', '--quiet'], cwd, undefined, controller);
} catch (error) {
if (error instanceof Error && error.message === 'Process aborted') {
if (controller.signal.aborted) {
// Fetch timed out - log and continue; callers should not be blocked by a slow remote
logger.warn(
`fetchRemotes timed out after ${FETCH_TIMEOUT_MS}ms - continuing without latest remote refs`
);
} else {
logger.warn(`fetchRemotes failed: ${getErrorMessage(error)} - continuing with local refs`);
}
// Ignore all fetch errors (timeout or otherwise) - we may be offline or the
// remote may be temporarily unavailable. The branch switch itself has
// already succeeded at this point.
// Non-fatal: continue with locally available refs regardless of failure type
} finally {
clearTimeout(timerId);
}
@@ -126,13 +128,13 @@ async function isRemoteBranch(cwd: string, branchName: string): Promise<boolean>
* Perform a full branch switch workflow on the given worktree.
*
* The workflow:
* 1. Get current branch name
* 2. Detect remote vs local branch and determine target
* 3. Return early if already on target branch
* 4. Validate branch existence
* 5. Stash local changes if any
* 6. Checkout the target branch
* 7. Fetch latest from remotes
* 1. Fetch latest from all remotes (ensures remote refs are up-to-date)
* 2. Get current branch name
* 3. Detect remote vs local branch and determine target
* 4. Return early if already on target branch
* 5. Validate branch existence
* 6. Stash local changes if any
* 7. Checkout the target branch
* 8. Reapply stashed changes (detect conflicts)
* 9. Handle error recovery (restore stash if checkout fails)
*
@@ -149,14 +151,20 @@ export async function performSwitchBranch(
// Emit start event
events?.emit('switch:start', { worktreePath, branchName });
// 1. Get current branch
// 1. Fetch latest from all remotes before switching
// This ensures remote branch refs are up-to-date so that isRemoteBranch()
// can detect newly created remote branches and local tracking branches
// are aware of upstream changes.
await fetchRemotes(worktreePath);
// 2. Get current branch
const currentBranchOutput = await execGitCommand(
['rev-parse', '--abbrev-ref', 'HEAD'],
worktreePath
);
const previousBranch = currentBranchOutput.trim();
// 2. Determine the actual target branch name for checkout
// 3. Determine the actual target branch name for checkout
let targetBranch = branchName;
let isRemote = false;
@@ -180,7 +188,7 @@ export async function performSwitchBranch(
}
}
// 3. Return early if already on the target branch
// 4. Return early if already on the target branch
if (previousBranch === targetBranch) {
events?.emit('switch:done', {
worktreePath,
@@ -198,7 +206,7 @@ export async function performSwitchBranch(
};
}
// 4. Check if target branch exists as a local branch
// 5. Check if target branch exists as a local branch
if (!isRemote) {
if (!(await localBranchExists(worktreePath, branchName))) {
events?.emit('switch:error', {
@@ -213,7 +221,7 @@ export async function performSwitchBranch(
}
}
// 5. Stash local changes if any exist
// 6. Stash local changes if any exist
const hadChanges = await hasAnyChanges(worktreePath, { excludeWorktreePaths: true });
let didStash = false;
@@ -242,7 +250,7 @@ export async function performSwitchBranch(
}
try {
// 6. Switch to the target branch
// 7. Switch to the target branch
events?.emit('switch:checkout', {
worktreePath,
targetBranch,
@@ -265,9 +273,6 @@ export async function performSwitchBranch(
await execGitCommand(['checkout', targetBranch], worktreePath);
}
// 7. Fetch latest from remotes after switching
await fetchRemotes(worktreePath);
// 8. Reapply stashed changes if we stashed earlier
let hasConflicts = false;
let conflictMessage = '';
@@ -347,7 +352,7 @@ export async function performSwitchBranch(
};
}
} catch (checkoutError) {
// 9. If checkout failed and we stashed, try to restore the stash
// 9. Error recovery: if checkout failed and we stashed, try to restore the stash
if (didStash) {
const popResult = await popStash(worktreePath);
if (popResult.hasConflicts) {

View File

@@ -328,6 +328,86 @@ describe('auto-loop-coordinator.ts', () => {
// Should not have executed features because at capacity
expect(mockExecuteFeature).not.toHaveBeenCalled();
});
it('counts all running features (auto + manual) against concurrency limit', async () => {
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([testFeature]);
// 2 manual features running — total count is 2
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(2);
await coordinator.startAutoLoopForProject('/test/project', null, 2);
await vi.advanceTimersByTimeAsync(6000);
await coordinator.stopAutoLoopForProject('/test/project', null);
// Should NOT execute because total running count (2) meets the concurrency limit (2)
expect(mockExecuteFeature).not.toHaveBeenCalled();
// Verify it was called WITHOUT autoModeOnly (counts all tasks)
// The coordinator's wrapper passes options through as undefined when not specified
expect(mockConcurrencyManager.getRunningCountForWorktree).toHaveBeenCalledWith(
'/test/project',
null,
undefined
);
});
it('allows auto dispatch when manual tasks finish and capacity becomes available', async () => {
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([testFeature]);
// First call: at capacity (2 manual features running)
// Second call: capacity freed (1 feature running)
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree)
.mockResolvedValueOnce(2) // at capacity
.mockResolvedValueOnce(1); // capacity available after manual task completes
await coordinator.startAutoLoopForProject('/test/project', null, 2);
// First iteration: at capacity, should wait
await vi.advanceTimersByTimeAsync(5000);
// Second iteration: capacity available, should execute
await vi.advanceTimersByTimeAsync(6000);
await coordinator.stopAutoLoopForProject('/test/project', null);
// Should execute after capacity freed
expect(mockExecuteFeature).toHaveBeenCalledWith('/test/project', 'feature-1', true, true);
});
it('waits when manually started tasks already fill concurrency limit at auto mode activation', async () => {
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([testFeature]);
// Manual tasks already fill the limit
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(3);
await coordinator.startAutoLoopForProject('/test/project', null, 3);
await vi.advanceTimersByTimeAsync(6000);
await coordinator.stopAutoLoopForProject('/test/project', null);
// Auto mode should remain waiting, not dispatch
expect(mockExecuteFeature).not.toHaveBeenCalled();
});
it('resumes dispatching when all running tasks complete simultaneously', async () => {
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([testFeature]);
// First check: all 3 slots occupied
// Second check: all tasks completed simultaneously
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree)
.mockResolvedValueOnce(3) // all slots full
.mockResolvedValueOnce(0); // all tasks completed at once
await coordinator.startAutoLoopForProject('/test/project', null, 3);
// First iteration: at capacity
await vi.advanceTimersByTimeAsync(5000);
// Second iteration: all freed
await vi.advanceTimersByTimeAsync(6000);
await coordinator.stopAutoLoopForProject('/test/project', null);
// Should execute after all tasks freed capacity
expect(mockExecuteFeature).toHaveBeenCalledWith('/test/project', 'feature-1', true, true);
});
});
describe('priority-based feature selection', () => {
@@ -788,7 +868,23 @@ describe('auto-loop-coordinator.ts', () => {
expect(count).toBe(3);
expect(mockConcurrencyManager.getRunningCountForWorktree).toHaveBeenCalledWith(
'/test/project',
null
null,
undefined
);
});
it('passes autoModeOnly option to ConcurrencyManager', async () => {
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(1);
const count = await coordinator.getRunningCountForWorktree('/test/project', null, {
autoModeOnly: true,
});
expect(count).toBe(1);
expect(mockConcurrencyManager.getRunningCountForWorktree).toHaveBeenCalledWith(
'/test/project',
null,
{ autoModeOnly: true }
);
});
});

View File

@@ -416,6 +416,90 @@ describe('ConcurrencyManager', () => {
expect(mainCount).toBe(2);
});
it('should count only auto-mode features when autoModeOnly is true', async () => {
// Auto-mode feature on main worktree
manager.acquire({
featureId: 'feature-auto',
projectPath: '/test/project',
isAutoMode: true,
});
// Manual feature on main worktree
manager.acquire({
featureId: 'feature-manual',
projectPath: '/test/project',
isAutoMode: false,
});
// Without autoModeOnly: counts both
const totalCount = await manager.getRunningCountForWorktree('/test/project', null);
expect(totalCount).toBe(2);
// With autoModeOnly: counts only auto-mode features
const autoModeCount = await manager.getRunningCountForWorktree('/test/project', null, {
autoModeOnly: true,
});
expect(autoModeCount).toBe(1);
});
it('should count only auto-mode features on specific worktree when autoModeOnly is true', async () => {
// Auto-mode feature on feature branch
manager.acquire({
featureId: 'feature-auto',
projectPath: '/test/project',
isAutoMode: true,
});
manager.updateRunningFeature('feature-auto', { branchName: 'feature-branch' });
// Manual feature on same feature branch
manager.acquire({
featureId: 'feature-manual',
projectPath: '/test/project',
isAutoMode: false,
});
manager.updateRunningFeature('feature-manual', { branchName: 'feature-branch' });
// Another auto-mode feature on different branch (should not be counted)
manager.acquire({
featureId: 'feature-other',
projectPath: '/test/project',
isAutoMode: true,
});
manager.updateRunningFeature('feature-other', { branchName: 'other-branch' });
const autoModeCount = await manager.getRunningCountForWorktree(
'/test/project',
'feature-branch',
{ autoModeOnly: true }
);
expect(autoModeCount).toBe(1);
const totalCount = await manager.getRunningCountForWorktree(
'/test/project',
'feature-branch'
);
expect(totalCount).toBe(2);
});
it('should return 0 when autoModeOnly is true and only manual features are running', async () => {
manager.acquire({
featureId: 'feature-manual-1',
projectPath: '/test/project',
isAutoMode: false,
});
manager.acquire({
featureId: 'feature-manual-2',
projectPath: '/test/project',
isAutoMode: false,
});
const autoModeCount = await manager.getRunningCountForWorktree('/test/project', null, {
autoModeOnly: true,
});
expect(autoModeCount).toBe(0);
});
it('should filter by both projectPath and branchName', async () => {
manager.acquire({
featureId: 'feature-1',

View File

@@ -486,7 +486,7 @@ describe('dev-server-service.ts', () => {
await service.startDevServer(testDir, testDir);
// Simulate HTTPS dev server
mockProcess.stdout.emit('data', Buffer.from('Server at https://localhost:3443\n'));
mockProcess.stdout.emit('data', Buffer.from('Server listening at https://localhost:3443\n'));
await new Promise((resolve) => setTimeout(resolve, 50));
@@ -521,6 +521,368 @@ describe('dev-server-service.ts', () => {
expect(serverInfo?.url).toBe(firstUrl);
expect(serverInfo?.url).toBe('http://localhost:5173/');
});
it('should detect Astro format URL', async () => {
vi.mocked(secureFs.access).mockResolvedValue(undefined);
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
await service.startDevServer(testDir, testDir);
// Astro uses the same "Local:" prefix as Vite
mockProcess.stdout.emit('data', Buffer.from(' 🚀 astro v4.0.0 started in 200ms\n'));
mockProcess.stdout.emit('data', Buffer.from(' ┃ Local http://localhost:4321/\n'));
await new Promise((resolve) => setTimeout(resolve, 50));
const serverInfo = service.getServerInfo(testDir);
// Astro doesn't use "Local:" with colon, so it should be caught by the localhost URL pattern
expect(serverInfo?.url).toBe('http://localhost:4321/');
expect(serverInfo?.urlDetected).toBe(true);
});
it('should detect Remix format URL', async () => {
vi.mocked(secureFs.access).mockResolvedValue(undefined);
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
await service.startDevServer(testDir, testDir);
mockProcess.stdout.emit(
'data',
Buffer.from('Remix App Server started at http://localhost:3000\n')
);
await new Promise((resolve) => setTimeout(resolve, 50));
const serverInfo = service.getServerInfo(testDir);
expect(serverInfo?.url).toBe('http://localhost:3000');
expect(serverInfo?.urlDetected).toBe(true);
});
it('should detect Django format URL', async () => {
vi.mocked(secureFs.access).mockResolvedValue(undefined);
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
await service.startDevServer(testDir, testDir);
mockProcess.stdout.emit(
'data',
Buffer.from('Starting development server at http://127.0.0.1:8000/\n')
);
await new Promise((resolve) => setTimeout(resolve, 50));
const serverInfo = service.getServerInfo(testDir);
expect(serverInfo?.url).toBe('http://127.0.0.1:8000/');
expect(serverInfo?.urlDetected).toBe(true);
});
it('should detect Webpack Dev Server format URL', async () => {
vi.mocked(secureFs.access).mockResolvedValue(undefined);
const mockProcess = createMockProcess();
vi.mocked(spawn).mockReturnValue(mockProcess as any);
const { getDevServerService } = await import('@/services/dev-server-service.js');
const service = getDevServerService();
await service.startDevServer(testDir, testDir);
mockProcess.stdout.emit(
'data',
Buffer.from('<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.kill = vi.fn();
mockProcess.killed = false;
mockProcess.pid = 12345;
// Don't exit immediately - let the test control the lifecycle
return mockProcess;

View File

@@ -10,6 +10,7 @@ import {
ChevronRight,
RefreshCw,
GitBranch,
GitMerge,
AlertCircle,
Plus,
Minus,
@@ -20,7 +21,7 @@ import { Button } from './button';
import { useWorktreeDiffs, useGitDiffs } from '@/hooks/queries';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
import type { FileStatus } from '@/types/electron';
import type { FileStatus, MergeStateInfo } from '@/types/electron';
interface GitDiffPanelProps {
projectPath: string;
@@ -318,6 +319,86 @@ function StagingBadge({ state }: { state: 'staged' | 'unstaged' | 'partial' }) {
);
}
function MergeBadge({ mergeType }: { mergeType?: string }) {
if (!mergeType) return null;
const label = (() => {
switch (mergeType) {
case 'both-modified':
return 'Both Modified';
case 'added-by-us':
return 'Added by Us';
case 'added-by-them':
return 'Added by Them';
case 'deleted-by-us':
return 'Deleted by Us';
case 'deleted-by-them':
return 'Deleted by Them';
case 'both-added':
return 'Both Added';
case 'both-deleted':
return 'Both Deleted';
case 'merged':
return 'Merged';
default:
return 'Merge';
}
})();
return (
<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({
fileDiff,
isExpanded,
@@ -348,9 +429,21 @@ function FileDiffSection({
const stagingState = fileStatus ? getStagingState(fileStatus) : undefined;
const isMergeFile = fileStatus?.isMergeAffected;
return (
<div className="border border-border rounded-lg overflow-hidden">
<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">
<div
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 */}
<button onClick={onToggle} className="flex items-center gap-2 flex-1 min-w-0 text-left">
{isExpanded ? (
@@ -358,7 +451,9 @@ function FileDiffSection({
) : (
<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)
) : (
<FileText className="w-4 h-4 text-muted-foreground flex-shrink-0" />
@@ -370,6 +465,7 @@ function FileDiffSection({
</button>
{/* Indicators & staging row */}
<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} />}
{fileDiff.isNew && (
<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 queryError = useWorktrees ? worktreeError : gitError;
// Extract files and diff content from the data
// Extract files, diff content, and merge state from the data
const files: FileStatus[] = diffsData?.files ?? [];
const diffContent = diffsData?.diff ?? '';
const mergeState: MergeStateInfo | undefined = diffsData?.mergeState;
const error = queryError
? queryError instanceof Error
? queryError.message
@@ -495,8 +592,6 @@ export function GitDiffPanel({
// Refetch function
const loadDiffs = useWorktrees ? refetchWorktree : refetchGit;
const parsedDiffs = useMemo(() => parseDiff(diffContent), [diffContent]);
// Build a map from file path to FileStatus for quick lookup
const fileStatusMap = useMemo(() => {
const map = new Map<string, FileStatus>();
@@ -506,6 +601,24 @@ export function GitDiffPanel({
return map;
}, [files]);
const parsedDiffs = useMemo(() => {
const diffs = parseDiff(diffContent);
// Sort: merge-affected files first, then preserve original order
if (mergeState?.isMerging || mergeState?.isMergeCommit) {
const mergeSet = new Set(mergeState.mergeAffectedFiles);
diffs.sort((a, b) => {
const aIsMerge =
mergeSet.has(a.filePath) || (fileStatusMap.get(a.filePath)?.isMergeAffected ?? false);
const bIsMerge =
mergeSet.has(b.filePath) || (fileStatusMap.get(b.filePath)?.isMergeAffected ?? false);
if (aIsMerge && !bIsMerge) return -1;
if (!aIsMerge && bIsMerge) return 1;
return 0;
});
}
return diffs;
}, [diffContent, mergeState, fileStatusMap]);
const toggleFile = (filePath: string) => {
setExpandedFiles((prev) => {
const next = new Set(prev);
@@ -682,6 +795,18 @@ export function GitDiffPanel({
);
}, [worktreePath, projectPath, useWorktrees, enableStaging, files, executeStagingAction]);
// Compute merge summary
const mergeSummary = useMemo(() => {
const mergeFiles = files.filter((f) => f.isMergeAffected);
if (mergeFiles.length === 0) return null;
return {
total: mergeFiles.length,
conflicted: mergeFiles.filter(
(f) => f.mergeType === 'both-modified' || f.mergeType === 'both-added'
).length,
};
}, [files]);
// Compute staging summary
const stagingSummary = useMemo(() => {
if (!enableStaging) return null;
@@ -776,6 +901,11 @@ export function GitDiffPanel({
</div>
) : (
<div>
{/* Merge state banner */}
{(mergeState?.isMerging || mergeState?.isMergeCommit) && (
<MergeStateBanner mergeState={mergeState} />
)}
{/* Summary bar */}
<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">
@@ -799,7 +929,7 @@ export function GitDiffPanel({
{} 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
key={status}
className="flex items-center gap-1.5"
@@ -817,6 +947,24 @@ export function GitDiffPanel({
</span>
</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 className="flex items-center gap-1 flex-wrap">
@@ -907,7 +1055,7 @@ export function GitDiffPanel({
fileDiff={fileDiff}
isExpanded={expandedFiles.has(fileDiff.filePath)}
onToggle={() => toggleFile(fileDiff.filePath)}
fileStatus={enableStaging ? fileStatusMap.get(fileDiff.filePath) : undefined}
fileStatus={fileStatusMap.get(fileDiff.filePath)}
enableStaging={enableStaging}
onStage={enableStaging ? handleStageFile : undefined}
onUnstage={enableStaging ? handleUnstageFile : undefined}
@@ -919,15 +1067,28 @@ export function GitDiffPanel({
<div className="space-y-2">
{files.map((file) => {
const stagingState = getStagingState(file);
const isFileMerge = file.isMergeAffected;
return (
<div
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 */}
<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
path={file.path}
className="flex-1 text-sm font-mono text-foreground"
@@ -935,6 +1096,7 @@ export function GitDiffPanel({
</div>
{/* Indicators & staging row */}
<div className="flex items-center gap-2 flex-shrink-0 pl-6 sm:pl-0">
{isFileMerge && <MergeBadge mergeType={file.mergeType} />}
{enableStaging && <StagingBadge state={stagingState} />}
<span
className={cn(

View File

@@ -126,7 +126,7 @@ const SelectItem = React.forwardRef<
</span>
{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>
{description}
</div>

View File

@@ -215,7 +215,7 @@ function TestLogsPanelContent({
return (
<>
{/* 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">
<DialogTitle className="flex items-center gap-2 text-base">
<FlaskConical className="w-4 h-4 text-primary" />

View File

@@ -375,10 +375,20 @@ export function BoardView() {
return specificTargetCollisions;
}
// Priority 2: Columns
const columnCollisions = pointerCollisions.filter((collision: Collision) =>
COLUMNS.some((col) => col.id === collision.id)
);
// Priority 2: Columns (including column headers and pipeline columns)
const columnCollisions = pointerCollisions.filter((collision: Collision) => {
const colId = String(collision.id);
// Direct column ID match (e.g. 'backlog', 'in_progress')
if (COLUMNS.some((col) => col.id === colId)) return true;
// Column header droppable (e.g. 'column-header-backlog')
if (colId.startsWith('column-header-')) {
const baseId = colId.replace('column-header-', '');
return COLUMNS.some((col) => col.id === baseId) || baseId.startsWith('pipeline_');
}
// Pipeline column IDs (e.g. 'pipeline_tests')
if (colId.startsWith('pipeline_')) return true;
return false;
});
// If we found a column collision, use that
if (columnCollisions.length > 0) {
@@ -1426,13 +1436,12 @@ export function BoardView() {
},
});
// Also update backend if auto mode is running
// Also update backend if auto mode is running.
// Use restartWithConcurrency to avoid toggle flickering - it restarts
// the backend without toggling isRunning off/on in the UI.
if (autoMode.isRunning) {
// Restart auto mode with new concurrency (backend will handle this)
autoMode.stop().then(() => {
autoMode.start().catch((error) => {
logger.error('[AutoMode] Failed to restart with new concurrency:', error);
});
autoMode.restartWithConcurrency().catch((error) => {
logger.error('[AutoMode] Failed to restart with new concurrency:', error);
});
}
}

View File

@@ -17,6 +17,8 @@ import {
interface CardActionsProps {
feature: Feature;
isCurrentAutoTask: boolean;
/** Whether this feature is tracked as a running task (may be true even before status updates to in_progress) */
isRunningTask?: boolean;
hasContext?: boolean;
shortcutKey?: string;
isSelectionMode?: boolean;
@@ -36,6 +38,7 @@ interface CardActionsProps {
export const CardActions = memo(function CardActions({
feature,
isCurrentAutoTask,
isRunningTask = false,
hasContext: _hasContext,
shortcutKey,
isSelectionMode = false,
@@ -340,7 +343,57 @@ export const CardActions = memo(function CardActions({
) : null}
</>
)}
{/* Running task with stale status: feature is tracked as running but status hasn't updated yet.
Show Logs/Stop controls instead of Make to avoid confusing UI. */}
{!isCurrentAutoTask &&
isRunningTask &&
(feature.status === 'backlog' ||
feature.status === 'interrupted' ||
feature.status === 'ready') && (
<>
{onViewOutput && (
<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 === 'interrupted' ||
feature.status === 'ready') && (

View File

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

View File

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

View File

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

View File

@@ -60,6 +60,8 @@ export interface RowActionsProps {
handlers: RowActionHandlers;
/** Whether this feature is the current auto task (agent is running) */
isCurrentAutoTask?: boolean;
/** Whether this feature is tracked as a running task (may be true even before status updates to in_progress) */
isRunningTask?: boolean;
/** Whether the dropdown menu is open */
isOpen?: boolean;
/** Callback when the dropdown open state changes */
@@ -115,7 +117,8 @@ const MenuItem = memo(function MenuItem({
function getPrimaryAction(
feature: Feature,
handlers: RowActionHandlers,
isCurrentAutoTask: boolean
isCurrentAutoTask: boolean,
isRunningTask: boolean = false
): {
icon: React.ComponentType<{ className?: string }>;
label: string;
@@ -135,6 +138,24 @@ function getPrimaryAction(
return null;
}
// Running task with stale status - show stop instead of Make
// This handles the race window where the feature is tracked as running
// but status hasn't updated to in_progress yet
if (
isRunningTask &&
(feature.status === 'backlog' ||
feature.status === 'ready' ||
feature.status === 'interrupted') &&
handlers.onForceStop
) {
return {
icon: StopCircle,
label: 'Stop',
onClick: handlers.onForceStop,
variant: 'destructive',
};
}
// Backlog - implement is primary
if (feature.status === 'backlog' && handlers.onImplement) {
return {
@@ -263,6 +284,7 @@ export const RowActions = memo(function RowActions({
feature,
handlers,
isCurrentAutoTask = false,
isRunningTask = false,
isOpen,
onOpenChange,
className,
@@ -286,7 +308,7 @@ export const RowActions = memo(function RowActions({
[setOpen]
);
const primaryAction = getPrimaryAction(feature, handlers, isCurrentAutoTask);
const primaryAction = getPrimaryAction(feature, handlers, isCurrentAutoTask, isRunningTask);
const secondaryActions = getSecondaryActions(feature, handlers);
// Helper to close menu after action
@@ -403,7 +425,7 @@ export const RowActions = memo(function RowActions({
)}
{/* Backlog actions */}
{!isCurrentAutoTask && feature.status === 'backlog' && (
{!isCurrentAutoTask && !isRunningTask && feature.status === 'backlog' && (
<>
<MenuItem icon={Edit} label="Edit" onClick={withClose(handlers.onEdit)} />
{feature.planSpec?.content && handlers.onViewPlan && (

View File

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

View File

@@ -20,6 +20,7 @@ import {
} from '@/components/ui/select';
import {
GitCommit,
GitMerge,
Sparkles,
FilePlus,
FileX,
@@ -36,7 +37,7 @@ import { toast } from 'sonner';
import { useAppStore } from '@/store/app-store';
import { cn } from '@/lib/utils';
import { TruncatedFilePath } from '@/components/ui/truncated-file-path';
import type { FileStatus } from '@/types/electron';
import type { FileStatus, MergeStateInfo } from '@/types/electron';
import { parseDiff, type ParsedFileDiff } from '@/lib/diff-utils';
interface RemoteInfo {
@@ -116,6 +117,27 @@ const getStatusBadgeColor = (status: string) => {
}
};
const getMergeTypeLabel = (mergeType?: string) => {
switch (mergeType) {
case 'both-modified':
return 'Both Modified';
case 'added-by-us':
return 'Added by Us';
case 'added-by-them':
return 'Added by Them';
case 'deleted-by-us':
return 'Deleted by Us';
case 'deleted-by-them':
return 'Deleted by Them';
case 'both-added':
return 'Both Added';
case 'both-deleted':
return 'Both Deleted';
default:
return 'Merge';
}
};
function DiffLine({
type,
content,
@@ -190,6 +212,7 @@ export function CommitWorktreeDialog({
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
const [expandedFile, setExpandedFile] = useState<string | null>(null);
const [isLoadingDiffs, setIsLoadingDiffs] = useState(false);
const [mergeState, setMergeState] = useState<MergeStateInfo | undefined>(undefined);
// Push after commit state
const [pushAfterCommit, setPushAfterCommit] = useState(false);
@@ -274,6 +297,7 @@ export function CommitWorktreeDialog({
setDiffContent('');
setSelectedFiles(new Set());
setExpandedFile(null);
setMergeState(undefined);
// Reset push state
setPushAfterCommit(false);
setRemotes([]);
@@ -292,8 +316,20 @@ export function CommitWorktreeDialog({
const result = await api.git.getDiffs(worktree.path);
if (result.success) {
const fileList = result.files ?? [];
// Sort merge-affected files first when a merge is in progress
if (result.mergeState?.isMerging) {
const mergeSet = new Set(result.mergeState.mergeAffectedFiles);
fileList.sort((a, b) => {
const aIsMerge = mergeSet.has(a.path) || (a.isMergeAffected ?? false);
const bIsMerge = mergeSet.has(b.path) || (b.isMergeAffected ?? false);
if (aIsMerge && !bIsMerge) return -1;
if (!aIsMerge && bIsMerge) return 1;
return 0;
});
}
if (!cancelled) setFiles(fileList);
if (!cancelled) setDiffContent(result.diff ?? '');
if (!cancelled) setMergeState(result.mergeState);
// If any files are already staged, pre-select only staged files
// Otherwise select all files by default
const stagedFiles = fileList.filter((f) => {
@@ -579,6 +615,34 @@ export function CommitWorktreeDialog({
</DialogHeader>
<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 */}
<div className="flex flex-col min-h-0">
<div className="flex items-center justify-between mb-1.5">
@@ -625,13 +689,25 @@ export function CommitWorktreeDialog({
const isStaged = idx !== ' ' && idx !== '?';
const isUnstaged = wt !== ' ' && wt !== '?';
const isUntracked = idx === '?' && wt === '?';
const isMergeFile =
file.isMergeAffected ||
(mergeState?.mergeAffectedFiles?.includes(file.path) ?? false);
return (
<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
className={cn(
'flex items-center gap-2 px-3 py-1.5 hover:bg-accent/50 transition-colors group',
isExpanded && 'bg-accent/30'
'flex items-center gap-2 px-3 py-1.5 transition-colors group',
isMergeFile
? 'bg-purple-500/5 hover:bg-purple-500/10'
: 'hover:bg-accent/50',
isExpanded && (isMergeFile ? 'bg-purple-500/10' : 'bg-accent/30')
)}
>
{/* Checkbox */}
@@ -651,11 +727,21 @@ export function CommitWorktreeDialog({
) : (
<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
path={file.path}
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
className={cn(
'text-[10px] px-1.5 py-0.5 rounded border font-medium flex-shrink-0',
@@ -810,11 +896,16 @@ export function CommitWorktreeDialog({
</SelectTrigger>
<SelectContent>
{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="ml-2 text-muted-foreground text-xs inline-block truncate max-w-[200px] align-bottom">
{remote.url}
</span>
</SelectItem>
))}
</SelectContent>

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import {
Dialog,
DialogContent,
@@ -17,11 +17,17 @@ import {
FileWarning,
Wrench,
Sparkles,
GitMerge,
GitCommitHorizontal,
FileText,
Settings,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { Checkbox } from '@/components/ui/checkbox';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import { useAppStore } from '@/store/app-store';
import type { MergeConflictInfo } from '../worktree-panel/types';
interface WorktreeInfo {
@@ -37,6 +43,7 @@ type PullPhase =
| 'local-changes' // Local changes detected, asking user what to do
| 'pulling' // Actively pulling (with or without stash)
| 'success' // Pull completed successfully
| 'merge-complete' // Pull resulted in a merge (not fast-forward, no conflicts)
| 'conflict' // Merge conflicts detected
| 'error'; // Something went wrong
@@ -53,6 +60,9 @@ interface PullResult {
stashed?: boolean;
stashRestored?: boolean;
stashRecoveryFailed?: boolean;
isMerge?: boolean;
isFastForward?: boolean;
mergeAffectedFiles?: string[];
}
interface GitPullDialogProps {
@@ -62,6 +72,8 @@ interface GitPullDialogProps {
remote?: string;
onPulled?: () => void;
onCreateConflictResolutionFeature?: (conflictInfo: MergeConflictInfo) => void;
/** Called when user chooses to commit the merge — opens the commit dialog */
onCommitMerge?: (worktree: { path: string; branch: string; isMain: boolean }) => void;
}
export function GitPullDialog({
@@ -71,10 +83,54 @@ export function GitPullDialog({
remote,
onPulled,
onCreateConflictResolutionFeature,
onCommitMerge,
}: GitPullDialogProps) {
const [phase, setPhase] = useState<PullPhase>('checking');
const [pullResult, setPullResult] = useState<PullResult | 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 () => {
if (!worktree) return;
@@ -103,9 +159,7 @@ export function GitPullDialog({
setPhase('local-changes');
} else if (result.result?.pulled !== undefined) {
// No local changes, pull went through (or already up to date)
setPullResult(result.result);
setPhase('success');
onPulled?.();
handleSuccessfulPull(result.result);
} else {
// Unexpected response: success but no recognizable fields
setPullResult(result.result ?? null);
@@ -116,18 +170,33 @@ export function GitPullDialog({
setErrorMessage(err instanceof Error ? err.message : 'Failed to check for changes');
setPhase('error');
}
}, [worktree, remote, onPulled]);
}, [worktree, remote, handleSuccessfulPull]);
// 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(() => {
if (open && worktree) {
setPhase('checking');
setPullResult(null);
setErrorMessage(null);
// Start the initial check
checkForLocalChanges();
setRememberChoice(false);
setShowMergeFiles(false);
// Start the initial check using the ref so we always call the latest version
// without making it a dependency of this effect.
checkForLocalChangesRef.current();
}
}, [open, worktree, checkForLocalChanges]);
}, [open, worktree]);
const handlePullWithStash = useCallback(async () => {
if (!worktree) return;
@@ -155,8 +224,7 @@ export function GitPullDialog({
if (result.result?.hasConflicts) {
setPhase('conflict');
} else if (result.result?.pulled) {
setPhase('success');
onPulled?.();
handleSuccessfulPull(result.result);
} else {
// Unrecognized response: no pulled flag and no conflicts
console.warn('handlePullWithStash: unrecognized response', result.result);
@@ -167,7 +235,7 @@ export function GitPullDialog({
setErrorMessage(err instanceof Error ? err.message : 'Failed to pull');
setPhase('error');
}
}, [worktree, remote, onPulled]);
}, [worktree, remote, handleSuccessfulPull]);
const handleResolveWithAI = useCallback(() => {
if (!worktree || !pullResult || !onCreateConflictResolutionFeature) return;
@@ -186,6 +254,35 @@ export function GitPullDialog({
onOpenChange(false);
}, [worktree, pullResult, remote, onCreateConflictResolutionFeature, onOpenChange]);
const handleCommitMerge = useCallback(() => {
if (!worktree || !onCommitMerge) {
// No handler available — show feedback and bail without persisting preference
toast.error('Commit merge is not available', {
description: 'The commit merge action is not configured for this context.',
duration: 4000,
});
return;
}
if (rememberChoice) {
setMergePostAction('commit');
}
onPulled?.();
onCommitMerge(worktree);
onOpenChange(false);
}, [rememberChoice, setMergePostAction, worktree, onCommitMerge, onPulled, onOpenChange]);
const handleMergeManually = useCallback(() => {
if (rememberChoice) {
setMergePostAction('manual');
}
toast.info('Merge left for manual review', {
description: 'Review the merged files and commit when ready.',
duration: 5000,
});
onPulled?.();
onOpenChange(false);
}, [rememberChoice, setMergePostAction, onPulled, onOpenChange]);
const handleClose = useCallback(() => {
onOpenChange(false);
}, [onOpenChange]);
@@ -336,6 +433,137 @@ export function GitPullDialog({
</>
)}
{/* Merge Complete Phase — post-merge prompt */}
{phase === 'merge-complete' && (
<>
<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 */}
{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 (
<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>
<DialogTitle className="flex items-center gap-2">
<GitCommit className="w-5 h-5" />
@@ -263,7 +263,7 @@ export function ViewCommitsDialog({ open, onOpenChange, worktree }: ViewCommitsD
</DialogDescription>
</DialogHeader>
<div className="flex-1 sm:min-h-[400px] sm:max-h-[60vh] overflow-y-auto scrollbar-visible -mx-6 -mb-6">
<div className="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">
{isLoading && (
<div className="flex items-center justify-center py-12">

View File

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

View File

@@ -33,7 +33,7 @@ export function ViewWorktreeChangesDialog({
return (
<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>
<DialogTitle className="flex items-center gap-2">
<FileText className="w-5 h-5" />
@@ -54,7 +54,7 @@ export function ViewWorktreeChangesDialog({
</DialogDescription>
</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">
<GitDiffPanel
projectPath={projectPath}

View File

@@ -94,8 +94,6 @@ export function useBoardActions({
skipVerificationInAutoMode,
isPrimaryWorktreeBranch,
getPrimaryWorktreeBranch,
getAutoModeState,
getMaxConcurrencyForWorktree,
} = useAppStore();
const autoMode = useAutoMode();
@@ -561,38 +559,9 @@ export function useBoardActions({
const handleStartImplementation = useCallback(
async (feature: Feature) => {
// Check capacity for the feature's specific worktree, not the current view
// Normalize the branch name: if the feature's branch is the primary worktree branch,
// treat it as null (main worktree) to match how running tasks are stored
const rawBranchName = feature.branchName ?? null;
const featureBranchName =
currentProject?.path &&
rawBranchName &&
isPrimaryWorktreeBranch(currentProject.path, rawBranchName)
? null
: rawBranchName;
const featureWorktreeState = currentProject
? getAutoModeState(currentProject.id, featureBranchName)
: null;
// Use getMaxConcurrencyForWorktree which correctly falls back to global maxConcurrency
// instead of autoMode.maxConcurrency which only falls back to DEFAULT_MAX_CONCURRENCY (1)
const featureMaxConcurrency = currentProject
? getMaxConcurrencyForWorktree(currentProject.id, featureBranchName)
: autoMode.maxConcurrency;
const featureRunningCount = featureWorktreeState?.runningTasks?.length ?? 0;
const canStartInWorktree = featureRunningCount < featureMaxConcurrency;
if (!canStartInWorktree) {
const worktreeDesc = featureBranchName
? `worktree "${featureBranchName}"`
: 'main worktree';
toast.error('Concurrency limit reached', {
description: `${worktreeDesc} can only have ${featureMaxConcurrency} task${
featureMaxConcurrency > 1 ? 's' : ''
} running at a time. Wait for a task to complete or increase the limit.`,
});
return false;
}
// Note: No concurrency limit check here. Manual feature starts should never
// be blocked by the auto mode concurrency limit. The concurrency limit only
// governs how many features the auto-loop picks up automatically.
// Check for blocking dependencies and show warning if enabled
if (enableDependencyBlocking) {
@@ -681,18 +650,7 @@ export function useBoardActions({
return false;
}
},
[
autoMode,
enableDependencyBlocking,
features,
updateFeature,
persistFeatureUpdate,
handleRunFeature,
currentProject,
getAutoModeState,
getMaxConcurrencyForWorktree,
isPrimaryWorktreeBranch,
]
[enableDependencyBlocking, features, updateFeature, persistFeatureUpdate, handleRunFeature]
);
const handleVerifyFeature = useCallback(

View File

@@ -163,13 +163,22 @@ export function useBoardDragDrop({
let targetStatus: ColumnId | null = null;
// Normalize the over ID: strip 'column-header-' prefix if the card was dropped
// directly onto the column header droppable zone (e.g. 'column-header-backlog' → 'backlog')
const effectiveOverId = overId.startsWith('column-header-')
? overId.replace('column-header-', '')
: overId;
// Check if we dropped on a column
const column = COLUMNS.find((c) => c.id === overId);
const column = COLUMNS.find((c) => c.id === effectiveOverId);
if (column) {
targetStatus = column.id;
} else if (effectiveOverId.startsWith('pipeline_')) {
// Pipeline step column (not in static COLUMNS list)
targetStatus = effectiveOverId as ColumnId;
} else {
// Dropped on another feature - find its column
const overFeature = features.find((f) => f.id === overId);
const overFeature = features.find((f) => f.id === effectiveOverId);
if (overFeature) {
targetStatus = overFeature.status;
}

View File

@@ -136,7 +136,7 @@ export function DevServerLogsPanel({
compact
>
{/* 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">
<DialogTitle className="flex items-center gap-2 text-base">
<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">
<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>
<DropdownMenuItem
onClick={() => onOpenDevServerUrl(worktree)}
className="text-xs"
aria-label={`Open dev server on port ${devServerInfo?.port} in browser`}
disabled={devServerInfo?.urlDetected === false}
aria-label={
devServerInfo?.urlDetected === false
? 'Open dev server in browser'
: `Open dev server on port ${devServerInfo?.port} in browser`
}
>
<Globe className="w-3.5 h-3.5 mr-2" aria-hidden="true" />
Open in Browser

View File

@@ -308,7 +308,11 @@ export function WorktreeDropdown({
{selectedStatus.devServerRunning && (
<span
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" />
</span>

View File

@@ -206,6 +206,16 @@ export function useDevServerLogs({ worktreePath, autoSubscribe = true }: UseDevS
}));
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) {
const serversMap = new Map<string, DevServerInfo>();
for (const server of result.result.servers) {
serversMap.set(server.worktreePath, server);
serversMap.set(normalizePath(server.worktreePath), {
...server,
urlDetected: server.urlDetected ?? true,
});
}
setRunningDevServers(serversMap);
}
@@ -38,6 +41,39 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
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(
(worktree: WorktreeInfo) => {
const path = worktree.isMain ? projectPath : worktree.path;
@@ -68,10 +104,11 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
worktreePath: result.result!.worktreePath,
port: result.result!.port,
url: result.result!.url,
urlDetected: false,
});
return next;
});
toast.success(`Dev server started on port ${result.result.port}`);
toast.success('Dev server started, detecting port...');
} else {
toast.error(result.error || 'Failed to start dev server');
}

View File

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

View File

@@ -646,57 +646,101 @@ export function WorktreePanel({
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
const handlePullCompleted = useCallback(() => {
// Refresh branch data (ahead/behind counts, tracking) and worktree list
// after GitPullDialog completes the pull operation
if (pullDialogWorktree) {
fetchBranches(pullDialogWorktree.path);
if (pullDialogWorktreeRef.current) {
fetchBranches(pullDialogWorktreeRef.current.path);
}
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
// Now opens the pull dialog which handles stash management and conflict resolution
const handlePullWithRemoteSelection = useCallback(async (worktree: WorktreeInfo) => {
try {
const api = getHttpApiClient();
const result = await api.worktree.listRemotes(worktree.path);
if (result.success && result.result && result.result.remotes.length > 1) {
// Multiple remotes - show selection dialog 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(
// If the branch has a tracked remote, pull from it directly (skip the remote selection dialog)
const handlePullWithRemoteSelection = useCallback(
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 {
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
// 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);
setSelectRemoteOperation('push');
setSelectRemoteDialogOpen(true);
@@ -713,7 +757,7 @@ export function WorktreePanel({
handlePush(worktree);
}
},
[handlePush]
[handlePush, getTrackingRemote]
);
// Handle confirming remote selection for pull/push
@@ -992,6 +1036,7 @@ export function WorktreePanel({
remote={pullDialogRemote}
onPulled={handlePullCompleted}
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
onCommitMerge={handleCommitMerge}
/>
{/* Dev Server Logs Panel */}
@@ -1445,6 +1490,7 @@ export function WorktreePanel({
onOpenChange={setViewStashesDialogOpen}
worktree={viewStashesWorktree}
onStashApplied={handleStashApplied}
onStashApplyConflict={onStashApplyConflict}
/>
{/* Cherry Pick Dialog */}
@@ -1463,6 +1509,7 @@ export function WorktreePanel({
worktree={pullDialogWorktree}
remote={pullDialogRemote}
onPulled={handlePullCompleted}
onCommitMerge={handleCommitMerge}
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
/>
</div>

View File

@@ -25,6 +25,9 @@ import { getHttpApiClient } from '@/lib/http-api-client';
import type { Project } from '@/lib/electron';
import { ProjectFileSelectorDialog } from '@/components/dialogs/project-file-selector-dialog';
// Stable empty array reference to prevent unnecessary re-renders when no copy files are set
const EMPTY_FILES: string[] = [];
interface WorktreePreferencesSectionProps {
project: Project;
}
@@ -38,20 +41,30 @@ interface InitScriptResponse {
}
export function WorktreePreferencesSection({ project }: WorktreePreferencesSectionProps) {
// Use direct store subscriptions (not getter functions) so the component
// properly re-renders when these values change in the store.
const globalUseWorktrees = useAppStore((s) => s.useWorktrees);
const getProjectUseWorktrees = useAppStore((s) => s.getProjectUseWorktrees);
const projectUseWorktrees = useAppStore((s) => s.useWorktreesByProject[project.path]);
const setProjectUseWorktrees = useAppStore((s) => s.setProjectUseWorktrees);
const getShowInitScriptIndicator = useAppStore((s) => s.getShowInitScriptIndicator);
const showIndicator = useAppStore(
(s) => s.showInitScriptIndicatorByProject[project.path] ?? true
);
const setShowInitScriptIndicator = useAppStore((s) => s.setShowInitScriptIndicator);
const getDefaultDeleteBranch = useAppStore((s) => s.getDefaultDeleteBranch);
const defaultDeleteBranch = useAppStore(
(s) => s.defaultDeleteBranchByProject[project.path] ?? false
);
const setDefaultDeleteBranch = useAppStore((s) => s.setDefaultDeleteBranch);
const getAutoDismissInitScriptIndicator = useAppStore((s) => s.getAutoDismissInitScriptIndicator);
const autoDismiss = useAppStore(
(s) => s.autoDismissInitScriptIndicatorByProject[project.path] ?? true
);
const setAutoDismissInitScriptIndicator = useAppStore((s) => s.setAutoDismissInitScriptIndicator);
const copyFiles = useAppStore((s) => s.worktreeCopyFilesByProject[project.path] ?? []);
// Use a stable empty array reference to prevent new array on every render when
// worktreeCopyFilesByProject[project.path] is undefined (not yet loaded).
const copyFilesFromStore = useAppStore((s) => s.worktreeCopyFilesByProject[project.path]);
const copyFiles = copyFilesFromStore ?? EMPTY_FILES;
const setWorktreeCopyFiles = useAppStore((s) => s.setWorktreeCopyFiles);
// Get effective worktrees setting (project override or global fallback)
const projectUseWorktrees = getProjectUseWorktrees(project.path);
const effectiveUseWorktrees = projectUseWorktrees ?? globalUseWorktrees;
const [scriptContent, setScriptContent] = useState('');
@@ -65,11 +78,6 @@ export function WorktreePreferencesSection({ project }: WorktreePreferencesSecti
const [newCopyFilePath, setNewCopyFilePath] = useState('');
const [fileSelectorOpen, setFileSelectorOpen] = useState(false);
// Get the current settings for this project
const showIndicator = getShowInitScriptIndicator(project.path);
const defaultDeleteBranch = getDefaultDeleteBranch(project.path);
const autoDismiss = getAutoDismissInitScriptIndicator(project.path);
// Check if there are unsaved changes
const hasChanges = scriptContent !== originalContent;

View File

@@ -1,12 +1,16 @@
import { useState } from 'react';
import { Workflow, RotateCcw, Replace, Sparkles } from 'lucide-react';
import { Workflow, RotateCcw, Replace, Sparkles, Brain } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAppStore } from '@/store/app-store';
import { Button } from '@/components/ui/button';
import { PhaseModelSelector } from './phase-model-selector';
import { BulkReplaceDialog } from './bulk-replace-dialog';
import type { PhaseModelKey, PhaseModelEntry } from '@automaker/types';
import { DEFAULT_PHASE_MODELS, DEFAULT_GLOBAL_SETTINGS } from '@automaker/types';
import type { PhaseModelKey, PhaseModelEntry, ThinkingLevel } from '@automaker/types';
import {
DEFAULT_PHASE_MODELS,
DEFAULT_GLOBAL_SETTINGS,
REASONING_EFFORT_LEVELS,
} from '@automaker/types';
interface PhaseConfig {
key: PhaseModelKey;
@@ -161,6 +165,121 @@ function FeatureDefaultModelSection() {
);
}
// Thinking level options with descriptions for the settings UI
const THINKING_LEVEL_OPTIONS: { id: ThinkingLevel; label: string; description: string }[] = [
{ id: 'none', label: 'None', description: 'No extended thinking' },
{ id: 'low', label: 'Low', description: 'Light reasoning (1k tokens)' },
{ id: 'medium', label: 'Medium', description: 'Moderate reasoning (10k tokens)' },
{ id: 'high', label: 'High', description: 'Deep reasoning (16k tokens)' },
{ id: 'ultrathink', label: 'Ultra', description: 'Maximum reasoning (32k tokens)' },
{ id: 'adaptive', label: 'Adaptive', description: 'Model decides reasoning depth' },
];
/**
* Default thinking level / reasoning effort section.
* These defaults are applied when selecting a model via the primary button
* in the two-stage model selector (i.e. clicking the model name directly).
*/
function DefaultThinkingLevelSection() {
const {
defaultThinkingLevel,
setDefaultThinkingLevel,
defaultReasoningEffort,
setDefaultReasoningEffort,
} = useAppStore();
return (
<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() {
const { resetPhaseModels, claudeCompatibleProviders } = useAppStore();
const [showBulkReplace, setShowBulkReplace] = useState(false);
@@ -222,6 +341,9 @@ export function ModelDefaultsSection() {
{/* Feature Defaults */}
<FeatureDefaultModelSection />
{/* Default Thinking Level / Reasoning Effort */}
<DefaultThinkingLevelSection />
{/* Quick Tasks */}
<PhaseGroup
title="Quick Tasks"

View File

@@ -13,6 +13,9 @@ import {
X,
SquarePlus,
Settings,
GitBranch,
ChevronDown,
FolderGit,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { getServerUrlSync } from '@/lib/http-api-client';
@@ -28,6 +31,17 @@ import { Label } from '@/components/ui/label';
import { Slider } from '@/components/ui/slider';
import { Switch } from '@/components/ui/switch';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Select,
SelectContent,
@@ -255,6 +269,8 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
setTerminalScrollbackLines,
setTerminalScreenReaderMode,
updateTerminalPanelSizes,
currentWorktreeByProject,
worktreesByProject,
} = useAppStore();
const navigate = useNavigate();
@@ -946,13 +962,50 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
}
};
// Helper: find the branchName of the given session ID within a layout tree
const findSessionBranchName = (
layout: TerminalPanelContent | null,
sessionId: string
): string | undefined => {
if (!layout) return undefined;
if (layout.type === 'terminal') {
return layout.sessionId === sessionId ? layout.branchName : undefined;
}
if (layout.type === 'split') {
for (const panel of layout.panels) {
const found = findSessionBranchName(panel, sessionId);
if (found !== undefined) return found;
}
}
return undefined;
};
// Helper: resolve the worktree cwd and branchName for the currently active terminal session.
// Returns { cwd, branchName } if the active terminal was opened in a worktree, or {} otherwise.
const getActiveSessionWorktreeInfo = (): { cwd?: string; branchName?: string } => {
const activeSessionId = terminalState.activeSessionId;
if (!activeSessionId || !activeTab?.layout || !currentProject) return {};
const branchName = findSessionBranchName(activeTab.layout, activeSessionId);
if (!branchName) return {};
// Look up the worktree path for this branch in the project's worktree list
const projectWorktrees = worktreesByProject[currentProject.path] ?? [];
const worktree = projectWorktrees.find((wt) => wt.branch === branchName);
if (!worktree) return { branchName };
return { cwd: worktree.path, branchName };
};
// Create a new terminal session
// targetSessionId: the terminal to split (if splitting an existing terminal)
// customCwd: optional working directory to use instead of the current project path
// branchName: optional branch name to display in the terminal panel header
const createTerminal = async (
direction?: 'horizontal' | 'vertical',
targetSessionId?: string,
customCwd?: string
customCwd?: string,
branchName?: string
) => {
if (!canCreateTerminal('[Terminal] Debounced terminal creation')) {
return;
@@ -971,7 +1024,7 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
const data = await response.json();
if (data.success) {
addTerminalToLayout(data.data.id, direction, targetSessionId);
addTerminalToLayout(data.data.id, direction, targetSessionId, branchName);
// Mark this session as new for running initial command
if (defaultRunScript) {
setNewSessionIds((prev) => new Set(prev).add(data.data.id));
@@ -1004,11 +1057,18 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
};
// Create terminal in new tab
const createTerminalInNewTab = async () => {
// customCwd: optional working directory (e.g., a specific worktree path)
// branchName: optional branch name to display in the terminal panel header
const createTerminalInNewTab = async (customCwd?: string, branchName?: string) => {
if (!canCreateTerminal('[Terminal] Debounced terminal tab creation')) {
return;
}
// Use provided cwd/branch, or inherit from active session's worktree
const { cwd: worktreeCwd, branchName: worktreeBranch } = customCwd
? { cwd: customCwd, branchName: branchName }
: getActiveSessionWorktreeInfo();
const tabId = addTerminalTab();
try {
const headers: Record<string, string> = {};
@@ -1018,14 +1078,14 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
const response = await apiFetch('/api/terminal/sessions', 'POST', {
headers,
body: { cwd: currentProject?.path || undefined, cols: 80, rows: 24 },
body: { cwd: worktreeCwd || currentProject?.path || undefined, cols: 80, rows: 24 },
});
const data = await response.json();
if (data.success) {
// Add to the newly created tab
// Add to the newly created tab (passing branchName so the panel header shows the branch badge)
const { addTerminalToTab } = useAppStore.getState();
addTerminalToTab(data.data.id, tabId);
addTerminalToTab(data.data.id, tabId, undefined, worktreeBranch);
// Mark this session as new for running initial command
if (defaultRunScript) {
setNewSessionIds((prev) => new Set(prev).add(data.data.id));
@@ -1344,8 +1404,14 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
isActive={terminalState.activeSessionId === content.sessionId}
onFocus={() => setActiveTerminalSession(content.sessionId)}
onClose={() => killTerminal(content.sessionId)}
onSplitHorizontal={() => createTerminal('horizontal', content.sessionId)}
onSplitVertical={() => createTerminal('vertical', content.sessionId)}
onSplitHorizontal={() => {
const { cwd, branchName } = getActiveSessionWorktreeInfo();
createTerminal('horizontal', content.sessionId, cwd, branchName);
}}
onSplitVertical={() => {
const { cwd, branchName } = getActiveSessionWorktreeInfo();
createTerminal('vertical', content.sessionId, cwd, branchName);
}}
onNewTab={createTerminalInNewTab}
onNavigateUp={() => navigateToTerminal('up')}
onNavigateDown={() => navigateToTerminal('down')}
@@ -1502,6 +1568,15 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
// No terminals yet - show welcome screen
if (terminalState.tabs.length === 0) {
// Get the current worktree for this project (if any)
const currentWorktreeInfo = currentProject
? (currentWorktreeByProject[currentProject.path] ?? null)
: null;
// Only show worktree button when the current worktree has a specific path set
// (non-null path means a worktree is selected, as opposed to the main project)
const currentWorktreePath = currentWorktreeInfo?.path ?? null;
const currentWorktreeBranch = currentWorktreeInfo?.branch ?? null;
return (
<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">
@@ -1518,10 +1593,40 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
)}
</p>
<Button onClick={() => createTerminal()}>
<Plus className="h-4 w-4 mr-2" />
New Terminal
</Button>
<div className="flex flex-col items-center gap-3 w-full max-w-xs">
{currentWorktreePath && (
<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 && (
<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} />}
{/* New tab button */}
<button
className="flex items-center justify-center p-1.5 rounded hover:bg-accent text-muted-foreground hover:text-foreground"
onClick={createTerminalInNewTab}
title="New Tab"
>
<Plus className="h-4 w-4" />
</button>
{/* New tab split button */}
<div className="flex items-center">
<button
className="flex items-center justify-center p-1.5 rounded-l hover:bg-accent text-muted-foreground hover:text-foreground"
onClick={() => createTerminalInNewTab()}
title="New Tab"
>
<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>
{/* Toolbar buttons */}
@@ -1580,7 +1765,10 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
variant="ghost"
size="sm"
className="h-7 px-2 text-muted-foreground hover:text-foreground"
onClick={() => createTerminal('horizontal')}
onClick={() => {
const { cwd, branchName } = getActiveSessionWorktreeInfo();
createTerminal('horizontal', undefined, cwd, branchName);
}}
title="Split Right"
>
<SplitSquareHorizontal className="h-4 w-4" />
@@ -1589,7 +1777,10 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
variant="ghost"
size="sm"
className="h-7 px-2 text-muted-foreground hover:text-foreground"
onClick={() => createTerminal('vertical')}
onClick={() => {
const { cwd, branchName } = getActiveSessionWorktreeInfo();
createTerminal('vertical', undefined, cwd, branchName);
}}
title="Split Down"
>
<SplitSquareVertical className="h-4 w-4" />
@@ -1771,12 +1962,14 @@ export function TerminalView({ initialCwd, initialBranch, initialMode, nonce }:
isActive={true}
onFocus={() => setActiveTerminalSession(terminalState.maximizedSessionId!)}
onClose={() => killTerminal(terminalState.maximizedSessionId!)}
onSplitHorizontal={() =>
createTerminal('horizontal', terminalState.maximizedSessionId!)
}
onSplitVertical={() =>
createTerminal('vertical', terminalState.maximizedSessionId!)
}
onSplitHorizontal={() => {
const { cwd, branchName } = getActiveSessionWorktreeInfo();
createTerminal('horizontal', terminalState.maximizedSessionId!, cwd, branchName);
}}
onSplitVertical={() => {
const { cwd, branchName } = getActiveSessionWorktreeInfo();
createTerminal('vertical', terminalState.maximizedSessionId!, cwd, branchName);
}}
onNewTab={createTerminalInNewTab}
onSessionInvalid={() => {
const sessionId = terminalState.maximizedSessionId!;

View File

@@ -1,6 +1,18 @@
import { useCallback, useRef, useEffect, useState } from 'react';
import { ArrowUp, ArrowDown, ArrowLeft, ArrowRight, ChevronUp, ChevronDown } from 'lucide-react';
import {
ArrowUp,
ArrowDown,
ArrowLeft,
ArrowRight,
ChevronUp,
ChevronDown,
Copy,
ClipboardPaste,
CheckSquare,
TextSelect,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { StickyModifierKeys, type StickyModifier } from './sticky-modifier-keys';
/**
* ANSI escape sequences for special keys.
@@ -37,6 +49,20 @@ interface MobileTerminalShortcutsProps {
onSendInput: (data: string) => void;
/** Whether the terminal is connected and ready */
isConnected: boolean;
/** Currently active sticky modifier (Ctrl or Alt) */
activeModifier: StickyModifier;
/** Callback when sticky modifier is toggled */
onModifierChange: (modifier: StickyModifier) => void;
/** Callback to copy selected text to clipboard */
onCopy?: () => void;
/** Callback to paste from clipboard into terminal */
onPaste?: () => void;
/** Callback to select all terminal content */
onSelectAll?: () => void;
/** Callback to toggle text selection mode (renders selectable text overlay) */
onToggleSelectMode?: () => void;
/** Whether text selection mode is currently active */
isSelectMode?: boolean;
}
/**
@@ -50,6 +76,13 @@ interface MobileTerminalShortcutsProps {
export function MobileTerminalShortcuts({
onSendInput,
isConnected,
activeModifier,
onModifierChange,
onCopy,
onPaste,
onSelectAll,
onToggleSelectMode,
isSelectMode,
}: MobileTerminalShortcutsProps) {
const [isCollapsed, setIsCollapsed] = useState(false);
@@ -135,6 +168,54 @@ export function MobileTerminalShortcuts({
{/* Separator */}
<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 */}
<ShortcutButton
label="Esc"
@@ -300,3 +381,42 @@ function ArrowButton({
</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 { getElectronAPI } from '@/lib/electron';
import { getApiKey, getSessionToken, getServerUrlSync } from '@/lib/http-api-client';
import { writeToClipboard, readFromClipboard } from '@/lib/clipboard-utils';
import { useIsMobile } from '@/hooks/use-media-query';
import { useVirtualKeyboardResize } from '@/hooks/use-virtual-keyboard-resize';
import { MobileTerminalShortcuts } from './mobile-terminal-shortcuts';
import {
StickyModifierKeys,
applyStickyModifier,
type StickyModifier,
} from './sticky-modifier-keys';
import { applyStickyModifier, type StickyModifier } from './sticky-modifier-keys';
import { TerminalScriptsDropdown } from './terminal-scripts-dropdown';
const logger = createLogger('Terminal');
@@ -81,6 +78,9 @@ const LARGE_PASTE_WARNING_THRESHOLD = 1024 * 1024; // 1MB - show warning for pas
const PASTE_CHUNK_SIZE = 8 * 1024; // 8KB chunks for large pastes
const PASTE_CHUNK_DELAY_MS = 10; // Small delay between chunks to prevent overwhelming WebSocket
// Mobile overlay buffer cap - limit lines read from terminal buffer to avoid DOM blow-up on mobile
const MAX_OVERLAY_LINES = 1000; // Maximum number of lines to read for the mobile select-mode overlay
interface TerminalPanelProps {
sessionId: string;
authToken: string | null;
@@ -157,6 +157,9 @@ export function TerminalPanel({
const [isImageDragOver, setIsImageDragOver] = useState(false);
const [isProcessingImage, setIsProcessingImage] = useState(false);
const hasRunInitialCommandRef = useRef(false);
// Long-press timer for mobile context menu
const longPressTimerRef = useRef<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.).
// Maintained as a ref (not state) so sendCommand can read the current value without
// causing unnecessary re-renders or stale closure issues. Set inside ws.onmessage
@@ -169,6 +172,10 @@ export function TerminalPanel({
const showSearchRef = useRef(false);
const [isAtBottom, setIsAtBottom] = useState(true);
// Mobile text selection mode - renders terminal buffer as selectable DOM text
const [isSelectMode, setIsSelectMode] = useState(false);
const [selectModeText, setSelectModeText] = useState('');
// Sticky modifier key state (Ctrl or Alt) for the terminal toolbar
const [stickyModifier, setStickyModifier] = useState<StickyModifier>(null);
const stickyModifierRef = useRef<StickyModifier>(null);
@@ -330,9 +337,16 @@ export function TerminalPanel({
try {
// Strip any ANSI escape codes that might be in the selection
const cleanText = stripAnsi(selection);
await navigator.clipboard.writeText(cleanText);
toast.success('Copied to clipboard');
return true;
const success = await writeToClipboard(cleanText);
if (success) {
toast.success('Copied to clipboard');
return true;
} else {
toast.error('Copy failed', {
description: 'Could not access clipboard',
});
return false;
}
} catch (err) {
logger.error('Copy failed:', err);
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
@@ -399,7 +413,7 @@ export function TerminalPanel({
if (!terminal || !wsRef.current) return;
try {
const text = await navigator.clipboard.readText();
const text = await readFromClipboard();
if (!text) {
toast.error('Nothing to paste', {
description: 'Clipboard is empty',
@@ -428,7 +442,9 @@ export function TerminalPanel({
toast.error('Paste failed', {
description: errorMessage.includes('permission')
? 'Clipboard permission denied'
: 'Could not read from clipboard',
: errorMessage.includes('not supported')
? errorMessage
: 'Could not read from clipboard',
});
}
}, [sendTextInChunks]);
@@ -439,6 +455,45 @@ export function TerminalPanel({
xtermRef.current?.selectAll();
}, []);
// Extract terminal buffer text for mobile selection mode overlay
const getTerminalBufferText = useCallback((): string => {
const terminal = xtermRef.current;
if (!terminal) return '';
const buffer = terminal.buffer.active;
const lines: string[] = [];
// Cap the number of lines read to MAX_OVERLAY_LINES to avoid blowing up the DOM on mobile
const startIndex = Math.max(0, buffer.length - MAX_OVERLAY_LINES);
for (let i = startIndex; i < buffer.length; i++) {
const line = buffer.getLine(i);
if (line) {
lines.push(line.translateToString(true));
}
}
// Trim trailing empty lines but keep internal structure
while (lines.length > 0 && lines[lines.length - 1].trim() === '') {
lines.pop();
}
return lines.join('\n');
}, []);
// Toggle mobile text selection mode
const toggleSelectMode = useCallback(() => {
if (isSelectMode) {
setIsSelectMode(false);
setSelectModeText('');
} else {
const text = getTerminalBufferText();
// Strip ANSI escape codes for clean display
const cleanText = stripAnsi(text);
setSelectModeText(cleanText);
setIsSelectMode(true);
}
}, [isSelectMode, getTerminalBufferText]);
// Clear terminal
const clearTerminal = useCallback(() => {
xtermRef.current?.clear();
@@ -944,17 +999,17 @@ export function TerminalPanel({
const otherModKey = isMacRef.current ? event.ctrlKey : event.metaKey;
// Ctrl+Shift+C / Cmd+Shift+C - Always copy (Linux terminal convention)
// Don't preventDefault() — allow the native browser copy to work alongside our custom copy
if (modKey && !otherModKey && event.shiftKey && !event.altKey && code === 'KeyC') {
event.preventDefault();
copySelectionRef.current();
return false;
}
// Ctrl+C / Cmd+C - Copy if text is selected, otherwise send SIGINT
// Don't preventDefault() when copying — allow the native browser copy to work alongside our custom copy
if (modKey && !otherModKey && !event.shiftKey && !event.altKey && code === 'KeyC') {
const hasSelection = terminal.hasSelection();
if (hasSelection) {
event.preventDefault();
copySelectionRef.current();
terminal.clearSelection();
return false;
@@ -964,9 +1019,11 @@ export function TerminalPanel({
}
// Ctrl+V / Cmd+V or Ctrl+Shift+V / Cmd+Shift+V - Paste
// Don't preventDefault() — allow the native browser paste to work.
// Return false to prevent xterm from sending \x16 (literal next),
// but the browser's native paste event will still fire and xterm will
// receive the pasted text through its onData handler.
if (modKey && !otherModKey && !event.altKey && code === 'KeyV') {
event.preventDefault();
pasteFromClipboardRef.current();
return false;
}
@@ -1014,6 +1071,12 @@ export function TerminalPanel({
resizeDebounceRef.current = null;
}
// Clear long-press timer
if (longPressTimerRef.current) {
clearTimeout(longPressTimerRef.current);
longPressTimerRef.current = null;
}
// Clear search decorations before disposing to prevent visual artifacts
if (searchAddonRef.current) {
searchAddonRef.current.clearDecorations();
@@ -1571,6 +1634,17 @@ export function TerminalPanel({
buttons[focusedMenuIndex]?.focus();
}, [focusedMenuIndex, contextMenu]);
// Reset select mode when viewport transitions from mobile to non-mobile.
// The select-mode overlay is only rendered when (isSelectMode && isMobile), so if the
// viewport becomes non-mobile while isSelectMode is true the overlay disappears but the
// state is left dirty with no UI to clear it. Resetting here keeps state consistent.
useEffect(() => {
if (!isMobile && isSelectMode) {
setIsSelectMode(false);
setSelectModeText('');
}
}, [isMobile]); // eslint-disable-line react-hooks/exhaustive-deps
// Handle right-click context menu with boundary checking
const handleContextMenu = useCallback((e: React.MouseEvent) => {
e.preventDefault();
@@ -1602,6 +1676,77 @@ export function TerminalPanel({
setContextMenu({ x, y });
}, []);
// Long-press handlers for mobile context menu
// On mobile, there's no right-click, so we trigger the context menu on long-press (500ms hold)
const LONG_PRESS_DURATION = 500; // ms
const LONG_PRESS_MOVE_THRESHOLD = 10; // px - cancel if finger moves more than this
const handleTouchStart = useCallback(
(e: React.TouchEvent) => {
if (!isMobile) return;
const touch = e.touches[0];
if (!touch) return;
// Clear any existing timer before creating a new one to avoid orphaned timeouts
if (longPressTimerRef.current) {
clearTimeout(longPressTimerRef.current);
longPressTimerRef.current = null;
}
// Capture initial touch coordinates into an immutable local snapshot
const startPos = { x: touch.clientX, y: touch.clientY };
longPressTouchStartRef.current = startPos;
longPressTimerRef.current = setTimeout(() => {
// Use the locally captured startPos rather than re-reading the ref
// Menu dimensions (approximate)
const menuWidth = 160;
const menuHeight = 152;
const padding = 8;
let x = startPos.x;
let y = startPos.y;
// Boundary checks
if (x + menuWidth + padding > window.innerWidth) {
x = window.innerWidth - menuWidth - padding;
}
if (y + menuHeight + padding > window.innerHeight) {
y = window.innerHeight - menuHeight - padding;
}
x = Math.max(padding, x);
y = Math.max(padding, y);
setContextMenu({ x, y });
longPressTouchStartRef.current = null;
}, LONG_PRESS_DURATION);
},
[isMobile]
);
const handleTouchMove = useCallback((e: React.TouchEvent) => {
if (!longPressTimerRef.current || !longPressTouchStartRef.current) return;
const touch = e.touches[0];
if (!touch) return;
const dx = touch.clientX - longPressTouchStartRef.current.x;
const dy = touch.clientY - longPressTouchStartRef.current.y;
if (Math.sqrt(dx * dx + dy * dy) > LONG_PRESS_MOVE_THRESHOLD) {
// Finger moved too far, cancel long-press
clearTimeout(longPressTimerRef.current);
longPressTimerRef.current = null;
longPressTouchStartRef.current = null;
}
}, []);
const handleTouchEnd = useCallback(() => {
if (longPressTimerRef.current) {
clearTimeout(longPressTimerRef.current);
longPressTimerRef.current = null;
}
longPressTouchStartRef.current = null;
}, []);
// Convert file to base64
const fileToBase64 = useCallback((file: File): Promise<string> => {
return new Promise((resolve, reject) => {
@@ -2092,15 +2237,6 @@ export function TerminalPanel({
<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 */}
<Button
variant="ghost"
@@ -2221,24 +2357,116 @@ export function TerminalPanel({
</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 && (
<MobileTerminalShortcuts
onSendInput={sendTerminalInput}
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 */}
<div
ref={terminalRef}
className="flex-1 overflow-hidden relative"
style={{ backgroundColor: currentTerminalTheme.background }}
onContextMenu={handleContextMenu}
onDragOver={handleImageDragOver}
onDragLeave={handleImageDragLeave}
onDrop={handleImageDrop}
/>
{/* Terminal area wrapper - relative container for the terminal and selection overlay */}
<div className="flex-1 overflow-hidden relative">
{/* Terminal container - xterm.js mounts here */}
<div
ref={terminalRef}
className="absolute inset-0"
style={{ backgroundColor: currentTerminalTheme.background }}
onContextMenu={handleContextMenu}
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 */}
{!isAtBottom && (

View File

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

View File

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

View File

@@ -157,8 +157,40 @@ export function useAutoMode(worktree?: WorktreeInfo) {
// Check if we can start a new task based on concurrency limit
const canStartNewTask = runningAutoTasks.length < maxConcurrency;
// Ref to prevent refreshStatus from overwriting optimistic state during start/stop
// Ref to prevent refreshStatus and WebSocket handlers from overwriting optimistic state
// during start/stop transitions.
const isTransitioningRef = useRef(false);
// Tracks specifically a restart-for-concurrency transition. When true, the
// auto_mode_started WebSocket handler will clear isTransitioningRef, ensuring
// delayed auto_mode_stopped events that arrive after the HTTP calls complete
// (but before the WebSocket events) are still suppressed.
const isRestartTransitionRef = useRef(false);
// Safety timeout ID to clear the transition flag if the auto_mode_started event never arrives
const restartSafetyTimeoutRef = useRef<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 () => {
if (!currentProject) return;
@@ -175,20 +207,25 @@ export function useAutoMode(worktree?: WorktreeInfo) {
if (result.success && result.isAutoLoopRunning !== undefined) {
const backendIsRunning = result.isAutoLoopRunning;
const backendRunningFeatures = result.runningFeatures ?? [];
// Read latest state from refs to avoid stale closure values
const currentIsRunning = isAutoModeRunningRef.current;
const currentRunningTasks = runningAutoTasksRef.current;
const needsSync =
backendIsRunning !== isAutoModeRunning ||
backendIsRunning !== currentIsRunning ||
// Also sync when backend has runningFeatures we're missing (handles missed WebSocket events)
(backendIsRunning &&
Array.isArray(backendRunningFeatures) &&
backendRunningFeatures.length > 0 &&
!arraysEqual(backendRunningFeatures, runningAutoTasks)) ||
!arraysEqual(backendRunningFeatures, currentRunningTasks)) ||
// Also sync when UI has stale running tasks but backend has none
// (handles server restart where features were reconciled to backlog/ready)
(!backendIsRunning && runningAutoTasks.length > 0 && backendRunningFeatures.length === 0);
(!backendIsRunning &&
currentRunningTasks.length > 0 &&
backendRunningFeatures.length === 0);
if (needsSync) {
const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
if (backendIsRunning !== isAutoModeRunning) {
if (backendIsRunning !== currentIsRunning) {
logger.info(
`[AutoMode] Syncing UI state with backend for ${worktreeDesc} in ${currentProject.path}: ${backendIsRunning ? 'ON' : 'OFF'}`
);
@@ -206,7 +243,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
} catch (error) {
logger.error('Error syncing auto mode state with backend:', error);
}
}, [branchName, currentProject, isAutoModeRunning, runningAutoTasks, setAutoModeRunning]);
}, [branchName, currentProject, setAutoModeRunning]);
// On mount, query backend for current auto loop status and sync UI state.
// This handles cases where the backend is still running after a page refresh.
@@ -281,8 +318,23 @@ export function useAutoMode(worktree?: WorktreeInfo) {
'maxConcurrency' in event && typeof event.maxConcurrency === 'number'
? event.maxConcurrency
: getMaxConcurrencyForWorktree(eventProjectId, eventBranchName);
// Always apply start events even during transitions - this confirms the optimistic state
setAutoModeRunning(eventProjectId, eventBranchName, true, eventMaxConcurrency);
}
// If we were in a restart transition (concurrency change), the arrival of
// auto_mode_started confirms the restart is complete. Clear the transition
// flags so future auto_mode_stopped events are processed normally.
// Only clear transition refs when the event is for this hook's worktree,
// to avoid events for worktree B incorrectly affecting worktree A's state.
if (isRestartTransitionRef.current && eventBranchName === branchName) {
logger.debug(`[AutoMode] Restart transition complete for ${worktreeDesc}`);
isTransitioningRef.current = false;
isRestartTransitionRef.current = false;
if (restartSafetyTimeoutRef.current) {
clearTimeout(restartSafetyTimeoutRef.current);
restartSafetyTimeoutRef.current = null;
}
}
}
break;
@@ -307,12 +359,23 @@ export function useAutoMode(worktree?: WorktreeInfo) {
break;
case 'auto_mode_stopped':
// Backend stopped auto loop - update UI state
// Backend stopped auto loop - update UI state.
// Skip during transitions (e.g., restartWithConcurrency) to avoid flickering the toggle
// off between stop and start. The transition handler will set the correct final state.
// Only suppress (and only apply transition guard) when the event is for this hook's
// worktree, to avoid worktree B's stop events being incorrectly suppressed by
// worktree A's transition state.
{
const worktreeDesc = eventBranchName ? `worktree ${eventBranchName}` : 'main worktree';
logger.info(`[AutoMode] Backend stopped auto loop for ${worktreeDesc}`);
if (eventProjectId) {
setAutoModeRunning(eventProjectId, eventBranchName, false);
if (eventBranchName === branchName && isTransitioningRef.current) {
logger.info(
`[AutoMode] Backend stopped auto loop for ${worktreeDesc} (ignored during transition)`
);
} else {
logger.info(`[AutoMode] Backend stopped auto loop for ${worktreeDesc}`);
if (eventProjectId) {
setAutoModeRunning(eventProjectId, eventBranchName, false);
}
}
}
break;
@@ -574,6 +637,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
return unsubscribe;
}, [
projectId,
branchName,
addRunningTask,
removeRunningTask,
addAutoModeActivity,
@@ -582,7 +646,6 @@ export function useAutoMode(worktree?: WorktreeInfo) {
setAutoModeRunning,
currentProject?.path,
getMaxConcurrencyForWorktree,
setMaxConcurrencyForWorktree,
isPrimaryWorktreeBranch,
]);
@@ -624,8 +687,10 @@ export function useAutoMode(worktree?: WorktreeInfo) {
}
logger.debug(`[AutoMode] Started successfully for ${worktreeDesc}`);
// Sync with backend after success (gets runningFeatures if events were delayed)
queueMicrotask(() => void refreshStatus());
// Sync with backend after a short delay to get runningFeatures if events were delayed.
// The delay ensures the backend has fully processed the start before we poll status,
// avoiding a race where status returns stale data and briefly flickers the toggle.
setTimeout(() => void refreshStatus(), 500);
} catch (error) {
// Revert UI state on error
setAutoModeSessionForWorktree(currentProject.path, branchName, false);
@@ -635,7 +700,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
} finally {
isTransitioningRef.current = false;
}
}, [currentProject, branchName, setAutoModeRunning, getMaxConcurrencyForWorktree]);
}, [currentProject, branchName, setAutoModeRunning, getMaxConcurrencyForWorktree, refreshStatus]);
// Stop auto mode - calls backend to stop the auto loop for this worktree
const stop = useCallback(async () => {
@@ -672,8 +737,8 @@ export function useAutoMode(worktree?: WorktreeInfo) {
// NOTE: Running tasks will continue until natural completion.
// The backend stops picking up new features but doesn't abort running ones.
logger.info(`Stopped ${worktreeDesc} - running tasks will continue`);
// Sync with backend after success
queueMicrotask(() => void refreshStatus());
// Sync with backend after a short delay to confirm stopped state
setTimeout(() => void refreshStatus(), 500);
} catch (error) {
// Revert UI state on error
setAutoModeSessionForWorktree(currentProject.path, branchName, true);
@@ -683,7 +748,95 @@ export function useAutoMode(worktree?: WorktreeInfo) {
} finally {
isTransitioningRef.current = false;
}
}, [currentProject, branchName, setAutoModeRunning]);
}, [currentProject, branchName, setAutoModeRunning, refreshStatus]);
// Restart auto mode with new concurrency without flickering the toggle.
// Unlike stop() + start(), this keeps isRunning=true throughout the transition
// so the toggle switch never visually turns off.
//
// IMPORTANT: isTransitioningRef is NOT cleared in the finally block here.
// Instead, it stays true until the auto_mode_started WebSocket event arrives,
// which confirms the backend restart is complete. This prevents a race condition
// where a delayed auto_mode_stopped WebSocket event (sent by the backend during
// stop()) arrives after the HTTP calls complete but before the WebSocket events,
// which would briefly set isRunning=false and cause a visible toggle flicker.
// A safety timeout ensures the flag is cleared even if the event never arrives.
const restartWithConcurrency = useCallback(async () => {
if (!currentProject) {
logger.error('No project selected');
return;
}
// Clear any previous safety timeout
if (restartSafetyTimeoutRef.current) {
clearTimeout(restartSafetyTimeoutRef.current);
restartSafetyTimeoutRef.current = null;
}
isTransitioningRef.current = true;
isRestartTransitionRef.current = true;
try {
const api = getElectronAPI();
if (!api?.autoMode?.stop || !api?.autoMode?.start) {
throw new Error('Auto mode API not available');
}
const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
logger.info(
`[AutoMode] Restarting with new concurrency for ${worktreeDesc} in ${currentProject.path}`
);
// Stop backend without updating UI state (keep isRunning=true)
const stopResult = await api.autoMode.stop(currentProject.path, branchName);
if (!stopResult.success) {
logger.error('Failed to stop auto mode during restart:', stopResult.error);
// Don't throw - try to start anyway since the goal is to update concurrency
}
// Start backend with the new concurrency (UI state stays isRunning=true)
const currentMaxConcurrency = getMaxConcurrencyForWorktree(currentProject.id, branchName);
const startResult = await api.autoMode.start(
currentProject.path,
branchName,
currentMaxConcurrency
);
if (!startResult.success) {
// If start fails, we need to revert UI state since we're actually stopped now
isTransitioningRef.current = false;
isRestartTransitionRef.current = false;
setAutoModeSessionForWorktree(currentProject.path, branchName, false);
setAutoModeRunning(currentProject.id, branchName, false);
logger.error('Failed to restart auto mode with new concurrency:', startResult.error);
throw new Error(startResult.error || 'Failed to restart auto mode');
}
logger.debug(`[AutoMode] Restarted successfully for ${worktreeDesc}`);
// Don't clear isTransitioningRef here - let the auto_mode_started WebSocket
// event handler clear it. Set a safety timeout in case the event never arrives.
restartSafetyTimeoutRef.current = setTimeout(() => {
if (isRestartTransitionRef.current) {
logger.warn('[AutoMode] Restart transition safety timeout - clearing transition flag');
isTransitioningRef.current = false;
isRestartTransitionRef.current = false;
restartSafetyTimeoutRef.current = null;
}
}, 5000);
} catch (error) {
// On error, clear the transition flags immediately
isTransitioningRef.current = false;
isRestartTransitionRef.current = false;
// Revert UI state since the backend may be stopped after a partial restart
if (currentProject) {
setAutoModeSessionForWorktree(currentProject.path, branchName, false);
setAutoModeRunning(currentProject.id, branchName, false);
}
logger.error('Error restarting auto mode:', error);
throw error;
}
}, [currentProject, branchName, setAutoModeRunning, getMaxConcurrencyForWorktree]);
// Stop a specific feature
const stopFeature = useCallback(
@@ -731,6 +884,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
start,
stop,
stopFeature,
restartWithConcurrency,
refreshStatus,
};
}

View File

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

View File

@@ -58,6 +58,7 @@ const SETTINGS_FIELDS_TO_SYNC = [
'defaultSkipTests',
'enableDependencyBlocking',
'skipVerificationInAutoMode',
'mergePostAction',
'useWorktrees',
'defaultPlanningMode',
'defaultRequirePlanApproval',
@@ -717,6 +718,7 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
defaultSkipTests: serverSettings.defaultSkipTests,
enableDependencyBlocking: serverSettings.enableDependencyBlocking,
skipVerificationInAutoMode: serverSettings.skipVerificationInAutoMode,
mergePostAction: serverSettings.mergePostAction ?? null,
useWorktrees: serverSettings.useWorktrees,
defaultPlanningMode: serverSettings.defaultPlanningMode,
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:output'
| 'dev-server:stopped'
| 'dev-server:url-detected'
| 'test-runner:started'
| 'test-runner:output'
| 'test-runner:completed'
@@ -576,13 +577,17 @@ type EventType =
/**
* Dev server log event payloads for WebSocket streaming
*/
export interface DevServerStartedEvent {
/** Shared base for dev server events that carry URL/port information */
interface DevServerUrlEvent {
worktreePath: string;
port: number;
url: string;
port: number;
timestamp: string;
}
export type DevServerStartedEvent = DevServerUrlEvent;
export interface DevServerOutputEvent {
worktreePath: string;
content: string;
@@ -597,10 +602,13 @@ export interface DevServerStoppedEvent {
timestamp: string;
}
export type DevServerUrlDetectedEvent = DevServerUrlEvent;
export type DevServerLogEvent =
| { type: 'dev-server:started'; payload: DevServerStartedEvent }
| { type: 'dev-server:output'; payload: DevServerOutputEvent }
| { type: 'dev-server:stopped'; payload: DevServerStoppedEvent };
| { type: 'dev-server:stopped'; payload: DevServerStoppedEvent }
| { type: 'dev-server:url-detected'; payload: DevServerUrlDetectedEvent };
/**
* Test runner event payloads for WebSocket streaming
@@ -2204,10 +2212,14 @@ export class HttpApiClient implements ElectronAPI {
const unsub3 = this.subscribeToEvent('dev-server:stopped', (payload) =>
callback({ type: 'dev-server:stopped', payload: payload as DevServerStoppedEvent })
);
const unsub4 = this.subscribeToEvent('dev-server:url-detected', (payload) =>
callback({ type: 'dev-server:url-detected', payload: payload as DevServerUrlDetectedEvent })
);
return () => {
unsub1();
unsub2();
unsub3();
unsub4();
};
},
getPRInfo: (worktreePath: string, branchName: string) =>

View File

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

View File

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

View File

@@ -21,6 +21,8 @@ import type {
ClaudeApiProfile,
ClaudeCompatibleProvider,
SidebarStyle,
ThinkingLevel,
ReasoningEffort,
} from '@automaker/types';
import type {
@@ -127,6 +129,7 @@ export interface AppState {
enableDependencyBlocking: boolean; // When true, show blocked badges and warnings for features with incomplete dependencies (default: true)
skipVerificationInAutoMode: boolean; // When true, auto-mode grabs features even if dependencies are not verified (only checks they're not running)
enableAiCommitMessages: boolean; // When true, auto-generate commit messages using AI when opening commit dialog
mergePostAction: 'commit' | 'manual' | null; // User's preferred action after a clean merge (null = ask every time)
planUseSelectedWorktreeBranch: boolean; // When true, Plan dialog creates features on the currently selected worktree branch
addFeatureUseSelectedWorktreeBranch: boolean; // When true, Add Feature dialog defaults to custom mode with selected worktree branch
@@ -175,6 +178,10 @@ export interface AppState {
phaseModels: PhaseModelConfig;
favoriteModels: string[];
// Default thinking/reasoning levels for two-stage model selector primary button
defaultThinkingLevel: ThinkingLevel;
defaultReasoningEffort: ReasoningEffort;
// Cursor CLI Settings (global)
enabledCursorModels: CursorModelId[]; // Which Cursor models are available in feature modal
cursorDefaultModel: CursorModelId; // Default Cursor model selection
@@ -488,6 +495,7 @@ export interface AppActions {
setEnableDependencyBlocking: (enabled: boolean) => void;
setSkipVerificationInAutoMode: (enabled: boolean) => Promise<void>;
setEnableAiCommitMessages: (enabled: boolean) => Promise<void>;
setMergePostAction: (action: 'commit' | 'manual' | null) => Promise<void>;
setPlanUseSelectedWorktreeBranch: (enabled: boolean) => Promise<void>;
setAddFeatureUseSelectedWorktreeBranch: (enabled: boolean) => Promise<void>;
@@ -548,6 +556,8 @@ export interface AppActions {
setPhaseModels: (models: Partial<PhaseModelConfig>) => Promise<void>;
resetPhaseModels: () => Promise<void>;
toggleFavoriteModel: (modelId: string) => void;
setDefaultThinkingLevel: (level: ThinkingLevel) => void;
setDefaultReasoningEffort: (effort: ReasoningEffort) => void;
// Cursor CLI Settings actions
setEnabledCursorModels: (models: CursorModelId[]) => void;

View File

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

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

View File

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

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